From 24d24dde349cc2fd3c1c413ad9c9b6f62baf1f1b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 11:11:18 +0700 Subject: [PATCH 1/3] test: fix the shadowed grid local and scroll the reset button into view --- TableProTests/Views/Structure/TriggerListGridTests.swift | 4 ++-- TableProUITests/AppearanceSettingsUITests.swift | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/TableProTests/Views/Structure/TriggerListGridTests.swift b/TableProTests/Views/Structure/TriggerListGridTests.swift index 87c443146f..1def47235f 100644 --- a/TableProTests/Views/Structure/TriggerListGridTests.swift +++ b/TableProTests/Views/Structure/TriggerListGridTests.swift @@ -121,8 +121,8 @@ struct TriggerListGridTests { TriggerListPane(triggers: triggers, databaseType: .mysql, state: TriggerInspectorState()), size: NSSize(width: 700, height: 300) ) - let tableView = try #require(tableView(in: window.contentView)) - let presented = tableView.tableColumns.filter { $0.identifier != ColumnIdentitySchema.rowNumberIdentifier && !$0.isHidden } + let grid = try #require(tableView(in: window.contentView)) + let presented = grid.tableColumns.filter { $0.identifier != ColumnIdentitySchema.rowNumberIdentifier && !$0.isHidden } let minimums = Dictionary(uniqueKeysWithValues: presented.map { ($0.headerCell.stringValue, $0.minWidth) }) let titles = TriggerListColumns.titles(showsEnabled: true) diff --git a/TableProUITests/AppearanceSettingsUITests.swift b/TableProUITests/AppearanceSettingsUITests.swift index 7c778a6e23..379df13e7e 100644 --- a/TableProUITests/AppearanceSettingsUITests.swift +++ b/TableProUITests/AppearanceSettingsUITests.swift @@ -135,6 +135,7 @@ final class AppearanceSettingsUITests: UITestCase { let reset = sheet.buttons["theme-color-reset-editor.background"] XCTAssertTrue(reset.waitToExist(timeout: 10), "Every color of a Dracula copy starts overridden") XCTAssertEqual(reset.label, "Use Default") + XCTAssertTrue(scrollUntilHittable(reset, in: sheet, timeout: 10)) reset.click() XCTAssertTrue(waitForPredicate(timeout: 5) { !reset.exists }, "Use Default should clear the override") XCTAssertTrue( From 0f2ff4976175128f122647b7e71e67491f00a565 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 12:44:35 +0700 Subject: [PATCH 2/3] test: delete registry-engine tests, move source guards to lint, cut the UI suite --- .swiftlint.yml | 478 +++++- Native/DamengBridge/src/lib.rs | 385 ----- .../HanaBridge/internal/frame/frame_test.go | 233 --- .../HanaBridge/internal/hana/bridge_test.go | 127 -- .../HanaBridge/internal/hana/config_test.go | 93 -- .../HanaBridge/internal/hana/control_test.go | 60 - Native/HanaBridge/internal/hana/dialer.go | 6 - .../HanaBridge/internal/hana/dialer_test.go | 146 -- .../HanaBridge/internal/hana/envelope_test.go | 170 --- .../HanaBridge/internal/hana/errors_test.go | 239 --- .../HanaBridge/internal/hana/explain_test.go | 101 -- Native/HanaBridge/internal/hana/lob_test.go | 112 -- .../HanaBridge/internal/hana/opslot_test.go | 424 ------ .../HanaBridge/internal/hana/params_test.go | 320 ---- .../HanaBridge/internal/hana/registry_test.go | 96 -- .../HanaBridge/internal/hana/routing_test.go | 49 - .../internal/hana/scripted_driver_test.go | 132 -- .../HanaBridge/internal/hana/session_test.go | 202 --- .../internal/hana/statement_test.go | 173 --- Native/HanaBridge/internal/hana/tls_test.go | 339 ----- .../HanaBridge/internal/hana/values_test.go | 270 ---- .../HanaBridge/internal/hana/watchdog_test.go | 97 -- Native/HanaBridge/process_test.go | 236 --- Native/HanaBridge/server_test.go | 548 ------- Packages/TableProCore/Package.swift | 35 - .../DocumentPathTests.swift | 74 - .../FirebaseClientTests.swift | 222 --- .../FirebaseEmulatorTests.swift | 123 -- .../FirebaseJSONTests.swift | 43 - .../FirebaseSettingsAndStatementTests.swift | 133 -- .../FirebaseTestSupport.swift | 71 - .../FirestoreCellAndQueryTests.swift | 205 --- .../FirestoreValueTests.swift | 122 -- .../RealtimeDatabaseTests.swift | 145 -- .../GoogleSQLLiteralTests.swift | 78 - .../SQLPlaceholderRewriterTests.swift | 137 -- .../R2SQLEnvelopeDecodingTests.swift | 95 -- .../R2SQLIntrospectionSQLTests.swift | 71 - .../R2SQLJSONValueTests.swift | 39 - .../R2SQLRequestBuilderTests.swift | 32 - .../R2SQLRowMapperTests.swift | 48 - .../R2SQLTypeMapperTests.swift | 32 - .../R2SQLURLSessionTransportTests.swift | 86 -- .../SpannerAPIErrorTests.swift | 115 -- .../SpannerBrowseRendererTests.swift | 292 ---- .../SpannerCatalogTests.swift | 298 ---- .../SpannerConnectionSettingsTests.swift | 174 --- .../SpannerDDLCatalogTests.swift | 256 ---- .../SpannerDialectTests.swift | 93 -- .../SpannerExecutorEmulatorTests.swift | 101 -- .../SpannerExecutorTests.swift | 425 ------ .../SpannerFakeServer.swift | 187 --- .../SpannerParameterEncoderTests.swift | 140 -- .../SpannerPartialResultAssemblerTests.swift | 152 -- .../SpannerPlanRendererTests.swift | 75 - .../SpannerRESTClientEmulatorTests.swift | 161 -- .../SpannerRESTClientTests.swift | 484 ------ .../SpannerRowEditSQLTests.swift | 231 --- .../SpannerSchemaNameTests.swift | 31 - .../SpannerStatementClassifierTests.swift | 109 -- .../SpannerStreamFramerTests.swift | 125 -- .../SpannerTestSupport.swift | 210 --- .../SpannerValueDecoderTests.swift | 124 -- .../SpannerWireDecodingTests.swift | 222 --- .../SyncMapperFieldAccessTests.swift | 47 - .../SyncRecordNameConstructionTests.swift | 76 - .../BigUIntTests.swift | 72 - .../TableProTeradataCoreTests/DHVectors.swift | 26 - .../TableProTeradataCoreTests/DerTests.swift | 18 - .../DiffieHellmanTests.swift | 39 - .../GcmCipherTests.swift | 23 - .../LanMessageTests.swift | 49 - .../ParcelTests.swift | 30 - .../RecordDecoderDecimalTests.swift | 38 - .../RecordDecoderTests.swift | 37 - .../TeradataColumnTypeTests.swift | 32 - .../TeradataConnectTimeoutTests.swift | 47 - .../TeradataExecuteLoopTests.swift | 39 - .../TeradataLogMechTests.swift | 29 - .../TeradataSchemaQueriesTests.swift | 129 -- .../TeradataTLSTests.swift | 155 -- .../WebSocketFrameTests.swift | 47 - .../TrinoClientConfigTests.swift | 38 - .../TrinoDDLSQLTests.swift | 90 -- .../TrinoIntrospectionSQLTests.swift | 96 -- .../TrinoJSONValueTests.swift | 56 - .../TrinoLiteralTests.swift | 64 - .../TrinoMutualTLSTests.swift | 369 ----- .../TrinoQueryResultsDecodingTests.swift | 53 - .../TrinoRedirectPolicyTests.swift | 143 -- .../TrinoRowEditSQLTests.swift | 60 - .../TrinoSessionStateTests.swift | 61 - .../TrinoStatementClientTests.swift | 242 --- .../TrinoStatementSplitterTests.swift | 60 - .../TrinoStreamingTests.swift | 52 - .../TrinoTLSFailureTests.swift | 316 ---- .../TrinoTestSupport.swift | 87 -- .../TrinoTypeMapperTests.swift | 48 - .../TrinoURLSessionTransportTests.swift | 275 ---- .../TrinoValueDecoderTests.swift | 40 - .../WeaviateAuthAndQueryTests.swift | 195 --- .../WeaviateClientTests.swift | 305 ---- .../WeaviateFilterAndPathTests.swift | 614 -------- .../WeaviateObjectPagesTests.swift | 163 --- .../WeaviateOperationsTests.swift | 34 - .../WeaviateTestSupport.swift | 120 -- .../TextLayoutLineStorageTests.swift | 57 - Plugins/.swiftlint.yml | 18 + .../DamengConnectionRecoveryTests.swift | 130 -- .../DamengPluginDriverTests.swift | 809 ---------- .../DamengScriptSplitterTests.swift | 135 -- .../HanaBridgeContractTests.swift | 249 ---- .../HanaCatalogTests.swift | 386 ----- .../HanaConnectionTests.swift | 371 ----- .../HanaExplainStatementTests.swift | 54 - .../HanaFailureMappingTests.swift | 169 --- .../HanaFakeBridge.swift | 318 ---- .../HanaFakeSession.swift | 158 -- .../HanaHelperBridgeTests.swift | 499 ------- .../HanaHelperFrameTests.swift | 268 ---- .../HanaHelperPipeTests.swift | 68 - .../HanaHelperProcessTests.swift | 265 ---- .../HanaHelperTestSupport.swift | 252 ---- .../HanaHelperTrustTests.swift | 217 --- .../HanaOperationSlotTests.swift | 74 - .../HanaPluginDriverTests.swift | 654 --------- .../MongoBsonBuilder.swift | 3 - Plugins/TableProPluginKit/.swiftlint.yml | 3 + .../TableProPluginKit/PluginHostStorage.swift | 27 +- TablePro/Core/MCP/MCPAuditLogger.swift | 4 +- TablePro/Core/Plugins/PluginManager.swift | 6 +- .../AppActivationPolicyController.swift | 8 +- .../SampleDatabaseService.swift | 11 - .../Infrastructure/UITestJsonFixture.swift | 72 - .../Core/Storage/AppStorageEnvironment.swift | 56 +- .../ThirdParty/ThirdPartyComponent.swift | 2 +- .../Models/UI/SplitViewAutosaveName.swift | 73 +- .../Resources/ThirdPartyLicenses/licenses.yml | 8 +- TablePro/Theme/ThemeDefinition.swift | 6 +- .../Views/Welcome/WelcomeLibraryPane.swift | 2 +- TableProMobile/.swiftlint.yml | 6 + .../Drivers/MSSQLDriverTests.swift | 277 ---- .../Drivers/OracleDriverTests.swift | 285 ---- .../PostgreSQLDriverCatalogTests.swift | 141 -- .../OffMainActorHandlerGuardTests.swift | 71 - TableProTests/.swiftlint.yml | 2 + .../AccessibleControlNameTests.swift | 100 +- .../CloudSQL/CloudSQLProxyModelTests.swift | 8 - .../Cloudflare/CloudflareModelTests.swift | 8 - .../AI/ChatGPTCodexRegistrationTests.swift | 6 - .../Core/AI/ChatToolRegistryTests.swift | 9 - .../KeywordVocabularyParityTests.swift | 106 +- .../MongoCompletionCaseTests.swift | 73 - .../MongoContextAnalyzerTests.swift | 147 -- .../MongoVocabularyConstructorTests.swift | 36 - .../SQLCompletionProviderTests.swift | 14 - .../SQLContextAnalyzerTests.swift | 12 - .../SQLContextAnalyzerWindowingTests.swift | 20 - .../DataChangeManagerExtendedTests.swift | 8 - .../SQLStatementGeneratorMSSQLTests.swift | 187 --- .../SQLStatementGeneratorTests.swift | 25 - .../CloudflareD1DriverHelperTests.swift | 245 ---- .../CloudflareD1PluginMetadataTests.swift | 172 --- .../D1HttpClientCancellationTests.swift | 153 -- .../CloudflareD1/D1ResponseParsingTests.swift | 326 ----- .../CloudflareD1/D1ValueDecodingTests.swift | 237 --- .../CompareCapabilityDeclarationTests.swift | 57 - .../OffMainActorHandlerGuardTests.swift | 130 -- .../DataWrite/FieldAbsenceWriteTests.swift | 471 ------ ...wChangeStatementFactoryCoverageTests.swift | 164 --- .../Database/ConnectionHealthCheckTests.swift | 7 - .../FilterSQLGeneratorMSSQLTests.swift | 215 --- .../Database/FilterSQLGeneratorTests.swift | 123 -- .../Core/Database/MSSQLDriverTests.swift | 217 --- .../Core/Database/PostgreSQLDriverTests.swift | 258 ---- .../SchemaCompositionGuardTests.swift | 62 - .../SwitchDatabasePooledConnectionTests.swift | 3 +- .../SwitchDatabaseReconnectFailureTests.swift | 3 +- .../MongoDiagnosticsProducerTests.swift | 122 -- .../Core/EllipsisConventionTests.swift | 53 - .../CancelledExecutionOwnershipTests.swift | 40 - .../TabExecutionSettleGuardTests.swift | 192 --- .../Execution/TabQueryTaskGuardTests.swift | 102 -- .../Execution/WindowBusyStateGuardTests.swift | 86 +- .../Export/ExportFormatCatalogTests.swift | 9 - .../Core/MCP/MCPAuditLogStorageTests.swift | 85 -- .../Core/MCP/MCPPairingValidationTests.swift | 23 +- .../Core/MCP/MCPReportedStatusTests.swift | 56 - .../Core/MCP/MCPTokenStoreTests.swift | 30 - .../Tools/MCPStatementConsentGuardTests.swift | 27 +- .../Core/MCP/Results/MCPResultTests.swift | 8 - .../Core/Menu/ChromeSnapshotTests.swift | 190 --- .../MoveTabToConnectionCommandTests.swift | 6 - .../MongoDB/BsonDocumentFlattenerTests.swift | 653 --------- .../MongoDB/BsonFieldPathArrayTests.swift | 124 -- .../Core/MongoDB/BsonFieldPathTests.swift | 89 -- .../MongoDB/MongoDBExtendedJsonTests.swift | 218 --- .../MongoDB/MongoDBFindLimitPolicyTests.swift | 66 - .../MongoDBGeneratedDDLContainmentTests.swift | 218 --- .../Core/MongoDB/MongoDBSrvHostTests.swift | 44 - .../MongoDB/MongoDBTimeoutPolicyTests.swift | 154 -- .../MongoDB/MongoScriptCommandTests.swift | 714 --------- .../MongoScriptDefinitionRoundTripTests.swift | 194 --- .../MongoDB/MongoScriptJsonEscapeTests.swift | 47 - .../MongoScriptPreludeMemberTests.swift | 93 -- .../MongoDB/MongoScriptPreludeTests.swift | 800 ---------- .../MongoDB/MongoScriptPreludeViewTests.swift | 49 - .../MongoScriptReadEnforcementTests.swift | 231 --- .../MongoShellParserChainedMethodTests.swift | 149 -- .../Core/MongoDB/MongoShellParserTests.swift | 1264 ---------------- .../Core/MongoDB/MongoShellValueTests.swift | 230 --- .../MongoShellValueTranslatorTests.swift | 238 --- .../AppSettingsCategoryParityTests.swift | 54 +- .../Operations/ResultVisibilityTests.swift | 27 - .../Core/Plugins/ConnectionFieldTests.swift | 51 - .../Plugins/ContainerEntityNameTests.swift | 6 - .../Plugins/DriverPluginMetadataTests.swift | 253 ---- .../NeedsRestartPersistenceTests.swift | 22 - .../Plugins/OracleCuratedParityTests.swift | 121 -- .../Plugins/PingNeverReconnectsTests.swift | 103 -- ...tadataRegistryCuratedCapabilityTests.swift | 10 - ...MetadataRegistrySchemaSwitchingTests.swift | 111 -- ...PluginMetadataRegistryTypeCountTests.swift | 11 - .../Core/Plugins/PluginSettingsTests.swift | 24 - .../PluginSignatureGatePlacementTests.swift | 110 -- .../PluginSignatureValidationFlagsTests.swift | 40 - .../Core/Plugins/PluginValidationTests.swift | 56 - .../Plugins/SQLServerImportBatchTests.swift | 224 --- .../Redis/SidebarRedisCommandsTests.swift | 23 - .../LibSSH2SFTPSessionIntegrationTests.swift | 169 --- .../Core/SSH/SSHConfigurationTests.swift | 6 - .../Core/SSH/TestSupport/SFTPTestServer.swift | 112 -- ...tureChangeManagerClusteredIndexTests.swift | 192 --- .../Core/Scripting/ScriptingPolicyTests.swift | 12 - .../ScriptingVisibilityGuardTests.swift | 90 -- .../TypesenseDashboardProviderTests.swift | 63 - .../Core/Services/ColumnTypeTests.swift | 6 - .../Services/DateEditingServiceTests.swift | 5 - .../CellValueContentDetectorTests.swift | 10 - .../Formatting/MongoShellFormatterTests.swift | 50 - .../Formatting/ValueDisplayFormatTests.swift | 9 - .../Services/ImportExecutionGateTests.swift | 19 - .../Core/Services/ImportStateTests.swift | 74 - .../AppActivationPolicySourceTests.swift | 73 - .../ToolbarSymbolConventionTests.swift | 119 -- .../TrailingPaneCommandTitleTests.swift | 18 - .../Query/DamengPlanParserTests.swift | 60 - .../TabSessionRegistryTableRowsTests.swift | 6 - .../SchemaMetadataGeneratedColumnTests.swift | 7 - .../TableQueryBuilderMSSQLTests.swift | 122 -- .../WindowLifecycleMonitorTests.swift | 5 - TableProTests/Core/SiteWordingTests.swift | 64 - .../Core/Storage/AIChatStorageTests.swift | 142 -- .../Storage/AppStorageEnvironmentTests.swift | 70 +- .../ConnectionLocalStatePurgeTests.swift | 68 +- .../Storage/PreferenceKeysGuardTests.swift | 108 -- .../Core/Storage/SafeModeMigrationTests.swift | 8 - .../TableScopedSettingsRegistryTests.swift | 199 --- .../EntitlementsEnvironmentParityTests.swift | 52 - .../Core/Sync/SyncPushBatchPlannerTests.swift | 5 - .../ThirdPartyLicenseInventoryTests.swift | 260 +--- .../ConnectionURLParserMSSQLTests.swift | 86 -- .../Utilities/ConnectionURLParserTests.swift | 11 - .../Utilities/DatabaseURLSchemeTests.swift | 9 - .../FixedFormatDateFormatterLocaleTests.swift | 67 - .../Validation/SettingsValidationTests.swift | 7 - .../Core/Vim/VimEngineSearchTests.swift | 10 - .../MongoToolsConnectionStringTests.swift | 166 --- .../Database/SQLServerDumpTests.swift | 73 - TableProTests/Helpers/SQLTestHelpers.swift | 52 - TableProTests/Helpers/StringCatalog.swift | 89 ++ .../Helpers/UserFacingSourceScan.swift | 117 -- .../CountedNounAgreementTests.swift | 35 - .../EditorMenuAndStatementCountTests.swift | 63 - .../StringCatalogIntegrityTests.swift | 553 ------- TableProTests/Models/AISettingsTests.swift | 5 - .../Models/ColumnLayoutStateTests.swift | 93 -- .../ConnectionFormRequestTests.swift | 14 - .../Database/SQLDDLFallbackPolicyTests.swift | 88 +- .../DatabaseConnectionAIRulesTests.swift | 23 - ...abaseConnectionAdditionalFieldsTests.swift | 153 -- .../Models/DatabaseObjectRefCodingTests.swift | 12 - .../Models/DatabaseTypeCassandraTests.swift | 97 -- .../Models/DatabaseTypeCockroachDBTests.swift | 54 - .../Models/DatabaseTypeDatabendTests.swift | 45 - .../Models/DatabaseTypeMSSQLTests.swift | 57 - .../Models/DatabaseTypeOceanBaseTests.swift | 49 - .../Models/DatabaseTypePGliteTests.swift | 49 - .../Models/DatabaseTypeRedisTests.swift | 50 - TableProTests/Models/DatabaseTypeTests.swift | 138 -- .../Models/DatabaseTypeTiDBTests.swift | 44 - .../Models/ExportRowScopeTests.swift | 8 - .../Models/KeyboardShortcutTests.swift | 42 - .../Models/PaginationStateTests.swift | 5 - TableProTests/Models/PreviewTabTests.swift | 12 - .../Models/Query/ExplainRequestTests.swift | 15 - .../Query/ResultSetOwnershipTests.swift | 76 - .../Query/TabSessionRegistryTests.swift | 6 - .../Models/SQLFileDeduplicationTests.swift | 20 - TableProTests/Models/SafeModeLevelTests.swift | 102 -- .../ColumnClassificationHintTests.swift | 44 - .../Models/SharedSidebarStateTests.swift | 6 - TableProTests/Models/SortStateTests.swift | 73 - .../Models/SplitViewAutosaveNameTests.swift | 97 +- .../Models/TableOperationPromptTests.swift | 24 - .../BeancountConnectTimeoutTests.swift | 188 --- .../BeancountDriverMetadataTests.swift | 56 - .../BeancountIncludeResolverTests.swift | 214 --- .../Plugins/BeancountPluginDriverTests.swift | 1183 --------------- .../Plugins/BeancountProjectionTests.swift | 824 ----------- .../Plugins/BeancountRledgerOutputTests.swift | 188 --- .../BigQueryCredentialFactoryTests.swift | 300 ---- .../Plugins/BigQueryQueryBuilderTests.swift | 493 ------- .../BigQueryQueryParametersTests.swift | 251 ---- .../BigQueryStatementGeneratorTests.swift | 321 ---- .../Plugins/BigQueryTypeMapperTests.swift | 373 ----- .../CassandraBrowseClassificationTests.swift | 40 - .../CassandraBrowseStatementTests.swift | 224 --- .../CassandraCommentStatementTests.swift | 56 - .../CassandraConnectTimeoutTests.swift | 68 - .../Plugins/CassandraObjectTextTests.swift | 84 -- .../Plugins/CassandraResumePointsTests.swift | 112 -- .../Plugins/CassandraRowWriterTests.swift | 162 -- .../Plugins/CassandraValueParserTests.swift | 163 --- .../Plugins/CheckConstraintParsingTests.swift | 16 - .../CloudPluginConnectionFieldsTests.swift | 25 - .../CloudflareR2SQLConnectTimeoutTests.swift | 56 - .../CloudflareR2SQLMetadataParityTests.swift | 77 - .../Plugins/DamengConnectTimeoutTests.swift | 55 - .../Plugins/DamengParameterBinderTests.swift | 129 -- .../DamengStatementClassifierTests.swift | 38 - .../Plugins/DamengSystemSchemasTests.swift | 37 - .../DocumentStoreCaseSensitivityTests.swift | 215 --- .../Plugins/DuckDBConnectAttemptTests.swift | 120 -- .../Plugins/DuckDBConnectionFieldsTests.swift | 130 -- .../Plugins/DuckDBFileKindsTests.swift | 49 - .../Plugins/DuckDBIdleReleaseTests.swift | 94 -- .../Plugins/DuckDBIndexClausesTests.swift | 60 - .../Plugins/DuckDBLockConflictTests.swift | 98 -- .../Plugins/DuckDBPositionParserTests.swift | 87 -- .../Plugins/DuckDBQuackConnectTests.swift | 70 - .../Plugins/DuckDBSchemaQueriesTests.swift | 386 ----- .../Plugins/DuckDBTransactionProbeTests.swift | 66 - .../Plugins/DuckDBTypeRenderingTests.swift | 157 -- .../Plugins/DuckDBViewDefinitionTests.swift | 64 - .../DynamoDB/DynamoDBAccessPlannerTests.swift | 721 --------- .../DynamoDBAttributeValueTests.swift | 188 --- .../DynamoDB/DynamoDBCatalogTests.swift | 292 ---- .../DynamoDB/DynamoDBCellCodecTests.swift | 356 ----- .../DynamoDB/DynamoDBClientTests.swift | 426 ------ .../DynamoDB/DynamoDBDriverTests.swift | 1056 ------------- .../DynamoDB/DynamoDBEndpointTests.swift | 245 ---- .../Plugins/DynamoDB/DynamoDBErrorTests.swift | 243 --- .../DynamoDB/DynamoDBExpressionTests.swift | 257 ---- .../DynamoDBFilterTranslatorTests.swift | 640 -------- .../DynamoDB/DynamoDBItemTableTests.swift | 315 ---- .../Plugins/DynamoDB/DynamoDBJSONTests.swift | 255 ---- .../DynamoDBLocalIntegrationTests.swift | 367 ----- .../DynamoDB/DynamoDBNumberTests.swift | 169 --- .../DynamoDBParameterBinderTests.swift | 242 --- .../DynamoDB/DynamoDBPartiQLTests.swift | 402 ----- .../DynamoDB/DynamoDBRetryPolicyTests.swift | 263 ---- .../DynamoDB/DynamoDBSignerTests.swift | 188 --- .../DynamoDB/DynamoDBStatementTests.swift | 422 ------ .../DynamoDBTableManagementTests.swift | 427 ------ .../DynamoDBWriteStatementsTests.swift | 278 ---- .../Plugins/DynamoDBMetadataParityTests.swift | 113 -- .../ElasticsearchConnectionFieldsTests.swift | 78 - .../Plugins/ElasticsearchDriverTests.swift | 1303 ----------------- .../Plugins/ElasticsearchNodesTests.swift | 304 ---- .../ElasticsearchOperationsTests.swift | 70 - .../Plugins/EtcdCommandArgumentTests.swift | 82 -- .../Plugins/EtcdGatewayRouteTests.swift | 53 - .../Plugins/EtcdHttpClientUtilityTests.swift | 161 -- TableProTests/Plugins/EtcdKeyspaceTests.swift | 205 --- TableProTests/Plugins/EtcdLeaseIDTests.swift | 34 - .../Plugins/EtcdQueryBuilderTests.swift | 251 ---- .../Plugins/EtcdRequestRecoveryTests.swift | 103 -- .../Plugins/EtcdRowFilterTests.swift | 193 --- .../Plugins/EtcdServerFaultTests.swift | 150 -- .../Plugins/EtcdServerTrustTests.swift | 97 -- .../Plugins/EtcdStatementGeneratorTests.swift | 852 ----------- .../Plugins/ExportFormatEscapingTests.swift | 134 -- .../FirebaseConnectionFieldsTests.swift | 100 -- .../Plugins/FirebaseDriverTests.swift | 374 ----- ...HranaHttpClientArgumentEncodingTests.swift | 148 -- .../HranaHttpClientCancellationTests.swift | 148 -- .../Plugins/HttpQueryTimeoutTests.swift | 8 - .../Plugins/IndexDDLOwnershipTests.swift | 59 - .../IndexStatementRenderingTests.swift | 120 +- .../KafkaBootstrapEndpointsTests.swift | 54 - .../Plugins/KafkaCompressionTests.swift | 204 --- .../Plugins/KafkaConnectionFieldTests.swift | 185 --- .../Plugins/KafkaIntegrationTests.swift | 623 -------- .../Plugins/KafkaMessageFlattenerTests.swift | 215 --- .../Plugins/KafkaProtocolCodecTests.swift | 274 ---- TableProTests/Plugins/KafkaQLTests.swift | 248 ---- .../Plugins/KafkaRecordBatchTests.swift | 281 ---- TableProTests/Plugins/KafkaRoutingTests.swift | 389 ----- TableProTests/Plugins/KafkaSASLTests.swift | 137 -- .../Plugins/KafkaTLSConfigurationTests.swift | 86 -- TableProTests/Plugins/KafkaTestBroker.swift | 225 --- .../LibPQCheckConstraintCoverageTests.swift | 88 -- .../Plugins/LibPQConnectionStringTests.swift | 27 - .../Plugins/LibPQKeepaliveTests.swift | 32 - ...LibPQPluginConnectionSourceScanTests.swift | 349 ----- .../Plugins/LibPQSessionCheckTests.swift | 48 - .../Plugins/LibSQLBusyTimeoutTests.swift | 388 ----- .../Plugins/LibSQLConnectionFieldsTests.swift | 113 -- .../Plugins/LibSQLRelationDDLTests.swift | 73 - .../MSSQLEntraConnectSessionTests.swift | 14 - .../Plugins/MSSQLFreeTDSConfigTests.swift | 292 ---- .../Plugins/MSSQLSSLMappingTests.swift | 36 - .../MSSQLSessionTransactionTests.swift | 52 - .../MSSQLStatementGeneratorTests.swift | 219 --- .../MSSQLTableDefinitionSQLTests.swift | 182 --- .../Plugins/MSSQLTypeQueryTests.swift | 252 ---- .../Plugins/MongoBsonAssemblyTests.swift | 194 --- .../MongoDBAuthSourceResolverTests.swift | 55 - .../Plugins/MongoDBCollectionDDLTests.swift | 300 ---- .../MongoDBCollectionSchemaTests.swift | 243 --- .../MongoDBCreateDatabasePlanTests.swift | 45 - .../Plugins/MongoDBFieldNameUpdateTests.swift | 252 ---- .../MongoDBFieldPresenceWriteTests.swift | 137 -- .../Plugins/MongoDBIndexEntryTests.swift | 205 --- .../Plugins/MongoDBJsonLayoutTests.swift | 111 -- .../Plugins/MongoDBNameValidatorTests.swift | 79 - .../Plugins/MongoDBNamespaceEntryTests.swift | 297 ---- .../Plugins/MongoDBNestedFilterTests.swift | 447 ------ .../MongoDBNestedValueWriteTests.swift | 539 ------- .../Plugins/MongoDBQueryBuilderTests.swift | 1226 ---------------- .../Plugins/MongoDBSSLMappingTests.swift | 68 - .../Plugins/MongoDBShellLiteralTests.swift | 157 -- .../Plugins/MongoDBShellTextTests.swift | 68 - .../MongoDBStatementGeneratorTests.swift | 1100 -------------- .../MongoDBStructureEditingParityTests.swift | 37 - .../Plugins/MongoDBWriteBackBinaryTests.swift | 307 ---- .../Plugins/MongoDBWriteBackTypeTests.swift | 299 ---- .../Plugins/MongoDocumentGuardTests.swift | 185 --- .../Plugins/MongoDocumentIdentityTests.swift | 99 -- .../MongoDocumentPresentationTests.swift | 116 -- .../MongoDocumentReplacementTests.swift | 94 -- .../Plugins/MongoDocumentTextTests.swift | 116 -- .../Plugins/MongoDocumentWritePlanTests.swift | 145 -- .../Plugins/MongoEditableDocumentTests.swift | 277 ---- .../Plugins/MongoFieldCensusTests.swift | 168 --- .../MongoFieldChangeAssessmentTests.swift | 403 ----- .../Plugins/MongoFieldChangeTests.swift | 159 -- .../Plugins/MongoFieldDataProbeTests.swift | 282 ---- .../Plugins/MongoFieldReferencesTests.swift | 364 ----- .../Plugins/MongoInsertOptionsTests.swift | 52 - .../Plugins/MongoResultRebuildTests.swift | 68 - .../MongoScriptAccessPolicyTests.swift | 202 --- .../Plugins/MongoSearchIndexTests.swift | 135 -- .../Plugins/MongoStreamProjectionTests.swift | 186 --- .../Plugins/MongoWriteFailureTests.swift | 242 --- .../Plugins/MongoWriteLedgerTests.swift | 354 ----- .../MySQLConnectionEncodingTests.swift | 19 - .../Plugins/MySQLKillLatchTests.swift | 53 +- .../Plugins/MySQLLocalSocketTests.swift | 7 - .../MySQLResultSynthesisSourceScanTests.swift | 61 - .../Plugins/ObjectCatalogQueryTests.swift | 406 ----- .../Plugins/OracleBindPlaceholdersTests.swift | 123 -- .../Plugins/OracleBrowseSQLTests.swift | 114 -- .../Plugins/OracleColumnStatementsTests.swift | 200 --- .../Plugins/OracleCommentStatementTests.swift | 56 - .../Plugins/OracleConnectionErrorTests.swift | 57 - .../Plugins/OracleNationalTextTests.swift | 152 -- ...racleObjectQueriesQualificationTests.swift | 49 - .../Plugins/OraclePartitionMappingTests.swift | 61 - .../Plugins/OracleRowWriterTests.swift | 485 ------ .../Plugins/OracleTableDDLTests.swift | 120 -- .../Plugins/OracleTypeCatalogTests.swift | 98 -- .../ParquetExportCancellationTests.swift | 102 -- .../PluginColumnInfoCodableTests.swift | 12 - .../Plugins/PluginQueryTimeoutTests.swift | 12 - .../Plugins/PluginSSLClassifierTests.swift | 123 -- .../Plugins/PluginTableInfoTests.swift | 8 - .../PluginURLSessionTaskBoxTests.swift | 52 - .../PostgreSQLLiteralQuotingTests.swift | 98 -- .../Plugins/RedisConnectProbeTests.swift | 27 - .../Plugins/RedisQueuedReplyTests.swift | 32 - .../SQLLexicalFeatureMappingTests.swift | 5 +- .../Plugins/SQLiteDefaultValueTests.swift | 4 - .../Plugins/SnowflakeAuthTests.swift | 137 -- .../Plugins/SnowflakeGeneratorTests.swift | 328 ----- .../Plugins/SnowflakeImportedKeysTests.swift | 50 - .../Plugins/SnowflakeProtocolTests.swift | 193 --- TableProTests/Plugins/SnowflakeSQLTests.swift | 100 -- .../Plugins/SnowflakeSessionKeyTests.swift | 61 - .../Plugins/SnowflakeStatementTypeTests.swift | 70 - .../Plugins/SnowflakeTypeMapperTests.swift | 58 - .../Plugins/SnowflakeValueDecoderTests.swift | 228 --- .../SpannerRegistrySnapshotTests.swift | 93 -- .../Plugins/SurrealDBCBORTests.swift | 176 --- .../Plugins/SurrealDBDriverTests.swift | 929 ------------ .../Plugins/SurrealDBMetadataTests.swift | 83 -- .../Plugins/TeradataRowEditSQLTests.swift | 35 - .../Plugins/TeradataSSLMappingTests.swift | 100 -- .../Plugins/TrinoRowEditValuesTests.swift | 52 - .../Plugins/TrinoSSLMappingTests.swift | 141 -- .../TypesenseConnectionFieldsTests.swift | 127 -- .../Plugins/TypesenseDriverTests.swift | 1063 -------------- .../TypesenseOperationsConsoleTextTests.swift | 54 - .../Plugins/VendoredSQLiteImportTests.swift | 91 -- .../WeaviateConnectionFieldsTests.swift | 137 -- .../Services/ContentWindowBackdropTests.swift | 39 - .../Services/MenuQueryScopeGuardTests.swift | 115 -- .../Services/ToolbarSourceAccessTests.swift | 240 --- .../CustomSlashCommandStorageSyncTests.swift | 11 - .../Storage/SplitViewAutosaveSweepTests.swift | 5 +- .../Theme/ConnectionIdentityColorTests.swift | 34 - .../Theme/LiteralColorGuardTests.swift | 113 -- TableProTests/Theme/NativeDefaultsTests.swift | 2 +- .../Theme/PaneBackgroundGuardTests.swift | 216 --- TableProTests/Theme/ThemeCatalogTests.swift | 2 +- .../Theme/ThemeColorReadGuardTests.swift | 289 ---- TableProTests/Theme/ThemeContrastTests.swift | 5 +- .../Theme/ThemeSlotCoverageTests.swift | 67 +- TableProTests/Theme/ThemeSourceScan.swift | 234 --- TableProTests/Theme/ValueFontTests.swift | 157 +- .../Utilities/RowSortComparatorTests.swift | 6 - .../Compare/CompareCountedStringTests.swift | 61 - .../SQLCompletionAdapterFuzzyTests.swift | 5 - .../SQLEditorCoordinatorCleanupTests.swift | 20 - .../Views/EmptyTitleControlLabelTests.swift | 104 -- .../HostingViewSizingOptionsTests.swift | 66 +- .../Views/IconOnlyControlLabelTests.swift | 99 -- .../Main/CoordinatorSidebarActionsTests.swift | 109 -- ...EditorTabStripGestureConventionTests.swift | 112 -- .../Main/EditorTabStripGlassGuardTests.swift | 87 -- .../Main/ResultBufferHandoffGuardTests.swift | 65 - .../Views/Main/SaveCompletionTests.swift | 18 - .../Main/StructureActionHandlerTests.swift | 17 - .../Menu/MenuItemImageVisibilityTests.swift | 39 - .../Views/MenuDisclosureIndicatorTests.swift | 260 ---- .../Views/ReduceMotionGateTests.swift | 88 -- .../Views/Results/CellPositionTests.swift | 46 - .../Results/DataGridBodyChromeTests.swift | 39 - .../Views/Results/HexEditorTests.swift | 58 - .../ResultChartLocalizationTests.swift | 46 +- .../Views/Results/StatusBarChromeTests.swift | 23 - .../ValueViewerWindowMinimumSizeTests.swift | 31 - .../GeometryFieldChromeTests.swift | 64 - .../LicenseKeyRowAccessibilityTests.swift | 176 --- .../Sidebar/DatabaseTreeSelectionTests.swift | 9 - .../Views/SidebarNavigationResultTests.swift | 14 - .../Structure/ClickHousePartsGridTests.swift | 6 - .../Views/TrailingPaneHouseRuleTests.swift | 95 -- .../TransferAlertWindowOwnershipTests.swift | 129 -- .../Views/UserFacingEmDashGuardTests.swift | 115 -- .../Views/UserFacingMiddleDotGuardTests.swift | 90 -- TableProUITests/.swiftlint.yml | 16 + TableProUITests/AIProviderPresetUITests.swift | 51 - TableProUITests/AIQueryActionsUITests.swift | 59 - TableProUITests/AgentModeMenuUITests.swift | 124 -- .../AgentModeRoundTripUITests.swift | 72 +- TableProUITests/AgentSessionRailUITests.swift | 97 -- .../AppearanceSettingsUITests.swift | 56 +- .../AuxiliaryWindowCloseUITests.swift | 76 +- .../BackgroundLaunchDockPresenceUITests.swift | 75 +- TableProUITests/BackupScopeSheetUITests.swift | 50 - TableProUITests/CellFilterMenuUITests.swift | 66 - TableProUITests/CellImageViewerUITests.swift | 112 -- .../CloseTabBeforeFirstClickUITests.swift | 64 - .../ColumnDefaultPickerUITests.swift | 119 -- TableProUITests/ColumnJumpUITests.swift | 112 -- TableProUITests/CompareSyncUITests.swift | 77 - .../ConnectionActivityFooterUITests.swift | 32 - TableProUITests/ConnectionCloseUITests.swift | 56 +- .../ConnectionExportOptionsUITests.swift | 93 -- .../ConnectionFormDatabaseFileUITests.swift | 132 -- .../ConnectionFormLocalSocketUITests.swift | 104 -- .../ConnectionFormSidebarUITests.swift | 55 - .../ConnectionFormTLSPortUITests.swift | 126 -- .../ConnectionFormTimeoutUITests.swift | 137 -- .../ConnectionFormTransportUITests.swift | 115 -- .../ConnectionHealthCheckSettingUITests.swift | 32 - .../ConnectionIconPickerUITests.swift | 98 -- .../ConnectionImportReviewUITests.swift | 44 +- .../ConnectionTagEditorUITests.swift | 96 -- .../ConnectionWindowChromeUITests.swift | 201 --- TableProUITests/CopyObjectsUITests.swift | 216 +-- .../CreateTableValidationUITests.swift | 63 - TableProUITests/DataEditUITests.swift | 47 + TableProUITests/DataFileWindowUITests.swift | 173 +-- .../DataGridReloadViewportUITests.swift | 92 -- TableProUITests/DataSettingsUITests.swift | 19 - .../DiagramKeyboardZoomUITests.swift | 89 -- TableProUITests/DiagramPointerUITests.swift | 120 +- TableProUITests/DiagramZoomUITests.swift | 83 -- .../EditorAutocompleteFocusUITests.swift | 159 +- TableProUITests/EditorClipboardUITests.swift | 63 - TableProUITests/EditorTabDetachUITests.swift | 165 +-- .../EditorTabKeepOpenUITests.swift | 112 -- TableProUITests/EditorTabReorderUITests.swift | 261 +--- .../EditorTrailingClickUITests.swift | 54 - TableProUITests/ExportOptionsUITests.swift | 64 - TableProUITests/ExportUITests.swift | 77 + .../ExternalLinkFilterUITests.swift | 13 +- TableProUITests/FeatureTipsUITests.swift | 23 - TableProUITests/FilterBarReturnUITests.swift | 111 +- .../FilterPersistenceUITests.swift | 58 - TableProUITests/FindBarTabSwitchUITests.swift | 112 -- .../ForeignKeyNavigationUITests.swift | 102 +- TableProUITests/ForeignKeyPickerUITests.swift | 195 +-- .../GridSelectionAcrossTabsUITests.swift | 90 -- .../GridTimeZoneOffsetUITests.swift | 106 -- .../GroupedConnectionSwitcherUITests.swift | 105 +- TableProUITests/HeaderSortUITests.swift | 137 +- TableProUITests/HighlightRulesUITests.swift | 159 -- TableProUITests/ImportFromAWSUITests.swift | 81 - .../InspectorEditReachesGridUITests.swift | 99 +- .../InspectorFieldAffordanceUITests.swift | 158 -- .../InspectorGeometryFieldUITests.swift | 130 -- .../InspectorLongTextFieldUITests.swift | 105 -- .../InvisibleCharacterUITests.swift | 59 - TableProUITests/JSONRowInspectorUITests.swift | 165 --- .../JSONTreeSelectionUITests.swift | 148 -- TableProUITests/LicenseSettingsUITests.swift | 55 - .../LoadableExtensionListUITests.swift | 86 -- .../MoveTabToConnectionUITests.swift | 106 -- .../NavigationHistoryUITests.swift | 131 -- .../NewConnectionCommandUITests.swift | 82 +- .../OpenQuicklyCommandUITests.swift | 198 +-- .../PairingDisplayScopeUITests.swift | 48 - TableProUITests/QueryCodeFoldingUITests.swift | 79 - TableProUITests/QueryErrorBannerUITests.swift | 164 +-- ...ueryFormatUnterminatedLiteralUITests.swift | 57 - .../QueryHistoryActionsUITests.swift | 137 -- .../QueryHistoryFocusUITests.swift | 79 - .../QueryHistoryPanelUITests.swift | 121 +- TableProUITests/QueryInsightsTabUITests.swift | 138 -- .../QueryParameterSortUITests.swift | 86 -- TableProUITests/QueryPlanResultUITests.swift | 306 +--- TableProUITests/QueryRunUITests.swift | 342 +---- .../QueryStatementNavigationUITests.swift | 97 -- .../QueryStatementRunUITests.swift | 117 -- .../QueryTabDeleteLineUITests.swift | 76 - .../QuickSwitcherCrossConnectionUITests.swift | 135 -- .../RecentTabSwitchingUITests.swift | 93 +- .../RedisConnectionModeUITests.swift | 150 -- TableProUITests/ReopenClosedTabUITests.swift | 74 +- TableProUITests/ResultChartUITests.swift | 44 - TableProUITests/ResultMapModeUITests.swift | 174 --- TableProUITests/ResultSetPinUITests.swift | 55 - .../ResultStatementLinkUITests.swift | 106 -- TableProUITests/ResultStatusBarUITests.swift | 289 +--- .../RowImportMappingMemoryUITests.swift | 67 - .../RowImportNewTableEditsUITests.swift | 57 - .../RowImportSourceNameUITests.swift | 38 +- .../InspectorJsonFieldEditUITests.swift | 99 -- .../SQLiteFirstTableLoadUITests.swift | 78 +- .../SavedQueryHistoryUITests.swift | 78 - .../SchemaManagementMenuUITests.swift | 41 - TableProUITests/SettingsDeeplinkUITests.swift | 39 +- .../SettingsWindowTitleUITests.swift | 28 - TableProUITests/SidebarAddMenuUITests.swift | 87 +- .../SidebarFavoritesFirstEntryUITests.swift | 40 - .../SidebarSchemaFilterMenuUITests.swift | 28 - .../SidebarSystemContainersUITests.swift | 34 - TableProUITests/SidebarTableTabUITests.swift | 119 +- .../SingleWindowMenuContractUITests.swift | 139 -- .../SoftwareUpdateSettingsUITests.swift | 99 -- .../StructureColumnMoveUITests.swift | 67 - .../StructureConstraintsTabUITests.swift | 48 - .../StructureForeignKeyEditUITests.swift | 80 - .../StructureIndexNameReuseUITests.swift | 134 -- .../StructurePreviewSQLUITests.swift | 73 - .../StructureRowMenuParityUITests.swift | 69 - .../StructureSaveConfirmationUITests.swift | 213 +-- .../StructureTabIdentityUITests.swift | 57 - .../StructureTypelessColumnUITests.swift | 87 -- TableProUITests/Support/AppShortcut.swift | 70 + .../Support/SeededSQLiteSession.swift | 136 +- .../Support/UITestCase+EditorTabs.swift | 62 + .../Support/UITestCase+FixtureGrid.swift | 146 ++ .../Support/UITestCase+RowImport.swift | 58 +- .../Support/UITestCase+Welcome.swift | 51 + TableProUITests/Support/UITestCase.swift | 464 ++---- TableProUITests/Support/UITestFixture.swift | 29 + TableProUITests/SupportWindowUITests.swift | 89 -- ...itchConnectionAfterDisconnectUITests.swift | 59 - TableProUITests/SwitcherEscapeUITests.swift | 57 - .../SwitcherWithoutToolbarAnchorUITests.swift | 82 -- .../TableChangeReloadUITests.swift | 411 +----- TableProUITests/TableFolderUITests.swift | 106 -- TableProUITests/TableInfoTriggerUITests.swift | 34 - .../TableLoadKeepsGridUITests.swift | 107 -- TableProUITests/TableProLaunchUITests.swift | 20 - .../TrailingPaneSurfaceUITests.swift | 164 --- TableProUITests/ValueFilterEditUITests.swift | 248 +--- .../VimNormalModeChordUITests.swift | 134 +- TableProUITests/WarmTabSwitchUITests.swift | 108 +- .../WelcomeConnectionListUITests.swift | 193 +-- TableProUITests/WelcomeWindowUITests.swift | 51 +- .../WindowExecutionIndicatorUITests.swift | 65 - TableProUITests/WindowFocusUITests.swift | 134 +- project.yml | 349 +---- scripts/check-mongodb-filter-shapes.sh | 211 --- scripts/ci/check-entitlements.py | 32 + scripts/ci/check-license-inventory.py | 113 ++ scripts/ci/check-log-privacy.py | 86 -- scripts/ci/check-plugin-os-import.py | 78 - scripts/ci/check-string-catalogs.py | 124 ++ scripts/ci/check-test-suite-attributes.py | 273 ---- scripts/ci/test_check_log_privacy.py | 80 - scripts/ci/test_check_string_catalogs.py | 72 + .../ci/test_check_test_suite_attributes.py | 162 -- scripts/dynamodb-test-local.sh | 55 - scripts/generate-ios-acknowledgements.py | 2 +- scripts/kafka-test-broker.sh | 147 -- 712 files changed, 3217 insertions(+), 97371 deletions(-) delete mode 100644 Native/HanaBridge/internal/frame/frame_test.go delete mode 100644 Native/HanaBridge/internal/hana/bridge_test.go delete mode 100644 Native/HanaBridge/internal/hana/config_test.go delete mode 100644 Native/HanaBridge/internal/hana/control_test.go delete mode 100644 Native/HanaBridge/internal/hana/dialer_test.go delete mode 100644 Native/HanaBridge/internal/hana/envelope_test.go delete mode 100644 Native/HanaBridge/internal/hana/errors_test.go delete mode 100644 Native/HanaBridge/internal/hana/explain_test.go delete mode 100644 Native/HanaBridge/internal/hana/lob_test.go delete mode 100644 Native/HanaBridge/internal/hana/opslot_test.go delete mode 100644 Native/HanaBridge/internal/hana/params_test.go delete mode 100644 Native/HanaBridge/internal/hana/registry_test.go delete mode 100644 Native/HanaBridge/internal/hana/routing_test.go delete mode 100644 Native/HanaBridge/internal/hana/scripted_driver_test.go delete mode 100644 Native/HanaBridge/internal/hana/session_test.go delete mode 100644 Native/HanaBridge/internal/hana/statement_test.go delete mode 100644 Native/HanaBridge/internal/hana/tls_test.go delete mode 100644 Native/HanaBridge/internal/hana/values_test.go delete mode 100644 Native/HanaBridge/internal/hana/watchdog_test.go delete mode 100644 Native/HanaBridge/process_test.go delete mode 100644 Native/HanaBridge/server_test.go delete mode 100644 Packages/TableProCore/Tests/TableProDocumentPathTests/DocumentPathTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseClientTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseEmulatorTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseJSONTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseSettingsAndStatementTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseTestSupport.swift delete mode 100644 Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreCellAndQueryTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreValueTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProFirebaseCoreTests/RealtimeDatabaseTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleSQLLiteralTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProGoogleCloudTests/SQLPlaceholderRewriterTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLURLSessionTransportTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerAPIErrorTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerBrowseRendererTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerCatalogTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerConnectionSettingsTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerDDLCatalogTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerDialectTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerExecutorEmulatorTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerExecutorTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerFakeServer.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerParameterEncoderTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerPartialResultAssemblerTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerPlanRendererTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRESTClientEmulatorTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRESTClientTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRowEditSQLTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerSchemaNameTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerStatementClassifierTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerStreamFramerTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerTestSupport.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerValueDecoderTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerWireDecodingTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSyncTests/SyncMapperFieldAccessTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProSyncTests/SyncRecordNameConstructionTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/BigUIntTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/DHVectors.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/DerTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/DiffieHellmanTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/GcmCipherTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/LanMessageTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/ParcelTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/RecordDecoderDecimalTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/RecordDecoderTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataColumnTypeTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataConnectTimeoutTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataExecuteLoopTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataLogMechTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataTLSTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTeradataCoreTests/WebSocketFrameTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoClientConfigTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoDDLSQLTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoJSONValueTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoLiteralTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoMutualTLSTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoQueryResultsDecodingTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoRedirectPolicyTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoRowEditSQLTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoSessionStateTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementClientTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementSplitterTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStreamingTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTLSFailureTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTypeMapperTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoURLSessionTransportTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoValueDecoderTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateAuthAndQueryTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateClientTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateFilterAndPathTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateObjectPagesTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateOperationsTests.swift delete mode 100644 Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateTestSupport.swift create mode 100644 Plugins/.swiftlint.yml delete mode 100644 Plugins/DamengDriverPluginTests/DamengConnectionRecoveryTests.swift delete mode 100644 Plugins/DamengDriverPluginTests/DamengPluginDriverTests.swift delete mode 100644 Plugins/DamengDriverPluginTests/DamengScriptSplitterTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaBridgeContractTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaCatalogTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaConnectionTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaExplainStatementTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaFailureMappingTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaFakeBridge.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaFakeSession.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaHelperBridgeTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaHelperFrameTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaHelperPipeTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaHelperProcessTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaHelperTestSupport.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaHelperTrustTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaOperationSlotTests.swift delete mode 100644 Plugins/HanaDriverPluginTests/HanaPluginDriverTests.swift create mode 100644 Plugins/TableProPluginKit/.swiftlint.yml delete mode 100644 TablePro/Core/Services/Infrastructure/UITestJsonFixture.swift create mode 100644 TableProMobile/.swiftlint.yml delete mode 100644 TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift delete mode 100644 TableProMobile/TableProMobileTests/Drivers/OracleDriverTests.swift delete mode 100644 TableProMobile/TableProMobileTests/Drivers/PostgreSQLDriverCatalogTests.swift delete mode 100644 TableProMobile/TableProMobileTests/OffMainActorHandlerGuardTests.swift create mode 100644 TableProTests/.swiftlint.yml delete mode 100644 TableProTests/Core/Autocomplete/MongoCompletionCaseTests.swift delete mode 100644 TableProTests/Core/Autocomplete/MongoContextAnalyzerTests.swift delete mode 100644 TableProTests/Core/Autocomplete/MongoVocabularyConstructorTests.swift delete mode 100644 TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift delete mode 100644 TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift delete mode 100644 TableProTests/Core/CloudflareD1/CloudflareD1PluginMetadataTests.swift delete mode 100644 TableProTests/Core/CloudflareD1/D1HttpClientCancellationTests.swift delete mode 100644 TableProTests/Core/CloudflareD1/D1ResponseParsingTests.swift delete mode 100644 TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift delete mode 100644 TableProTests/Core/Compare/CompareCapabilityDeclarationTests.swift delete mode 100644 TableProTests/Core/Concurrency/OffMainActorHandlerGuardTests.swift delete mode 100644 TableProTests/Core/DataWrite/FieldAbsenceWriteTests.swift delete mode 100644 TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift delete mode 100644 TableProTests/Core/Database/MSSQLDriverTests.swift delete mode 100644 TableProTests/Core/Database/SchemaCompositionGuardTests.swift delete mode 100644 TableProTests/Core/Diagnostics/MongoDiagnosticsProducerTests.swift delete mode 100644 TableProTests/Core/EllipsisConventionTests.swift delete mode 100644 TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift delete mode 100644 TableProTests/Core/Execution/TabQueryTaskGuardTests.swift delete mode 100644 TableProTests/Core/MCP/MCPReportedStatusTests.swift delete mode 100644 TableProTests/Core/Menu/ChromeSnapshotTests.swift delete mode 100644 TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift delete mode 100644 TableProTests/Core/MongoDB/BsonFieldPathArrayTests.swift delete mode 100644 TableProTests/Core/MongoDB/BsonFieldPathTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoDBExtendedJsonTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoDBFindLimitPolicyTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoDBGeneratedDDLContainmentTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoDBSrvHostTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoDBTimeoutPolicyTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoScriptCommandTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoScriptDefinitionRoundTripTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoScriptJsonEscapeTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoScriptPreludeMemberTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoScriptPreludeTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoScriptPreludeViewTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoScriptReadEnforcementTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoShellParserChainedMethodTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoShellParserTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoShellValueTests.swift delete mode 100644 TableProTests/Core/MongoDB/MongoShellValueTranslatorTests.swift delete mode 100644 TableProTests/Core/Plugins/OracleCuratedParityTests.swift delete mode 100644 TableProTests/Core/Plugins/PingNeverReconnectsTests.swift delete mode 100644 TableProTests/Core/Plugins/PluginSignatureGatePlacementTests.swift delete mode 100644 TableProTests/Core/Plugins/SQLServerImportBatchTests.swift delete mode 100644 TableProTests/Core/Redis/SidebarRedisCommandsTests.swift delete mode 100644 TableProTests/Core/SSH/LibSSH2SFTPSessionIntegrationTests.swift delete mode 100644 TableProTests/Core/SSH/TestSupport/SFTPTestServer.swift delete mode 100644 TableProTests/Core/SchemaTracking/StructureChangeManagerClusteredIndexTests.swift delete mode 100644 TableProTests/Core/ServerDashboard/TypesenseDashboardProviderTests.swift delete mode 100644 TableProTests/Core/Services/Formatting/MongoShellFormatterTests.swift delete mode 100644 TableProTests/Core/Services/ImportStateTests.swift delete mode 100644 TableProTests/Core/Services/Infrastructure/AppActivationPolicySourceTests.swift delete mode 100644 TableProTests/Core/Services/Infrastructure/ToolbarSymbolConventionTests.swift delete mode 100644 TableProTests/Core/Services/Query/DamengPlanParserTests.swift delete mode 100644 TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift delete mode 100644 TableProTests/Core/SiteWordingTests.swift delete mode 100644 TableProTests/Core/Storage/AIChatStorageTests.swift delete mode 100644 TableProTests/Core/Sync/EntitlementsEnvironmentParityTests.swift delete mode 100644 TableProTests/Core/Utilities/ConnectionURLParserMSSQLTests.swift delete mode 100644 TableProTests/Database/MongoToolsConnectionStringTests.swift delete mode 100644 TableProTests/Database/SQLServerDumpTests.swift delete mode 100644 TableProTests/Helpers/SQLTestHelpers.swift create mode 100644 TableProTests/Helpers/StringCatalog.swift delete mode 100644 TableProTests/Helpers/UserFacingSourceScan.swift delete mode 100644 TableProTests/Localization/StringCatalogIntegrityTests.swift delete mode 100644 TableProTests/Models/DatabaseTypeCassandraTests.swift delete mode 100644 TableProTests/Models/DatabaseTypeDatabendTests.swift delete mode 100644 TableProTests/Models/DatabaseTypeMSSQLTests.swift delete mode 100644 TableProTests/Models/DatabaseTypeOceanBaseTests.swift delete mode 100644 TableProTests/Models/DatabaseTypeRedisTests.swift delete mode 100644 TableProTests/Models/DatabaseTypeTiDBTests.swift delete mode 100644 TableProTests/Models/Query/ResultSetOwnershipTests.swift delete mode 100644 TableProTests/Plugins/BeancountConnectTimeoutTests.swift delete mode 100644 TableProTests/Plugins/BeancountDriverMetadataTests.swift delete mode 100644 TableProTests/Plugins/BeancountIncludeResolverTests.swift delete mode 100644 TableProTests/Plugins/BeancountPluginDriverTests.swift delete mode 100644 TableProTests/Plugins/BeancountProjectionTests.swift delete mode 100644 TableProTests/Plugins/BeancountRledgerOutputTests.swift delete mode 100644 TableProTests/Plugins/BigQueryCredentialFactoryTests.swift delete mode 100644 TableProTests/Plugins/BigQueryQueryBuilderTests.swift delete mode 100644 TableProTests/Plugins/BigQueryQueryParametersTests.swift delete mode 100644 TableProTests/Plugins/BigQueryStatementGeneratorTests.swift delete mode 100644 TableProTests/Plugins/BigQueryTypeMapperTests.swift delete mode 100644 TableProTests/Plugins/CassandraBrowseClassificationTests.swift delete mode 100644 TableProTests/Plugins/CassandraBrowseStatementTests.swift delete mode 100644 TableProTests/Plugins/CassandraCommentStatementTests.swift delete mode 100644 TableProTests/Plugins/CassandraConnectTimeoutTests.swift delete mode 100644 TableProTests/Plugins/CassandraObjectTextTests.swift delete mode 100644 TableProTests/Plugins/CassandraResumePointsTests.swift delete mode 100644 TableProTests/Plugins/CassandraRowWriterTests.swift delete mode 100644 TableProTests/Plugins/CassandraValueParserTests.swift delete mode 100644 TableProTests/Plugins/CloudPluginConnectionFieldsTests.swift delete mode 100644 TableProTests/Plugins/CloudflareR2SQLConnectTimeoutTests.swift delete mode 100644 TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift delete mode 100644 TableProTests/Plugins/DamengConnectTimeoutTests.swift delete mode 100644 TableProTests/Plugins/DamengParameterBinderTests.swift delete mode 100644 TableProTests/Plugins/DamengStatementClassifierTests.swift delete mode 100644 TableProTests/Plugins/DamengSystemSchemasTests.swift delete mode 100644 TableProTests/Plugins/DocumentStoreCaseSensitivityTests.swift delete mode 100644 TableProTests/Plugins/DuckDBConnectAttemptTests.swift delete mode 100644 TableProTests/Plugins/DuckDBConnectionFieldsTests.swift delete mode 100644 TableProTests/Plugins/DuckDBFileKindsTests.swift delete mode 100644 TableProTests/Plugins/DuckDBIdleReleaseTests.swift delete mode 100644 TableProTests/Plugins/DuckDBIndexClausesTests.swift delete mode 100644 TableProTests/Plugins/DuckDBLockConflictTests.swift delete mode 100644 TableProTests/Plugins/DuckDBPositionParserTests.swift delete mode 100644 TableProTests/Plugins/DuckDBQuackConnectTests.swift delete mode 100644 TableProTests/Plugins/DuckDBSchemaQueriesTests.swift delete mode 100644 TableProTests/Plugins/DuckDBTransactionProbeTests.swift delete mode 100644 TableProTests/Plugins/DuckDBTypeRenderingTests.swift delete mode 100644 TableProTests/Plugins/DuckDBViewDefinitionTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBAccessPlannerTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBAttributeValueTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBCatalogTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBCellCodecTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBClientTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBDriverTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBEndpointTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBErrorTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBExpressionTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBFilterTranslatorTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBItemTableTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBJSONTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBLocalIntegrationTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBNumberTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBParameterBinderTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBPartiQLTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBRetryPolicyTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBSignerTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBStatementTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBTableManagementTests.swift delete mode 100644 TableProTests/Plugins/DynamoDB/DynamoDBWriteStatementsTests.swift delete mode 100644 TableProTests/Plugins/DynamoDBMetadataParityTests.swift delete mode 100644 TableProTests/Plugins/ElasticsearchConnectionFieldsTests.swift delete mode 100644 TableProTests/Plugins/ElasticsearchDriverTests.swift delete mode 100644 TableProTests/Plugins/ElasticsearchNodesTests.swift delete mode 100644 TableProTests/Plugins/ElasticsearchOperationsTests.swift delete mode 100644 TableProTests/Plugins/EtcdCommandArgumentTests.swift delete mode 100644 TableProTests/Plugins/EtcdGatewayRouteTests.swift delete mode 100644 TableProTests/Plugins/EtcdHttpClientUtilityTests.swift delete mode 100644 TableProTests/Plugins/EtcdKeyspaceTests.swift delete mode 100644 TableProTests/Plugins/EtcdLeaseIDTests.swift delete mode 100644 TableProTests/Plugins/EtcdQueryBuilderTests.swift delete mode 100644 TableProTests/Plugins/EtcdRequestRecoveryTests.swift delete mode 100644 TableProTests/Plugins/EtcdRowFilterTests.swift delete mode 100644 TableProTests/Plugins/EtcdServerFaultTests.swift delete mode 100644 TableProTests/Plugins/EtcdServerTrustTests.swift delete mode 100644 TableProTests/Plugins/EtcdStatementGeneratorTests.swift delete mode 100644 TableProTests/Plugins/FirebaseConnectionFieldsTests.swift delete mode 100644 TableProTests/Plugins/FirebaseDriverTests.swift delete mode 100644 TableProTests/Plugins/HranaHttpClientArgumentEncodingTests.swift delete mode 100644 TableProTests/Plugins/HranaHttpClientCancellationTests.swift delete mode 100644 TableProTests/Plugins/IndexDDLOwnershipTests.swift delete mode 100644 TableProTests/Plugins/KafkaBootstrapEndpointsTests.swift delete mode 100644 TableProTests/Plugins/KafkaCompressionTests.swift delete mode 100644 TableProTests/Plugins/KafkaConnectionFieldTests.swift delete mode 100644 TableProTests/Plugins/KafkaIntegrationTests.swift delete mode 100644 TableProTests/Plugins/KafkaMessageFlattenerTests.swift delete mode 100644 TableProTests/Plugins/KafkaProtocolCodecTests.swift delete mode 100644 TableProTests/Plugins/KafkaQLTests.swift delete mode 100644 TableProTests/Plugins/KafkaRecordBatchTests.swift delete mode 100644 TableProTests/Plugins/KafkaRoutingTests.swift delete mode 100644 TableProTests/Plugins/KafkaSASLTests.swift delete mode 100644 TableProTests/Plugins/KafkaTLSConfigurationTests.swift delete mode 100644 TableProTests/Plugins/KafkaTestBroker.swift delete mode 100644 TableProTests/Plugins/LibPQPluginConnectionSourceScanTests.swift delete mode 100644 TableProTests/Plugins/LibSQLBusyTimeoutTests.swift delete mode 100644 TableProTests/Plugins/LibSQLConnectionFieldsTests.swift delete mode 100644 TableProTests/Plugins/LibSQLRelationDDLTests.swift delete mode 100644 TableProTests/Plugins/MSSQLEntraConnectSessionTests.swift delete mode 100644 TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift delete mode 100644 TableProTests/Plugins/MSSQLSSLMappingTests.swift delete mode 100644 TableProTests/Plugins/MSSQLSessionTransactionTests.swift delete mode 100644 TableProTests/Plugins/MSSQLStatementGeneratorTests.swift delete mode 100644 TableProTests/Plugins/MSSQLTableDefinitionSQLTests.swift delete mode 100644 TableProTests/Plugins/MSSQLTypeQueryTests.swift delete mode 100644 TableProTests/Plugins/MongoBsonAssemblyTests.swift delete mode 100644 TableProTests/Plugins/MongoDBAuthSourceResolverTests.swift delete mode 100644 TableProTests/Plugins/MongoDBCollectionDDLTests.swift delete mode 100644 TableProTests/Plugins/MongoDBCollectionSchemaTests.swift delete mode 100644 TableProTests/Plugins/MongoDBCreateDatabasePlanTests.swift delete mode 100644 TableProTests/Plugins/MongoDBFieldNameUpdateTests.swift delete mode 100644 TableProTests/Plugins/MongoDBFieldPresenceWriteTests.swift delete mode 100644 TableProTests/Plugins/MongoDBIndexEntryTests.swift delete mode 100644 TableProTests/Plugins/MongoDBJsonLayoutTests.swift delete mode 100644 TableProTests/Plugins/MongoDBNameValidatorTests.swift delete mode 100644 TableProTests/Plugins/MongoDBNamespaceEntryTests.swift delete mode 100644 TableProTests/Plugins/MongoDBNestedFilterTests.swift delete mode 100644 TableProTests/Plugins/MongoDBNestedValueWriteTests.swift delete mode 100644 TableProTests/Plugins/MongoDBQueryBuilderTests.swift delete mode 100644 TableProTests/Plugins/MongoDBSSLMappingTests.swift delete mode 100644 TableProTests/Plugins/MongoDBShellLiteralTests.swift delete mode 100644 TableProTests/Plugins/MongoDBShellTextTests.swift delete mode 100644 TableProTests/Plugins/MongoDBStatementGeneratorTests.swift delete mode 100644 TableProTests/Plugins/MongoDBStructureEditingParityTests.swift delete mode 100644 TableProTests/Plugins/MongoDBWriteBackBinaryTests.swift delete mode 100644 TableProTests/Plugins/MongoDBWriteBackTypeTests.swift delete mode 100644 TableProTests/Plugins/MongoDocumentGuardTests.swift delete mode 100644 TableProTests/Plugins/MongoDocumentIdentityTests.swift delete mode 100644 TableProTests/Plugins/MongoDocumentPresentationTests.swift delete mode 100644 TableProTests/Plugins/MongoDocumentReplacementTests.swift delete mode 100644 TableProTests/Plugins/MongoDocumentTextTests.swift delete mode 100644 TableProTests/Plugins/MongoDocumentWritePlanTests.swift delete mode 100644 TableProTests/Plugins/MongoEditableDocumentTests.swift delete mode 100644 TableProTests/Plugins/MongoFieldCensusTests.swift delete mode 100644 TableProTests/Plugins/MongoFieldChangeAssessmentTests.swift delete mode 100644 TableProTests/Plugins/MongoFieldChangeTests.swift delete mode 100644 TableProTests/Plugins/MongoFieldDataProbeTests.swift delete mode 100644 TableProTests/Plugins/MongoFieldReferencesTests.swift delete mode 100644 TableProTests/Plugins/MongoInsertOptionsTests.swift delete mode 100644 TableProTests/Plugins/MongoResultRebuildTests.swift delete mode 100644 TableProTests/Plugins/MongoScriptAccessPolicyTests.swift delete mode 100644 TableProTests/Plugins/MongoSearchIndexTests.swift delete mode 100644 TableProTests/Plugins/MongoStreamProjectionTests.swift delete mode 100644 TableProTests/Plugins/MongoWriteFailureTests.swift delete mode 100644 TableProTests/Plugins/MongoWriteLedgerTests.swift delete mode 100644 TableProTests/Plugins/MySQLResultSynthesisSourceScanTests.swift delete mode 100644 TableProTests/Plugins/OracleBindPlaceholdersTests.swift delete mode 100644 TableProTests/Plugins/OracleBrowseSQLTests.swift delete mode 100644 TableProTests/Plugins/OracleColumnStatementsTests.swift delete mode 100644 TableProTests/Plugins/OracleCommentStatementTests.swift delete mode 100644 TableProTests/Plugins/OracleConnectionErrorTests.swift delete mode 100644 TableProTests/Plugins/OracleNationalTextTests.swift delete mode 100644 TableProTests/Plugins/OracleObjectQueriesQualificationTests.swift delete mode 100644 TableProTests/Plugins/OraclePartitionMappingTests.swift delete mode 100644 TableProTests/Plugins/OracleRowWriterTests.swift delete mode 100644 TableProTests/Plugins/OracleTableDDLTests.swift delete mode 100644 TableProTests/Plugins/OracleTypeCatalogTests.swift delete mode 100644 TableProTests/Plugins/ParquetExportCancellationTests.swift delete mode 100644 TableProTests/Plugins/PluginURLSessionTaskBoxTests.swift delete mode 100644 TableProTests/Plugins/SnowflakeAuthTests.swift delete mode 100644 TableProTests/Plugins/SnowflakeGeneratorTests.swift delete mode 100644 TableProTests/Plugins/SnowflakeImportedKeysTests.swift delete mode 100644 TableProTests/Plugins/SnowflakeProtocolTests.swift delete mode 100644 TableProTests/Plugins/SnowflakeSQLTests.swift delete mode 100644 TableProTests/Plugins/SnowflakeSessionKeyTests.swift delete mode 100644 TableProTests/Plugins/SnowflakeStatementTypeTests.swift delete mode 100644 TableProTests/Plugins/SnowflakeTypeMapperTests.swift delete mode 100644 TableProTests/Plugins/SnowflakeValueDecoderTests.swift delete mode 100644 TableProTests/Plugins/SpannerRegistrySnapshotTests.swift delete mode 100644 TableProTests/Plugins/SurrealDBCBORTests.swift delete mode 100644 TableProTests/Plugins/SurrealDBDriverTests.swift delete mode 100644 TableProTests/Plugins/SurrealDBMetadataTests.swift delete mode 100644 TableProTests/Plugins/TeradataRowEditSQLTests.swift delete mode 100644 TableProTests/Plugins/TeradataSSLMappingTests.swift delete mode 100644 TableProTests/Plugins/TrinoRowEditValuesTests.swift delete mode 100644 TableProTests/Plugins/TrinoSSLMappingTests.swift delete mode 100644 TableProTests/Plugins/TypesenseConnectionFieldsTests.swift delete mode 100644 TableProTests/Plugins/TypesenseDriverTests.swift delete mode 100644 TableProTests/Plugins/TypesenseOperationsConsoleTextTests.swift delete mode 100644 TableProTests/Plugins/VendoredSQLiteImportTests.swift delete mode 100644 TableProTests/Plugins/WeaviateConnectionFieldsTests.swift delete mode 100644 TableProTests/Services/MenuQueryScopeGuardTests.swift delete mode 100644 TableProTests/Services/ToolbarSourceAccessTests.swift delete mode 100644 TableProTests/Theme/LiteralColorGuardTests.swift delete mode 100644 TableProTests/Theme/PaneBackgroundGuardTests.swift delete mode 100644 TableProTests/Theme/ThemeColorReadGuardTests.swift delete mode 100644 TableProTests/Theme/ThemeSourceScan.swift delete mode 100644 TableProTests/Views/EmptyTitleControlLabelTests.swift delete mode 100644 TableProTests/Views/IconOnlyControlLabelTests.swift delete mode 100644 TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift delete mode 100644 TableProTests/Views/Main/EditorTabStripGestureConventionTests.swift delete mode 100644 TableProTests/Views/Main/EditorTabStripGlassGuardTests.swift delete mode 100644 TableProTests/Views/Main/ResultBufferHandoffGuardTests.swift delete mode 100644 TableProTests/Views/MenuDisclosureIndicatorTests.swift delete mode 100644 TableProTests/Views/ReduceMotionGateTests.swift delete mode 100644 TableProTests/Views/RowInspector/GeometryFieldChromeTests.swift delete mode 100644 TableProTests/Views/Settings/LicenseKeyRowAccessibilityTests.swift delete mode 100644 TableProTests/Views/TrailingPaneHouseRuleTests.swift delete mode 100644 TableProTests/Views/TransferAlertWindowOwnershipTests.swift delete mode 100644 TableProTests/Views/UserFacingEmDashGuardTests.swift delete mode 100644 TableProTests/Views/UserFacingMiddleDotGuardTests.swift create mode 100644 TableProUITests/.swiftlint.yml delete mode 100644 TableProUITests/AIProviderPresetUITests.swift delete mode 100644 TableProUITests/AIQueryActionsUITests.swift delete mode 100644 TableProUITests/AgentModeMenuUITests.swift delete mode 100644 TableProUITests/AgentSessionRailUITests.swift delete mode 100644 TableProUITests/BackupScopeSheetUITests.swift delete mode 100644 TableProUITests/CellFilterMenuUITests.swift delete mode 100644 TableProUITests/CellImageViewerUITests.swift delete mode 100644 TableProUITests/CloseTabBeforeFirstClickUITests.swift delete mode 100644 TableProUITests/ColumnDefaultPickerUITests.swift delete mode 100644 TableProUITests/ColumnJumpUITests.swift delete mode 100644 TableProUITests/CompareSyncUITests.swift delete mode 100644 TableProUITests/ConnectionActivityFooterUITests.swift delete mode 100644 TableProUITests/ConnectionExportOptionsUITests.swift delete mode 100644 TableProUITests/ConnectionFormDatabaseFileUITests.swift delete mode 100644 TableProUITests/ConnectionFormLocalSocketUITests.swift delete mode 100644 TableProUITests/ConnectionFormSidebarUITests.swift delete mode 100644 TableProUITests/ConnectionFormTLSPortUITests.swift delete mode 100644 TableProUITests/ConnectionFormTimeoutUITests.swift delete mode 100644 TableProUITests/ConnectionFormTransportUITests.swift delete mode 100644 TableProUITests/ConnectionHealthCheckSettingUITests.swift delete mode 100644 TableProUITests/ConnectionIconPickerUITests.swift delete mode 100644 TableProUITests/ConnectionTagEditorUITests.swift delete mode 100644 TableProUITests/ConnectionWindowChromeUITests.swift delete mode 100644 TableProUITests/CreateTableValidationUITests.swift create mode 100644 TableProUITests/DataEditUITests.swift delete mode 100644 TableProUITests/DataGridReloadViewportUITests.swift delete mode 100644 TableProUITests/DataSettingsUITests.swift delete mode 100644 TableProUITests/DiagramKeyboardZoomUITests.swift delete mode 100644 TableProUITests/DiagramZoomUITests.swift delete mode 100644 TableProUITests/EditorClipboardUITests.swift delete mode 100644 TableProUITests/EditorTabKeepOpenUITests.swift delete mode 100644 TableProUITests/EditorTrailingClickUITests.swift delete mode 100644 TableProUITests/ExportOptionsUITests.swift create mode 100644 TableProUITests/ExportUITests.swift delete mode 100644 TableProUITests/FeatureTipsUITests.swift delete mode 100644 TableProUITests/FilterPersistenceUITests.swift delete mode 100644 TableProUITests/FindBarTabSwitchUITests.swift delete mode 100644 TableProUITests/GridSelectionAcrossTabsUITests.swift delete mode 100644 TableProUITests/GridTimeZoneOffsetUITests.swift delete mode 100644 TableProUITests/HighlightRulesUITests.swift delete mode 100644 TableProUITests/ImportFromAWSUITests.swift delete mode 100644 TableProUITests/InspectorFieldAffordanceUITests.swift delete mode 100644 TableProUITests/InspectorGeometryFieldUITests.swift delete mode 100644 TableProUITests/InspectorLongTextFieldUITests.swift delete mode 100644 TableProUITests/InvisibleCharacterUITests.swift delete mode 100644 TableProUITests/JSONRowInspectorUITests.swift delete mode 100644 TableProUITests/JSONTreeSelectionUITests.swift delete mode 100644 TableProUITests/LicenseSettingsUITests.swift delete mode 100644 TableProUITests/LoadableExtensionListUITests.swift delete mode 100644 TableProUITests/MoveTabToConnectionUITests.swift delete mode 100644 TableProUITests/NavigationHistoryUITests.swift delete mode 100644 TableProUITests/PairingDisplayScopeUITests.swift delete mode 100644 TableProUITests/QueryCodeFoldingUITests.swift delete mode 100644 TableProUITests/QueryFormatUnterminatedLiteralUITests.swift delete mode 100644 TableProUITests/QueryHistoryActionsUITests.swift delete mode 100644 TableProUITests/QueryHistoryFocusUITests.swift delete mode 100644 TableProUITests/QueryInsightsTabUITests.swift delete mode 100644 TableProUITests/QueryParameterSortUITests.swift delete mode 100644 TableProUITests/QueryStatementNavigationUITests.swift delete mode 100644 TableProUITests/QueryStatementRunUITests.swift delete mode 100644 TableProUITests/QueryTabDeleteLineUITests.swift delete mode 100644 TableProUITests/QuickSwitcherCrossConnectionUITests.swift delete mode 100644 TableProUITests/RedisConnectionModeUITests.swift delete mode 100644 TableProUITests/ResultChartUITests.swift delete mode 100644 TableProUITests/ResultMapModeUITests.swift delete mode 100644 TableProUITests/ResultSetPinUITests.swift delete mode 100644 TableProUITests/ResultStatementLinkUITests.swift delete mode 100644 TableProUITests/RowImportMappingMemoryUITests.swift delete mode 100644 TableProUITests/RowImportNewTableEditsUITests.swift delete mode 100644 TableProUITests/RowInspector/InspectorJsonFieldEditUITests.swift delete mode 100644 TableProUITests/SavedQueryHistoryUITests.swift delete mode 100644 TableProUITests/SchemaManagementMenuUITests.swift delete mode 100644 TableProUITests/SettingsWindowTitleUITests.swift delete mode 100644 TableProUITests/SidebarFavoritesFirstEntryUITests.swift delete mode 100644 TableProUITests/SidebarSchemaFilterMenuUITests.swift delete mode 100644 TableProUITests/SidebarSystemContainersUITests.swift delete mode 100644 TableProUITests/SingleWindowMenuContractUITests.swift delete mode 100644 TableProUITests/SoftwareUpdateSettingsUITests.swift delete mode 100644 TableProUITests/StructureColumnMoveUITests.swift delete mode 100644 TableProUITests/StructureConstraintsTabUITests.swift delete mode 100644 TableProUITests/StructureForeignKeyEditUITests.swift delete mode 100644 TableProUITests/StructureIndexNameReuseUITests.swift delete mode 100644 TableProUITests/StructurePreviewSQLUITests.swift delete mode 100644 TableProUITests/StructureRowMenuParityUITests.swift delete mode 100644 TableProUITests/StructureTabIdentityUITests.swift delete mode 100644 TableProUITests/StructureTypelessColumnUITests.swift create mode 100644 TableProUITests/Support/AppShortcut.swift create mode 100644 TableProUITests/Support/UITestCase+EditorTabs.swift create mode 100644 TableProUITests/Support/UITestCase+FixtureGrid.swift create mode 100644 TableProUITests/Support/UITestCase+Welcome.swift create mode 100644 TableProUITests/Support/UITestFixture.swift delete mode 100644 TableProUITests/SupportWindowUITests.swift delete mode 100644 TableProUITests/SwitchConnectionAfterDisconnectUITests.swift delete mode 100644 TableProUITests/SwitcherEscapeUITests.swift delete mode 100644 TableProUITests/SwitcherWithoutToolbarAnchorUITests.swift delete mode 100644 TableProUITests/TableFolderUITests.swift delete mode 100644 TableProUITests/TableInfoTriggerUITests.swift delete mode 100644 TableProUITests/TableLoadKeepsGridUITests.swift delete mode 100644 TableProUITests/TableProLaunchUITests.swift delete mode 100644 TableProUITests/TrailingPaneSurfaceUITests.swift delete mode 100644 TableProUITests/WindowExecutionIndicatorUITests.swift delete mode 100755 scripts/check-mongodb-filter-shapes.sh create mode 100755 scripts/ci/check-entitlements.py create mode 100755 scripts/ci/check-license-inventory.py delete mode 100644 scripts/ci/check-log-privacy.py delete mode 100755 scripts/ci/check-plugin-os-import.py create mode 100755 scripts/ci/check-string-catalogs.py delete mode 100644 scripts/ci/check-test-suite-attributes.py delete mode 100644 scripts/ci/test_check_log_privacy.py create mode 100644 scripts/ci/test_check_string_catalogs.py delete mode 100644 scripts/ci/test_check_test_suite_attributes.py delete mode 100755 scripts/dynamodb-test-local.sh delete mode 100755 scripts/kafka-test-broker.sh diff --git a/.swiftlint.yml b/.swiftlint.yml index 569e8de0ab..79e4bb0fe9 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,12 +1,11 @@ -# SwiftLint configuration -# https://github.com/realm/SwiftLint - -# Paths to include during linting included: - TablePro - Packages + - Plugins + - TableProMobile + - TableProTests + - TableProUITests -# Paths to exclude during linting. # `.build` has to be globbed: SwiftLint resolves an `excluded:` entry against this file's own # directory, so a bare `.build` misses `Packages/*/.build`, and linting a resolved dependency's # checkout reports tens of thousands of violations in code nobody here wrote. @@ -17,7 +16,6 @@ excluded: - Carthage - vendor -# Enabled rules (beyond default) opt_in_rules: - closure_end_indentation - collection_alignment @@ -75,7 +73,6 @@ opt_in_rules: - xct_specific_matcher - yoda_condition -# Disabled rules disabled_rules: - trailing_comma - todo @@ -89,7 +86,6 @@ disabled_rules: - static_over_final_class - force_try -# Rule configurations line_length: warning: 180 error: 300 @@ -180,7 +176,6 @@ colon: comma: warning -# No print statements is now disabled - use Logger in the future custom_rules: storage_environment_directory: name: "Storage Environment Directory" @@ -199,3 +194,468 @@ custom_rules: regex: '(\.(localizedDescription|errorDescription|failureReason)(\.prefix\(\d+\))?(\s*\?\?\s*"[^"]*")?|String\(describing:\s*\w*[eE]rr(or)?\)|\\\((?!(has|is|did|was|should)[A-Z])(\w*[eE]rr(or)?|e)(\.(message|description|debugDescription))?)\s*,\s*privacy:\s*\.public' message: "A driver or Foundation error's text carries row values, paths and server messages. Publish `error.publicLogShape`, or `LogRedaction.publicDescription(of: error)` in a plugin, and log the description at .private." severity: error + + # Storage and preferences + preference_key_namespace: + name: "Preference Key Namespace" + regex: '\bforKey:(?, } -#[cfg_attr(test, derive(Debug, PartialEq))] enum TpDmCell { Null, Text(Vec), @@ -758,387 +757,3 @@ pub unsafe extern "C" fn tp_dm_result_cell_bytes( } bytes.as_ptr() } - -#[cfg(test)] -mod tests { - use std::env; - - use super::*; - - unsafe fn error_text(error: *mut TpDmError) -> String { - let mut length = 0; - let bytes = tp_dm_error_message(error, &mut length); - let text = String::from_utf8_lossy(slice::from_raw_parts(bytes, length)).into_owned(); - tp_dm_error_free(error); - text - } - - unsafe fn execute( - connection: *mut TpDmConnection, - sql: &str, - expects_rows: bool, - ) -> *mut TpDmResult { - let mut error = ptr::null_mut(); - let result = tp_dm_execute( - connection, - sql.as_ptr(), - sql.len(), - expects_rows, - 0, - 0, - &mut error, - ); - assert!(error.is_null(), "{}", error_text(error)); - assert!(!result.is_null()); - result - } - - fn unmapped_column() -> dameng::row::Column { - dameng::row::Column { - name: "VALUE".to_string(), - // Deliberately a code no DmValueType maps, so the value cannot decode. - type_code: 99, - type_name: "UNKNOWN".to_string(), - precision: 0, - scale: 0, - nullable: true, - display_size: 0, - table_name: String::new(), - schema_name: String::new(), - lob_tab_id: 0, - lob_col_id: 0, - } - } - - /// A large object DM8 stored outside the row arrives as a locator, a pointer into the - /// server's LOB store. Handing its bytes to the app would render the pointer as though it - /// were the stored text, and dereferencing one makes DM8 8.1 close the connection. - /// A small value DM8 keeps in the row is real content and still reads. - #[test] - fn a_large_object_reads_its_inline_value_and_never_its_locator() { - let mut lob = unmapped_column(); - lob.type_code = 14; // CLOB - let columns = vec![lob]; - - // NBLOB_HEAD: flag(1) + blob_id(8) + blob_len(4), then the content. - let mut in_row = vec![0x01u8]; - in_row.extend_from_slice(&[0u8; 8]); - in_row.extend_from_slice(&3u32.to_le_bytes()); - in_row.extend_from_slice(b"abc"); - let inline = dameng::Row { - row_id: 0, - values: vec![Some(in_row)], - }; - assert_eq!( - convert_cell(&inline, &columns, 0).unwrap(), - TpDmCell::Text(b"abc".to_vec()) - ); - - let mut out_of_row = vec![0x02u8]; - out_of_row.extend_from_slice(&[0u8; 16]); - let locator = dameng::Row { - row_id: 0, - values: vec![Some(out_of_row)], - }; - assert_eq!(convert_cell(&locator, &columns, 0).unwrap(), TpDmCell::Null); - - // A locator the decoder cannot read at all must not fall through to its raw bytes. - let undecodable = dameng::Row { - row_id: 0, - values: vec![Some(vec![0x02, 0xFF])], - }; - assert_eq!( - convert_cell(&undecodable, &columns, 0).unwrap(), - TpDmCell::Null - ); - } - - #[test] - fn an_undecodable_value_keeps_its_raw_bytes() { - let columns = vec![unmapped_column()]; - - let undecodable = dameng::Row { - row_id: 0, - values: vec![Some(vec![0xC1, 0x02])], - }; - assert_eq!( - convert_cell(&undecodable, &columns, 0).unwrap(), - TpDmCell::Bytes(vec![0xC1, 0x02]) - ); - - let sql_null = dameng::Row { - row_id: 0, - values: vec![None], - }; - assert_eq!( - convert_cell(&sql_null, &columns, 0).unwrap(), - TpDmCell::Null - ); - - let empty = dameng::Row { - row_id: 0, - values: vec![Some(vec![])], - }; - assert_eq!( - convert_cell(&empty, &columns, 0).unwrap(), - TpDmCell::Null - ); - - let out_of_range = dameng::Row { - row_id: 0, - values: vec![], - }; - assert_eq!( - convert_cell(&out_of_range, &columns, 0).unwrap(), - TpDmCell::Null - ); - } - - #[test] - fn rejects_zero_port_before_connecting() { - unsafe { - let mut error = ptr::null_mut(); - let connection = tp_dm_connect( - b"localhost".as_ptr(), - 9, - 0, - b"user".as_ptr(), - 4, - b"password".as_ptr(), - 8, - &mut error, - ); - assert!(connection.is_null()); - assert_eq!(error_text(error), "port must be greater than zero"); - } - } - - #[test] - fn cancelling_a_null_connection_is_a_no_op() { - unsafe { - tp_dm_cancel(ptr::null()); - } - } - - #[test] - fn a_null_error_is_not_a_cancellation() { - unsafe { - assert!(!tp_dm_error_is_cancellation(ptr::null())); - } - } - - #[test] - fn a_server_failure_is_not_reported_as_a_cancellation() { - unsafe { - let mut error: *mut TpDmError = ptr::null_mut(); - set_driver_error(&mut error, &dameng::Error::QueryFailed("boom".to_string())); - assert!(!error.is_null()); - assert!(!tp_dm_error_is_cancellation(error)); - tp_dm_error_free(error); - } - } - - #[test] - fn a_cancellation_survives_the_c_boundary() { - unsafe { - let mut error: *mut TpDmError = ptr::null_mut(); - set_driver_error(&mut error, &dameng::Error::Cancelled); - assert!(!error.is_null()); - assert!(tp_dm_error_is_cancellation(error)); - tp_dm_error_free(error); - } - } - - #[test] - fn an_abandoned_response_leaves_the_connection_unusable() { - // Both stop mid-message, so the stream is desynced and the client must be closed - // rather than handed the next statement. - assert!(!is_recoverable(&dameng::Error::Cancelled)); - assert!(!is_recoverable(&dameng::Error::Timeout("slow".to_string()))); - assert!(is_recoverable(&dameng::Error::QueryFailed("bad sql".to_string()))); - } - - /// Swift reconnects on this flag, so it has to say the same thing `dispose` acted on. - /// A statement the server rejected must not report a lost connection, or every syntax - /// error would throw the session away. - #[test] - fn a_failure_reports_whether_it_closed_the_connection() { - unsafe { - let mut error = ptr::null_mut(); - set_driver_error(&mut error, &dameng::Error::Cancelled); - assert!(tp_dm_error_is_connection_lost(error)); - assert!(tp_dm_error_is_cancellation(error)); - tp_dm_error_free(error); - - let mut error = ptr::null_mut(); - set_driver_error(&mut error, &dameng::Error::Timeout("slow".to_string())); - assert!(tp_dm_error_is_connection_lost(error)); - assert!(!tp_dm_error_is_cancellation(error)); - tp_dm_error_free(error); - - let mut error = ptr::null_mut(); - set_driver_error(&mut error, &dameng::Error::QueryFailed("bad sql".to_string())); - assert!(!tp_dm_error_is_connection_lost(error)); - tp_dm_error_free(error); - - let mut error = ptr::null_mut(); - set_closed_error(&mut error, "Dameng connection is closed"); - assert!(tp_dm_error_is_connection_lost(error)); - tp_dm_error_free(error); - - let mut error = ptr::null_mut(); - set_error(&mut error, "query length is invalid"); - assert!(!tp_dm_error_is_connection_lost(error)); - tp_dm_error_free(error); - - assert!(!tp_dm_error_is_connection_lost(ptr::null())); - } - } - - #[test] - #[ignore = "requires DM8 in OrbStack"] - fn orbstack_connection_query_and_transaction() { - unsafe { - let host = env::var("DM_HOST").expect("DM_HOST is required"); - let port = env::var("DM_PORT") - .expect("DM_PORT is required") - .parse() - .expect("DM_PORT must be a UInt16"); - let username = env::var("DM_USER").expect("DM_USER is required"); - let password = env::var("DM_PASS").expect("DM_PASS is required"); - let mut error = ptr::null_mut(); - let connection = tp_dm_connect( - host.as_ptr(), - host.len(), - port, - username.as_ptr(), - username.len(), - password.as_ptr(), - password.len(), - &mut error, - ); - assert!(error.is_null(), "{}", error_text(error)); - assert!(!connection.is_null()); - - let drop_result = execute( - connection, - "DROP TABLE IF EXISTS TABLEPRO_BRIDGE_TEST", - false, - ); - tp_dm_result_free(drop_result); - let create_result = execute( - connection, - "CREATE TABLE TABLEPRO_BRIDGE_TEST (\ - ID INT PRIMARY KEY, \ - NAME VARCHAR(100), \ - STATUS VARCHAR(20), \ - TOTAL DECIMAL(12, 2), \ - CREATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP\ - )", - false, - ); - tp_dm_result_free(create_result); - let insert_result = execute( - connection, - "INSERT INTO TABLEPRO_BRIDGE_TEST (ID, NAME, STATUS, TOTAL) \ - VALUES (1, 'TablePro 达梦', 'READY', 42.50)", - false, - ); - assert_eq!(tp_dm_result_rows_affected(insert_result), 1); - tp_dm_result_free(insert_result); - - let select_result = execute( - connection, - "SELECT NAME FROM TABLEPRO_BRIDGE_TEST WHERE ID = 1", - true, - ); - assert_eq!(tp_dm_result_row_count(select_result), 1); - let mut length = 0; - let bytes = tp_dm_result_cell_bytes(select_result, 0, 0, &mut length); - assert_eq!( - slice::from_raw_parts(bytes, length), - "TablePro 达梦".as_bytes() - ); - tp_dm_result_free(select_result); - - let precise_decimal = "1234567890123456789012345678.1234567890"; - let decimal_result = execute( - connection, - "SELECT CAST('1234567890123456789012345678.1234567890' AS DECIMAL(38, 10)) FROM DUAL", - true, - ); - let bytes = tp_dm_result_cell_bytes(decimal_result, 0, 0, &mut length); - assert_eq!( - slice::from_raw_parts(bytes, length), - precise_decimal.as_bytes() - ); - tp_dm_result_free(decimal_result); - - let browse_result = execute( - connection, - "SELECT ID, NAME, STATUS, TOTAL, CREATED_AT \ - FROM TABLEPRO_BRIDGE_TEST ORDER BY 1 \ - OFFSET 0 ROWS FETCH NEXT 1000 ROWS ONLY", - true, - ); - assert_eq!(tp_dm_result_column_count(browse_result), 5); - assert_eq!(tp_dm_result_row_count(browse_result), 1); - tp_dm_result_free(browse_result); - - let empty_result = execute( - connection, - "SELECT NAME FROM TABLEPRO_BRIDGE_TEST WHERE ID = -1", - true, - ); - assert_eq!(tp_dm_result_column_count(empty_result), 1); - assert_eq!(tp_dm_result_row_count(empty_result), 0); - tp_dm_result_free(empty_result); - - assert!(tp_dm_begin(connection, &mut error)); - let failed_sql = b"INSERT INTO TABLEPRO_BRIDGE_TEST (ID, NAME) VALUES (1, 'duplicate')"; - let failed_insert = tp_dm_execute( - connection, - failed_sql.as_ptr(), - failed_sql.len(), - false, - 0, - 0, - &mut error, - ); - assert!(failed_insert.is_null()); - assert!(!error.is_null()); - let _ = error_text(error); - error = ptr::null_mut(); - let transaction_insert = execute( - connection, - "INSERT INTO TABLEPRO_BRIDGE_TEST (ID, NAME) VALUES (2, 'rollback')", - false, - ); - tp_dm_result_free(transaction_insert); - assert!(tp_dm_rollback(connection, &mut error)); - assert!(tp_dm_ping(connection, &mut error)); - let count_result = execute( - connection, - "SELECT COUNT(*) FROM TABLEPRO_BRIDGE_TEST", - true, - ); - let bytes = tp_dm_result_cell_bytes(count_result, 0, 0, &mut length); - assert_eq!(slice::from_raw_parts(bytes, length), b"1"); - tp_dm_result_free(count_result); - - assert!(tp_dm_begin(connection, &mut error)); - let committed_insert = execute( - connection, - "INSERT INTO TABLEPRO_BRIDGE_TEST (ID, NAME) VALUES (3, 'commit')", - false, - ); - tp_dm_result_free(committed_insert); - assert!(tp_dm_commit(connection, &mut error)); - assert!(tp_dm_ping(connection, &mut error)); - - let cleanup_result = execute(connection, "DROP TABLE TABLEPRO_BRIDGE_TEST", false); - tp_dm_result_free(cleanup_result); - tp_dm_disconnect(connection); - } - } -} diff --git a/Native/HanaBridge/internal/frame/frame_test.go b/Native/HanaBridge/internal/frame/frame_test.go deleted file mode 100644 index 3c2ee0d016..0000000000 --- a/Native/HanaBridge/internal/frame/frame_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package frame - -import ( - "bytes" - "encoding/binary" - "errors" - "io" - "math" - "testing" -) - -var errBrokenStream = errors.New("broken stream") - -type failingReader struct{} - -func (failingReader) Read([]byte) (int, error) { - return 0, errBrokenStream -} - -type failingWriter struct { - failAfter int - writes int -} - -func (w *failingWriter) Write(data []byte) (int, error) { - if w.writes >= w.failAfter { - return 0, errBrokenStream - } - w.writes++ - return len(data), nil -} - -func encoded(t *testing.T, outgoing Frame) []byte { - t.Helper() - var buffer bytes.Buffer - if err := Write(&buffer, outgoing); err != nil { - t.Fatalf("Write: %v", err) - } - return buffer.Bytes() -} - -func headerBytes(length uint32, id uint64, code byte) []byte { - encoded, err := encodeHeader(id, code, int(length)) - if err != nil { - panic(err) - } - return encoded[:] -} - -func TestHeaderIsLengthThenIDThenCodeInBigEndian(t *testing.T) { - got := encoded(t, Frame{ID: 0x0102030405060708, Code: 3, Body: []byte(`{"a":1}`)}) - want := append([]byte{0, 0, 0, 7, 1, 2, 3, 4, 5, 6, 7, 8, 3}, `{"a":1}`...) - if !bytes.Equal(got, want) { - t.Fatalf("encoded = % x; want % x", got, want) - } -} - -func TestFrameRoundTrips(t *testing.T) { - outgoing := Frame{ID: 42, Code: 1, Body: []byte(`{"host":"db.example.com"}`)} - incoming, err := Read(bytes.NewReader(encoded(t, outgoing)), MaxBodyLength) - if err != nil { - t.Fatalf("Read: %v", err) - } - if incoming.ID != outgoing.ID || incoming.Code != outgoing.Code || !bytes.Equal(incoming.Body, outgoing.Body) { - t.Fatalf("read %+v; want %+v", incoming, outgoing) - } -} - -func TestMaximumIDRoundTrips(t *testing.T) { - data := encoded(t, Frame{ID: math.MaxUint64, Code: 255}) - if !bytes.Equal(data[4:12], bytes.Repeat([]byte{0xFF}, 8)) { - t.Fatalf("id bytes = % x; want all ones", data[4:12]) - } - incoming, err := Read(bytes.NewReader(data), MaxBodyLength) - if err != nil { - t.Fatalf("Read: %v", err) - } - if incoming.ID != math.MaxUint64 || incoming.Code != 255 { - t.Fatalf("read id %d code %d; want the maximum of each", incoming.ID, incoming.Code) - } -} - -func TestEmptyBodyIsTheHeaderAlone(t *testing.T) { - data := encoded(t, Frame{ID: 9, Code: 6}) - if len(data) != HeaderSize { - t.Fatalf("an empty frame took %d bytes; want %d", len(data), HeaderSize) - } - incoming, err := Read(bytes.NewReader(data), MaxBodyLength) - if err != nil { - t.Fatalf("Read: %v", err) - } - if incoming.ID != 9 || incoming.Code != 6 || len(incoming.Body) != 0 { - t.Fatalf("read %+v; want id 9, code 6 and no body", incoming) - } -} - -func TestConsecutiveFramesReadInOrder(t *testing.T) { - var stream []byte - for id := uint64(1); id <= 3; id++ { - stream = append(stream, encoded(t, Frame{ID: id, Code: byte(id), Body: bytes.Repeat([]byte{'x'}, int(id))})...) - } - input := bytes.NewReader(stream) - for id := uint64(1); id <= 3; id++ { - incoming, err := Read(input, MaxBodyLength) - if err != nil { - t.Fatalf("frame %d: %v", id, err) - } - if incoming.ID != id || len(incoming.Body) != int(id) { - t.Fatalf("frame %d read as %+v", id, incoming) - } - } - if _, err := Read(input, MaxBodyLength); !errors.Is(err, io.EOF) { - t.Fatalf("after the last frame err = %v; want io.EOF", err) - } -} - -func TestStreamEndingBetweenFramesIsEOF(t *testing.T) { - if _, err := Read(bytes.NewReader(nil), MaxBodyLength); !errors.Is(err, io.EOF) { - t.Fatalf("err = %v; want io.EOF", err) - } -} - -func TestStreamEndingInsideTheHeaderIsAShortHeader(t *testing.T) { - for length := 1; length < HeaderSize; length++ { - partial := headerBytes(0, 1, 1)[:length] - if _, err := Read(bytes.NewReader(partial), MaxBodyLength); !errors.Is(err, ErrShortHeader) { - t.Fatalf("%d header bytes: err = %v; want ErrShortHeader", length, err) - } - } -} - -func TestStreamEndingInsideTheBodyIsAShortBody(t *testing.T) { - truncated := append(headerBytes(10, 1, 3), "four"...) - if _, err := Read(bytes.NewReader(truncated), MaxBodyLength); !errors.Is(err, ErrShortBody) { - t.Fatalf("err = %v; want ErrShortBody", err) - } - if _, err := Read(bytes.NewReader(headerBytes(10, 1, 3)), MaxBodyLength); !errors.Is(err, ErrShortBody) { - t.Fatalf("a header with no body: err = %v; want ErrShortBody", err) - } -} - -func TestBodyOverTheLimitIsRefusedBeforeItIsRead(t *testing.T) { - const limit = 16 - input := bytes.NewReader(append(headerBytes(limit+1, 1, 3), bytes.Repeat([]byte{'x'}, limit+1)...)) - if _, err := Read(input, limit); !errors.Is(err, ErrBodyTooLarge) { - t.Fatalf("err = %v; want ErrBodyTooLarge", err) - } - if unread := input.Len(); unread != limit+1 { - t.Fatalf("%d body bytes left unread; want all %d", unread, limit+1) - } -} - -func TestBodyAtTheLimitIsAccepted(t *testing.T) { - const limit = 16 - body := bytes.Repeat([]byte{'x'}, limit) - incoming, err := Read(bytes.NewReader(encoded(t, Frame{ID: 1, Code: 3, Body: body})), limit) - if err != nil || !bytes.Equal(incoming.Body, body) { - t.Fatalf("read %q, %v; want the whole body", incoming.Body, err) - } -} - -func TestTheCapIsTwoGiBMinusOne(t *testing.T) { - if MaxBodyLength != 1<<31-1 { - t.Fatalf("MaxBodyLength = %d; want 2^31-1, the cap both ends of the pipe share", MaxBodyLength) - } -} - -func TestHeaderRefusesALengthOverTheCap(t *testing.T) { - if _, err := encodeHeader(1, 0, MaxBodyLength+1); !errors.Is(err, ErrBodyTooLarge) { - t.Fatalf("length 2^31: err = %v; want ErrBodyTooLarge", err) - } - if _, err := encodeHeader(1, 0, math.MaxUint32); !errors.Is(err, ErrBodyTooLarge) { - t.Fatalf("length 2^32-1: err = %v; want ErrBodyTooLarge", err) - } - if _, err := encodeHeader(1, 0, -1); !errors.Is(err, ErrBodyTooLarge) { - t.Fatalf("length -1: err = %v; want ErrBodyTooLarge", err) - } - encoded, err := encodeHeader(1, 0, MaxBodyLength) - if err != nil { - t.Fatalf("length 2^31-1: %v", err) - } - if decoded := decodeHeader(encoded); decoded.length != MaxBodyLength { - t.Fatalf("length decoded as %d; want %d", decoded.length, MaxBodyLength) - } -} - -func TestWriteRefusesABodyOverTheCapBeforeWritingAnything(t *testing.T) { - var output bytes.Buffer - err := Write(&output, Frame{ID: 1, Body: make([]byte, MaxBodyLength+1)}) - if !errors.Is(err, ErrBodyTooLarge) { - t.Fatalf("err = %v; want ErrBodyTooLarge", err) - } - if output.Len() != 0 { - t.Fatalf("wrote %d bytes; want nothing", output.Len()) - } -} - -func TestReadRefusesAHeaderOverTheCapBeforeReadingTheBody(t *testing.T) { - for _, length := range []uint32{MaxBodyLength + 1, math.MaxUint32} { - header := make([]byte, HeaderSize) - binary.BigEndian.PutUint32(header[0:4], length) - binary.BigEndian.PutUint64(header[4:12], 1) - input := bytes.NewReader(append(header, "body"...)) - if _, err := Read(input, MaxBodyLength); !errors.Is(err, ErrBodyTooLarge) { - t.Fatalf("length %d: err = %v; want ErrBodyTooLarge", length, err) - } - if unread := input.Len(); unread != len("body") { - t.Fatalf("length %d: %d body bytes left unread; want all %d", length, unread, len("body")) - } - } -} - -func TestReadErrorsOtherThanEOFPassThrough(t *testing.T) { - if _, err := Read(failingReader{}, MaxBodyLength); !errors.Is(err, errBrokenStream) { - t.Fatalf("err = %v; want the reader's own error", err) - } - body := io.MultiReader(bytes.NewReader(headerBytes(4, 1, 3)), failingReader{}) - if _, err := Read(body, MaxBodyLength); !errors.Is(err, errBrokenStream) { - t.Fatalf("body read err = %v; want the reader's own error", err) - } -} - -func TestWriteErrorsPassThrough(t *testing.T) { - if err := Write(&failingWriter{failAfter: 0}, Frame{ID: 1, Body: []byte("{}")}); !errors.Is(err, errBrokenStream) { - t.Fatalf("header write err = %v; want the writer's own error", err) - } - if err := Write(&failingWriter{failAfter: 1}, Frame{ID: 1, Body: []byte("{}")}); !errors.Is(err, errBrokenStream) { - t.Fatalf("body write err = %v; want the writer's own error", err) - } - if err := Write(&failingWriter{failAfter: 1}, Frame{ID: 1}); err != nil { - t.Fatalf("an empty body wrote more than its header: %v", err) - } -} diff --git a/Native/HanaBridge/internal/hana/bridge_test.go b/Native/HanaBridge/internal/hana/bridge_test.go deleted file mode 100644 index 484f6d0e4f..0000000000 --- a/Native/HanaBridge/internal/hana/bridge_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package hana - -import ( - "encoding/json" - "testing" -) - -func decodeFailure(t *testing.T, encoded []byte) bridgeError { - t.Helper() - if encoded == nil { - t.Fatal("got no failure") - } - var failure bridgeError - if err := json.Unmarshal(encoded, &failure); err != nil { - t.Fatalf("failure %q is not JSON: %v", encoded, err) - } - return failure -} - -func assertFailureKind(t *testing.T, encoded []byte, kind string) { - t.Helper() - if failure := decodeFailure(t, encoded); failure.Kind != kind { - t.Fatalf("kind = %q (%s); want %q", failure.Kind, encoded, kind) - } -} - -func registeredSession(t *testing.T, bridge *Bridge, id uint64) *session { - t.Helper() - entry, failure := bridge.sessions.lookup(id) - if failure != nil { - t.Fatalf("session %d is not registered: %v", id, failure) - } - return entry -} - -func TestOpenAnswersAnInvalidConfigurationAsJSON(t *testing.T) { - bridge := NewBridge() - id, failure := bridge.Open([]byte(`{"host":"","port":30015,"username":"DBADMIN","tlsMode":"disabled"}`)) - if id != 0 { - t.Fatalf("id = %d; want 0 for a refused configuration", id) - } - decoded := decodeFailure(t, failure) - if decoded.Kind != kindConfiguration || decoded.Message != "host" { - t.Fatalf("failure = %+v; want the configuration field host", decoded) - } -} - -func TestOpenAnswersMalformedJSONAsAnInternalFailure(t *testing.T) { - _, failure := NewBridge().Open([]byte(`{"host":`)) - assertFailureKind(t, failure, kindInternal) -} - -func TestEveryCallOnAnUnknownSessionAnswersClosedAsJSON(t *testing.T) { - bridge := NewBridge() - result, failure := bridge.Connect(7, 1) - if result != nil { - t.Fatalf("connect result = %q; want none", result) - } - assertFailureKind(t, failure, kindClosed) - result, failure = bridge.Execute(7, 2, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - if result != nil { - t.Fatalf("execute result = %q; want none", result) - } - assertFailureKind(t, failure, kindClosed) - result, failure = bridge.Explain(7, 3, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - if result != nil { - t.Fatalf("explain result = %q; want none", result) - } - assertFailureKind(t, failure, kindClosed) - assertFailureKind(t, bridge.Ping(7, 4), kindClosed) - bridge.Cancel(7, 0) - bridge.Close(7) -} - -func TestBridgesKeepTheirOwnSessions(t *testing.T) { - owner := NewBridge() - stranger := NewBridge() - id, failure := owner.Open(unreachableConfig(1)) - if failure != nil { - t.Fatalf("open failed: %s", failure) - } - t.Cleanup(func() { owner.Close(id) }) - assertFailureKind(t, stranger.Ping(id, 1), kindClosed) - stranger.Close(id) - stranger.CloseAll() - if entry := registeredSession(t, owner, id); entry.currentState() != sessionOpened { - t.Fatalf("state = %v; another bridge's close reached this session", entry.currentState()) - } -} - -func TestCloseAllClosesEverySessionAndNeverReusesTheirIDs(t *testing.T) { - bridge := NewBridge() - first, failure := bridge.Open(unreachableConfig(1)) - if failure != nil { - t.Fatalf("open failed: %s", failure) - } - second, failure := bridge.Open(unreachableConfig(1)) - if failure != nil { - t.Fatalf("open failed: %s", failure) - } - entries := []*session{registeredSession(t, bridge, first), registeredSession(t, bridge, second)} - bridge.CloseAll() - for _, entry := range entries { - if entry.currentState() != sessionClosed || !entry.dialer.isSevered() { - t.Fatalf("state=%v severed=%v; want a closed, severed session", entry.currentState(), entry.dialer.isSevered()) - } - } - assertFailureKind(t, bridge.Ping(first, 1), kindClosed) - assertFailureKind(t, bridge.Ping(second, 1), kindClosed) - third, failure := bridge.Open(unreachableConfig(1)) - if failure != nil { - t.Fatalf("open failed: %s", failure) - } - t.Cleanup(func() { bridge.Close(third) }) - if third <= second { - t.Fatalf("id %d reused after %d", third, second) - } - bridge.CloseAll() - bridge.CloseAll() -} - -func TestInternalFailureIsABridgeErrorOfKindInternal(t *testing.T) { - decoded := decodeFailure(t, InternalFailure("unknown opcode 9")) - if decoded.Kind != kindInternal || decoded.Message != "unknown opcode 9" { - t.Fatalf("failure = %+v; want kind internal with the message", decoded) - } -} diff --git a/Native/HanaBridge/internal/hana/config_test.go b/Native/HanaBridge/internal/hana/config_test.go deleted file mode 100644 index 48139f6e23..0000000000 --- a/Native/HanaBridge/internal/hana/config_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package hana - -import ( - "context" - "log/slog" - "testing" - "time" - - "github.com/SAP/go-hdb/driver/dial" - "golang.org/x/text/transform" -) - -func TestConfigurationValidation(t *testing.T) { - cases := map[string]string{ - `{"host":"","port":443,"username":"U","tlsMode":"disabled"}`: "host", - `{"host":" ","port":443,"username":"U","tlsMode":"disabled"}`: "host", - `{"host":"h","port":0,"username":"U","tlsMode":"disabled"}`: "port", - `{"host":"h","port":65536,"username":"U","tlsMode":"disabled"}`: "port", - `{"host":"h","port":443,"username":" ","tlsMode":"disabled"}`: "username", - `{"host":"h","port":443,"username":"U","tlsMode":"sometimes"}`: "tlsMode", - `{"host":"h","port":443,"username":"U","tlsMode":""}`: "tlsMode", - `{"host":"h","port":443,"username":"U","tlsMode":"required","clientCertificatePath":"/tmp/c.pem"}`: "clientCertificate", - `{"host":"h","port":443,"username":"U","tlsMode":"required","clientKeyPath":"/tmp/k.pem"}`: "clientCertificate", - `{"host":"h","port":443,"username":"U","tlsMode":"disabled","connectTimeoutSeconds":-1}`: "connectTimeoutSeconds", - `{"host":"h","port":443,"username":"U","tlsMode":"verifyCa","caCertificatePath":"/nonexistent/ca.pem"}`: "caCertificate", - } - for body, field := range cases { - _, failure := testBridge.openSession([]byte(body)) - assertKind(t, failure, kindConfiguration) - if field != "" && failure.Message != field { - t.Errorf("%s rejected with %q; want %q", body, failure.Message, field) - } - } - _, failure := testBridge.openSession([]byte(`{"host":`)) - assertKind(t, failure, kindInternal) -} - -func TestConfigurationDefaultsAndAddresses(t *testing.T) { - config, failure := parseConnectionConfig([]byte(`{"host":" db.example.com ","port":443,"username":"U","tlsMode":"verifyIdentity"}`)) - if failure != nil { - t.Fatal(failure) - } - if config.address() != "db.example.com:443" || config.connectTimeout() != 30*time.Second { - t.Fatalf("address=%q timeout=%v", config.address(), config.connectTimeout()) - } - ipv6, failure := parseConnectionConfig([]byte(`{"host":"[::1]","port":30015,"username":"U","tlsMode":"disabled","connectTimeoutSeconds":2.5}`)) - if failure != nil { - t.Fatal(failure) - } - if ipv6.address() != "[::1]:30015" || ipv6.connectTimeout() != 2500*time.Millisecond { - t.Fatalf("address=%q timeout=%v", ipv6.address(), ipv6.connectTimeout()) - } -} - -func TestSessionConnectorSettings(t *testing.T) { - id := openTestSession(t, []byte(`{"host":"127.0.0.1","port":1,"username":"DBADMIN","password":"p","schema":"my schema","tlsMode":"disabled"}`)) - entry, failure := testBridge.sessions.lookup(id) - if failure != nil { - t.Fatal(failure) - } - connector := entry.connector - if connector.Timeout() != 0 { - t.Errorf("socket timeout = %v; want 0 so a long statement is never cut", connector.Timeout()) - } - if connector.ApplicationName() != "TablePro" { - t.Errorf("application name = %q", connector.ApplicationName()) - } - if connector.DefaultSchema() != "my schema" { - t.Errorf("default schema = %q", connector.DefaultSchema()) - } - if connector.Logger().Handler() != slog.DiscardHandler { - t.Errorf("logger handler = %T; want the discard handler", connector.Logger().Handler()) - } - if connector.Logger().Enabled(context.Background(), slog.LevelError) { - t.Error("go-hdb would still write errors to the host process") - } - if connector.Dialer() != entry.dialer { - t.Errorf("dialer = %T; want the session's tracking dialer", connector.Dialer()) - } - if entry.dialer.base != dial.DefaultDialer { - t.Errorf("tracking dialer wraps %T; want go-hdb's IPv4-first default dialer", entry.dialer.base) - } - if connector.TLSConfig() != nil || entry.dialer.tlsConfig != nil { - t.Error("a disabled TLS mode produced a TLS configuration") - } - decoded, _, err := transform.Bytes(connector.CESU8Decoder()(), []byte{'a', 0xED, 0xA0, 0x80, 'b'}) - if err != nil || string(decoded) != "a\uFFFDb" { - t.Errorf("go-hdb decodes a lone surrogate as %q, %v; want a replacement character", decoded, err) - } - if entry.connectTimeout != defaultConnectTimeout { - t.Errorf("connect timeout = %v", entry.connectTimeout) - } -} diff --git a/Native/HanaBridge/internal/hana/control_test.go b/Native/HanaBridge/internal/hana/control_test.go deleted file mode 100644 index 74cafeefde..0000000000 --- a/Native/HanaBridge/internal/hana/control_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package hana - -import ( - "testing" - "time" -) - -func connectedTestSession(t *testing.T, port int, connectionID int64) *session { - t.Helper() - id := openTestSession(t, sessionConfig(port, tlsModeDisabled, "")) - entry, failure := testBridge.sessions.lookup(id) - if failure != nil { - t.Fatal(failure) - } - entry.mu.Lock() - entry.state = sessionConnected - entry.connectionID = connectionID - entry.mu.Unlock() - return entry -} - -func TestCancelStatementNamesTheServerConnection(t *testing.T) { - if got := cancelSessionStatement(200123); got != "ALTER SYSTEM CANCEL SESSION '200123'" { - t.Fatalf("cancel statement = %q", got) - } -} - -func TestFailedCancelClosesTheSocketsAndLosesTheSession(t *testing.T) { - entry := connectedTestSession(t, closedPort(t), 200123) - started := time.Now() - entry.interruptStatement(stopCancelled) - if elapsed := time.Since(started); elapsed > 5*time.Second { - t.Fatalf("a refused control connection took %v", elapsed) - } - if entry.currentState() != sessionLost || !entry.dialer.isSevered() { - t.Fatalf("state=%v severed=%v; want a lost, severed session", entry.currentState(), entry.dialer.isSevered()) - } -} - -func TestCancelWithoutAServerConnectionIDLosesTheSession(t *testing.T) { - server := newSilentServer(t) - entry := connectedTestSession(t, server.port(), 0) - entry.interruptStatement(stopTimedOut) - if entry.currentState() != sessionLost { - t.Fatalf("state = %v; want lost", entry.currentState()) - } - if accepted := server.accepted.Load(); accepted != 0 { - t.Fatalf("a cancel with no connection id dialed %d control connections", accepted) - } -} - -func TestClosingInterruptSendsNoCancel(t *testing.T) { - server := newSilentServer(t) - entry := connectedTestSession(t, server.port(), 200123) - entry.interruptStatement(stopClosed) - time.Sleep(50 * time.Millisecond) - if accepted := server.accepted.Load(); accepted != 0 || entry.currentState() != sessionConnected { - t.Fatalf("closing interrupt dialed %d times, state %v", accepted, entry.currentState()) - } -} diff --git a/Native/HanaBridge/internal/hana/dialer.go b/Native/HanaBridge/internal/hana/dialer.go index e42f6002b5..1c5086c0d4 100644 --- a/Native/HanaBridge/internal/hana/dialer.go +++ b/Native/HanaBridge/internal/hana/dialer.go @@ -72,12 +72,6 @@ func (d *trackingDialer) isSevered() bool { return d.severed } -func (d *trackingDialer) liveConnections() int { - d.mu.Lock() - defer d.mu.Unlock() - return len(d.live) -} - func (d *trackingDialer) sever() { d.mu.Lock() d.severed = true diff --git a/Native/HanaBridge/internal/hana/dialer_test.go b/Native/HanaBridge/internal/hana/dialer_test.go deleted file mode 100644 index b3b2822a6f..0000000000 --- a/Native/HanaBridge/internal/hana/dialer_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package hana - -import ( - "context" - "crypto/tls" - "errors" - "io" - "net" - "strconv" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/SAP/go-hdb/driver/dial" -) - -type silentServer struct { - listener net.Listener - accepted atomic.Int32 - mu sync.Mutex - conns []net.Conn - closed chan struct{} -} - -func newSilentServer(t *testing.T) *silentServer { - t.Helper() - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - server := &silentServer{listener: listener, closed: make(chan struct{}, 16)} - go func() { - for { - conn, err := listener.Accept() - if err != nil { - return - } - server.accepted.Add(1) - server.mu.Lock() - server.conns = append(server.conns, conn) - server.mu.Unlock() - go func() { - _, _ = io.Copy(io.Discard, conn) - server.closed <- struct{}{} - }() - } - }() - t.Cleanup(func() { - _ = listener.Close() - server.mu.Lock() - defer server.mu.Unlock() - for _, conn := range server.conns { - _ = conn.Close() - } - }) - return server -} - -func (s *silentServer) address() string { - return s.listener.Addr().String() -} - -func (s *silentServer) port() int { - return s.listener.Addr().(*net.TCPAddr).Port -} - -func (s *silentServer) waitForClientHangUp(t *testing.T) { - t.Helper() - select { - case <-s.closed: - case <-time.After(5 * time.Second): - t.Fatal("the client never closed its socket") - } -} - -func TestTrackingDialerForgetsClosedConnections(t *testing.T) { - server := newSilentServer(t) - dialer := newTrackingDialer(dial.DefaultDialer, nil) - conn, err := dialer.DialContext(context.Background(), server.address(), dial.DialerOptions{}) - if err != nil { - t.Fatal(err) - } - if dialer.liveConnections() != 1 { - t.Fatalf("live connections = %d", dialer.liveConnections()) - } - _ = conn.Close() - if dialer.liveConnections() != 0 { - t.Fatalf("a closed connection is still tracked: %d", dialer.liveConnections()) - } -} - -func TestSeverClosesEveryLiveSocketAndRefusesNewDials(t *testing.T) { - server := newSilentServer(t) - dialer := newTrackingDialer(dial.DefaultDialer, nil) - for range 2 { - if _, err := dialer.DialContext(context.Background(), server.address(), dial.DialerOptions{}); err != nil { - t.Fatal(err) - } - } - dialer.sever() - server.waitForClientHangUp(t) - server.waitForClientHangUp(t) - if dialer.liveConnections() != 0 { - t.Fatalf("live connections after sever = %d", dialer.liveConnections()) - } - if _, err := dialer.DialContext(context.Background(), server.address(), dial.DialerOptions{}); !errors.Is(err, errDialerSevered) { - t.Fatalf("dial after sever = %v; want errDialerSevered", err) - } -} - -func TestSeverInterruptsAHandshakeThatNeverEnds(t *testing.T) { - server := newSilentServer(t) - dialer := newTrackingDialer(dial.DefaultDialer, &tls.Config{InsecureSkipVerify: true, ServerName: "127.0.0.1"}) - result := make(chan error, 1) - go func() { - _, err := dialer.DialContext(context.Background(), server.address(), dial.DialerOptions{}) - result <- err - }() - waitFor(t, func() bool { return dialer.liveConnections() == 1 }) - dialer.sever() - select { - case err := <-result: - var handshake *handshakeError - if !errors.As(err, &handshake) { - t.Fatalf("dial = %v; want a handshake error", err) - } - case <-time.After(5 * time.Second): - t.Fatal("sever did not end the handshake") - } -} - -func TestHandshakeIsBoundedByTheDialContext(t *testing.T) { - server := newSilentServer(t) - dialer := newTrackingDialer(dial.DefaultDialer, &tls.Config{InsecureSkipVerify: true, ServerName: "127.0.0.1"}) - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - started := time.Now() - _, err := dialer.DialContext(ctx, "127.0.0.1:"+strconv.Itoa(server.port()), dial.DialerOptions{}) - if err == nil || time.Since(started) > 5*time.Second { - t.Fatalf("dial = %v after %v; want a prompt failure", err, time.Since(started)) - } - if dialer.liveConnections() != 0 { - t.Fatalf("a failed handshake left %d tracked sockets", dialer.liveConnections()) - } -} diff --git a/Native/HanaBridge/internal/hana/envelope_test.go b/Native/HanaBridge/internal/hana/envelope_test.go deleted file mode 100644 index 0f11e5b2c9..0000000000 --- a/Native/HanaBridge/internal/hana/envelope_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package hana - -import ( - "bytes" - "encoding/json" - "strings" - "testing" -) - -func decodeEnvelope(t *testing.T, envelope *resultEnvelope) map[string]json.RawMessage { - t.Helper() - encoded := envelope.appendJSON(nil) - if !json.Valid(encoded) { - t.Fatalf("envelope is not valid JSON: %s", encoded) - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(encoded, &fields); err != nil { - t.Fatal(err) - } - return fields -} - -func TestEnvelopeArraysAreNeverNull(t *testing.T) { - envelopes := map[string]*resultEnvelope{ - "affected rows": affectedRowsEnvelope(3), - "zero value": {}, - "no rows": tabularEnvelope([]columnInfo{column("INTEGER")}), - "no columns": tabularEnvelope(nil), - } - for label, envelope := range envelopes { - fields := decodeEnvelope(t, envelope) - for _, key := range []string{"columns", "columnTypeNames", "columnClassifications", "rows"} { - raw, ok := fields[key] - if !ok || !bytes.HasPrefix(raw, []byte("[")) { - t.Fatalf("%s: %s = %s; want an array", label, key, raw) - } - } - } -} - -func TestEnvelopeCarriesEveryContractKey(t *testing.T) { - envelope := tabularEnvelope([]columnInfo{ - {name: "ID", databaseTypeName: "INTEGER"}, - {name: "WHEN", databaseTypeName: "SECONDDATE"}, - }) - envelope.rows = append(envelope.rows, []cell{textCell("1"), nullCell()}) - envelope.executionTime = 0.0123 - envelope.isTruncated = true - envelope.truncatedLobCount = 2 - encoded := string(envelope.appendJSON(nil)) - want := `{"columns":["ID","WHEN"],"columnTypeNames":["INTEGER","SECONDDATE"],"columnClassifications":[null,"TIMESTAMP"],` + - `"rows":[["1",null]],"rowsAffected":0,"hasResultSet":true,"executionTime":0.0123,"isTruncated":true,"truncatedLobCount":2,"sessionLost":false}` - if encoded != want { - t.Fatalf("envelope =\n%s\nwant\n%s", encoded, want) - } -} - -func TestCellsEncodeAsNullStringOrBytesObject(t *testing.T) { - row := []cell{nullCell(), textCell("a\"b"), bytesCell([]byte{0, 1, 255}), bytesCell(nil), textCell("")} - encoded := appendRow(nil, row) - want := `[null,"a\"b",{"bytes":"AAH/"},{"bytes":""},""]` - if string(encoded) != want { - t.Fatalf("row = %s; want %s", encoded, want) - } - var decoded []cell - if err := json.Unmarshal(encoded, &decoded); err != nil { - t.Fatal(err) - } - if decoded[0].kind != cellNull || decoded[1].text != "a\"b" || string(decoded[2].bytes) != string([]byte{0, 1, 255}) { - t.Fatalf("round trip = %+v", decoded) - } -} - -func TestJSONStringEscapingMatchesEncodingJSON(t *testing.T) { - samples := []string{ - "", - "plain", - "quote \" and backslash \\", - "controls \x00 \x01 \x1f \t \n \r \b \f", - "unicode Grüße 😀 \u2028 \u2029", - "invalid \xff\xfe bytes", - "cut \xe2\x82", - " & 'quotes'", - "\x7f", - } - for _, sample := range samples { - encoded := appendJSONString(nil, sample) - if !json.Valid(encoded) { - t.Fatalf("%q encoded to invalid JSON %s", sample, encoded) - } - var decoded string - if err := json.Unmarshal(encoded, &decoded); err != nil { - t.Fatal(err) - } - reference, _ := json.Marshal(sample) - var referenceDecoded string - if err := json.Unmarshal(reference, &referenceDecoded); err != nil { - t.Fatal(err) - } - if decoded != referenceDecoded { - t.Fatalf("%q decodes to %q; encoding/json gives %q", sample, decoded, referenceDecoded) - } - } -} - -func TestExecuteRequestDecoding(t *testing.T) { - request, failure := decodeRequest[executeRequest]([]byte(`{"sql":"SELECT 1 FROM DUMMY","parameters":null,"rowCap":1000,"timeoutSeconds":2.5}`)) - if failure != nil { - t.Fatal(failure) - } - if request.hasParameters() || request.rowLimit() != 1000 || secondsDuration(request.TimeoutSeconds).Milliseconds() != 2500 { - t.Fatalf("request = %+v", request) - } - request, failure = decodeRequest[executeRequest]([]byte(`{"sql":"INSERT INTO T VALUES (?, ?, ?)","parameters":[null,"text",{"bytes":"AAH/"}],"rowCap":0,"timeoutSeconds":0}`)) - if failure != nil { - t.Fatal(failure) - } - if !request.hasParameters() || request.rowLimit() != 0 || secondsDuration(request.TimeoutSeconds) != 0 { - t.Fatalf("request = %+v", request) - } - values := *request.Parameters - if values[0].kind != cellNull || values[1].kind != cellText || values[1].text != "text" || values[2].kind != cellBytes || len(values[2].bytes) != 3 { - t.Fatalf("parameters = %+v", values) - } - empty, _ := decodeRequest[executeRequest]([]byte(`{"sql":"SELECT 1 FROM DUMMY","parameters":[]}`)) - if empty.hasParameters() { - t.Fatal("an empty parameter array routed to the prepared path") - } - negative, _ := decodeRequest[executeRequest]([]byte(`{"sql":"x","rowCap":-5}`)) - if negative.rowLimit() != 0 { - t.Fatalf("a negative row cap became %d", negative.rowLimit()) - } -} - -func TestMalformedRequestsAreInternalErrors(t *testing.T) { - for _, body := range []string{`not json`, `{"sql":"x","parameters":[{"text":"x"}]}`, `{"sql":"x","parameters":[{"bytes":"%%%"}]}`, `{"sql":"x","parameters":[1]}`} { - _, failure := decodeRequest[executeRequest]([]byte(body)) - assertKind(t, failure, kindInternal) - } -} - -func TestErrorJSONCarriesEveryKey(t *testing.T) { - failure := &bridgeError{Kind: kindServer, Code: 259, Position: 14, Message: "invalid table name: Could not find table/view X"} - encoded := string(failure.encoded()) - want := `{"kind":"server","code":259,"position":14,"message":"invalid table name: Could not find table/view X","parameter":0,"expected":""}` - if encoded != want { - t.Fatalf("error JSON = %s; want %s", encoded, want) - } - if !strings.Contains(string(parameterError(2, expectScale, "1.234").encoded()), `"parameter":2,"expected":"scale"`) { - t.Fatal("parameter errors lost their position or expectation") - } -} - -func TestEnvelopeAlwaysCarriesSessionLost(t *testing.T) { - envelopes := map[string]*resultEnvelope{ - "affected rows": affectedRowsEnvelope(3), - "zero value": {}, - "no rows": tabularEnvelope([]columnInfo{column("INTEGER")}), - "plan": planEnvelope(nil), - } - for label, envelope := range envelopes { - if raw := string(decodeEnvelope(t, envelope)["sessionLost"]); raw != "false" { - t.Fatalf("%s: sessionLost = %q; want false", label, raw) - } - envelope.sessionLost = true - if raw := string(decodeEnvelope(t, envelope)["sessionLost"]); raw != "true" { - t.Fatalf("%s: sessionLost = %q; want true", label, raw) - } - } -} diff --git a/Native/HanaBridge/internal/hana/errors_test.go b/Native/HanaBridge/internal/hana/errors_test.go deleted file mode 100644 index 729db3a3db..0000000000 --- a/Native/HanaBridge/internal/hana/errors_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package hana - -import ( - "context" - "crypto/tls" - "crypto/x509" - "database/sql" - "database/sql/driver" - "errors" - "fmt" - "io" - "net" - "strings" - "syscall" - "testing" -) - -type fakeServerError struct { - code int - position int - text string -} - -func (e fakeServerError) Error() string { - return fmt.Sprintf("SQL Error %d - %s", e.code, e.text) -} -func (e fakeServerError) StmtNo() int { return 0 } -func (e fakeServerError) Code() int { return e.code } -func (e fakeServerError) Position() int { return e.position } -func (e fakeServerError) Level() int { return 1 } -func (e fakeServerError) Text() string { return e.text } -func (e fakeServerError) IsWarning() bool { return false } -func (e fakeServerError) IsError() bool { return true } -func (e fakeServerError) IsFatal() bool { return false } - -var cancelledByServer = fakeServerError{code: hanaStatementCancelledCode, text: "current operation cancelled by request and transaction rolled back"} - -func TestServerErrorsKeepCodePositionAndText(t *testing.T) { - failure := classifyError(fmt.Errorf("wrapped: %w", fakeServerError{code: 259, position: 14, text: "invalid table name"})) - assertKind(t, failure, kindServer) - if failure.Code != 259 || failure.Position != 14 || failure.Message != "invalid table name" { - t.Fatalf("failure = %+v", failure) - } - lenient := classifyError(fakeServerError{code: 7, text: "bad \xed\xa0\x80 text"}) - if lenient.Message != "bad \uFFFD text" { - t.Fatalf("server text = %q; want the invalid CESU-8 replaced", lenient.Message) - } -} - -func TestStatementCancelledAfterOurInterruptIsCancelledOrTimeout(t *testing.T) { - assertKind(t, resolveFailure(sessionConnected, stopCancelled, cancelledByServer), kindCancelled) - assertKind(t, resolveFailure(sessionConnected, stopTimedOut, cancelledByServer), kindTimeout) - assertKind(t, resolveFailure(sessionConnected, stopCancelled, fmt.Errorf("sql: Scan error: %w", errOperationStopped)), kindCancelled) - assertKind(t, resolveFailure(sessionConnected, stopTimedOut, errOperationStopped), kindTimeout) - notOurs := resolveFailure(sessionConnected, stopNone, cancelledByServer) - assertKind(t, notOurs, kindServer) - if notOurs.Code != hanaStatementCancelledCode { - t.Fatalf("a cancel we did not send lost its code: %+v", notOurs) - } - assertKind(t, resolveFailure(sessionConnected, stopCancelled, fakeServerError{code: 259}), kindServer) -} - -func TestConnectionFailuresAreConnectionLost(t *testing.T) { - failures := []error{ - driver.ErrBadConn, - fmt.Errorf("%w: %w", driver.ErrBadConn, io.EOF), - sql.ErrConnDone, - io.ErrUnexpectedEOF, - net.ErrClosed, - errDialerSevered, - errPingUnanswered, - &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}, - } - for _, failure := range failures { - assertKind(t, resolveFailure(sessionConnected, stopNone, failure), kindConnectionLost) - assertKind(t, resolveFailure(sessionConnected, stopCancelled, failure), kindConnectionLost) - } - message := classifyError(fmt.Errorf("%w: %w", driver.ErrBadConn, io.EOF)).Message - if message != "EOF" { - t.Fatalf("message = %q; want the bad connection prefix stripped", message) - } -} - -func TestLostSessionAnswersConnectionLostForServerErrors(t *testing.T) { - assertKind(t, resolveFailure(sessionLost, stopCancelled, cancelledByServer), kindConnectionLost) - assertKind(t, resolveFailure(sessionLost, stopNone, fakeServerError{code: 259}), kindConnectionLost) -} - -func TestClosedAndDroppedOperations(t *testing.T) { - assertKind(t, resolveFailure(sessionClosed, stopNone, driver.ErrBadConn), kindClosed) - assertKind(t, resolveFailure(sessionConnected, stopNone, errSlotClosed), kindClosed) - assertKind(t, resolveFailure(sessionConnected, stopNone, errOperationDroppedWhileQueued), kindCancelled) - assertKind(t, resolveFailure(sessionLost, stopNone, errOperationDroppedWhileQueued), kindCancelled) -} - -func TestBridgeErrorsPassThroughClassification(t *testing.T) { - original := parameterError(2, expectDate, "2024-02-30") - if classifyError(original) != original { - t.Fatal("a classified error was rewritten") - } - assertKind(t, classifyError(errors.New("something else")), kindInternal) -} - -func TestSessionStaysLostAfterAConnectionFailure(t *testing.T) { - id := openTestSession(t, unreachableConfig(1)) - entry, failure := testBridge.sessions.lookup(id) - if failure != nil { - t.Fatal(failure) - } - assertKind(t, entry.failure(nil, fmt.Errorf("%w: %w", driver.ErrBadConn, io.EOF)), kindConnectionLost) - if entry.currentState() != sessionLost || !entry.dialer.isSevered() { - t.Fatalf("state=%v severed=%v; want a lost, severed session", entry.currentState(), entry.dialer.isSevered()) - } - _, failure = testBridge.executeOnSession(id, 1, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - assertKind(t, failure, kindConnectionLost) - _, failure = testBridge.explainOnSession(id, 2, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - assertKind(t, failure, kindConnectionLost) - assertKind(t, testBridge.pingSession(id, 3), kindConnectionLost) - _, failure = testBridge.connectSession(id, 4) - assertKind(t, failure, kindConnectionLost) -} - -func TestHandshakeFailuresMapToTLSSubkinds(t *testing.T) { - hostnameMismatch := x509.HostnameError{Certificate: &x509.Certificate{}, Host: "db.example.com"} - cases := []struct { - err error - code int - }{ - {x509.UnknownAuthorityError{}, tlsFailureUntrusted}, - {x509.CertificateInvalidError{Reason: x509.Expired}, tlsFailureUntrusted}, - {&tls.CertificateVerificationError{Err: x509.UnknownAuthorityError{}}, tlsFailureUntrusted}, - {&tls.CertificateVerificationError{Err: errors.New("x509: platform verifier said no")}, tlsFailureUntrusted}, - {hostnameMismatch, tlsFailureHostnameMismatch}, - {&tls.CertificateVerificationError{Err: hostnameMismatch}, tlsFailureHostnameMismatch}, - {tls.RecordHeaderError{Msg: "first record does not look like a TLS handshake"}, tlsFailurePlaintextServer}, - {io.EOF, tlsFailurePlaintextServer}, - {fmt.Errorf("read: %w", syscall.ECONNRESET), tlsFailurePlaintextServer}, - {errors.New("tls: handshake failure"), tlsFailureOther}, - } - for _, testCase := range cases { - failure := classifyConnectError(&opaqueConnectError{cause: &handshakeError{cause: testCase.err}}) - assertKind(t, failure, kindTLS) - if failure.Code != testCase.code { - t.Errorf("%v classified as code %d; want %d", testCase.err, failure.Code, testCase.code) - } - } -} - -func TestConnectFailuresOutsideTheHandshake(t *testing.T) { - dialFailure := &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED} - assertKind(t, classifyConnectError(&opaqueConnectError{cause: dialFailure}), kindConnect) - assertKind(t, classifyConnectError(&net.DNSError{Err: "no such host", Name: "nowhere.invalid"}), kindConnect) - authentication := classifyConnectError(&opaqueConnectError{cause: fakeServerError{code: 10, text: "authentication failed"}}) - assertKind(t, authentication, kindServer) - if authentication.Code != 10 { - t.Fatalf("authentication failure = %+v", authentication) - } - assertKind(t, classifyError(&opaqueConnectError{cause: dialFailure}), kindConnect) - if errors.Is(&opaqueConnectError{cause: fmt.Errorf("%w: %w", driver.ErrBadConn, io.EOF)}, driver.ErrBadConn) { - t.Fatal("a connect error exposes ErrBadConn, so database/sql would dial again") - } -} - -func TestConnectContextDecidesCancelledAndTimeout(t *testing.T) { - expired, cancelExpired := context.WithTimeout(context.Background(), 0) - defer cancelExpired() - <-expired.Done() - assertKind(t, connectFailure(expired, net.ErrClosed), kindTimeout) - cancelled, cancel := context.WithCancel(context.Background()) - cancel() - assertKind(t, connectFailure(cancelled, net.ErrClosed), kindCancelled) - assertKind(t, connectFailure(context.Background(), &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}), kindConnect) -} - -func TestCleanupConnectionFailureTurnsASuccessIntoConnectionLost(t *testing.T) { - var err error - cleanupInto(&err, func() error { return fmt.Errorf("%w: %w", driver.ErrBadConn, io.EOF) }) - failure := resolveFailure(sessionConnected, stopNone, err) - assertKind(t, failure, kindConnectionLost) - if failure.Message != "EOF" { - t.Fatalf("message = %q; want the cleanup's own failure", failure.Message) - } -} - -func TestCleanupFailureThatIsNotAConnectionFailureNeverOverridesTheStatement(t *testing.T) { - err := error(fakeServerError{code: 259, text: "invalid table name"}) - cleanupInto(&err, func() error { return fakeServerError{code: 7, text: "drop statement refused"} }) - failure := resolveFailure(sessionConnected, stopNone, err) - assertKind(t, failure, kindServer) - if failure.Code != 259 { - t.Fatalf("failure = %+v; want the statement's error", failure) - } - var succeeded error - cleanupInto(&succeeded, func() error { return errors.New("drop statement refused") }) - if succeeded != nil { - t.Fatalf("a cleanup error that is not a connection failure failed a statement that succeeded: %v", succeeded) - } -} - -func TestCleanupConnectionFailureOutranksTheStatementError(t *testing.T) { - statementErrors := []error{ - fakeServerError{code: 259, text: "invalid table name"}, - cancelledByServer, - errOperationStopped, - parameterError(1, expectDate, "2024-02-30"), - } - for _, statementErr := range statementErrors { - err := joinCleanupFailure(statementErr, net.ErrClosed) - if !errors.Is(err, statementErr) { - t.Fatalf("the joined failure dropped the statement error %v", statementErr) - } - for _, reason := range []stopReason{stopNone, stopCancelled, stopTimedOut} { - failure := resolveFailure(sessionConnected, reason, err) - assertKind(t, failure, kindConnectionLost) - if failure.Message != net.ErrClosed.Error() { - t.Fatalf("message = %q; want the cleanup failure alone", failure.Message) - } - } - } -} - -func TestAnUnfinishedTransactionDropsTheSession(t *testing.T) { - statementErr := errors.New("unique constraint violated") - rollbackErr := errors.New("SQL error 3: fatal error: transaction is not active") - failure := unsettledTransaction(statementErr, rollbackErr) - if !isConnectionFailure(failure) { - t.Fatal("a failed rollback left the session reusable") - } - classified := resolveFailure(sessionConnected, stopNone, failure) - assertKind(t, classified, kindConnectionLost) - if !strings.Contains(classified.Message, "transaction is not active") || !strings.Contains(classified.Message, "unique constraint violated") { - t.Fatalf("message %q lost a cause", classified.Message) - } - commitFailure := unsettledTransaction(nil, errors.New("commit refused")) - assertKind(t, resolveFailure(sessionConnected, stopNone, commitFailure), kindConnectionLost) - if isConnectionFailure(statementErr) { - t.Fatal("a plain statement error was read as a lost session") - } -} diff --git a/Native/HanaBridge/internal/hana/explain_test.go b/Native/HanaBridge/internal/hana/explain_test.go deleted file mode 100644 index e756f2997c..0000000000 --- a/Native/HanaBridge/internal/hana/explain_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package hana - -import ( - "reflect" - "strings" - "testing" -) - -func child(operatorID, parentID, position int64, name string) planNode { - return planNode{operatorID: operatorID, parentID: parentID, hasParent: true, position: position, name: name} -} - -func TestPlanRendersDepthFirstInPositionOrder(t *testing.T) { - nodes := []planNode{ - {operatorID: 1, name: "COLUMN SEARCH", details: "T.ID, T.NAME", hasOutputSize: true, outputSize: 1000, hasCost: true, cost: 0.0123}, - child(2, 1, 2, "JOIN"), - child(3, 1, 1, "FILTER"), - child(4, 2, 1, "COLUMN TABLE"), - child(5, 2, 1, "ROW TABLE"), - child(6, 3, 1, "LIMIT"), - } - nodes[3].schema, nodes[3].table = "APP", "ORDERS" - nodes[4].table = "CUSTOMERS" - want := []string{ - "COLUMN SEARCH T.ID, T.NAME rows 1000 cost 0.0123", - " FILTER", - " LIMIT", - " JOIN", - " COLUMN TABLE [table APP.ORDERS]", - " ROW TABLE [table CUSTOMERS]", - } - if got := renderPlan(nodes); !reflect.DeepEqual(got, want) { - t.Fatalf("plan =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) - } -} - -func TestPlanKeepsOrphansCyclesAndSeveralRoots(t *testing.T) { - nodes := []planNode{ - child(10, 99, 1, "ORPHAN"), - {operatorID: 1, name: "ROOT"}, - child(2, 3, 1, "LOOP A"), - child(3, 2, 1, "LOOP B"), - child(4, 4, 2, "SELF"), - } - got := renderPlan(nodes) - if len(got) != len(nodes) { - t.Fatalf("rendered %d lines for %d operators: %q", len(got), len(nodes), got) - } - want := []string{"ROOT", "ORPHAN", "SELF", "LOOP A", " LOOP B"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("plan = %q; want %q", got, want) - } -} - -func TestPlanNodeTextStaysOnOneLine(t *testing.T) { - node := planNode{name: "COLUMN SEARCH", details: "FILTER\n CONDITION:\tA = 1\r\n", table: "T", hasCost: true, cost: 2} - if got := describePlanNode(node); got != "COLUMN SEARCH FILTER CONDITION: A = 1 [table T] cost 2" { - t.Fatalf("node text = %q", got) - } - if got := describePlanNode(planNode{name: "PROJECT"}); got != "PROJECT" { - t.Fatalf("bare node text = %q", got) - } - if got := describePlanNode(planNode{name: "X", hasOutputSize: true, outputSize: 1e22}); got != "X rows 1e+22" { - t.Fatalf("large size text = %q", got) - } -} - -func TestExplainStatementsUseTheGeneratedName(t *testing.T) { - name := newPlanStatementName() - if !strings.HasPrefix(name, "TABLEPRO_") || strings.ContainsAny(name, "' ") { - t.Fatalf("statement name %q", name) - } - if other := newPlanStatementName(); other == name { - t.Fatal("two plan statement names collided") - } - if got := explainStatement("TABLEPRO_X", "SELECT * FROM T; \n"); got != "EXPLAIN PLAN SET STATEMENT_NAME = 'TABLEPRO_X' FOR SELECT * FROM T" { - t.Fatalf("explain statement = %q", got) - } - if got := planQuery("TABLEPRO_X"); !strings.Contains(got, "FROM SYS.EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = 'TABLEPRO_X' ORDER BY OPERATOR_ID") { - t.Fatalf("plan query = %q", got) - } - if got := planCleanupStatement("TABLEPRO_X"); got != "DELETE FROM SYS.EXPLAIN_PLAN_TABLE WHERE STATEMENT_NAME = 'TABLEPRO_X'" { - t.Fatalf("cleanup = %q", got) - } - if got := quoteLiteral("it's"); got != "'it''s'" { - t.Fatalf("literal = %q", got) - } -} - -func TestPlanEnvelopeIsOneTextColumn(t *testing.T) { - encoded := string(planEnvelope([]string{"ROOT", " CHILD"}).appendJSON(nil)) - want := `{"columns":["QUERY PLAN"],"columnTypeNames":["NVARCHAR"],"columnClassifications":[null],"rows":[["ROOT"],[" CHILD"]],` + - `"rowsAffected":0,"hasResultSet":true,"executionTime":0,"isTruncated":false,"truncatedLobCount":0,"sessionLost":false}` - if encoded != want { - t.Fatalf("plan envelope =\n%s\nwant\n%s", encoded, want) - } - empty := string(planEnvelope(nil).appendJSON(nil)) - if !strings.Contains(empty, `"rows":[]`) { - t.Fatalf("empty plan = %s", empty) - } -} diff --git a/Native/HanaBridge/internal/hana/lob_test.go b/Native/HanaBridge/internal/hana/lob_test.go deleted file mode 100644 index 2432c89411..0000000000 --- a/Native/HanaBridge/internal/hana/lob_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package hana - -import ( - "database/sql" - "errors" - "io" - "strings" - "testing" -) - -var ( - _ io.Writer = (*lobCell)(nil) - _ sql.Scanner = (*lobCell)(nil) -) - -func TestLobCellKeepsEverythingUnderTheLimit(t *testing.T) { - lob := newLobCell(16, nil) - for _, chunk := range []string{"hello ", "world"} { - written, err := lob.Write([]byte(chunk)) - if err != nil || written != len(chunk) { - t.Fatalf("Write(%q) = %d, %v", chunk, written, err) - } - } - if lob.truncated || lob.null { - t.Fatalf("truncated=%v null=%v under the limit", lob.truncated, lob.null) - } - assertText(t, lob.cell(column("NCLOB")), "hello world") -} - -func TestLobCellCutsAtTheLimitAndKeepsConsumingTheStream(t *testing.T) { - lob := newLobCell(8, nil) - for _, chunk := range []string{"12345", "67890", "abcdef"} { - written, err := lob.Write([]byte(chunk)) - if err != nil || written != len(chunk) { - t.Fatalf("Write(%q) = %d, %v; a capped cell must accept the whole stream", chunk, written, err) - } - } - if !lob.truncated || string(lob.data) != "12345678" { - t.Fatalf("data=%q truncated=%v; want the first 8 bytes and the truncation flag", lob.data, lob.truncated) - } - binary := lob.cell(column("BLOB")) - if binary.kind != cellBytes || string(binary.bytes) != "12345678" { - t.Fatalf("BLOB cell = %+v", binary) - } -} - -func TestTruncatedTextLobEndsOnARuneBoundary(t *testing.T) { - lob := newLobCell(len("ab")+2, nil) - if _, err := lob.Write([]byte("ab😀cd")); err != nil { - t.Fatal(err) - } - assertText(t, lob.cell(column("NCLOB")), "ab") -} - -func TestLobCellScansNullAsNull(t *testing.T) { - lob := newLobCell(lobCellLimit, nil) - if err := lob.Scan(nil); err != nil { - t.Fatal(err) - } - if got := lob.cell(column("NCLOB")); got.kind != cellNull { - t.Fatalf("NULL LOB became %+v", got) - } -} - -func TestLobCellAcceptsInlineValues(t *testing.T) { - lob := newLobCell(lobCellLimit, nil) - if err := lob.Scan([]byte("inline")); err != nil { - t.Fatal(err) - } - assertText(t, lob.cell(column("CLOB")), "inline") - other := newLobCell(lobCellLimit, nil) - if err := other.Scan(42); err == nil { - t.Fatal("an unexpected LOB source was accepted") - } -} - -func TestEmptyLobIsNotNull(t *testing.T) { - lob := newLobCell(lobCellLimit, nil) - assertText(t, lob.cell(column("NCLOB")), "") - binary := newLobCell(lobCellLimit, nil).cell(column("BLOB")) - if binary.kind != cellBytes || len(binary.bytes) != 0 { - t.Fatalf("empty BLOB became %+v", binary) - } -} - -func TestStoppedOperationAbortsALobRead(t *testing.T) { - stopped := false - lob := newLobCell(lobCellLimit, func() bool { return stopped }) - if _, err := lob.Write([]byte("first chunk")); err != nil { - t.Fatal(err) - } - stopped = true - if _, err := lob.Write([]byte("second chunk")); !errors.Is(err, errOperationStopped) { - t.Fatalf("Write after stop = %v; want errOperationStopped", err) - } -} - -func TestLobLimitIsSixtyFourMebibytes(t *testing.T) { - if lobCellLimit != 64*1024*1024 { - t.Fatalf("lobCellLimit = %d", lobCellLimit) - } - lob := newLobCell(lobCellLimit, nil) - chunk := []byte(strings.Repeat("x", 1<<20)) - for range 65 { - if _, err := lob.Write(chunk); err != nil { - t.Fatal(err) - } - } - if !lob.truncated || len(lob.data) != lobCellLimit { - t.Fatalf("65 MiB kept %d bytes, truncated=%v", len(lob.data), lob.truncated) - } -} diff --git a/Native/HanaBridge/internal/hana/opslot_test.go b/Native/HanaBridge/internal/hana/opslot_test.go deleted file mode 100644 index 27217f8a60..0000000000 --- a/Native/HanaBridge/internal/hana/opslot_test.go +++ /dev/null @@ -1,424 +0,0 @@ -package hana - -import ( - "errors" - "sync" - "sync/atomic" - "testing" - "time" -) - -type recordingInterrupter struct { - mu sync.Mutex - reasons []stopReason - release chan struct{} -} - -func newRecordingInterrupter() *recordingInterrupter { - return &recordingInterrupter{} -} - -func newBlockingInterrupter() *recordingInterrupter { - return &recordingInterrupter{release: make(chan struct{})} -} - -func (r *recordingInterrupter) interrupt(reason stopReason) { - r.mu.Lock() - r.reasons = append(r.reasons, reason) - release := r.release - r.mu.Unlock() - if release != nil { - <-release - } -} - -func (r *recordingInterrupter) calls() []stopReason { - r.mu.Lock() - defer r.mu.Unlock() - return append([]stopReason(nil), r.reasons...) -} - -func awaitSignal(t *testing.T, signal <-chan struct{}, what string) { - t.Helper() - select { - case <-signal: - case <-time.After(5 * time.Second): - t.Fatalf("%s did not happen within 5 seconds", what) - } -} - -func waitFor(t *testing.T, condition func() bool) { - t.Helper() - deadline := time.Now().Add(5 * time.Second) - for !condition() { - if time.Now().After(deadline) { - t.Fatal("condition not met within 5 seconds") - } - time.Sleep(time.Millisecond) - } -} - -func TestQueuedOperationCancelledBeforeItArrivesIsDroppedWithoutInterrupting(t *testing.T) { - slot := newOperationSlot(0, nil) - interrupter := newRecordingInterrupter() - slot.cancel(5) - op, err := slot.begin(5, interrupter.interrupt) - if !errors.Is(err, errOperationDroppedWhileQueued) || op != nil { - t.Fatalf("begin(5) = %v, %v; want a dropped operation", op, err) - } - if calls := interrupter.calls(); len(calls) != 0 { - t.Fatalf("a dropped operation reached the interrupter: %v", calls) - } - next, err := slot.begin(6, interrupter.interrupt) - if err != nil { - t.Fatalf("begin(6) after a dropped operation: %v", err) - } - if next.stopped() { - t.Fatal("the operation after a dropped one started stopped") - } - slot.finish(next) -} - -func TestCancellingAFinishedOperationIsIgnored(t *testing.T) { - slot := newOperationSlot(0, nil) - interrupter := newRecordingInterrupter() - op, err := slot.begin(3, interrupter.interrupt) - if err != nil { - t.Fatal(err) - } - slot.finish(op) - slot.cancel(3) - next, err := slot.begin(4, interrupter.interrupt) - if err != nil { - t.Fatal(err) - } - defer slot.finish(next) - if next.stopped() { - t.Fatal("cancelling a finished operation stopped the next one") - } - if calls := interrupter.calls(); len(calls) != 0 { - t.Fatalf("cancelling a finished operation interrupted: %v", calls) - } -} - -func TestOperationZeroTargetsTheRunningOperation(t *testing.T) { - slot := newOperationSlot(0, nil) - interrupter := newRecordingInterrupter() - op, err := slot.begin(7, interrupter.interrupt) - if err != nil { - t.Fatal(err) - } - slot.cancel(0) - slot.finish(op) - if !op.stopped() || op.currentStopReason() != stopCancelled { - t.Fatalf("stopped=%v reason=%v; want a cancelled operation", op.stopped(), op.currentStopReason()) - } - if calls := interrupter.calls(); len(calls) != 1 || calls[0] != stopCancelled { - t.Fatalf("interrupter calls = %v; want one cancellation", calls) - } -} - -func TestOperationZeroWithNothingRunningDoesNothing(t *testing.T) { - slot := newOperationSlot(0, nil) - interrupter := newRecordingInterrupter() - slot.cancel(0) - op, err := slot.begin(1, interrupter.interrupt) - if err != nil { - t.Fatal(err) - } - defer slot.finish(op) - if op.stopped() { - t.Fatal("a cancel of operation 0 with nothing running stopped a later operation") - } -} - -func TestCancellingTheRunningOperationByIDInterruptsOnlyThatOperation(t *testing.T) { - slot := newOperationSlot(0, nil) - interrupter := newRecordingInterrupter() - op, err := slot.begin(9, interrupter.interrupt) - if err != nil { - t.Fatal(err) - } - slot.cancel(8) - if op.stopped() { - t.Fatal("cancelling an older operation stopped the running one") - } - slot.cancel(9) - slot.cancel(9) - slot.finish(op) - if calls := interrupter.calls(); len(calls) != 1 { - t.Fatalf("interrupter calls = %v; want exactly one", calls) - } -} - -func TestRunningOperationDoesNotFinishUntilTheInFlightCancelSettles(t *testing.T) { - slot := newOperationSlot(0, nil) - interrupter := newBlockingInterrupter() - op, err := slot.begin(1, interrupter.interrupt) - if err != nil { - t.Fatal(err) - } - slot.cancel(1) - waitFor(t, func() bool { return len(interrupter.calls()) == 1 }) - - var finished atomic.Bool - finishDone := make(chan struct{}) - go func() { - slot.finish(op) - finished.Store(true) - close(finishDone) - }() - var nextStarted atomic.Bool - nextDone := make(chan *operation) - go func() { - next, beginErr := slot.begin(2, interrupter.interrupt) - if beginErr != nil { - t.Error(beginErr) - } - nextStarted.Store(true) - nextDone <- next - }() - - time.Sleep(100 * time.Millisecond) - if finished.Load() { - t.Fatal("the operation finished while its cancel was still in flight") - } - if nextStarted.Load() { - t.Fatal("the next operation started while a cancel was still in flight") - } - - close(interrupter.release) - <-finishDone - next := <-nextDone - if next.stopped() { - t.Fatal("the in-flight cancel reached the next operation") - } - slot.finish(next) -} - -func TestTimeoutStopsTheOperationAndTheFirstCauseWins(t *testing.T) { - slot := newOperationSlot(0, nil) - interrupter := newRecordingInterrupter() - op, err := slot.begin(1, interrupter.interrupt) - if err != nil { - t.Fatal(err) - } - op.stopAfter(10 * time.Millisecond) - waitFor(t, op.stopped) - slot.cancel(1) - slot.finish(op) - if op.currentStopReason() != stopTimedOut { - t.Fatalf("reason = %v; want the timeout that fired first", op.currentStopReason()) - } - if calls := interrupter.calls(); len(calls) != 1 || calls[0] != stopTimedOut { - t.Fatalf("interrupter calls = %v; want one timeout", calls) - } -} - -func TestSettledOperationIgnoresLaterCancelsAndTimeouts(t *testing.T) { - slot := newOperationSlot(0, nil) - interrupter := newRecordingInterrupter() - op, err := slot.begin(1, interrupter.interrupt) - if err != nil { - t.Fatal(err) - } - op.stopAfter(5 * time.Millisecond) - op.settle() - slot.cancel(1) - time.Sleep(20 * time.Millisecond) - slot.finish(op) - if op.stopped() || len(interrupter.calls()) != 0 { - t.Fatalf("a settled operation was stopped: stopped=%v calls=%v", op.stopped(), interrupter.calls()) - } -} - -func TestOperationsRunOneAtATime(t *testing.T) { - slot := newOperationSlot(0, nil) - first, err := slot.begin(1, nil) - if err != nil { - t.Fatal(err) - } - var secondStarted atomic.Bool - secondDone := make(chan *operation) - go func() { - second, beginErr := slot.begin(2, nil) - if beginErr != nil { - t.Error(beginErr) - } - secondStarted.Store(true) - secondDone <- second - }() - time.Sleep(50 * time.Millisecond) - if secondStarted.Load() { - t.Fatal("a second operation started while the first was running") - } - slot.finish(first) - slot.finish(<-secondDone) -} - -func TestCancelForAWaitingOperationDropsItWhenItArrives(t *testing.T) { - slot := newOperationSlot(0, nil) - interrupter := newRecordingInterrupter() - first, err := slot.begin(1, interrupter.interrupt) - if err != nil { - t.Fatal(err) - } - result := make(chan error) - go func() { - _, beginErr := slot.begin(2, interrupter.interrupt) - result <- beginErr - }() - time.Sleep(20 * time.Millisecond) - slot.cancel(2) - if first.stopped() { - t.Fatal("cancelling the waiting operation stopped the running one") - } - slot.finish(first) - if err := <-result; !errors.Is(err, errOperationDroppedWhileQueued) { - t.Fatalf("waiting operation began with %v; want it dropped", err) - } -} - -func TestEarlyCancellationsOlderThanAStartedOperationAreForgotten(t *testing.T) { - slot := newOperationSlot(0, nil) - slot.cancel(10) - op, err := slot.begin(11, nil) - if err != nil { - t.Fatalf("begin(11) = %v; an unrelated early cancel dropped it", err) - } - slot.finish(op) - if len(slot.cancelledBeforeStart) != 0 { - t.Fatalf("stale early cancellations kept: %v", slot.cancelledBeforeStart) - } -} - -func TestClosingTheSlotStopsTheRunningOperationAndRefusesNewOnes(t *testing.T) { - slot := newOperationSlot(0, nil) - interrupter := newRecordingInterrupter() - op, err := slot.begin(1, interrupter.interrupt) - if err != nil { - t.Fatal(err) - } - waiting := make(chan error) - go func() { - _, beginErr := slot.begin(2, nil) - waiting <- beginErr - }() - time.Sleep(20 * time.Millisecond) - slot.close() - if err := <-waiting; !errors.Is(err, errSlotClosed) { - t.Fatalf("waiting operation = %v; want the closed slot error", err) - } - slot.finish(op) - if op.currentStopReason() != stopClosed { - t.Fatalf("reason = %v; want closed", op.currentStopReason()) - } - if calls := interrupter.calls(); len(calls) != 1 || calls[0] != stopClosed { - t.Fatalf("interrupter calls = %v; want one close", calls) - } - if _, err := slot.begin(3, nil); !errors.Is(err, errSlotClosed) { - t.Fatalf("begin after close = %v; want the closed slot error", err) - } - slot.cancel(3) -} - -func TestForcedSeverThatFiredBeforeSettleFinishesBeforeTheSlotIsReleased(t *testing.T) { - severStarted := make(chan struct{}) - releaseSever := make(chan struct{}) - var severs atomic.Int32 - var severFinished atomic.Bool - slot := newOperationSlot(time.Millisecond, func() { - severs.Add(1) - close(severStarted) - <-releaseSever - severFinished.Store(true) - }) - op, err := slot.begin(1, nil) - if err != nil { - t.Fatal(err) - } - slot.cancel(1) - awaitSignal(t, severStarted, "the forced sever") - - finished := make(chan bool, 1) - go func() { - slot.finish(op) - finished <- severFinished.Load() - }() - nextBegan := make(chan bool, 1) - go func() { - next, beginErr := slot.begin(2, nil) - if beginErr != nil { - t.Error(beginErr) - nextBegan <- false - return - } - nextBegan <- severFinished.Load() - slot.finish(next) - }() - - select { - case <-finished: - t.Fatal("the slot released the operation while its forced sever was still running") - case <-nextBegan: - t.Fatal("the next operation began while the forced sever was still running") - case <-time.After(50 * time.Millisecond): - } - close(releaseSever) - if !<-finished { - t.Fatal("finish returned before the forced sever finished") - } - if !<-nextBegan { - t.Fatal("the next operation began before the forced sever finished") - } - if count := severs.Load(); count != 1 { - t.Fatalf("forced sever ran %d times; want once", count) - } -} - -func TestATimeoutArmsTheForcedSever(t *testing.T) { - severed := make(chan struct{}) - slot := newOperationSlot(time.Millisecond, func() { close(severed) }) - op, err := slot.begin(1, nil) - if err != nil { - t.Fatal(err) - } - op.stopAfter(time.Millisecond) - awaitSignal(t, severed, "the forced sever of a timed-out operation") - slot.finish(op) -} - -func TestSettlingInsideTheGraceNeverSevers(t *testing.T) { - var severs atomic.Int32 - slot := newOperationSlot(50*time.Millisecond, func() { severs.Add(1) }) - stopped, err := slot.begin(1, nil) - if err != nil { - t.Fatal(err) - } - slot.cancel(1) - slot.finish(stopped) - unstopped, err := slot.begin(2, nil) - if err != nil { - t.Fatal(err) - } - time.Sleep(100 * time.Millisecond) - slot.finish(unstopped) - if count := severs.Load(); count != 0 { - t.Fatalf("forced sever ran %d times; want none", count) - } -} - -func TestClosingTheSlotArmsNoForcedSever(t *testing.T) { - var severs atomic.Int32 - slot := newOperationSlot(time.Millisecond, func() { severs.Add(1) }) - op, err := slot.begin(1, nil) - if err != nil { - t.Fatal(err) - } - slot.close() - time.Sleep(30 * time.Millisecond) - slot.finish(op) - if count := severs.Load(); count != 0 { - t.Fatalf("closing the slot armed a forced sever that ran %d times", count) - } -} diff --git a/Native/HanaBridge/internal/hana/params_test.go b/Native/HanaBridge/internal/hana/params_test.go deleted file mode 100644 index 451e16508f..0000000000 --- a/Native/HanaBridge/internal/hana/params_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package hana - -import ( - "bytes" - "math" - "strconv" - "testing" - "time" -) - -func parameter(databaseTypeName string) parameterType { - return parameterType{databaseTypeName: databaseTypeName} -} - -func fixedParameter(databaseTypeName string, precision, scale int64) parameterType { - return parameterType{databaseTypeName: databaseTypeName, precision: precision, scale: scale, hasDecimalSize: true} -} - -func bindText(t *testing.T, text string, target parameterType) any { - t.Helper() - value, failure := bindParameter(1, textCell(text), target) - if failure != nil { - t.Fatalf("binding %q to %s failed: %v", text, target.databaseTypeName, failure) - } - return value -} - -func assertParameterFailure(t *testing.T, text string, target parameterType, expected string) { - t.Helper() - _, failure := bindParameter(3, textCell(text), target) - assertKind(t, failure, kindParameter) - if failure.Parameter != 3 || failure.Expected != expected || failure.Message != text { - t.Fatalf("failure = %+v; want parameter 3, expected %q, message %q", failure, expected, text) - } -} - -func hanaDayNumber(value time.Time) int64 { - midnight := time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC) - return int64(midnight.Sub(hanaEpoch)/(24*time.Hour)) + 1 -} - -func encodeDaydate(value time.Time) int64 { - return hanaDayNumber(value) -} - -func encodeSeconddate(value time.Time) int64 { - return ((((hanaDayNumber(value)-1)*24+int64(value.Hour()))*60+int64(value.Minute()))*60 + int64(value.Second())) + 1 -} - -func encodeLongdate(value time.Time) int64 { - seconds := (((hanaDayNumber(value)-1)*24+int64(value.Hour()))*60+int64(value.Minute()))*60 + int64(value.Second()) - return seconds*10000000 + int64(value.Nanosecond()/100) + 1 -} - -func TestNullAndBytesBindAsIs(t *testing.T) { - value, failure := bindParameter(1, nullCell(), parameter("DAYDATE")) - if failure != nil || value != nil { - t.Fatalf("null bound as %v, %v", value, failure) - } - value, failure = bindParameter(1, bytesCell([]byte{1, 2}), parameter("BLOB")) - data, ok := value.([]byte) - if failure != nil || !ok || len(data) != 2 { - t.Fatalf("bytes bound as %v, %v", value, failure) - } -} - -func TestDateParametersParseInUTC(t *testing.T) { - want := time.Date(2024, time.February, 29, 0, 0, 0, 0, time.UTC) - for _, typeName := range []string{"DATE", "DAYDATE"} { - if got := bindText(t, "2024-02-29", parameter(typeName)); got != want { - t.Fatalf("%s bound as %v", typeName, got) - } - } - for _, text := range []string{"2023-02-29", "2024-13-01", "2024-00-10", "0000-01-01", "2024-1-01", "2024/01/01", "2024-01-01 00:00:00", "", "20240101"} { - assertParameterFailure(t, text, parameter("DAYDATE"), expectDate) - } -} - -func TestTimeParametersParseTheClock(t *testing.T) { - got := bindText(t, "13:45:07", parameter("SECONDTIME")) - if got != hanaEpoch.Add(13*time.Hour+45*time.Minute+7*time.Second) { - t.Fatalf("SECONDTIME bound as %v", got) - } - bindText(t, "00:00:00", parameter("TIME")) - bindText(t, "23:59:59", parameter("TIME")) - for _, text := range []string{"24:00:00", "12:60:00", "12:00:60", "12:00", "12:00:00.5", "1:02:03", "12-00-00"} { - assertParameterFailure(t, text, parameter("SECONDTIME"), expectTime) - } -} - -func TestSecondDateParametersAcceptTheDateFormAndATSeparator(t *testing.T) { - want := time.Date(2024, time.February, 29, 13, 45, 7, 0, time.UTC) - if got := bindText(t, "2024-02-29 13:45:07", parameter("SECONDDATE")); got != want { - t.Fatalf("bound as %v", got) - } - if got := bindText(t, "2024-02-29T13:45:07", parameter("SECONDDATE")); got != want { - t.Fatalf("T separator bound as %v", got) - } - if got := bindText(t, "2024-02-29", parameter("SECONDDATE")); got != want.Truncate(24*time.Hour) { - t.Fatalf("date form bound as %v", got) - } - for _, text := range []string{"2024-02-29 13:45:07.1", "2024-02-29 13:45", "2024-02-29X13:45:07", "2024-02-29 13:45:07Z"} { - assertParameterFailure(t, text, parameter("SECONDDATE"), expectSecondDate) - } -} - -func TestTimestampParametersAcceptUpToSevenFractionDigits(t *testing.T) { - cases := map[string]time.Time{ - "2024-02-29 13:45:07.1234567": time.Date(2024, 2, 29, 13, 45, 7, 123456700, time.UTC), - "2024-02-29 13:45:07.5": time.Date(2024, 2, 29, 13, 45, 7, 500000000, time.UTC), - "2024-02-29T13:45:07.0000001": time.Date(2024, 2, 29, 13, 45, 7, 100, time.UTC), - "2024-02-29 13:45:07": time.Date(2024, 2, 29, 13, 45, 7, 0, time.UTC), - "2024-02-29": time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC), - "0001-01-01 00:00:00.0000000": time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC), - "9999-12-31 23:59:59.9999999": time.Date(9999, 12, 31, 23, 59, 59, 999999900, time.UTC), - } - for text, want := range cases { - for _, typeName := range []string{"TIMESTAMP", "LONGDATE"} { - if got := bindText(t, text, parameter(typeName)); got != want { - t.Fatalf("%s %q bound as %v; want %v", typeName, text, got, want) - } - } - } - for _, text := range []string{"2024-02-29 13:45:07.12345678", "2024-02-29 13:45:07.", "2024-02-29 13:45:07,5", "2024-02-29 13:45:07.12a"} { - assertParameterFailure(t, text, parameter("LONGDATE"), expectTimestamp) - } -} - -func TestEmptyDateSpellingsRoundTripToTheInstantsGoHdbEncodesAsEmpty(t *testing.T) { - cases := []struct { - column string - decoded time.Time - encode func(time.Time) int64 - spellings []string - }{ - {"DAYDATE", hanaEpoch.AddDate(0, 0, -1), encodeDaydate, []string{"0000-00-00"}}, - {"SECONDDATE", hanaEpoch.Add(-time.Second), encodeSeconddate, []string{"0000-00-00", "0000-00-00 00:00:00", "0000-00-00T00:00:00"}}, - {"LONGDATE", hanaEpoch.Add(-100 * time.Nanosecond), encodeLongdate, []string{"0000-00-00", "0000-00-00 00:00:00", "0000-00-00 00:00:00.0000000"}}, - } - for _, testCase := range cases { - if encoded := testCase.encode(testCase.decoded); encoded != 0 { - t.Fatalf("%s: go-hdb's decoding of wire value 0 re-encodes as %d", testCase.column, encoded) - } - formatted := formatValue(testCase.decoded, column(testCase.column)).text - spellings := append([]string{formatted}, testCase.spellings...) - for _, spelling := range spellings { - bound := bindText(t, spelling, parameter(testCase.column)) - instant, ok := bound.(time.Time) - if !ok || !instant.Equal(testCase.decoded) { - t.Fatalf("%s %q bound as %v; want %v", testCase.column, spelling, bound, testCase.decoded) - } - if encoded := testCase.encode(instant); encoded != 0 { - t.Fatalf("%s %q encodes as %d; want the empty value 0", testCase.column, spelling, encoded) - } - } - } - assertParameterFailure(t, "0000-00-00 00:00:01", parameter("SECONDDATE"), expectSecondDate) - assertParameterFailure(t, "0000-00-00 00:00:00.0000001", parameter("LONGDATE"), expectTimestamp) -} - -func TestFormattedTemporalValuesParseBackToTheSameInstant(t *testing.T) { - cases := []struct { - column string - value time.Time - }{ - {"DAYDATE", time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC)}, - {"DAYDATE", time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, - {"SECONDTIME", hanaEpoch.Add(23*time.Hour + 59*time.Minute + 59*time.Second)}, - {"SECONDDATE", time.Date(1999, 12, 31, 23, 59, 59, 0, time.UTC)}, - {"LONGDATE", time.Date(2024, 2, 29, 13, 45, 7, 123456700, time.UTC)}, - {"LONGDATE", time.Date(9999, 12, 31, 23, 59, 59, 999999900, time.UTC)}, - } - for _, testCase := range cases { - formatted := formatValue(testCase.value, column(testCase.column)).text - bound := bindText(t, formatted, parameter(testCase.column)) - if instant, ok := bound.(time.Time); !ok || !instant.Equal(testCase.value) { - t.Fatalf("%s %q bound as %v; want %v", testCase.column, formatted, bound, testCase.value) - } - } -} - -func TestBooleanParameters(t *testing.T) { - for text, want := range map[string]bool{"TRUE": true, "true": true, "True": true, "1": true, "FALSE": false, "false": false, "0": false} { - if got := bindText(t, text, parameter("BOOLEAN")); got != want { - t.Fatalf("%q bound as %v", text, got) - } - } - for _, text := range []string{"yes", "t", "2", "", "UNKNOWN"} { - assertParameterFailure(t, text, parameter("BOOLEAN"), expectBoolean) - } -} - -func TestIntegerParametersParseAndRespectTheColumnRange(t *testing.T) { - cases := []struct { - typeName string - minimum int64 - maximum int64 - }{ - {"TINYINT", 0, math.MaxUint8}, - {"SMALLINT", math.MinInt16, math.MaxInt16}, - {"INTEGER", math.MinInt32, math.MaxInt32}, - {"BIGINT", math.MinInt64, math.MaxInt64}, - } - for _, testCase := range cases { - for _, value := range []int64{testCase.minimum, testCase.maximum} { - text := strconv.FormatInt(value, 10) - if got := bindText(t, text, parameter(testCase.typeName)); got != value { - t.Fatalf("%s %q bound as %v", testCase.typeName, text, got) - } - } - if testCase.typeName != "BIGINT" { - assertParameterFailure(t, strconv.FormatInt(testCase.maximum+1, 10), parameter(testCase.typeName), expectInteger) - assertParameterFailure(t, strconv.FormatInt(testCase.minimum-1, 10), parameter(testCase.typeName), expectInteger) - } - } - for _, text := range []string{"1.5", "abc", "", " 1", "1e3", "9223372036854775808"} { - assertParameterFailure(t, text, parameter("BIGINT"), expectInteger) - } -} - -func TestDecimalParametersRejectMoreFractionDigitsThanTheScale(t *testing.T) { - fixed := fixedParameter("FIXED8", 10, 2) - for _, text := range []string{"123.45", "-0.05", "1.50", "1.500", "12345678.9", "0", "1E2", "1.2E1", "-12345678.99", "+7", ".5", "5."} { - if got := bindText(t, text, fixed); got != text { - t.Fatalf("%q bound as %v; want the text passed through", text, got) - } - } - assertParameterFailure(t, "123.456", fixed, expectScale) - assertParameterFailure(t, "-0.001", fixed, expectScale) - assertParameterFailure(t, "1E-3", fixed, expectScale) - assertParameterFailure(t, "123456789", fixed, expectDecimal) - for _, text := range []string{"abc", "", "1/3", "0x10", "1.2.3", "--1", "1e", "Inf", "NaN", ".", "1 000"} { - assertParameterFailure(t, text, fixed, expectDecimal) - } - for _, typeName := range []string{"FIXED12", "FIXED16"} { - assertParameterFailure(t, "0.123", fixedParameter(typeName, 28, 2), expectScale) - } -} - -func TestFloatingDecimalAndDoubleParametersPassTheTextThrough(t *testing.T) { - floating := fixedParameter("DECIMAL", 34, 32767) - if got := bindText(t, "1.234567890123456789E-40", floating); got != "1.234567890123456789E-40" { - t.Fatalf("DECIMAL bound as %v", got) - } - bindText(t, "3.14", parameter("SMALLDECIMAL")) - assertParameterFailure(t, "pi", parameter("SMALLDECIMAL"), expectDecimal) - for _, typeName := range []string{"REAL", "DOUBLE"} { - if got := bindText(t, "2.5e-3", parameter(typeName)); got != "2.5e-3" { - t.Fatalf("%s bound as %v", typeName, got) - } - assertParameterFailure(t, "two", parameter(typeName), expectDouble) - } -} - -func TestOtherTypesBindTheTextUnchanged(t *testing.T) { - for _, typeName := range []string{"NVARCHAR", "VARCHAR", "NCLOB", "CLOB", "VARBINARY", "ALPHANUM"} { - if got := bindText(t, "value", parameter(typeName)); got != "value" { - t.Fatalf("%s bound as %v", typeName, got) - } - } -} - -func TestBindParametersNamesTheFailingPosition(t *testing.T) { - values := []cell{textCell("1"), textCell("2024-02-30")} - types := []parameterType{parameter("INTEGER"), parameter("DAYDATE")} - _, failure := bindParameters(values, types) - assertKind(t, failure, kindParameter) - if failure.Parameter != 2 || failure.Expected != expectDate { - t.Fatalf("failure = %+v; want parameter 2 expecting a date", failure) - } - _, failure = bindParameters(values[:1], types) - assertKind(t, failure, kindInternal) - arguments, failure := bindParameters([]cell{textCell("7"), nullCell()}, types) - if failure != nil || arguments[0] != int64(7) || arguments[1] != nil { - t.Fatalf("arguments = %v, %v", arguments, failure) - } -} - -func TestSpatialParametersTakeHexWellKnownBinaryOnly(t *testing.T) { - point := "0101000000000000000000F03F0000000000000040" - for _, typeName := range []string{"STGEOMETRY", "STPOINT", "ST_GEOMETRY", "ST_POINT"} { - if got := bindText(t, point, parameter(typeName)); got != point { - t.Fatalf("%s bound as %v", typeName, got) - } - assertParameterFailure(t, "POINT (1 2)", parameter(typeName), expectHex) - bound, failure := bindParameter(1, bytesCell([]byte{0x01, 0xFF}), parameter(typeName)) - if failure != nil || bound != "01ff" { - t.Fatalf("%s bytes bound as %v, %v; want the hex text go-hdb decodes", typeName, bound, failure) - } - } - bound, failure := bindParameter(1, bytesCell([]byte{0x01, 0xFF}), parameter("VARBINARY")) - if failure != nil || !bytes.Equal(bound.([]byte), []byte{0x01, 0xFF}) { - t.Fatalf("VARBINARY bytes bound as %v, %v", bound, failure) - } -} - -func TestOutputParametersAreRefusedWithTheirPosition(t *testing.T) { - input := parameter("INTEGER") - output := parameter("INTEGER") - output.output = true - if failure := refuseOutputParameters([]parameterType{input, input}); failure != nil { - t.Fatalf("input parameters refused: %+v", failure) - } - failure := refuseOutputParameters([]parameterType{input, output}) - assertKind(t, failure, kindParameter) - if failure.Parameter != 2 || failure.Expected != expectOutput { - t.Fatalf("failure = %+v; want parameter 2 refused as an OUT parameter", failure) - } -} - -func TestOnlyLargeObjectParametersNeedATransaction(t *testing.T) { - for _, typeName := range []string{"BLOB", "CLOB", "NCLOB", "TEXT", "BINTEXT"} { - if !bindsLargeObject([]parameterType{parameter("INTEGER"), parameter(typeName)}) { - t.Fatalf("%s does not ask for a transaction", typeName) - } - } - if bindsLargeObject([]parameterType{parameter("NVARCHAR"), parameter("VARBINARY"), parameter("DECIMAL")}) { - t.Fatal("a statement without LOB parameters asks for a transaction") - } -} diff --git a/Native/HanaBridge/internal/hana/registry_test.go b/Native/HanaBridge/internal/hana/registry_test.go deleted file mode 100644 index 5576eeb195..0000000000 --- a/Native/HanaBridge/internal/hana/registry_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package hana - -import ( - "fmt" - "testing" -) - -var testBridge = NewBridge() - -func unreachableConfig(port int) []byte { - return []byte(fmt.Sprintf(`{"host":"127.0.0.1","port":%d,"username":"DBADMIN","password":"secret","tlsMode":"disabled","connectTimeoutSeconds":2}`, port)) -} - -func openTestSession(t *testing.T, config []byte) uint64 { - t.Helper() - id, failure := testBridge.openSession(config) - if failure != nil { - t.Fatalf("openSession: %v", failure) - } - t.Cleanup(func() { testBridge.closeSession(id) }) - return id -} - -func assertKind(t *testing.T, failure *bridgeError, kind string) { - t.Helper() - if failure == nil { - t.Fatalf("got no error; want kind %q", kind) - } - if failure.Kind != kind { - t.Fatalf("kind = %q (%v); want %q", failure.Kind, failure, kind) - } -} - -func TestUnknownSessionIDAnswersClosedEverywhere(t *testing.T) { - const unknown = 1 << 60 - _, failure := testBridge.connectSession(unknown, 1) - assertKind(t, failure, kindClosed) - _, failure = testBridge.executeOnSession(unknown, 1, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - assertKind(t, failure, kindClosed) - _, failure = testBridge.explainOnSession(unknown, 1, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - assertKind(t, failure, kindClosed) - assertKind(t, testBridge.pingSession(unknown, 1), kindClosed) - testBridge.cancelOnSession(unknown, 0) - testBridge.closeSession(unknown) -} - -func TestSessionIDZeroIsNeverIssued(t *testing.T) { - _, failure := testBridge.executeOnSession(0, 1, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - assertKind(t, failure, kindClosed) - id := openTestSession(t, unreachableConfig(1)) - if id == 0 { - t.Fatal("openSession issued id 0") - } -} - -func TestClosedSessionAnswersClosedAndItsIDIsNeverReused(t *testing.T) { - first := openTestSession(t, unreachableConfig(1)) - testBridge.closeSession(first) - testBridge.closeSession(first) - _, failure := testBridge.executeOnSession(first, 1, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - assertKind(t, failure, kindClosed) - _, failure = testBridge.connectSession(first, 2) - assertKind(t, failure, kindClosed) - assertKind(t, testBridge.pingSession(first, 3), kindClosed) - testBridge.cancelOnSession(first, 0) - - second := openTestSession(t, unreachableConfig(1)) - third := openTestSession(t, unreachableConfig(1)) - if second <= first || third <= second { - t.Fatalf("ids %d, %d, %d are not strictly increasing", first, second, third) - } -} - -func TestRegistryKeepsSessionsApart(t *testing.T) { - registry := newSessionRegistry() - one := &session{} - two := &session{} - firstID := registry.register(one) - secondID := registry.register(two) - found, failure := registry.lookup(secondID) - if failure != nil || found != two { - t.Fatalf("lookup(%d) = %p, %v; want the second session", secondID, found, failure) - } - removed, ok := registry.remove(firstID) - if !ok || removed != one { - t.Fatalf("remove(%d) = %p, %v; want the first session", firstID, removed, ok) - } - if _, ok := registry.remove(firstID); ok { - t.Fatal("a removed id was removed twice") - } - _, failure = registry.lookup(firstID) - assertKind(t, failure, kindClosed) - if thirdID := registry.register(one); thirdID <= secondID { - t.Fatalf("id %d reused after %d", thirdID, secondID) - } -} diff --git a/Native/HanaBridge/internal/hana/routing_test.go b/Native/HanaBridge/internal/hana/routing_test.go deleted file mode 100644 index 8aa791ffaa..0000000000 --- a/Native/HanaBridge/internal/hana/routing_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package hana - -import "testing" - -func TestStatementRouting(t *testing.T) { - cases := []struct { - statement string - keyword string - exec bool - }{ - {"INSERT INTO T VALUES (1)", "INSERT", true}, - {"update t set c = 1", "UPDATE", true}, - {"Delete From T", "DELETE", true}, - {"UPSERT T VALUES (1) WITH PRIMARY KEY", "UPSERT", true}, - {"REPLACE T VALUES (1) WITH PRIMARY KEY", "REPLACE", true}, - {"MERGE INTO T USING S ON T.ID = S.ID WHEN MATCHED THEN UPDATE SET T.C = S.C", "MERGE", true}, - {" \n\tINSERT INTO T VALUES (1)", "INSERT", true}, - {"\uFEFFINSERT INTO T VALUES (1)", "INSERT", true}, - {"-- note\nINSERT INTO T VALUES (1)", "INSERT", true}, - {"-- note\r\nDELETE FROM T", "DELETE", true}, - {"/* leading */ UPDATE T SET C = 1", "UPDATE", true}, - {"/* one */ -- two\n /* three */INSERT INTO T VALUES (1)", "INSERT", true}, - {"(INSERT INTO T VALUES (1))", "INSERT", true}, - {";;INSERT INTO T VALUES (1)", "INSERT", true}, - {"; -- after an empty statement\nDELETE FROM T", "DELETE", true}, - {"SELECT * FROM T", "SELECT", false}, - {"(SELECT 1 FROM DUMMY)", "SELECT", false}, - {"WITH X AS (SELECT 1 AS A FROM DUMMY) SELECT * FROM X", "WITH", false}, - {"CALL P()", "CALL", false}, - {"DO BEGIN SELECT 1 FROM DUMMY; END", "DO", false}, - {"CREATE TABLE T (C INTEGER)", "CREATE", false}, - {"EXPLAIN PLAN FOR SELECT 1 FROM DUMMY", "EXPLAIN", false}, - {"SET SCHEMA APP", "SET", false}, - {"INSERTED_ROWS", "INSERTED_ROWS", false}, - {"INSERT1 INTO T", "INSERT1", false}, - {"-- only a comment", "", false}, - {"/* unterminated", "", false}, - {"", "", false}, - {" ", "", false}, - } - for _, testCase := range cases { - if keyword := leadingKeyword(testCase.statement); keyword != testCase.keyword { - t.Errorf("leadingKeyword(%q) = %q; want %q", testCase.statement, keyword, testCase.keyword) - } - if exec := routesToExec(testCase.statement); exec != testCase.exec { - t.Errorf("routesToExec(%q) = %v; want %v", testCase.statement, exec, testCase.exec) - } - } -} diff --git a/Native/HanaBridge/internal/hana/scripted_driver_test.go b/Native/HanaBridge/internal/hana/scripted_driver_test.go deleted file mode 100644 index cab78f83b0..0000000000 --- a/Native/HanaBridge/internal/hana/scripted_driver_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package hana - -import ( - "context" - "database/sql" - "database/sql/driver" - "errors" - "io" - "testing" - "time" -) - -var errScriptedCallUnsupported = errors.New("the scripted driver does not script this call") - -type scriptedDriver struct{} - -func (scriptedDriver) Open(string) (driver.Conn, error) { - return nil, errScriptedCallUnsupported -} - -type scriptedConnector struct { - conn *scriptedConn -} - -func (c scriptedConnector) Connect(context.Context) (driver.Conn, error) { - return c.conn, nil -} - -func (c scriptedConnector) Driver() driver.Driver { - return scriptedDriver{} -} - -type scriptedConn struct { - exec func(query string) (driver.Result, error) - query func(query string) (driver.Rows, error) - queryContext func(context.Context, string) (driver.Rows, error) - ping func() error -} - -func (c *scriptedConn) Prepare(string) (driver.Stmt, error) { - return nil, errScriptedCallUnsupported -} - -func (c *scriptedConn) Close() error { - return nil -} - -func (c *scriptedConn) Begin() (driver.Tx, error) { - return nil, errScriptedCallUnsupported -} - -func (c *scriptedConn) ExecContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Result, error) { - if c.exec == nil { - return nil, errScriptedCallUnsupported - } - return c.exec(query) -} - -func (c *scriptedConn) QueryContext(ctx context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) { - if c.queryContext != nil { - return c.queryContext(ctx, query) - } - if c.query == nil { - return nil, errScriptedCallUnsupported - } - return c.query(query) -} - -func (c *scriptedConn) Ping(context.Context) error { - if c.ping == nil { - return nil - } - return c.ping() -} - -type scriptedRows struct { - columns []string - values [][]driver.Value - next int - closeErr error - closes int -} - -func (r *scriptedRows) Columns() []string { - return r.columns -} - -func (r *scriptedRows) Close() error { - r.closes++ - return r.closeErr -} - -func (r *scriptedRows) Next(destination []driver.Value) error { - if r.next >= len(r.values) { - return io.EOF - } - copy(destination, r.values[r.next]) - r.next++ - return nil -} - -func scriptedSession(t *testing.T, conn *scriptedConn) *session { - t.Helper() - entry := connectedTestSession(t, closedPort(t), 0) - db := sql.OpenDB(scriptedConnector{conn: conn}) - t.Cleanup(func() { _ = db.Close() }) - pinned, err := db.Conn(context.Background()) - if err != nil { - t.Fatal(err) - } - entry.mu.Lock() - entry.conn = pinned - entry.mu.Unlock() - return entry -} - -func TestIdentityQueryUsesTheConnectDeadline(t *testing.T) { - conn := &scriptedConn{queryContext: func(ctx context.Context, _ string) (driver.Rows, error) { - <-ctx.Done() - return nil, ctx.Err() - }} - db := sql.OpenDB(scriptedConnector{conn: conn}) - t.Cleanup(func() { _ = db.Close() }) - entry := &session{db: db} - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - - _, _, err := entry.establish(ctx) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("establish returned %v; want the connect deadline", err) - } -} diff --git a/Native/HanaBridge/internal/hana/session_test.go b/Native/HanaBridge/internal/hana/session_test.go deleted file mode 100644 index 248809aadc..0000000000 --- a/Native/HanaBridge/internal/hana/session_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package hana - -import ( - "crypto/tls" - "crypto/x509" - "fmt" - "net" - "testing" - "time" -) - -func sessionConfig(port int, tlsMode string, extra string) []byte { - return timedSessionConfig(port, tlsMode, 10, extra) -} - -func timedSessionConfig(port int, tlsMode string, timeoutSeconds float64, extra string) []byte { - return []byte(fmt.Sprintf(`{"host":"127.0.0.1","port":%d,"username":"DBADMIN","password":"secret","tlsMode":%q,"connectTimeoutSeconds":%g%s}`, port, tlsMode, timeoutSeconds, extra)) -} - -func closedPort(t *testing.T) int { - t.Helper() - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - port := listener.Addr().(*net.TCPAddr).Port - _ = listener.Close() - return port -} - -type connectOutcome struct { - result []byte - failure *bridgeError - elapsed time.Duration -} - -func connectInBackground(id uint64, operationID uint64) <-chan connectOutcome { - outcome := make(chan connectOutcome, 1) - go func() { - started := time.Now() - result, failure := testBridge.connectSession(id, operationID) - outcome <- connectOutcome{result: result, failure: failure, elapsed: time.Since(started)} - }() - return outcome -} - -func awaitConnect(t *testing.T, outcome <-chan connectOutcome) connectOutcome { - t.Helper() - select { - case result := <-outcome: - return result - case <-time.After(8 * time.Second): - t.Fatal("connect did not return") - return connectOutcome{} - } -} - -func TestConnectToAClosedPortIsAConnectFailure(t *testing.T) { - id := openTestSession(t, sessionConfig(closedPort(t), tlsModeDisabled, "")) - _, failure := testBridge.connectSession(id, 1) - assertKind(t, failure, kindConnect) - _, failure = testBridge.executeOnSession(id, 2, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - assertKind(t, failure, kindConnectionLost) -} - -func TestConnectTimeoutEndsAConnectToASilentServer(t *testing.T) { - server := newSilentServer(t) - id := openTestSession(t, timedSessionConfig(server.port(), tlsModeDisabled, 0.3, "")) - outcome := awaitConnect(t, connectInBackground(id, 1)) - assertKind(t, outcome.failure, kindTimeout) - server.waitForClientHangUp(t) -} - -func TestCancellingTheConnectOperationStopsIt(t *testing.T) { - for _, operationToCancel := range []uint64{1, 0} { - server := newSilentServer(t) - id := openTestSession(t, sessionConfig(server.port(), tlsModeDisabled, "")) - outcome := connectInBackground(id, 1) - waitFor(t, func() bool { return server.accepted.Load() == 1 }) - time.Sleep(50 * time.Millisecond) - testBridge.cancelOnSession(id, operationToCancel) - result := awaitConnect(t, outcome) - assertKind(t, result.failure, kindCancelled) - if result.elapsed > 5*time.Second { - t.Fatalf("cancel took %v", result.elapsed) - } - server.waitForClientHangUp(t) - _, failure := testBridge.executeOnSession(id, 2, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - assertKind(t, failure, kindConnectionLost) - } -} - -func TestConnectCancelledBeforeItStartsNeverDials(t *testing.T) { - server := newSilentServer(t) - id := openTestSession(t, sessionConfig(server.port(), tlsModeDisabled, "")) - testBridge.cancelOnSession(id, 1) - _, failure := testBridge.connectSession(id, 1) - assertKind(t, failure, kindCancelled) - time.Sleep(50 * time.Millisecond) - if accepted := server.accepted.Load(); accepted != 0 { - t.Fatalf("a connect cancelled before it started dialed %d times", accepted) - } -} - -func TestClosingDuringConnectAnswersClosedPromptly(t *testing.T) { - server := newSilentServer(t) - id, failure := testBridge.openSession(sessionConfig(server.port(), tlsModeDisabled, "")) - if failure != nil { - t.Fatal(failure) - } - outcome := connectInBackground(id, 1) - waitFor(t, func() bool { return server.accepted.Load() == 1 }) - started := time.Now() - testBridge.closeSession(id) - if elapsed := time.Since(started); elapsed > time.Second { - t.Fatalf("closing the session blocked for %v", elapsed) - } - result := awaitConnect(t, outcome) - assertKind(t, result.failure, kindClosed) - server.waitForClientHangUp(t) - _, failure = testBridge.executeOnSession(id, 2, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - assertKind(t, failure, kindClosed) -} - -func TestEverySessionSeversAfterTheExportedGrace(t *testing.T) { - id := openTestSession(t, unreachableConfig(closedPort(t))) - entry, failure := testBridge.sessions.lookup(id) - if failure != nil { - t.Fatal(failure) - } - if entry.slot.severGrace != ForcedSeverGrace { - t.Fatalf("the session severs after %v; want the exported ForcedSeverGrace %v", entry.slot.severGrace, ForcedSeverGrace) - } -} - -func TestStatementsBeforeConnectAnswerConnectionLost(t *testing.T) { - id := openTestSession(t, unreachableConfig(1)) - _, failure := testBridge.executeOnSession(id, 1, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - assertKind(t, failure, kindConnectionLost) - _, failure = testBridge.explainOnSession(id, 2, []byte(`{"sql":"SELECT 1 FROM DUMMY"}`)) - assertKind(t, failure, kindConnectionLost) - assertKind(t, testBridge.pingSession(id, 3), kindConnectionLost) -} - -func TestConnectThroughGoHdbReportsTLSFailures(t *testing.T) { - plaintext := startPlaintextServer(t, func(conn net.Conn) { - buffer := make([]byte, 512) - _, _ = conn.Read(buffer) - _, _ = conn.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n")) - }) - id := openTestSession(t, sessionConfig(plaintext, tlsModeRequired, "")) - _, failure := testBridge.connectSession(id, 1) - assertTLSCode(t, failure, tlsFailurePlaintextServer) - - authority := newTestAuthority(t) - certificate, _, _ := authority.issue(t, 2, x509.ExtKeyUsageServerAuth, "hana.internal") - server := newTLSTestServer(t, certificate, nil) - untrusted := openTestSession(t, sessionConfig(server.port(), tlsModeVerifyCA, "")) - _, failure = testBridge.connectSession(untrusted, 1) - assertTLSCode(t, failure, tlsFailureUntrusted) - - mismatch := openTestSession(t, sessionConfig(server.port(), tlsModeVerifyIdentity, fmt.Sprintf(`,"caCertificatePath":%q`, authority.pemPath))) - _, failure = testBridge.connectSession(mismatch, 1) - assertTLSCode(t, failure, tlsFailureHostnameMismatch) - - hangingUp := startHangingUpTLSServer(t, certificate) - trusted := openTestSession(t, sessionConfig(hangingUp, tlsModeVerifyCA, fmt.Sprintf(`,"caCertificatePath":%q`, authority.pemPath))) - _, failure = testBridge.connectSession(trusted, 1) - assertKind(t, failure, kindConnect) -} - -func startHangingUpTLSServer(t *testing.T, certificate tls.Certificate) int { - t.Helper() - listener, err := tls.Listen("tcp4", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{certificate}}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = listener.Close() }) - go func() { - for { - conn, err := listener.Accept() - if err != nil { - return - } - go func() { - defer func() { _ = conn.Close() }() - if secured, ok := conn.(*tls.Conn); ok { - _ = secured.Handshake() - } - }() - } - }() - return listener.Addr().(*net.TCPAddr).Port -} - -func TestConnectTwiceIsRefused(t *testing.T) { - id := openTestSession(t, sessionConfig(closedPort(t), tlsModeDisabled, "")) - _, failure := testBridge.connectSession(id, 1) - assertKind(t, failure, kindConnect) - _, failure = testBridge.connectSession(id, 2) - assertKind(t, failure, kindConnectionLost) -} diff --git a/Native/HanaBridge/internal/hana/statement_test.go b/Native/HanaBridge/internal/hana/statement_test.go deleted file mode 100644 index 8bf86f623f..0000000000 --- a/Native/HanaBridge/internal/hana/statement_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package hana - -import ( - "database/sql/driver" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "reflect" - "strings" - "testing" -) - -type decodedResult struct { - Rows [][]string `json:"rows"` - RowsAffected int64 `json:"rowsAffected"` - IsTruncated bool `json:"isTruncated"` - SessionLost *bool `json:"sessionLost"` -} - -func decodeResult(t *testing.T, encoded []byte) decodedResult { - t.Helper() - var result decodedResult - if err := json.Unmarshal(encoded, &result); err != nil { - t.Fatalf("result %s: %v", encoded, err) - } - if result.SessionLost == nil { - t.Fatalf("result %s has no sessionLost", encoded) - } - return result -} - -func planRows(values ...[]driver.Value) *scriptedRows { - return &scriptedRows{ - columns: []string{"OPERATOR_ID", "PARENT_OPERATOR_ID", "POSITION", "OPERATOR_NAME", "OPERATOR_DETAILS", - "SCHEMA_NAME", "TABLE_NAME", "OUTPUT_SIZE", "SUBTREE_COST"}, - values: values, - } -} - -func TestHealthyStatementReportsTheSessionKept(t *testing.T) { - entry := scriptedSession(t, &scriptedConn{exec: func(string) (driver.Result, error) { - return driver.RowsAffected(2), nil - }}) - encoded, failure := entry.execute(1, executeRequest{SQL: "UPDATE T SET A = 1"}) - if failure != nil { - t.Fatal(failure) - } - result := decodeResult(t, encoded) - if *result.SessionLost || result.RowsAffected != 2 { - t.Fatalf("result = %+v; want two affected rows on a kept session", result) - } -} - -func TestStatementThatSucceedsAfterItsCancelFailedReportsTheLostSession(t *testing.T) { - var entry *session - entry = scriptedSession(t, &scriptedConn{exec: func(string) (driver.Result, error) { - entry.cancel(1) - waitFor(t, func() bool { return entry.currentState() == sessionLost }) - return driver.RowsAffected(1), nil - }}) - encoded, failure := entry.execute(1, executeRequest{SQL: "INSERT INTO T VALUES (1)"}) - if failure != nil { - t.Fatalf("a statement that committed answered %v; want its result", failure) - } - result := decodeResult(t, encoded) - if !*result.SessionLost || result.RowsAffected != 1 { - t.Fatalf("result = %+v; want the committed row and the lost session", result) - } -} - -func TestExplainReportsTheSessionLostWhileDiscardingThePlan(t *testing.T) { - entry := scriptedSession(t, &scriptedConn{ - exec: func(query string) (driver.Result, error) { - if strings.HasPrefix(query, "DELETE FROM SYS.EXPLAIN_PLAN_TABLE") { - return nil, io.ErrUnexpectedEOF - } - return driver.RowsAffected(0), nil - }, - query: func(string) (driver.Rows, error) { - return planRows([]driver.Value{int64(1), nil, int64(1), "COLUMN SEARCH", "T.ID", nil, nil, float64(10), float64(0.5)}), nil - }, - }) - encoded, failure := entry.explain(1, explainRequest{SQL: "SELECT ID FROM T"}) - if failure != nil { - t.Fatal(failure) - } - result := decodeResult(t, encoded) - want := [][]string{{"COLUMN SEARCH T.ID rows 10 cost 0.5"}} - if !*result.SessionLost || !reflect.DeepEqual(result.Rows, want) { - t.Fatalf("result = %+v; want the plan and the lost session", result) - } - if entry.currentState() != sessionLost { - t.Fatalf("state = %v; want lost", entry.currentState()) - } -} - -func TestExplainOnAKeptSessionDiscardsThePlan(t *testing.T) { - var discarded []string - entry := scriptedSession(t, &scriptedConn{ - exec: func(query string) (driver.Result, error) { - if strings.HasPrefix(query, "DELETE FROM SYS.EXPLAIN_PLAN_TABLE") { - discarded = append(discarded, query) - } - return driver.RowsAffected(0), nil - }, - query: func(string) (driver.Rows, error) { - return planRows(), nil - }, - }) - encoded, failure := entry.explain(1, explainRequest{SQL: "SELECT ID FROM T"}) - if failure != nil { - t.Fatal(failure) - } - if result := decodeResult(t, encoded); *result.SessionLost { - t.Fatalf("result = %+v; want a kept session", result) - } - if len(discarded) != 1 { - t.Fatalf("plan cleanup ran %d times; want once", len(discarded)) - } -} - -func TestTruncatedResultWhoseCloseLosesTheConnectionIsConnectionLost(t *testing.T) { - rows := &scriptedRows{columns: []string{"ID"}, values: [][]driver.Value{{int64(1)}, {int64(2)}}, closeErr: net.ErrClosed} - entry := scriptedSession(t, &scriptedConn{query: func(string) (driver.Rows, error) { return rows, nil }}) - _, failure := entry.execute(1, executeRequest{SQL: "SELECT ID FROM T", RowCap: 1}) - assertKind(t, failure, kindConnectionLost) - if entry.currentState() != sessionLost || rows.closes != 1 { - t.Fatalf("state=%v closes=%d; want a lost session after one close", entry.currentState(), rows.closes) - } -} - -func TestFullyReadResultWhoseCloseLosesTheConnectionIsConnectionLost(t *testing.T) { - socketFailure := fmt.Errorf("%w: %w", driver.ErrBadConn, io.EOF) - rows := &scriptedRows{columns: []string{"ID"}, values: [][]driver.Value{{int64(1)}}, closeErr: socketFailure} - entry := scriptedSession(t, &scriptedConn{query: func(string) (driver.Rows, error) { return rows, nil }}) - _, failure := entry.execute(1, executeRequest{SQL: "SELECT ID FROM T"}) - assertKind(t, failure, kindConnectionLost) - if entry.currentState() != sessionLost { - t.Fatalf("state = %v; want lost", entry.currentState()) - } -} - -func TestResultWhoseCloseFailsWithoutLosingTheConnectionKeepsTheResult(t *testing.T) { - rows := &scriptedRows{columns: []string{"ID"}, values: [][]driver.Value{{int64(1)}, {int64(2)}}, closeErr: errors.New("close refused")} - entry := scriptedSession(t, &scriptedConn{query: func(string) (driver.Rows, error) { return rows, nil }}) - encoded, failure := entry.execute(1, executeRequest{SQL: "SELECT ID FROM T", RowCap: 1}) - if failure != nil { - t.Fatal(failure) - } - result := decodeResult(t, encoded) - if *result.SessionLost || !result.IsTruncated || !reflect.DeepEqual(result.Rows, [][]string{{"1"}}) { - t.Fatalf("result = %+v; want the truncated row on a kept session", result) - } -} - -func TestPingThatAnswersAfterItsCancelFailedIsConnectionLost(t *testing.T) { - var entry *session - entry = scriptedSession(t, &scriptedConn{ping: func() error { - entry.cancel(1) - waitFor(t, func() bool { return entry.currentState() == sessionLost }) - return nil - }}) - assertKind(t, entry.ping(1), kindConnectionLost) -} - -func TestPingOnAKeptSessionSucceeds(t *testing.T) { - entry := scriptedSession(t, &scriptedConn{}) - if failure := entry.ping(1); failure != nil { - t.Fatal(failure) - } -} diff --git a/Native/HanaBridge/internal/hana/tls_test.go b/Native/HanaBridge/internal/hana/tls_test.go deleted file mode 100644 index f66ccb74ec..0000000000 --- a/Native/HanaBridge/internal/hana/tls_test.go +++ /dev/null @@ -1,339 +0,0 @@ -package hana - -import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "io" - "log" - "math/big" - "net" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strconv" - "sync" - "testing" - "time" - - "github.com/SAP/go-hdb/driver/dial" -) - -type testAuthority struct { - certificate *x509.Certificate - key *ecdsa.PrivateKey - pemPath string -} - -func newTestAuthority(t *testing.T) *testAuthority { - t.Helper() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "TablePro Test Root"}, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(time.Hour), - IsCA: true, - BasicConstraintsValid: true, - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, - } - der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) - if err != nil { - t.Fatal(err) - } - certificate, err := x509.ParseCertificate(der) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "ca.pem") - writePEM(t, path, "CERTIFICATE", der) - return &testAuthority{certificate: certificate, key: key, pemPath: path} -} - -func (a *testAuthority) issue(t *testing.T, serial int64, usage x509.ExtKeyUsage, dnsNames ...string) (tls.Certificate, string, string) { - t.Helper() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(serial), - Subject: pkix.Name{CommonName: "leaf"}, - DNSNames: dnsNames, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{usage}, - } - der, err := x509.CreateCertificate(rand.Reader, template, a.certificate, &key.PublicKey, a.key) - if err != nil { - t.Fatal(err) - } - keyDER, err := x509.MarshalECPrivateKey(key) - if err != nil { - t.Fatal(err) - } - directory := t.TempDir() - certificatePath := filepath.Join(directory, "leaf.pem") - keyPath := filepath.Join(directory, "leaf.key") - writePEM(t, certificatePath, "CERTIFICATE", der) - writePEM(t, keyPath, "EC PRIVATE KEY", keyDER) - pair, err := tls.LoadX509KeyPair(certificatePath, keyPath) - if err != nil { - t.Fatal(err) - } - return pair, certificatePath, keyPath -} - -func writePEM(t *testing.T, path string, blockType string, der []byte) { - t.Helper() - if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der}), 0o600); err != nil { - t.Fatal(err) - } -} - -type tlsTestServer struct { - server *httptest.Server - mu sync.Mutex - serverNames []string -} - -func newTLSTestServer(t *testing.T, certificate tls.Certificate, clientAuthority *testAuthority) *tlsTestServer { - t.Helper() - testServer := &tlsTestServer{} - testServer.server = httptest.NewUnstartedServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - testServer.server.Config.ErrorLog = log.New(io.Discard, "", 0) - testServer.server.TLS = &tls.Config{ - Certificates: []tls.Certificate{certificate}, - GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { - testServer.mu.Lock() - testServer.serverNames = append(testServer.serverNames, hello.ServerName) - testServer.mu.Unlock() - return nil, nil - }, - } - if clientAuthority != nil { - pool := x509.NewCertPool() - pool.AddCert(clientAuthority.certificate) - testServer.server.TLS.ClientCAs = pool - testServer.server.TLS.ClientAuth = tls.RequireAndVerifyClientCert - } - testServer.server.StartTLS() - t.Cleanup(testServer.server.Close) - return testServer -} - -func (s *tlsTestServer) port() int { - return s.server.Listener.Addr().(*net.TCPAddr).Port -} - -func (s *tlsTestServer) lastServerName() string { - s.mu.Lock() - defer s.mu.Unlock() - if len(s.serverNames) == 0 { - return "" - } - return s.serverNames[len(s.serverNames)-1] -} - -func handshake(t *testing.T, config connectionConfig, port int) *bridgeError { - t.Helper() - settings, failure := buildTLSConfig(config, config.hostName()) - if failure != nil { - t.Fatalf("buildTLSConfig: %v", failure) - } - dialer := newTrackingDialer(dial.DefaultDialer, settings) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - address := net.JoinHostPort(config.hostName(), strconv.Itoa(port)) - conn, err := dialer.DialContext(ctx, address, dial.DialerOptions{}) - if err != nil { - return classifyConnectError(err) - } - if _, ok := conn.(*tls.Conn); !ok { - t.Fatalf("dialer returned %T; want a TLS connection", conn) - } - _ = conn.Close() - return nil -} - -func tlsConfig(mode string, host string) connectionConfig { - return connectionConfig{Host: host, Port: 1, Username: "U", TLSMode: mode} -} - -func assertTLSCode(t *testing.T, failure *bridgeError, code int) { - t.Helper() - assertKind(t, failure, kindTLS) - if failure.Code != code { - t.Fatalf("tls code = %d (%s); want %d", failure.Code, failure.Message, code) - } -} - -func TestTLSModesAgainstAServerWithAPrivateAuthority(t *testing.T) { - authority := newTestAuthority(t) - serverCertificate, _, _ := authority.issue(t, 2, x509.ExtKeyUsageServerAuth, "hana.internal") - server := newTLSTestServer(t, serverCertificate, nil) - - verifyCA := tlsConfig(tlsModeVerifyCA, "127.0.0.1") - verifyCA.CACertificatePath = authority.pemPath - if failure := handshake(t, verifyCA, server.port()); failure != nil { - t.Fatalf("verifyCa rejected a trusted chain with a different host name: %v", failure) - } - - identity := tlsConfig(tlsModeVerifyIdentity, "127.0.0.1") - identity.CACertificatePath = authority.pemPath - assertTLSCode(t, handshake(t, identity, server.port()), tlsFailureHostnameMismatch) - - localhostIdentity := tlsConfig(tlsModeVerifyIdentity, "localhost") - localhostIdentity.CACertificatePath = authority.pemPath - assertTLSCode(t, handshake(t, localhostIdentity, server.port()), tlsFailureHostnameMismatch) - if name := server.lastServerName(); name != "localhost" { - t.Fatalf("SNI = %q; want the host", name) - } - - override := localhostIdentity - override.TLSServerName = "hana.internal" - if failure := handshake(t, override, server.port()); failure != nil { - t.Fatalf("verifyIdentity with tlsServerName rejected the matching certificate: %v", failure) - } - if name := server.lastServerName(); name != "localhost" { - t.Fatalf("SNI with an override = %q; want the host, not the verified name", name) - } - - wrongOverride := override - wrongOverride.TLSServerName = "other.internal" - assertTLSCode(t, handshake(t, wrongOverride, server.port()), tlsFailureHostnameMismatch) - - systemRootsIdentity := tlsConfig(tlsModeVerifyIdentity, "localhost") - systemRootsIdentity.TLSServerName = "hana.internal" - assertTLSCode(t, handshake(t, systemRootsIdentity, server.port()), tlsFailureUntrusted) - - systemRootsCA := tlsConfig(tlsModeVerifyCA, "127.0.0.1") - assertTLSCode(t, handshake(t, systemRootsCA, server.port()), tlsFailureUntrusted) - - for _, mode := range []string{tlsModeRequired, tlsModePreferred} { - if failure := handshake(t, tlsConfig(mode, "127.0.0.1"), server.port()); failure != nil { - t.Fatalf("%s refused an unverified server: %v", mode, failure) - } - } -} - -func TestTLSConfigurationShape(t *testing.T) { - disabled, failure := buildTLSConfig(tlsConfig(tlsModeDisabled, "h"), "h") - if failure != nil || disabled != nil { - t.Fatalf("disabled = %v, %v", disabled, failure) - } - for _, mode := range []string{tlsModePreferred, tlsModeRequired, tlsModeVerifyCA, tlsModeVerifyIdentity} { - settings, failure := buildTLSConfig(tlsConfig(mode, "db.example.com"), "db.example.com") - if failure != nil { - t.Fatal(failure) - } - if settings.ServerName != "db.example.com" || settings.MinVersion != tls.VersionTLS12 { - t.Fatalf("%s: ServerName=%q MinVersion=%x", mode, settings.ServerName, settings.MinVersion) - } - } - identity, _ := buildTLSConfig(tlsConfig(tlsModeVerifyIdentity, "db.example.com"), "db.example.com") - if identity.InsecureSkipVerify || identity.VerifyConnection != nil || identity.RootCAs != nil { - t.Fatal("verifyIdentity without an override must use standard verification against the system roots") - } - verifyCA, _ := buildTLSConfig(tlsConfig(tlsModeVerifyCA, "db.example.com"), "db.example.com") - if !verifyCA.InsecureSkipVerify || verifyCA.VerifyConnection == nil { - t.Fatal("verifyCa must replace the host name check with a chain check") - } -} - -func TestUnreadableAuthorityFileIsAConfigurationError(t *testing.T) { - garbage := filepath.Join(t.TempDir(), "ca.pem") - if err := os.WriteFile(garbage, []byte("not a certificate"), 0o600); err != nil { - t.Fatal(err) - } - config := tlsConfig(tlsModeVerifyCA, "h") - config.CACertificatePath = garbage - _, failure := buildTLSConfig(config, "h") - assertKind(t, failure, kindConfiguration) -} - -func TestClientCertificateLoadingAndMutualTLS(t *testing.T) { - authority := newTestAuthority(t) - serverCertificate, _, _ := authority.issue(t, 2, x509.ExtKeyUsageServerAuth, "hana.internal") - _, clientCertificatePath, clientKeyPath := authority.issue(t, 3, x509.ExtKeyUsageClientAuth, "client") - server := newTLSTestServer(t, serverCertificate, authority) - - withClient := tlsConfig(tlsModeRequired, "127.0.0.1") - withClient.ClientCertificatePath = clientCertificatePath - withClient.ClientKeyPath = clientKeyPath - settings, failure := buildTLSConfig(withClient, "127.0.0.1") - if failure != nil || len(settings.Certificates) != 1 { - t.Fatalf("client certificate not loaded: %v", failure) - } - if failure := handshake(t, withClient, server.port()); failure != nil { - t.Fatalf("mutual TLS failed with a valid client certificate: %v", failure) - } - - missingKey := withClient - missingKey.ClientKeyPath = filepath.Join(t.TempDir(), "missing.key") - _, failure = buildTLSConfig(missingKey, "127.0.0.1") - assertTLSCode(t, failure, tlsFailureClientCertificate) - - mismatched := withClient - _, otherCertificatePath, _ := authority.issue(t, 4, x509.ExtKeyUsageClientAuth, "other") - mismatched.ClientCertificatePath = otherCertificatePath - _, failure = buildTLSConfig(mismatched, "127.0.0.1") - assertTLSCode(t, failure, tlsFailureClientCertificate) - - encryptedKeyPath := filepath.Join(t.TempDir(), "encrypted.key") - encrypted := "-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIGbMFcGCSqGSIb3DQEFDTBKMCkGCSqGSIb3DQEFDDAcBAgAAAAAAAAAAAICCAAw\n-----END ENCRYPTED PRIVATE KEY-----\n" - if err := os.WriteFile(encryptedKeyPath, []byte(encrypted), 0o600); err != nil { - t.Fatal(err) - } - encryptedKey := withClient - encryptedKey.ClientKeyPath = encryptedKeyPath - _, failure = buildTLSConfig(encryptedKey, "127.0.0.1") - assertTLSCode(t, failure, tlsFailureClientCertificate) -} - -func TestPlaintextServerIsReportedAsNotSpeakingTLS(t *testing.T) { - answering := startPlaintextServer(t, func(conn net.Conn) { - buffer := make([]byte, 512) - _, _ = conn.Read(buffer) - _, _ = io.WriteString(conn, "HTTP/1.1 400 Bad Request\r\n\r\n") - }) - assertTLSCode(t, handshake(t, tlsConfig(tlsModeRequired, "127.0.0.1"), answering), tlsFailurePlaintextServer) - - hangingUp := startPlaintextServer(t, func(conn net.Conn) { - buffer := make([]byte, 512) - _, _ = conn.Read(buffer) - }) - assertTLSCode(t, handshake(t, tlsConfig(tlsModeRequired, "127.0.0.1"), hangingUp), tlsFailurePlaintextServer) -} - -func startPlaintextServer(t *testing.T, serve func(net.Conn)) int { - t.Helper() - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = listener.Close() }) - go func() { - for { - conn, err := listener.Accept() - if err != nil { - return - } - go func() { - defer func() { _ = conn.Close() }() - serve(conn) - }() - } - }() - return listener.Addr().(*net.TCPAddr).Port -} diff --git a/Native/HanaBridge/internal/hana/values_test.go b/Native/HanaBridge/internal/hana/values_test.go deleted file mode 100644 index 69c77a5c92..0000000000 --- a/Native/HanaBridge/internal/hana/values_test.go +++ /dev/null @@ -1,270 +0,0 @@ -package hana - -import ( - "math" - "math/big" - "strings" - "testing" - "time" - - "github.com/SAP/go-hdb/driver/unicode/cesu8" -) - -type wireDecimal struct { - mantissa *big.Int - exponent int -} - -func (d wireDecimal) Decompose(buffer []byte) (form byte, negative bool, coefficient []byte, exponent int32) { - negative = d.mantissa.Sign() < 0 - exponent = int32(d.exponent) - magnitude := d.mantissa - if negative { - magnitude = new(big.Int).Abs(d.mantissa) - } - size := (magnitude.BitLen() + 7) / 8 - if cap(buffer) >= size { - coefficient = magnitude.FillBytes(buffer[:size]) - } else { - coefficient = magnitude.Bytes() - } - return form, negative, coefficient, exponent -} - -type specialDecimal struct { - form byte - negative bool -} - -func (d specialDecimal) Decompose([]byte) (byte, bool, []byte, int32) { - return d.form, d.negative, nil, 0 -} - -func column(databaseTypeName string) columnInfo { - return columnInfo{name: "C", databaseTypeName: databaseTypeName} -} - -func textColumn(databaseTypeName string) columnInfo { - return columnInfo{name: "C", databaseTypeName: databaseTypeName, textual: true} -} - -func assertText(t *testing.T, got cell, want string) { - t.Helper() - if got.kind != cellText || got.text != want { - t.Fatalf("cell = %+v; want text %q", got, want) - } -} - -func TestIntegersAndBooleansFormatAsPlainText(t *testing.T) { - assertText(t, formatValue(int64(-9223372036854775808), column("BIGINT")), "-9223372036854775808") - assertText(t, formatValue(int64(255), column("TINYINT")), "255") - assertText(t, formatValue(true, column("BOOLEAN")), "TRUE") - assertText(t, formatValue(false, column("BOOLEAN")), "FALSE") - if cell := formatValue(nil, column("INTEGER")); cell.kind != cellNull { - t.Fatalf("nil formatted as %+v", cell) - } -} - -func TestDoubleUsesShortestDigitsAndPlainNotationInsideTheJavaScriptRange(t *testing.T) { - cases := map[float64]string{ - 1000000: "1000000", - 2500000.5: "2500000.5", - 123456789012: "123456789012", - 0.00001: "0.00001", - 1e-7: "0.0000001", - 0.1: "0.1", - -0.5: "-0.5", - 0: "0", - 1e21: "1e+21", - 1.5e-8: "1.5e-8", - -2.25e300: "-2.25e+300", - math.MaxFloat64: "1.7976931348623157e+308", - 999999999999999999: "1000000000000000000", - math.Inf(1): "Infinity", - math.Inf(-1): "-Infinity", - } - for value, want := range cases { - assertText(t, formatValue(value, column("DOUBLE")), want) - } - assertText(t, formatValue(math.NaN(), column("DOUBLE")), "NaN") -} - -func TestRealUsesThirtyTwoBitDigitsOfTheWidenedValue(t *testing.T) { - cases := map[float32]string{ - 1.1: "1.1", - 16777216: "16777216", - 3.4e38: "3.4e+38", - 1e-8: "1e-8", - -0.3: "-0.3", - } - for value, want := range cases { - widened := float64(value) - assertText(t, formatValue(widened, column("REAL")), want) - } - if got := formatValue(float64(float32(1.1)), column("DOUBLE")); got.text == "1.1" { - t.Fatal("DOUBLE formatting lost the widened float32 digits it must keep") - } -} - -func TestDecimalsFormatExactlyThroughDecompose(t *testing.T) { - huge, _ := new(big.Int).SetString("9999999999999999999999999999999999", 10) - fixed38, _ := new(big.Int).SetString("99999999999999999999999999999999999999", 10) - cases := []struct { - value wireDecimal - want string - }{ - {wireDecimal{big.NewInt(12345), -2}, "123.45"}, - {wireDecimal{big.NewInt(-1), -3}, "-0.001"}, - {wireDecimal{big.NewInt(1), 30}, "1000000000000000000000000000000"}, - {wireDecimal{big.NewInt(12345), 3}, "12345000"}, - {wireDecimal{big.NewInt(12345000), 0}, "12345000"}, - {wireDecimal{big.NewInt(0), -2}, "0.00"}, - {wireDecimal{big.NewInt(0), 0}, "0"}, - {wireDecimal{big.NewInt(-5), -2}, "-0.05"}, - {wireDecimal{big.NewInt(1500), -3}, "1.500"}, - {wireDecimal{big.NewInt(-42), 0}, "-42"}, - {wireDecimal{fixed38, -38}, "0." + strings.Repeat("9", 38)}, - {wireDecimal{big.NewInt(7), 64}, "7" + strings.Repeat("0", 64)}, - {wireDecimal{big.NewInt(7), -64}, "0." + strings.Repeat("0", 63) + "7"}, - {wireDecimal{big.NewInt(7), 65}, "7E+65"}, - {wireDecimal{big.NewInt(-7), -65}, "-7E-65"}, - {wireDecimal{huge, 6000}, strings.Repeat("9", 34) + "E+6000"}, - {wireDecimal{big.NewInt(5), -6176}, "5E-6176"}, - } - for _, testCase := range cases { - assertText(t, formatValue(testCase.value, column("DECIMAL")), testCase.want) - } - assertText(t, formatValue(specialDecimal{form: 1}, column("DECIMAL")), "Infinity") - assertText(t, formatValue(specialDecimal{form: 1, negative: true}, column("DECIMAL")), "-Infinity") - assertText(t, formatValue(specialDecimal{form: 2}, column("DECIMAL")), "NaN") -} - -func TestTemporalValuesFormatAsHanaLiteralsInUTC(t *testing.T) { - leapDay := time.Date(2024, time.February, 29, 0, 0, 0, 0, time.UTC) - instant := time.Date(2024, time.February, 29, 13, 45, 7, 123456700, time.UTC) - secondtime := hanaEpoch.Add(13*time.Hour + 45*time.Minute + 7*time.Second) - assertText(t, formatValue(leapDay, column("DAYDATE")), "2024-02-29") - assertText(t, formatValue(leapDay, column("DATE")), "2024-02-29") - assertText(t, formatValue(secondtime, column("SECONDTIME")), "13:45:07") - assertText(t, formatValue(time.Date(1, 1, 1, 23, 59, 59, 0, time.UTC), column("TIME")), "23:59:59") - assertText(t, formatValue(instant.Truncate(time.Second), column("SECONDDATE")), "2024-02-29 13:45:07") - assertText(t, formatValue(instant, column("LONGDATE")), "2024-02-29 13:45:07.1234567") - assertText(t, formatValue(instant.Truncate(time.Millisecond), column("TIMESTAMP")), "2024-02-29 13:45:07.1230000") - assertText(t, formatValue(time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC), column("LONGDATE")), "0001-01-01 00:00:00.0000000") - assertText(t, formatValue(time.Date(9999, 12, 31, 23, 59, 59, 999999900, time.UTC), column("LONGDATE")), "9999-12-31 23:59:59.9999999") - - shifted := time.Date(2024, time.February, 29, 14, 45, 7, 0, time.FixedZone("CET", 3600)) - assertText(t, formatValue(shifted, column("SECONDDATE")), "2024-02-29 13:45:07") -} - -func TestEmptyDatesUseHanaSpellings(t *testing.T) { - assertText(t, formatValue(emptyDateInstant, column("DAYDATE")), "0000-00-00") - assertText(t, formatValue(emptySecondInstant, column("SECONDDATE")), "0000-00-00 00:00:00") - assertText(t, formatValue(emptyLongdateInstant, column("LONGDATE")), "0000-00-00 00:00:00.0000000") - assertText(t, formatValue(emptySecondInstant, column("SECONDTIME")), "00:00:00") - if emptyDateInstant.Format(timestampLayout) != "0000-12-31 00:00:00.0000000" || - emptySecondInstant.Format(timestampLayout) != "0000-12-31 23:59:59.0000000" || - emptyLongdateInstant.Format(timestampLayout) != "0000-12-31 23:59:59.9999999" { - t.Fatalf("empty instants moved: %v %v %v", emptyDateInstant, emptySecondInstant, emptyLongdateInstant) - } -} - -func TestTextColumnsDecodeUTF8AndCESU8(t *testing.T) { - assertText(t, formatValue([]byte("Grüße"), textColumn("NVARCHAR")), "Grüße") - - emoji := '😀' - encoded := make([]byte, cesu8.RuneLen(emoji)) - cesu8.EncodeRune(encoded, emoji) - if len(encoded) != 6 { - t.Fatalf("go-hdb encoded %q in %d bytes; want a 6-byte surrogate pair", emoji, len(encoded)) - } - raw := append([]byte("a"), encoded...) - assertText(t, formatValue(raw, textColumn("VARCHAR")), "a😀") - - loneSurrogate := []byte{'a', 0xED, 0xA0, 0x80, 'b'} - assertText(t, formatValue(loneSurrogate, textColumn("VARCHAR")), "a\uFFFDb") - assertText(t, formatValue([]byte{'x', 0xFF, 'y'}, textColumn("CHAR")), "x\uFFFDy") - assertText(t, formatValue([]byte("plain"), column("CLOB")), "plain") - assertText(t, formatValue("0101000000000000000000F03F", column("STPOINT")), "0101000000000000000000F03F") -} - -func TestBinaryColumnsStayBinary(t *testing.T) { - data := []byte{0, 1, 0xFF} - for _, typeName := range []string{"VARBINARY", "BINARY", "BSTRING", "BLOB"} { - got := formatValue(data, column(typeName)) - if got.kind != cellBytes || string(got.bytes) != string(data) { - t.Fatalf("%s formatted as %+v; want the raw bytes", typeName, got) - } - } -} - -func TestColumnTypeNamesAreReportedAsSQLNames(t *testing.T) { - cases := []struct { - column columnInfo - reported string - classification string - }{ - {column("DAYDATE"), "DATE", ""}, - {column("SECONDTIME"), "TIME", ""}, - {column("LONGDATE"), "TIMESTAMP", ""}, - {column("SECONDDATE"), "SECONDDATE", "TIMESTAMP"}, - {columnInfo{databaseTypeName: "FIXED8", precision: 18, scale: 2, hasDecimalSize: true}, "DECIMAL(18,2)", ""}, - {columnInfo{databaseTypeName: "FIXED12", precision: 28, scale: 0, hasDecimalSize: true}, "DECIMAL(28,0)", ""}, - {columnInfo{databaseTypeName: "FIXED16", precision: 38, scale: 10, hasDecimalSize: true}, "DECIMAL(38,10)", ""}, - {column("FIXED16"), "DECIMAL", ""}, - {columnInfo{databaseTypeName: "DECIMAL", precision: 34, scale: 32767, hasDecimalSize: true}, "DECIMAL", ""}, - {column("SMALLDECIMAL"), "SMALLDECIMAL", "DECIMAL"}, - {column("STGEOMETRY"), "ST_GEOMETRY", "TEXT"}, - {column("STPOINT"), "ST_POINT", "TEXT"}, - {column("ALPHANUM"), "ALPHANUM", "NVARCHAR"}, - {column("SHORTTEXT"), "SHORTTEXT", "NVARCHAR"}, - {column("NSTRING"), "NSTRING", "NVARCHAR"}, - {column("STRING"), "STRING", ""}, - {column("BINTEXT"), "BINTEXT", "TEXT"}, - {column("BSTRING"), "BSTRING", "VARBINARY"}, - {column("NVARCHAR"), "NVARCHAR", ""}, - {column("INTEGER"), "INTEGER", ""}, - {column("NCLOB"), "NCLOB", ""}, - {column("BOOLEAN"), "BOOLEAN", ""}, - } - for _, testCase := range cases { - if got := testCase.column.reportedTypeName(); got != testCase.reported { - t.Errorf("%s reported as %q; want %q", testCase.column.databaseTypeName, got, testCase.reported) - } - if got := testCase.column.classification(); got != testCase.classification { - t.Errorf("%s classified as %q; want %q", testCase.column.databaseTypeName, got, testCase.classification) - } - } -} - -func TestLobColumnsAreRecognisedByTypeName(t *testing.T) { - for _, typeName := range []string{"BLOB", "CLOB", "NCLOB", "TEXT", "BINTEXT"} { - if !column(typeName).isLob() { - t.Errorf("%s is not treated as a LOB", typeName) - } - } - for _, typeName := range []string{"NVARCHAR", "VARBINARY", "STGEOMETRY", "DECIMAL"} { - if column(typeName).isLob() { - t.Errorf("%s is treated as a LOB", typeName) - } - } - if column("BLOB").holdsText() || !column("NCLOB").holdsText() || !column("BINTEXT").holdsText() { - t.Fatal("LOB text and binary kinds are mixed up") - } -} - -func TestTrimIncompleteRuneOnlyDropsATrailingPartialSequence(t *testing.T) { - complete := []byte("abc😀") - if got := trimIncompleteRune(complete); string(got) != "abc😀" { - t.Fatalf("a complete tail was trimmed to %q", got) - } - for cut := 1; cut < 4; cut++ { - partial := complete[:len(complete)-cut] - if got := trimIncompleteRune(partial); string(got) != "abc" { - t.Fatalf("cutting %d bytes left %q; want %q", cut, got, "abc") - } - } - if got := trimIncompleteRune(nil); len(got) != 0 { - t.Fatalf("nil trimmed to %q", got) - } -} diff --git a/Native/HanaBridge/internal/hana/watchdog_test.go b/Native/HanaBridge/internal/hana/watchdog_test.go deleted file mode 100644 index 1ad66061d5..0000000000 --- a/Native/HanaBridge/internal/hana/watchdog_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package hana - -import ( - "errors" - "sync/atomic" - "testing" - "time" -) - -type watchdogOutcome struct { - expired bool - err error - expiryFinishedAtReturn bool -} - -func TestWatchdogThatExpiresFinishesItsExpiryBeforeItReturns(t *testing.T) { - expiryStarted := make(chan struct{}) - releaseExpiry := make(chan struct{}) - var expiries atomic.Int32 - var expiryFinished atomic.Bool - expire := func() { - expiries.Add(1) - close(expiryStarted) - <-releaseExpiry - expiryFinished.Store(true) - } - workFailure := errors.New("work failed") - outcome := make(chan watchdogOutcome, 1) - go func() { - expired, err := runUnderWatchdog(time.Millisecond, expire, func() error { - select { - case <-expiryStarted: - case <-time.After(5 * time.Second): - } - return workFailure - }) - outcome <- watchdogOutcome{expired: expired, err: err, expiryFinishedAtReturn: expiryFinished.Load()} - }() - awaitSignal(t, expiryStarted, "the watchdog expiry") - select { - case <-outcome: - t.Fatal("the watchdog returned while its expiry was still running") - case <-time.After(50 * time.Millisecond): - } - close(releaseExpiry) - got := <-outcome - if !got.expired || !got.expiryFinishedAtReturn || !errors.Is(got.err, workFailure) { - t.Fatalf("outcome = %+v; want an expiry that finished before the watchdog returned the work's error", got) - } - if count := expiries.Load(); count != 1 { - t.Fatalf("expiry ran %d times; want once", count) - } -} - -func TestWatchdogStoppedBeforeItsDeadlineNeverExpires(t *testing.T) { - var expiries atomic.Int32 - expired, err := runUnderWatchdog(time.Hour, func() { expiries.Add(1) }, func() error { return nil }) - if expired || err != nil || expiries.Load() != 0 { - t.Fatalf("expired=%v err=%v expiries=%d; want the work's result and no expiry", expired, err, expiries.Load()) - } -} - -func TestDisarmingAWatchdogTwiceKeepsItsFirstAnswer(t *testing.T) { - var expiries atomic.Int32 - quiet := armWatchdog(time.Hour, func() { expiries.Add(1) }) - quietFirst := quiet.disarm() - quietSecond := quiet.disarm() - if quietFirst || quietSecond { - t.Fatal("a watchdog stopped before its deadline reported an expiry") - } - fired := make(chan struct{}) - loud := armWatchdog(time.Millisecond, func() { - expiries.Add(1) - close(fired) - }) - awaitSignal(t, fired, "the watchdog expiry") - loudFirst := loud.disarm() - loudSecond := loud.disarm() - if !loudFirst || !loudSecond { - t.Fatal("a watchdog that fired reported no expiry") - } - if count := expiries.Load(); count != 1 { - t.Fatalf("expiries = %d; want exactly one", count) - } -} - -func TestWatchdogIsDisarmedWhenTheWorkPanics(t *testing.T) { - var expiries atomic.Int32 - func() { - defer func() { _ = recover() }() - _, _ = runUnderWatchdog(20*time.Millisecond, func() { expiries.Add(1) }, func() error { panic("work panicked") }) - }() - time.Sleep(60 * time.Millisecond) - if count := expiries.Load(); count != 0 { - t.Fatalf("a watchdog whose work panicked expired %d times", count) - } -} diff --git a/Native/HanaBridge/process_test.go b/Native/HanaBridge/process_test.go deleted file mode 100644 index 17d32134a0..0000000000 --- a/Native/HanaBridge/process_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package main - -import ( - "bytes" - "context" - "errors" - "io" - "os" - "os/exec" - "strings" - "sync" - "testing" - "time" - - "github.com/TableProApp/TablePro/Native/HanaBridge/internal/frame" -) - -const ( - childBackendVariable = "TABLEPRO_HANA_HELPER_TEST_BACKEND" - blockingChild = "blocking" - panickingChild = "panicking" - panickingCallChild = "panicking-call" - childPanicMessage = "go-hdb worker goroutine panicked" - childTimeout = 20 * time.Second -) - -func TestMain(m *testing.M) { - if name := os.Getenv(childBackendVariable); name != "" { - os.Exit(run(os.Stdin, os.Stdout, childBackend(name))) - } - os.Exit(m.Run()) -} - -func childBackend(name string) backend { - switch name { - case panickingChild: - return panickingBackend{} - case panickingCallChild: - return panickingCallBackend{} - default: - return &blockingBackend{running: make(chan struct{})} - } -} - -type idleBackend struct{} - -func (idleBackend) Open([]byte) (uint64, []byte) { return 1, nil } -func (idleBackend) Connect(uint64, uint64) ([]byte, []byte) { return []byte("{}"), nil } -func (idleBackend) Execute(uint64, uint64, []byte) ([]byte, []byte) { return []byte("{}"), nil } -func (idleBackend) Explain(uint64, uint64, []byte) ([]byte, []byte) { return []byte("{}"), nil } -func (idleBackend) Ping(uint64, uint64) []byte { return nil } -func (idleBackend) Cancel(uint64, uint64) {} -func (idleBackend) Close(uint64) {} -func (idleBackend) CloseAll() {} - -type blockingBackend struct { - idleBackend - running chan struct{} - started sync.Once -} - -func (b *blockingBackend) Execute(uint64, uint64, []byte) ([]byte, []byte) { - b.started.Do(func() { close(b.running) }) - blockForever() - return nil, nil -} - -func (b *blockingBackend) Ping(uint64, uint64) []byte { - <-b.running - return nil -} - -type panickingBackend struct { - idleBackend -} - -func (panickingBackend) Execute(uint64, uint64, []byte) ([]byte, []byte) { - go func() { - panic(childPanicMessage) - }() - blockForever() - return nil, nil -} - -type panickingCallBackend struct { - idleBackend -} - -func (panickingCallBackend) Execute(uint64, uint64, []byte) ([]byte, []byte) { - panic(childPanicMessage) -} - -func blockForever() { - select {} -} - -type lockedBuffer struct { - mu sync.Mutex - buffer bytes.Buffer -} - -func (b *lockedBuffer) Write(data []byte) (int, error) { - b.mu.Lock() - defer b.mu.Unlock() - return b.buffer.Write(data) -} - -func (b *lockedBuffer) String() string { - b.mu.Lock() - defer b.mu.Unlock() - return b.buffer.String() -} - -type childProcess struct { - t *testing.T - command *exec.Cmd - stdin io.WriteCloser - stdout io.ReadCloser - stderr *lockedBuffer -} - -func startChild(t *testing.T, backendName string) *childProcess { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), childTimeout) - t.Cleanup(cancel) - command := exec.CommandContext(ctx, os.Args[0]) - command.Env = append(os.Environ(), childBackendVariable+"="+backendName) - command.WaitDelay = time.Second - stderr := &lockedBuffer{} - command.Stderr = stderr - stdin, err := command.StdinPipe() - if err != nil { - t.Fatal(err) - } - stdout, err := command.StdoutPipe() - if err != nil { - t.Fatal(err) - } - if err := command.Start(); err != nil { - t.Fatal(err) - } - child := &childProcess{t: t, command: command, stdin: stdin, stdout: stdout, stderr: stderr} - expectHello(t, child.next()) - return child -} - -func (c *childProcess) send(id uint64, opcode byte, body string) { - c.t.Helper() - if err := frame.Write(c.stdin, frame.Frame{ID: id, Code: opcode, Body: []byte(body)}); err != nil { - c.t.Fatalf("sending frame %d: %v", id, err) - } -} - -func (c *childProcess) next() frame.Frame { - c.t.Helper() - incoming, err := frame.Read(c.stdout, frame.MaxBodyLength) - if err != nil { - c.t.Fatalf("reading from the helper: %v (stderr %q)", err, c.stderr.String()) - } - return incoming -} - -func (c *childProcess) closeStdin() { - c.t.Helper() - if err := c.stdin.Close(); err != nil { - c.t.Fatal(err) - } -} - -func (c *childProcess) awaitExit() (code int, remainingOutput []byte) { - c.t.Helper() - remaining, err := io.ReadAll(c.stdout) - if err != nil { - c.t.Fatalf("draining the helper's output: %v", err) - } - err = c.command.Wait() - var exitError *exec.ExitError - if err != nil && !errors.As(err, &exitError) { - c.t.Fatalf("waiting for the helper: %v", err) - } - return c.command.ProcessState.ExitCode(), remaining -} - -func TestProcessExitsZeroOnEOFWhileAnOperationRuns(t *testing.T) { - child := startChild(t, blockingChild) - child.send(1, opcodeExecute, `{"session":1,"operation":1,"request":{}}`) - child.send(2, opcodePing, `{"session":1,"operation":2}`) - if reply := child.next(); reply.ID != 2 || reply.Code != statusOK { - t.Fatalf("reply = %+v; want the ping that proves the execute is running", reply) - } - child.closeStdin() - code, remaining := child.awaitExit() - if code != exitInputClosed { - t.Fatalf("exit = %d; want %d (stderr %q)", code, exitInputClosed, child.stderr.String()) - } - if len(remaining) != 0 { - t.Fatalf("the helper wrote %q after stdin closed; want nothing", remaining) - } - if child.stderr.String() != "" { - t.Fatalf("stderr = %q; want nothing", child.stderr.String()) - } -} - -func TestProcessExitsTwoWhenABackendGoroutinePanics(t *testing.T) { - for _, backendName := range []string{panickingChild, panickingCallChild} { - t.Run(backendName, func(t *testing.T) { - child := startChild(t, backendName) - child.send(1, opcodeExecute, `{"session":1,"operation":1,"request":{}}`) - code, remaining := child.awaitExit() - if code != 2 { - t.Fatalf("exit = %d; want 2 (stderr %q)", code, child.stderr.String()) - } - if len(remaining) != 0 { - t.Fatalf("the helper answered %q before it died; want nothing", remaining) - } - if trace := child.stderr.String(); !strings.Contains(trace, "panic: "+childPanicMessage) || !strings.Contains(trace, "goroutine ") { - t.Fatalf("stderr = %q; want the panic and its goroutine trace", trace) - } - }) - } -} - -func TestProcessExitsThreeOnAFramingViolation(t *testing.T) { - child := startChild(t, blockingChild) - if _, err := child.stdin.Write([]byte{0, 0, 0, 2, 0}); err != nil { - t.Fatal(err) - } - child.closeStdin() - code, _ := child.awaitExit() - if code != exitTransportFailure { - t.Fatalf("exit = %d; want %d (stderr %q)", code, exitTransportFailure, child.stderr.String()) - } - if child.stderr.String() != "" { - t.Fatalf("stderr = %q; want nothing", child.stderr.String()) - } -} diff --git a/Native/HanaBridge/server_test.go b/Native/HanaBridge/server_test.go deleted file mode 100644 index 002afb41ad..0000000000 --- a/Native/HanaBridge/server_test.go +++ /dev/null @@ -1,548 +0,0 @@ -package main - -import ( - "bytes" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "io" - "math" - "net" - "testing" - "time" - - "github.com/TableProApp/TablePro/Native/HanaBridge/internal/frame" - "github.com/TableProApp/TablePro/Native/HanaBridge/internal/hana" -) - -const replyTimeout = 5 * time.Second - -type backendCall struct { - name string - session uint64 - operation uint64 - request string -} - -type fakeBackend struct { - calls chan backendCall - openedSession uint64 - openFailure []byte - result []byte - failure []byte - executeGate chan struct{} -} - -func newFakeBackend() *fakeBackend { - return &fakeBackend{calls: make(chan backendCall, 64), result: []byte(`{"answer":42}`)} -} - -func (f *fakeBackend) record(call backendCall) { - f.calls <- call -} - -func (f *fakeBackend) Open(configJSON []byte) (uint64, []byte) { - f.record(backendCall{name: "open", request: string(configJSON)}) - return f.openedSession, f.openFailure -} - -func (f *fakeBackend) Connect(sessionID uint64, operationID uint64) ([]byte, []byte) { - f.record(backendCall{name: "connect", session: sessionID, operation: operationID}) - return f.result, f.failure -} - -func (f *fakeBackend) Execute(sessionID uint64, operationID uint64, requestJSON []byte) ([]byte, []byte) { - f.record(backendCall{name: "execute", session: sessionID, operation: operationID, request: string(requestJSON)}) - if f.executeGate != nil { - <-f.executeGate - } - return f.result, f.failure -} - -func (f *fakeBackend) Explain(sessionID uint64, operationID uint64, requestJSON []byte) ([]byte, []byte) { - f.record(backendCall{name: "explain", session: sessionID, operation: operationID, request: string(requestJSON)}) - return f.result, f.failure -} - -func (f *fakeBackend) Ping(sessionID uint64, operationID uint64) []byte { - f.record(backendCall{name: "ping", session: sessionID, operation: operationID}) - return f.failure -} - -func (f *fakeBackend) Cancel(sessionID uint64, operationID uint64) { - f.record(backendCall{name: "cancel", session: sessionID, operation: operationID}) -} - -func (f *fakeBackend) Close(sessionID uint64) { - f.record(backendCall{name: "close", session: sessionID}) -} - -func (f *fakeBackend) CloseAll() { - f.record(backendCall{name: "closeAll"}) -} - -func (f *fakeBackend) awaitCall(t *testing.T, name string) backendCall { - t.Helper() - select { - case call := <-f.calls: - if call.name != name { - t.Fatalf("backend call = %+v; want %s", call, name) - } - return call - case <-time.After(replyTimeout): - t.Fatalf("the backend never received %s", name) - return backendCall{} - } -} - -type harness struct { - t *testing.T - input *io.PipeWriter - output *io.PipeReader - exit chan int -} - -func startServer(t *testing.T, bridge backend) *harness { - t.Helper() - inputReader, inputWriter := io.Pipe() - outputReader, outputWriter := io.Pipe() - exit := make(chan int, 1) - go func() { - exit <- run(inputReader, outputWriter, bridge) - }() - t.Cleanup(func() { - _ = inputWriter.Close() - _ = outputReader.Close() - }) - h := &harness{t: t, input: inputWriter, output: outputReader, exit: exit} - expectHello(t, h.next()) - return h -} - -type helloFields struct { - Protocol *int `json:"protocol"` - ForcedSeverGraceSeconds *int64 `json:"forcedSeverGraceSeconds"` -} - -func decodeHello(t *testing.T, hello frame.Frame) helloFields { - t.Helper() - if hello.ID != helloFrameID || hello.Code != statusOK { - t.Fatalf("first frame = id %d status %d (%s); want the hello", hello.ID, hello.Code, hello.Body) - } - decoder := json.NewDecoder(bytes.NewReader(hello.Body)) - decoder.DisallowUnknownFields() - var fields helloFields - if err := decoder.Decode(&fields); err != nil { - t.Fatalf("hello %s does not decode as the protocol's hello: %v", hello.Body, err) - } - if fields.Protocol == nil || fields.ForcedSeverGraceSeconds == nil { - t.Fatalf("hello %s lacks protocol or forcedSeverGraceSeconds", hello.Body) - } - return fields -} - -func expectHello(t *testing.T, hello frame.Frame) { - t.Helper() - fields := decodeHello(t, hello) - if *fields.Protocol != protocolVersion { - t.Fatalf("hello %s announces protocol %d; want %d", hello.Body, *fields.Protocol, protocolVersion) - } - if *fields.ForcedSeverGraceSeconds != wholeSecondsRoundedUp(hana.ForcedSeverGrace) { - t.Fatalf("hello %s announces a %d s grace; want the bridge's %v", hello.Body, *fields.ForcedSeverGraceSeconds, hana.ForcedSeverGrace) - } -} - -func (h *harness) send(id uint64, opcode byte, body string) { - h.t.Helper() - if err := frame.Write(h.input, frame.Frame{ID: id, Code: opcode, Body: []byte(body)}); err != nil { - h.t.Fatalf("sending frame %d: %v", id, err) - } -} - -func (h *harness) next() frame.Frame { - h.t.Helper() - type readResult struct { - frame frame.Frame - err error - } - result := make(chan readResult, 1) - go func() { - incoming, err := frame.Read(h.output, frame.MaxBodyLength) - result <- readResult{frame: incoming, err: err} - }() - select { - case got := <-result: - if got.err != nil { - h.t.Fatalf("reading a reply: %v", got.err) - } - return got.frame - case <-time.After(replyTimeout): - h.t.Fatal("no reply arrived") - return frame.Frame{} - } -} - -func (h *harness) expectReply(id uint64, status byte) frame.Frame { - h.t.Helper() - reply := h.next() - if reply.ID != id || reply.Code != status { - h.t.Fatalf("reply = id %d status %d (%s); want id %d status %d", reply.ID, reply.Code, reply.Body, id, status) - } - return reply -} - -func (h *harness) expectFailure(id uint64, kind string) map[string]any { - h.t.Helper() - reply := h.expectReply(id, statusError) - var failure map[string]any - if err := json.Unmarshal(reply.Body, &failure); err != nil { - h.t.Fatalf("failure %q is not JSON: %v", reply.Body, err) - } - if failure["kind"] != kind { - h.t.Fatalf("failure = %s; want kind %q", reply.Body, kind) - } - return failure -} - -func (h *harness) sendExpectingInternalFailure(id uint64, opcode byte, body string) map[string]any { - h.t.Helper() - h.send(id, opcode, body) - return h.expectFailure(id, "internal") -} - -func (h *harness) closeInput() { - h.t.Helper() - if err := h.input.Close(); err != nil { - h.t.Fatal(err) - } -} - -func (h *harness) awaitExit() int { - h.t.Helper() - select { - case code := <-h.exit: - return code - case <-time.After(replyTimeout): - h.t.Fatal("the server did not stop") - return -1 - } -} - -func TestHelloAdvertisesTheProtocolAndTheForcedSeverGrace(t *testing.T) { - inputReader, inputWriter := io.Pipe() - outputReader, outputWriter := io.Pipe() - t.Cleanup(func() { - _ = inputWriter.Close() - _ = outputReader.Close() - }) - go run(inputReader, outputWriter, newFakeBackend()) - h := &harness{t: t, input: inputWriter, output: outputReader} - fields := decodeHello(t, h.next()) - if *fields.Protocol != 1 { - t.Fatalf("protocol = %d; want 1", *fields.Protocol) - } - advertised := time.Duration(*fields.ForcedSeverGraceSeconds) * time.Second - if advertised < hana.ForcedSeverGrace || advertised >= hana.ForcedSeverGrace+time.Second { - t.Fatalf("forcedSeverGraceSeconds = %d; want %v rounded up to a whole second", *fields.ForcedSeverGraceSeconds, hana.ForcedSeverGrace) - } -} - -func TestGraceSecondsRoundUpSoTheHostNeverStopsTheHelperEarly(t *testing.T) { - cases := map[time.Duration]int64{ - 0: 0, - 30 * time.Second: 30, - 30*time.Second + time.Millisecond: 31, - 29*time.Second + 999*time.Millisecond: 30, - } - for duration, want := range cases { - if got := wholeSecondsRoundedUp(duration); got != want { - t.Fatalf("wholeSecondsRoundedUp(%v) = %d; want %d", duration, got, want) - } - } -} - -func TestHelloIsTheFirstFrameAndEOFExitsZero(t *testing.T) { - bridge := newFakeBackend() - h := startServer(t, bridge) - h.closeInput() - if code := h.awaitExit(); code != exitInputClosed { - t.Fatalf("exit = %d; want %d", code, exitInputClosed) - } - bridge.awaitCall(t, "closeAll") -} - -func TestOpenPassesTheConfigThroughAndAnswersTheSession(t *testing.T) { - bridge := newFakeBackend() - bridge.openedSession = 7 - h := startServer(t, bridge) - h.send(1, opcodeOpen, `{"host":"db","password":"secret"}`) - if call := bridge.awaitCall(t, "open"); call.request != `{"host":"db","password":"secret"}` { - t.Fatalf("open received %q", call.request) - } - if reply := h.expectReply(1, statusOK); string(reply.Body) != `{"session":7}` { - t.Fatalf("open reply = %s", reply.Body) - } -} - -func TestOpenFailureIsSentVerbatim(t *testing.T) { - bridge := newFakeBackend() - bridge.openFailure = []byte(`{"kind":"configuration","code":0,"position":0,"message":"host","parameter":0,"expected":""}`) - h := startServer(t, bridge) - h.send(2, opcodeOpen, `{}`) - if reply := h.expectReply(2, statusError); string(reply.Body) != string(bridge.openFailure) { - t.Fatalf("open failure = %s", reply.Body) - } -} - -func TestOperationsReachTheBackendWithTheirSessionAndOperation(t *testing.T) { - bridge := newFakeBackend() - h := startServer(t, bridge) - - h.send(10, opcodeConnect, `{"session":3,"operation":4}`) - if call := bridge.awaitCall(t, "connect"); call.session != 3 || call.operation != 4 { - t.Fatalf("connect call = %+v", call) - } - if reply := h.expectReply(10, statusOK); string(reply.Body) != `{"answer":42}` { - t.Fatalf("connect reply = %s", reply.Body) - } - - h.send(11, opcodeExecute, `{"session":3,"operation":5,"request":{"sql":"SELECT 1 FROM DUMMY","rowCap":10}}`) - if call := bridge.awaitCall(t, "execute"); call.session != 3 || call.operation != 5 || call.request != `{"sql":"SELECT 1 FROM DUMMY","rowCap":10}` { - t.Fatalf("execute call = %+v", call) - } - h.expectReply(11, statusOK) - - h.send(12, opcodeExplain, `{"session":3,"operation":6,"request":{"sql":"SELECT 1 FROM DUMMY"}}`) - if call := bridge.awaitCall(t, "explain"); call.operation != 6 || call.request != `{"sql":"SELECT 1 FROM DUMMY"}` { - t.Fatalf("explain call = %+v", call) - } - h.expectReply(12, statusOK) - - h.send(13, opcodePing, `{"session":3,"operation":7}`) - if call := bridge.awaitCall(t, "ping"); call.operation != 7 { - t.Fatalf("ping call = %+v", call) - } - if reply := h.expectReply(13, statusOK); string(reply.Body) != `{}` { - t.Fatalf("ping reply = %s; want {}", reply.Body) - } -} - -func TestOperationFailuresAreSentVerbatim(t *testing.T) { - bridge := newFakeBackend() - bridge.failure = []byte(`{"kind":"closed","code":0,"position":0,"message":"","parameter":0,"expected":""}`) - h := startServer(t, bridge) - for id, opcode := range map[uint64]byte{20: opcodeConnect, 21: opcodeExecute, 22: opcodeExplain, 23: opcodePing} { - h.send(id, opcode, `{"session":1,"operation":1,"request":{}}`) - if reply := h.expectReply(id, statusError); string(reply.Body) != string(bridge.failure) { - t.Fatalf("opcode %d failure = %s", opcode, reply.Body) - } - <-bridge.calls - } -} - -func TestUnknownOpcodeAnswersAnInternalError(t *testing.T) { - h := startServer(t, newFakeBackend()) - failure := h.sendExpectingInternalFailure(30, 99, `{}`) - if failure["message"] != "unknown opcode 99" { - t.Fatalf("message = %v", failure["message"]) - } - h.sendExpectingInternalFailure(31, 0, `{}`) -} - -func TestMalformedBodiesAnswerAnInternalError(t *testing.T) { - bridge := newFakeBackend() - h := startServer(t, bridge) - for index, opcode := range []byte{opcodeConnect, opcodeExecute, opcodeExplain, opcodePing, opcodeCancel, opcodeClose} { - h.sendExpectingInternalFailure(uint64(40+index), opcode, `{"session":`) - h.sendExpectingInternalFailure(uint64(50+index), opcode, ``) - h.sendExpectingInternalFailure(uint64(60+index), opcode, `{"session":"one"}`) - } - select { - case call := <-bridge.calls: - t.Fatalf("a malformed request reached the backend: %+v", call) - default: - } -} - -func TestCancelAndCloseRunWhileAnExecuteBlocks(t *testing.T) { - bridge := newFakeBackend() - bridge.executeGate = make(chan struct{}) - h := startServer(t, bridge) - - h.send(70, opcodeExecute, `{"session":1,"operation":9,"request":{"sql":"SELECT * FROM BIG"}}`) - bridge.awaitCall(t, "execute") - - h.send(71, opcodeCancel, `{"session":1,"operation":9}`) - if call := bridge.awaitCall(t, "cancel"); call.session != 1 || call.operation != 9 { - t.Fatalf("cancel call = %+v", call) - } - h.send(72, opcodeClose, `{"session":1}`) - if call := bridge.awaitCall(t, "close"); call.session != 1 { - t.Fatalf("close call = %+v", call) - } - h.send(73, opcodePing, `{"session":2,"operation":1}`) - bridge.awaitCall(t, "ping") - h.expectReply(73, statusOK) - - close(bridge.executeGate) - h.expectReply(70, statusOK) -} - -func TestEOFExitsZeroWithoutWaitingForARunningOperation(t *testing.T) { - bridge := newFakeBackend() - bridge.executeGate = make(chan struct{}) - t.Cleanup(func() { close(bridge.executeGate) }) - h := startServer(t, bridge) - h.send(80, opcodeExecute, `{"session":1,"operation":1,"request":{}}`) - bridge.awaitCall(t, "execute") - h.closeInput() - if code := h.awaitExit(); code != exitInputClosed { - t.Fatalf("exit = %d; want %d", code, exitInputClosed) - } - bridge.awaitCall(t, "closeAll") -} - -func TestFramingViolationsExitThree(t *testing.T) { - violations := map[string][]byte{ - "short header": {0, 0, 0, 2, 0, 0}, - "short body": append(rawHeader(8, 1, opcodePing), `{}`...), - "body over the cap": rawHeader(maxRequestBodyLength+1, 1, opcodeExecute), - } - for name, data := range violations { - t.Run(name, func(t *testing.T) { - bridge := newFakeBackend() - h := startServer(t, bridge) - if _, err := h.input.Write(data); err != nil { - t.Fatal(err) - } - h.closeInput() - if code := h.awaitExit(); code != exitTransportFailure { - t.Fatalf("exit = %d; want %d", code, exitTransportFailure) - } - bridge.awaitCall(t, "closeAll") - }) - } -} - -func rawHeader(length uint32, id uint64, code byte) []byte { - header := make([]byte, frame.HeaderSize) - binary.BigEndian.PutUint32(header[0:4], length) - binary.BigEndian.PutUint64(header[4:12], id) - header[12] = code - return header -} - -func TestAnInputErrorOtherThanEOFExitsThree(t *testing.T) { - inputReader, inputWriter := io.Pipe() - outputReader, outputWriter := io.Pipe() - t.Cleanup(func() { _ = outputReader.Close() }) - exit := make(chan int, 1) - go func() { exit <- run(inputReader, outputWriter, newFakeBackend()) }() - h := &harness{t: t, input: inputWriter, output: outputReader, exit: exit} - h.next() - _ = inputWriter.CloseWithError(errors.New("stdin went away")) - if code := h.awaitExit(); code != exitTransportFailure { - t.Fatalf("exit = %d; want %d", code, exitTransportFailure) - } -} - -type failingWriter struct { - remaining int -} - -func (w *failingWriter) Write(data []byte) (int, error) { - if w.remaining == 0 { - return 0, io.ErrClosedPipe - } - w.remaining-- - return len(data), nil -} - -func TestHelloThatCannotBeWrittenExitsThree(t *testing.T) { - inputReader, inputWriter := io.Pipe() - t.Cleanup(func() { _ = inputWriter.Close() }) - if code := run(inputReader, &failingWriter{}, newFakeBackend()); code != exitTransportFailure { - t.Fatalf("exit = %d; want %d", code, exitTransportFailure) - } -} - -func TestReplyThatCannotBeWrittenExitsThree(t *testing.T) { - inputReader, inputWriter := io.Pipe() - t.Cleanup(func() { _ = inputWriter.Close() }) - bridge := newFakeBackend() - exit := make(chan int, 1) - go func() { exit <- run(inputReader, &failingWriter{remaining: 2}, bridge) }() - if err := frame.Write(inputWriter, frame.Frame{ID: 1, Code: opcodeOpen, Body: []byte(`{}`)}); err != nil { - t.Fatal(err) - } - select { - case code := <-exit: - if code != exitTransportFailure { - t.Fatalf("exit = %d; want %d", code, exitTransportFailure) - } - case <-time.After(replyTimeout): - t.Fatal("a lost reply did not stop the server") - } - bridge.awaitCall(t, "open") - bridge.awaitCall(t, "closeAll") -} - -func TestResultsOnlyFitAFrameUpToTwoGiBMinusOne(t *testing.T) { - if !fitsInFrame(0) || !fitsInFrame(frame.MaxBodyLength) { - t.Fatal("a result up to 2 GiB - 1 was refused") - } - if fitsInFrame(frame.MaxBodyLength+1) || fitsInFrame(math.MaxUint32) || fitsInFrame(-1) { - t.Fatal("a result over the frame cap was accepted") - } - if small := replyFrame(5, []byte("{}"), nil); small.Code != statusOK || small.ID != 5 { - t.Fatalf("a small result became %+v", small) - } -} - -func TestAResultOverTheCapAnswersAnInternalErrorAndTheServerKeepsServing(t *testing.T) { - bridge := newFakeBackend() - bridge.result = make([]byte, frame.MaxBodyLength+1) - h := startServer(t, bridge) - h.send(90, opcodeExecute, `{"session":1,"operation":1,"request":{}}`) - bridge.awaitCall(t, "execute") - if failure := h.expectFailure(90, "internal"); failure["message"] != "the result is larger than 2 GiB" { - t.Fatalf("message = %v; want the oversized result message", failure["message"]) - } - h.send(91, opcodePing, `{"session":1,"operation":2}`) - bridge.awaitCall(t, "ping") - h.expectReply(91, statusOK) -} - -func closedPort(t *testing.T) int { - t.Helper() - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - port := listener.Addr().(*net.TCPAddr).Port - _ = listener.Close() - return port -} - -func TestRealBridgeAnswersOverFrames(t *testing.T) { - h := startServer(t, hana.NewBridge()) - config := fmt.Sprintf(`{"host":"127.0.0.1","port":%d,"username":"DBADMIN","password":"secret","tlsMode":"disabled","connectTimeoutSeconds":2}`, closedPort(t)) - h.send(1, opcodeOpen, config) - if reply := h.expectReply(1, statusOK); string(reply.Body) != `{"session":1}` { - t.Fatalf("open reply = %s", reply.Body) - } - h.send(2, opcodeConnect, `{"session":1,"operation":1}`) - h.expectFailure(2, "connect") - h.send(3, opcodePing, `{"session":1,"operation":2}`) - h.expectFailure(3, "connectionLost") - h.send(4, opcodeClose, `{"session":1}`) - h.send(5, opcodeExecute, `{"session":1,"operation":3,"request":{"sql":"SELECT 1 FROM DUMMY"}}`) - h.expectFailure(5, "closed") - h.send(6, opcodeOpen, `{"host":`) - h.expectFailure(6, "internal") - h.closeInput() - if code := h.awaitExit(); code != exitInputClosed { - t.Fatalf("exit = %d; want %d", code, exitInputClosed) - } -} diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index e8da5e1fc1..1431520c03 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -232,11 +232,6 @@ let package = Package( dependencies: ["TableProNumberFormatting"], path: "Tests/TableProNumberFormattingTests" ), - .testTarget( - name: "TableProDocumentPathTests", - dependencies: ["TableProDocumentPath"], - path: "Tests/TableProDocumentPathTests" - ), .testTarget( name: "TableProJavaScriptTextTests", dependencies: ["TableProJavaScriptText"], @@ -282,16 +277,6 @@ let package = Package( dependencies: ["TableProMSSQLCore"], path: "Tests/TableProMSSQLCoreTests" ), - .testTarget( - name: "TableProTeradataCoreTests", - dependencies: ["TableProTeradataCore"], - path: "Tests/TableProTeradataCoreTests" - ), - .testTarget( - name: "TableProTrinoCoreTests", - dependencies: ["TableProTrinoCore", "TableProTLSClientIdentity", "TableProTLSTestFixtures"], - path: "Tests/TableProTrinoCoreTests" - ), .testTarget( name: "TableProTLSClientIdentityTests", dependencies: ["TableProTLSClientIdentity", "TableProTLSTestFixtures"], @@ -302,26 +287,6 @@ let package = Package( dependencies: ["TableProGoogleCloud"], path: "Tests/TableProGoogleCloudTests" ), - .testTarget( - name: "TableProSpannerCoreTests", - dependencies: ["TableProSpannerCore", "TableProGoogleCloud"], - path: "Tests/TableProSpannerCoreTests" - ), - .testTarget( - name: "TableProFirebaseCoreTests", - dependencies: ["TableProFirebaseCore"], - path: "Tests/TableProFirebaseCoreTests" - ), - .testTarget( - name: "TableProWeaviateCoreTests", - dependencies: ["TableProWeaviateCore"], - path: "Tests/TableProWeaviateCoreTests" - ), - .testTarget( - name: "TableProR2SQLCoreTests", - dependencies: ["TableProR2SQLCore"], - path: "Tests/TableProR2SQLCoreTests" - ), .testTarget( name: "TableProSyncTests", dependencies: ["TableProSync", "TableProSyncTransport", "TableProModels"], diff --git a/Packages/TableProCore/Tests/TableProDocumentPathTests/DocumentPathTests.swift b/Packages/TableProCore/Tests/TableProDocumentPathTests/DocumentPathTests.swift deleted file mode 100644 index 0a2cb1a467..0000000000 --- a/Packages/TableProCore/Tests/TableProDocumentPathTests/DocumentPathTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// DocumentPathTests.swift -// TableProDocumentPathTests -// - -import Foundation -import Testing - -@testable import TableProDocumentPath - -@Suite("Document Path") -struct DocumentPathTests { - @Test("A plain dotted path walks dictionaries") - func walksDictionaries() { - let document: [String: Any] = ["address": ["city": "Lisbon"]] - #expect(DocumentPath.value(in: document, atPath: "address.city") as? String == "Lisbon") - } - - @Test("A path no key answers is nil") - func missingKeyIsNil() { - let document: [String: Any] = ["address": ["city": "Lisbon"]] - #expect(DocumentPath.value(in: document, atPath: "address.zip") == nil) - #expect(DocumentPath.value(in: document, atPath: "contact.city") == nil) - } - - @Test("An array is a container, so the remaining keys read from every element") - func readsAcrossArrayElements() { - let document: [String: Any] = ["variants": [["sku": "A1"], ["sku": "B2"]]] - let values = DocumentPath.value(in: document, atPath: "variants.sku") as? [Any] - #expect(values?.compactMap { $0 as? String } == ["A1", "B2"]) - } - - @Test("An element missing the key keeps its place, so sibling columns stay aligned") - func missingElementKeepsItsPlace() { - let document: [String: Any] = ["variants": [["qty": 3], ["sku": "B2", "qty": 5]]] - let skus = DocumentPath.value(in: document, atPath: "variants.sku") as? [Any] - let quantities = DocumentPath.value(in: document, atPath: "variants.qty") as? [Any] - #expect(skus?.count == 2) - #expect(skus?[0] is NSNull) - #expect(skus?[1] as? String == "B2") - #expect(quantities?.count == 2) - } - - @Test("A key no element carries is nil rather than an array of nulls") - func absentAcrossEveryElementIsNil() { - let document: [String: Any] = ["variants": [["qty": 3], ["qty": 5]]] - #expect(DocumentPath.value(in: document, atPath: "variants.sku") == nil) - } - - @Test("An empty array answers nothing") - func emptyArrayIsNil() { - let document: [String: Any] = ["variants": []] - #expect(DocumentPath.value(in: document, atPath: "variants.sku") == nil) - } - - @Test("A path keeps walking through an array it meets on the way") - func walksThroughAnArrayMidPath() { - let document: [String: Any] = ["variants": [["price": ["eur": 10]], ["price": ["eur": 20]]]] - let values = DocumentPath.value(in: document, atPath: "variants.price.eur") as? [Any] - #expect(values?.compactMap { $0 as? Int } == [10, 20]) - } - - @Test("An empty path answers the whole document") - func emptyPathAnswersTheDocument() { - let document: [String: Any] = ["id": "1"] - #expect((DocumentPath.value(in: document, atPath: "") as? [String: Any])?["id"] as? String == "1") - } - - @Test("A scalar partway through the path stops it") - func scalarStopsThePath() { - let document: [String: Any] = ["address": "Lisbon"] - #expect(DocumentPath.value(in: document, atPath: "address.city") == nil) - } -} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseClientTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseClientTests.swift deleted file mode 100644 index f320955559..0000000000 --- a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseClientTests.swift +++ /dev/null @@ -1,222 +0,0 @@ -import Foundation -@testable import TableProFirebaseCore -import Testing - -@Suite("Firestore client") -struct FirestoreClientTests { - @Test("runQuery reads documents and skips the empty read-time entry") - func runQuery() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(json: """ - [{"document": {"name": "projects/demo/databases/(default)/documents/users/alice", - "fields": {"age": {"integerValue": "30"}}, "updateTime": "2026-10-12T08:00:00Z"}, - "readTime": "2026-10-12T08:00:01Z"}, - {"readTime": "2026-10-12T08:00:01Z"}] - """) - let client = makeFirestoreClient(transport) - let documents = try await client.runQuery(parent: "", body: json(#"{"structuredQuery": {}}"#), operation: "read users") - #expect(documents == [FirestoreDocument(path: "users/alice", fields: ["age": .integer(30)], updateTime: "2026-10-12T08:00:00Z")]) - let request = try #require(transport.requests.first) - #expect(request.method == "POST") - #expect(request.url.absoluteString == "https://firestore.googleapis.com/v1/projects/demo/databases/(default)/documents:runQuery") - #expect(request.headers["Authorization"] == "Bearer owner") - } - - @Test("A denial names the operation and points at IAM and the rules") - func permissionDenied() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(status: 403, json: #"{"error": {"code": 403, "message": "Missing or insufficient permissions.", "status": "PERMISSION_DENIED"}}"#) - let client = makeFirestoreClient(transport) - do { - _ = try await client.runQuery(parent: "", body: .object([:]), operation: "read users") - Issue.record("Expected a denial") - } catch let error as FirebaseError { - guard case .permissionDenied(let operation, let detail) = error else { - Issue.record("Expected permissionDenied, got \(error)") - return - } - #expect(operation == "read users") - #expect(detail.contains("Missing or insufficient permissions.")) - #expect(error.localizedDescription.hasPrefix("Permission denied: read users.")) - } - } - - @Test("A 401 drops the cached token and retries once") - func retriesOnce() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(status: 401, json: #"{"error": {"message": "expired"}}"#) - transport.enqueue(json: #"{"collectionIds": ["users", "orders"]}"#) - let counter = CountingCredential() - let client = makeFirestoreClient(transport, credential: counter.credential) - #expect(try await client.listCollectionIds(parent: "") == ["users", "orders"]) - #expect(counter.invalidateCount == 1) - #expect(transport.requests.map { $0.headers["Authorization"] } == ["Bearer token-1", "Bearer token-2"]) - } - - @Test("A missing document is nil, not an error") - func missingDocument() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(status: 404, json: #"{"error": {"message": "not found"}}"#) - #expect(try await makeFirestoreClient(transport).getDocument("users/ghost") == nil) - } - - @Test("A patch sends the mask, the precondition and encoded path segments") - func patch() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(json: "{}") - try await makeFirestoreClient(transport).patchDocument( - "users/a b", fields: ["first-name": .string("A")], mask: ["first-name"], updateTime: "2026-10-12T08:00:00Z" - ) - let request = try #require(transport.requests.first) - #expect(request.method == "PATCH") - #expect(request.url.absoluteString == "https://firestore.googleapis.com/v1/projects/demo/databases/(default)/documents/users/a%20b?updateMask.fieldPaths=%60first-name%60¤tDocument.updateTime=2026-10-12T08:00:00Z") - #expect(String(data: request.body ?? Data(), encoding: .utf8) == #"{"fields":{"first-name":{"stringValue":"A"}}}"#) - } - - @Test("A colon in a document ID is encoded, so it never reads as a custom method") - func colonInDocumentID() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(status: 404, json: "{}") - transport.enqueue(json: "[]") - let client = makeFirestoreClient(transport) - _ = try await client.getDocument("users/uid:123") - _ = try await client.runQuery(parent: "users/uid:123", body: .object([:]), operation: "read") - #expect(transport.requests.map(\.url.absoluteString) == [ - "https://firestore.googleapis.com/v1/projects/demo/databases/(default)/documents/users/uid%3A123", - "https://firestore.googleapis.com/v1/projects/demo/databases/(default)/documents/users/uid%3A123:runQuery" - ]) - } - - @Test("Firestore's FAILED_PRECONDITION is a precondition, other 400s are not") - func failedPrecondition() { - let stale = Data(#"{"error": {"code": 400, "message": "stored version mismatch", "status": "FAILED_PRECONDITION"}}"#.utf8) - #expect(FirebaseError.from(status: 400, body: stale, operation: "write") == .precondition("stored version mismatch")) - let invalid = Data(#"{"error": {"code": 400, "message": "bad field", "status": "INVALID_ARGUMENT"}}"#.utf8) - #expect(FirebaseError.from(status: 400, body: invalid, operation: "write") == .api(status: 400, message: "bad field")) - } - - @Test("Only native-mode databases are listed") - func databases() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(json: """ - {"databases": [{"name": "projects/demo/databases/(default)", "type": "FIRESTORE_NATIVE"}, - {"name": "projects/demo/databases/legacy", "type": "DATASTORE_MODE"}, - {"name": "projects/demo/databases/eu", "type": "FIRESTORE_NATIVE"}]} - """) - #expect(try await makeFirestoreClient(transport).listDatabases() == ["(default)", "eu"]) - } - - @Test("The count comes from the aggregation result") - func count() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(json: #"[{"result": {"aggregateFields": {"total": {"integerValue": "1234"}}}, "readTime": "x"}]"#) - #expect(try await makeFirestoreClient(transport).count(parent: "", body: .object([:]), operation: "count") == 1_234) - } -} - -@Suite("Realtime Database client") -struct RealtimeDatabaseClientTests { - @Test("URLs end in .json, encode segments and keep the emulator namespace") - func urls() throws { - let transport = FakeFirebaseTransport() - let client = makeDatabaseClient(transport) - #expect(try client.url("/chats/a b", query: [URLQueryItem(name: "orderBy", value: "\"$key\"")]).absoluteString - == "https://demo-default-rtdb.europe-west1.firebasedatabase.app/chats/a%20b.json?orderBy=%22$key%22") - #expect(try client.url("").absoluteString == "https://demo-default-rtdb.europe-west1.firebasedatabase.app/.json") - let emulator = makeDatabaseClient(transport, url: "http://127.0.0.1:9000/?ns=demo-app") - #expect(try emulator.url("x", query: [URLQueryItem(name: "shallow", value: "true")]).absoluteString - == "http://127.0.0.1:9000/x.json?ns=demo-app&shallow=true") - #expect(emulator.instanceName == "demo-app") - #expect(client.instanceName == "demo-default-rtdb") - } - - @Test("Children come back in the requested order with the offset dropped") - func children() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(json: #"{"c": {"n": 3}, "a": {"n": 1}, "b": {"n": 2}}"#) - let plan = try RealtimeDatabaseQueryPlanner.plan(spec: FirebaseBrowseSpec(table: "x", limit: 2, offset: 1), typeOf: noTypes) - let children = try await makeDatabaseClient(transport).children("x", plan: plan) - #expect(children.map(\.key) == ["b", "c"]) - } - - @Test("An array node is read as children keyed by index, without its holes") - func arrays() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(json: #"[null, "one", "two"]"#) - let plan = try RealtimeDatabaseQueryPlanner.plan(spec: FirebaseBrowseSpec(table: "x", limit: 0, offset: 0), typeOf: noTypes) - let children = try await makeDatabaseClient(transport).children("x", plan: plan) - #expect(children.map(\.key) == ["1", "2"]) - } - - @Test("An instance asked for in the wrong region moves to the URL the server names, if it is Firebase's") - func regionRedirect() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(status: 404, json: #"{"correctUrl": "https://demo-default-rtdb.europe-west1.firebasedatabase.app", "error": "Database lives in a different region."}"#) - transport.enqueue(json: "null") - let client = makeDatabaseClient(transport, url: "https://demo-default-rtdb.firebaseio.com/") - try await client.locate() - #expect(client.currentURL.absoluteString == "https://demo-default-rtdb.europe-west1.firebasedatabase.app/") - #expect(transport.requests[1].url.host == "demo-default-rtdb.europe-west1.firebasedatabase.app") - - let hostile = FakeFirebaseTransport() - hostile.enqueue(status: 404, json: #"{"correctUrl": "https://evil.example.com", "error": "moved"}"#) - let stays = makeDatabaseClient(hostile, url: "https://demo-default-rtdb.firebaseio.com/") - await #expect(throws: FirebaseError.self) { try await stays.locate() } - #expect(stays.currentURL.host == "demo-default-rtdb.firebaseio.com") - #expect(hostile.requests.count == 1) - } - - @Test("The health check reads at most one child and asks for no body") - func ping() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(status: 204, json: "") - try await makeDatabaseClient(transport).ping() - #expect(transport.requests.first?.url.query == "orderBy=%22$key%22&limitToFirst=1&print=silent") - } - - @Test("The instance list names a quota project only when the API asks for one") - func quotaProjectRetry() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(status: 403, json: #"{"error": {"message": "The firebasedatabase.googleapis.com API requires a quota project, which is not set by default."}}"#) - transport.enqueue(json: #"{"instances": [{"databaseUrl": "https://b.europe-west1.firebasedatabase.app", "state": "ACTIVE"}, {"databaseUrl": "https://a.firebaseio.com", "state": "ACTIVE"}, {"databaseUrl": "https://c.firebaseio.com", "state": "DISABLED"}]}"#) - let lister = RealtimeDatabaseInstanceLister(transport: transport, credential: .emulator, timeout: { 30 }) - let urls = try await lister.instances(projectId: "demo") - #expect(urls.map(\.absoluteString) == ["https://a.firebaseio.com/", "https://b.europe-west1.firebasedatabase.app/"]) - #expect(transport.requests[0].headers["x-goog-user-project"] == nil) - #expect(transport.requests[1].headers["x-goog-user-project"] == "demo") - } - - @Test("A rules denial is a permission error, not an empty node") - func denied() async { - let transport = FakeFirebaseTransport() - transport.enqueue(status: 401, json: #"{"error": "Permission denied"}"#) - transport.enqueue(status: 403, json: #"{"error": "Permission denied"}"#) - do { - _ = try await makeDatabaseClient(transport).get("secrets") - Issue.record("Expected a denial") - } catch let error as FirebaseError { - #expect(error == .permissionDenied( - operation: "read /secrets", - detail: "The credential lacks the IAM role for this, or the security rules block it." - )) - } catch { - Issue.record("Unexpected \(error)") - } - } - - @Test("A guarded write sends if-match, and a moved ETag never shows the node's data as the error") - func guardedWrite() async throws { - let transport = FakeFirebaseTransport() - transport.enqueue(json: #"{"a": 1}"#, headers: ["ETag": "abc"]) - transport.enqueue(status: 412, json: #"{"message": "hi", "error": "private"}"#) - let client = makeDatabaseClient(transport) - let read = try await client.getWithETag("node") - #expect(read.etag == "abc") - await #expect(throws: FirebaseError.precondition("The node changed after it was read.")) { - try await client.put("node", value: .object(["a": .int(2)]), ifMatch: "abc") - } - #expect(transport.requests[0].headers["X-Firebase-ETag"] == "true") - #expect(transport.requests[1].headers["if-match"] == "abc") - #expect(transport.requests[1].url.query == nil) - } -} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseEmulatorTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseEmulatorTests.swift deleted file mode 100644 index e78d64124a..0000000000 --- a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseEmulatorTests.swift +++ /dev/null @@ -1,123 +0,0 @@ -import Foundation -@testable import TableProFirebaseCore -import Testing - -/// Runs against the Firebase emulators when their standard variables are set, for example -/// `FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 FIREBASE_DATABASE_EMULATOR_HOST=127.0.0.1:9000`. -enum FirebaseEmulatorEnvironment { - static let firestoreHost = ProcessInfo.processInfo.environment["FIRESTORE_EMULATOR_HOST"] - static let databaseHost = ProcessInfo.processInfo.environment["FIREBASE_DATABASE_EMULATOR_HOST"] - static let project = "demo-tablepro" - - static func transport() -> URLSessionFirebaseTransport { - URLSessionFirebaseTransport(resourceTimeout: 30) - } -} - -@Suite("Firestore emulator", .enabled(if: FirebaseEmulatorEnvironment.firestoreHost != nil), .serialized) -struct FirestoreEmulatorTests { - private func client() throws -> FirestoreClient { - let host = try #require(FirebaseEmulatorEnvironment.firestoreHost) - return FirestoreClient( - projectId: FirebaseEmulatorEnvironment.project, - database: "(default)", - baseURL: try #require(URL(string: "http://\(host)/v1/")), - transport: FirebaseEmulatorEnvironment.transport(), - credential: .emulator, - timeout: { 10 } - ) - } - - @Test("Create, query, count, patch with a stale time, and delete") - func lifecycle() async throws { - let client = try client() - let collection = "tp_\(UUID().uuidString.prefix(8))" - let created = try await client.createDocument(collectionPath: collection, documentId: "alice", fields: [ - "age": .integer(30), "when": .timestamp("2026-10-12T08:00:00Z"), "tags": .array([.string("vip")]) - ]) - _ = try await client.createDocument(collectionPath: collection, documentId: nil, fields: ["age": .integer(20)]) - #expect(created.path == "\(collection)/alice") - - let target = try FirestoreCollectionTarget(table: collection) - let spec = FirebaseBrowseSpec(table: collection, filters: [FirebaseFilterSpec(column: "age", op: ">=", value: "25")], limit: 10, offset: 0) - let types: (String) -> String? = { $0 == "age" ? "integer" : nil } - let query = try FirestoreQueryBuilder.structuredQuery(spec: spec, target: target, documentRoot: client.documentRoot, typeOf: types) - let documents = try await client.runQuery(parent: "", body: query, operation: "read") - #expect(documents.map(\.path) == ["\(collection)/alice"]) - let all = FirebaseBrowseSpec(table: collection, limit: 0, offset: 0) - let counted = try await client.count( - parent: "", body: try FirestoreQueryBuilder.countQuery(spec: all, target: target, documentRoot: client.documentRoot, typeOf: types), operation: "count" - ) - #expect(counted == 2) - - let stale = "2000-01-01T00:00:00Z" - await expectPrecondition { - try await client.patchDocument("\(collection)/alice", fields: ["age": .integer(31)], mask: ["age"], updateTime: stale) - } - try await client.patchDocument("\(collection)/alice", fields: ["age": .integer(31)], mask: ["age", "tags"]) - let patched = try #require(try await client.getDocument("\(collection)/alice")) - #expect(patched.fields["age"] == .integer(31)) - #expect(patched.fields["tags"] == nil) - #expect(patched.fields["when"] == .timestamp("2026-10-12T08:00:00Z")) - - #expect(try await client.listCollectionIds(parent: "").contains(collection)) - try await client.deleteDocument("\(collection)/alice") - #expect(try await client.getDocument("\(collection)/alice") == nil) - } -} - -@Suite("Realtime Database emulator", .enabled(if: FirebaseEmulatorEnvironment.databaseHost != nil), .serialized) -struct RealtimeDatabaseEmulatorTests { - private func client() throws -> RealtimeDatabaseClient { - let host = try #require(FirebaseEmulatorEnvironment.databaseHost) - return RealtimeDatabaseClient( - instanceURL: try #require(URL(string: "http://\(host)/?ns=\(FirebaseEmulatorEnvironment.project)")), - transport: FirebaseEmulatorEnvironment.transport(), - credential: .emulator, - timeout: { 10 } - ) - } - - @Test("Write, read in order, guard with an ETag, and delete") - func lifecycle() async throws { - let client = try client() - let node = "tp_\(UUID().uuidString.prefix(8))" - try await client.put(node, value: json(#"{"b": {"n": 2}, "a": {"n": 3}, "c": {"n": 1}}"#)) - let pushed = try await client.push(node, value: json(#"{"n": 0}"#)) - #expect(pushed != nil) - - let byField = FirebaseBrowseSpec(table: node, sorts: [FirebaseSortSpec(column: "n", ascending: true)], limit: 2, offset: 1) - let plan = try RealtimeDatabaseQueryPlanner.plan(spec: byField, typeOf: noTypes) - do { - _ = try await client.children(node, plan: plan) - Issue.record("Expected the missing index to be reported") - } catch let error as FirebaseError { - #expect(error.localizedDescription.contains(".indexOn")) - } - try await client.put(".settings/rules", value: json(#"{"rules": {".read": "auth != null", ".write": "auth != null", "\#(node)": {".indexOn": ["n"]}}}"#)) - #expect(try await client.children(node, plan: plan).map(\.key) == ["c", "b"]) - #expect(try await client.childKeys(node).count == 4) - - let read = try await client.getWithETag("\(node)/a") - let etag = try #require(read.etag) - try await client.patch("\(node)/a", values: ["n": .int(30)]) - await expectPrecondition { - try await client.put("\(node)/a", value: json(#"{"n": 99}"#), ifMatch: etag) - } - #expect(try await client.get("\(node)/a") == json(#"{"n": 30}"#)) - - try await client.delete(node) - #expect(try await client.get(node) == .null) - } -} - -/// A stale write has to fail as a precondition: any other error would mean the guard never ran. -private func expectPrecondition(_ body: () async throws -> Void, sourceLocation: SourceLocation = #_sourceLocation) async { - do { - try await body() - Issue.record("Expected a precondition failure", sourceLocation: sourceLocation) - } catch FirebaseError.precondition { - } catch { - Issue.record("Expected a precondition failure, got \(error)", sourceLocation: sourceLocation) - } -} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseJSONTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseJSONTests.swift deleted file mode 100644 index 09ce4a2068..0000000000 --- a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseJSONTests.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -@testable import TableProFirebaseCore -import Testing - -@Suite("FirebaseJSON") -struct FirebaseJSONTests { - @Test("Integers and doubles stay apart") - func numberKinds() throws { - #expect(try FirebaseJSON.parse("1") == .int(1)) - #expect(try FirebaseJSON.parse("1.0") == .double(1)) - #expect(try FirebaseJSON.parse("[1, 2.5]") == .array([.int(1), .double(2.5)])) - #expect(try FirebaseJSON.parse("9007199254740993") == .int(9_007_199_254_740_993)) - #expect(try FirebaseJSON.parse("true") == .bool(true)) - #expect(try FirebaseJSON.parse("0") == .int(0)) - } - - @Test("Text is deterministic, sorted and escaped") - func text() throws { - let value = try FirebaseJSON.parse(#"{"b": 1, "a": "x\"y\n", "c": [true, null, 1.5]}"#) - #expect(value.text() == #"{"a":"x\"y\n","b":1,"c":[true,null,1.5]}"#) - #expect(FirebaseJSON.double(3).text() == "3.0") - #expect(FirebaseJSON.object([:]).text() == "{}") - } - - @Test("Pretty text indents nested values") - func pretty() { - let value = FirebaseJSON.object(["a": .array([.int(1)])]) - #expect(value.text(pretty: true) == "{\n \"a\": [\n 1\n ]\n}") - } - - @Test("Invalid JSON throws a configuration-free malformed error") - func invalid() { - #expect(throws: FirebaseError.self) { try FirebaseJSON.parse("{nope") } - } - - @Test("Round trip through text keeps the value") - func roundTrip() throws { - let original = FirebaseJSON.object([ - "n": .int(-42), "d": .double(0.1), "s": .string("é \u{1F600}"), "z": .null, "l": .array([.bool(false)]) - ]) - #expect(try FirebaseJSON.parse(original.text()) == original) - } -} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseSettingsAndStatementTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseSettingsAndStatementTests.swift deleted file mode 100644 index 7c9fde705c..0000000000 --- a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseSettingsAndStatementTests.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Foundation -@testable import TableProFirebaseCore -import Testing - -@Suite("Firebase connection settings") -struct FirebaseSettingsTests { - @Test("Firestore takes the project from the credential when the field is empty") - func projectFromCredential() throws { - let settings = try FirebaseConnectionSettings.parse(fields: ["fbAuthMethod": "serviceAccount"], credentialProject: "my-app") - #expect(settings.variant == .firestore) - #expect(settings.projectId == "my-app") - #expect(settings.firestoreDatabase == "(default)") - #expect(settings.firestoreBaseURL?.absoluteString == "https://firestore.googleapis.com/v1/") - } - - @Test("The field wins over the credential, and a project is required for Firestore") - func projectRequired() throws { - let explicit = try FirebaseConnectionSettings.parse(fields: ["fbProjectId": "other-app"], credentialProject: "my-app") - #expect(explicit.projectId == "other-app") - #expect(throws: FirebaseError.self) { try FirebaseConnectionSettings.parse(fields: [:]) } - #expect(throws: FirebaseError.self) { try FirebaseConnectionSettings.parse(fields: ["fbProjectId": "Bad Project"]) } - } - - @Test("The Realtime Database variant needs a URL or a project") - func realtimeDatabase() throws { - let fields = ["driverVariant": "realtimeDatabase", "fbDatabaseURL": "https://chat-1.europe-west1.firebasedatabase.app/x"] - let settings = try FirebaseConnectionSettings.parse(fields: fields) - #expect(settings.variant == .realtimeDatabase) - #expect(settings.defaultDatabaseURL?.absoluteString == "https://chat-1.europe-west1.firebasedatabase.app/") - let fromProject = try FirebaseConnectionSettings.parse(fields: ["driverVariant": "realtimeDatabase", "fbProjectId": "my-app"]) - #expect(fromProject.defaultDatabaseURL?.absoluteString == "https://my-app-default-rtdb.firebaseio.com/") - #expect(throws: FirebaseError.self) { try FirebaseConnectionSettings.parse(fields: ["driverVariant": "realtimeDatabase"]) } - } - - @Test("A token is never sent to a host that is not Firebase's") - func untrustedURL() { - for url in ["http://chat.firebaseio.com", "https://evil.example.com", "https://firebaseio.com.evil.io/", "nonsense"] { - #expect(throws: FirebaseError.self) { - try FirebaseConnectionSettings.parse(fields: ["driverVariant": "realtimeDatabase", "fbDatabaseURL": url]) - } - } - } - - @Test("The emulator takes host:port and plain HTTP") - func emulator() throws { - let firestore = try FirebaseConnectionSettings.parse(fields: [ - "fbAuthMethod": "emulator", "fbProjectId": "demo-app", "fbEmulatorHost": "http://127.0.0.1:8080/" - ]) - #expect(firestore.firestoreBaseURL?.absoluteString == "http://127.0.0.1:8080/v1/") - let database = try FirebaseConnectionSettings.parse(fields: [ - "driverVariant": "realtimeDatabase", "fbAuthMethod": "emulator", "fbProjectId": "demo-app", "fbEmulatorHost": "localhost:9000" - ]) - #expect(database.defaultDatabaseURL?.absoluteString == "http://localhost:9000/?ns=demo-app-default-rtdb") - #expect(throws: FirebaseError.self) { - try FirebaseConnectionSettings.parse(fields: ["fbAuthMethod": "emulator", "fbProjectId": "demo-app", "fbEmulatorHost": "localhost"]) - } - } -} - -@Suite("Firebase statements") -struct FirebaseStatementTests { - @Test("A browse spec survives the statement text") - func browse() throws { - let spec = FirebaseBrowseSpec(table: "users", filters: [FirebaseFilterSpec(column: "a", op: "=", value: "1")], limit: 10, offset: 20) - #expect(try FirebaseStatement.parse(FirebaseStatement.encode(spec)) == .browse(spec)) - #expect(throws: FirebaseError.self) { try FirebaseStatement.parse("FIREBASE_BROWSE:not-base64!") } - } - - @Test("Request lines read verb, path, query and body") - func requests() throws { - let patch = try FirebaseRequest.parse(#"patch /users/alice?updateMask=age,name {"age": 31}"#) - #expect(patch.method == .patch) - #expect(patch.path == "users/alice") - #expect(patch.queryValue("updateMask") == "age,name") - #expect(patch.body == #"{"age": 31}"#) - let multiline = try FirebaseRequest.parse("PUT /config/maintenance\nfalse") - #expect(multiline.body == "false") - let query = try FirebaseRequest.parse(#"QUERY {"structuredQuery": {}}"#) - #expect(query.path.isEmpty && query.body == #"{"structuredQuery": {}}"#) - let scoped = try FirebaseRequest.parse(#"QUERY users/alice {"structuredQuery": {}}"#) - #expect(scoped.path == "users/alice") - #expect(try FirebaseRequest.parse("GET /").path.isEmpty) - } - - @Test("Malformed requests explain the syntax") - func malformed() { - #expect(throws: FirebaseError.self) { try FirebaseRequest.parse("SELECT * FROM users") } - #expect(throws: FirebaseError.self) { try FirebaseRequest.parse("PATCH users/alice") } - #expect(throws: FirebaseError.self) { try FirebaseRequest.parse("QUERY users") } - } - - @Test("Every request the syntax message suggests parses, and its QUERY carries a structuredQuery") - func usageExamples() throws { - for example in FirebaseRequest.usageExamples { - #expect(FirebaseRequest.usage.contains(example)) - let request = try FirebaseRequest.parse(example) - if request.method == .query { - #expect(try FirebaseJSON.parse(request.body ?? "")["structuredQuery"] != nil) - } - } - } - - @Test("The request text reads back as the same request") - func text() throws { - let request = FirebaseRequest(method: .patch, path: "users/alice", query: [URLQueryItem(name: "updateMask", value: "age")], body: #"{"age":31}"#) - #expect(request.text == #"PATCH users/alice?updateMask=age {"age":31}"#) - #expect(try FirebaseRequest.parse(request.text) == request) - } - - @Test("An ID with a space, & or = reads back whole, and the rest of the line stays readable") - func escapedText() throws { - let delete = FirebaseRequest(method: .delete, path: "chats/John Smith") - #expect(delete.text == "DELETE chats/John%20Smith") - #expect(try FirebaseRequest.parse(delete.text) == delete) - - let post = FirebaseRequest(method: .post, path: "users", - query: [URLQueryItem(name: "documentId", value: "a&b=c #1 50%")], body: #"{"n":1}"#) - #expect(post.text == #"POST users?documentId=a%26b%3Dc%20%231%2050%25 {"n":1}"#) - #expect(try FirebaseRequest.parse(post.text) == post) - - let unicode = FirebaseRequest(method: .get, path: "người/uid:1") - #expect(unicode.text == "GET người/uid:1") - #expect(try FirebaseRequest.parse(unicode.text) == unicode) - } - - @Test("An update mask quotes a field name that holds a comma or a backtick") - func mask() { - let text = FirestoreQueryBuilder.maskText(["age", "a,b", "x`y", "first name"]) - #expect(text == #"age,`a,b`,`x\`y`,`first name`"#) - #expect(FirestoreQueryBuilder.maskFields(text) == ["age", "a,b", "x`y", "first name"]) - #expect(FirestoreQueryBuilder.maskFields(" age , name ") == ["age", "name"]) - } -} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseTestSupport.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseTestSupport.swift deleted file mode 100644 index d8a961c087..0000000000 --- a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirebaseTestSupport.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation -@testable import TableProFirebaseCore - -final class FakeFirebaseTransport: FirebaseTransport, @unchecked Sendable { - private let lock = NSLock() - private var queued: [FirebaseHTTPResponse] = [] - private var _requests: [FirebaseHTTPRequest] = [] - - var requests: [FirebaseHTTPRequest] { - lock.withLock { _requests } - } - - func enqueue(status: Int = 200, json: String, headers: [String: String] = [:]) { - lock.withLock { queued.append(FirebaseHTTPResponse(statusCode: status, headers: headers, body: Data(json.utf8))) } - } - - func send(_ request: FirebaseHTTPRequest) async throws -> FirebaseHTTPResponse { - try lock.withLock { - _requests.append(request) - guard !queued.isEmpty else { - throw FirebaseError.malformedResponse("No fake response for \(request.method) \(request.url)") - } - return queued.removeFirst() - } - } - - func cancelAll() {} -} - -final class CountingCredential: @unchecked Sendable { - private let lock = NSLock() - private var issued = 0 - private var invalidations = 0 - - var invalidateCount: Int { lock.withLock { invalidations } } - - var credential: FirebaseCredential { - FirebaseCredential( - token: { [self] in lock.withLock { issued += 1; return "token-\(issued)" } }, - invalidate: { [self] in lock.withLock { invalidations += 1 } } - ) - } -} - -let testDocumentRoot = "projects/demo/databases/(default)/documents" - -func makeFirestoreClient(_ transport: FakeFirebaseTransport, credential: FirebaseCredential = .emulator) -> FirestoreClient { - FirestoreClient( - projectId: "demo", - database: "(default)", - baseURL: URL(string: "https://firestore.googleapis.com/v1/") ?? URL(fileURLWithPath: "/"), - transport: transport, - credential: credential, - timeout: { 30 } - ) -} - -func makeDatabaseClient(_ transport: FakeFirebaseTransport, url: String = "https://demo-default-rtdb.europe-west1.firebasedatabase.app/") -> RealtimeDatabaseClient { - RealtimeDatabaseClient( - instanceURL: URL(string: url) ?? URL(fileURLWithPath: "/"), - transport: transport, - credential: .emulator, - timeout: { 30 } - ) -} - -func json(_ text: String) -> FirebaseJSON { - (try? FirebaseJSON.parse(text)) ?? .null -} - -func noTypes(_ column: String) -> String? { nil } diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreCellAndQueryTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreCellAndQueryTests.swift deleted file mode 100644 index ae33dccb1b..0000000000 --- a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreCellAndQueryTests.swift +++ /dev/null @@ -1,205 +0,0 @@ -import Foundation -@testable import TableProFirebaseCore -import Testing - -@Suite("Firestore cells") -struct FirestoreCellTests { - @Test("Values display as text the grid can show") - func display() { - #expect(FirestoreCell.display(.null) == .null) - #expect(FirestoreCell.display(.boolean(true)) == .text("true")) - #expect(FirestoreCell.display(.double(2)) == .text("2.0")) - #expect(FirestoreCell.display(.geoPoint(latitude: 45.5, longitude: 9.0)) == .text("45.5, 9.0")) - #expect(FirestoreCell.display(.map(["a": .integer(1)])) == .text(#"{"a":1}"#)) - #expect(FirestoreCell.display(.bytes(Data([1]))) == .bytes(Data([1]))) - } - - @Test("An edit keeps the type the field had") - func typedEdits() throws { - #expect(try FirestoreCell.value(text: "42", typeName: "string") == .string("42")) - #expect(try FirestoreCell.value(text: " 42 ", typeName: "integer") == .integer(42)) - #expect(try FirestoreCell.value(text: "4.5", typeName: "double") == .double(4.5)) - #expect(try FirestoreCell.value(text: "FALSE", typeName: "boolean") == .boolean(false)) - #expect(try FirestoreCell.value(text: "2026-10-12T08:00:00Z", typeName: "timestamp") == .timestamp("2026-10-12T08:00:00Z")) - #expect(try FirestoreCell.value(text: "/users/bob", typeName: "reference") == .reference("users/bob")) - #expect(try FirestoreCell.value(text: "45.5, 9", typeName: "geopoint") == .geoPoint(latitude: 45.5, longitude: 9)) - #expect(try FirestoreCell.value(text: #"{"a": [1]}"#, typeName: "map") == .map(["a": .array([.integer(1)])])) - } - - @Test("An edit that does not fit the type is refused with an example") - func mismatch() { - #expect(throws: FirebaseError.self) { try FirestoreCell.value(text: "4.5", typeName: "integer") } - #expect(throws: FirebaseError.self) { try FirestoreCell.value(text: "soon", typeName: "timestamp") } - #expect(throws: FirebaseError.self) { try FirestoreCell.value(text: "users", typeName: "reference") } - #expect(throws: FirebaseError.self) { try FirestoreCell.value(text: "1", typeName: "geopoint") } - } - - @Test("A new field infers JSON literals and keeps other text as a string") - func inferred() { - #expect(FirestoreCell.inferred("12") == .integer(12)) - #expect(FirestoreCell.inferred("true") == .boolean(true)) - #expect(FirestoreCell.inferred("[1]") == .array([.integer(1)])) - #expect(FirestoreCell.inferred("hello") == .string("hello")) - #expect(FirestoreCell.inferred("\"quoted\"") == .string("\"quoted\"")) - } - - @Test("A column's type is what most of its cells hold") - func dominantType() { - #expect(FirestoreCell.dominantType([.string("a"), .null, .string("b"), .integer(1)]) == "string") - #expect(FirestoreCell.dominantType([nil, .null]) == "null") - #expect(FirestoreCell.dominantType([nil]) == "string") - } - - @Test("Rows put __name__ first and mark missing fields absent") - func rows() throws { - let target = try FirestoreCollectionTarget(table: "users") - let rows = FirestoreRowSet(documents: [ - FirestoreDocument(path: "users/alice", fields: ["age": .integer(30), "name": .string("Alice")]), - FirestoreDocument(path: "users/bob", fields: ["name": .string("Bob")]) - ], target: target) - #expect(rows.columns == ["__name__", "age", "name"]) - #expect(rows.columnTypes == ["string", "integer", "string"]) - #expect(rows.rows[0] == [.text("alice"), .text("30"), .text("Alice")]) - #expect(rows.absentCells == [1: [1]]) - #expect(rows.locators == ["users/alice", "users/bob"]) - #expect(rows.cellTypes["users/bob"] == ["name": "string"]) - } -} - -@Suite("Firestore query builder") -struct FirestoreQueryBuilderTests { - private func filterJSON(_ filter: FirebaseFilterSpec, types: [String: String] = [:], table: String = "users") throws -> String { - let spec = FirebaseBrowseSpec(table: table, filters: [filter], limit: 0, offset: 0) - let query = try FirestoreQueryBuilder.structuredQuery( - spec: spec, target: try FirestoreCollectionTarget(table: table), - documentRoot: testDocumentRoot, typeOf: { types[$0] } - ) - return query["structuredQuery"]?["where"]?.text() ?? "" - } - - @Test("A page carries from, order, offset and limit") - func page() throws { - let spec = FirebaseBrowseSpec( - table: "users/alice/orders", sorts: [FirebaseSortSpec(column: "total", ascending: false)], limit: 50, offset: 100 - ) - let target = try FirestoreCollectionTarget(table: spec.table) - #expect(target.parentPath == "users/alice") - let query = try FirestoreQueryBuilder.structuredQuery(spec: spec, target: target, documentRoot: testDocumentRoot, typeOf: noTypes) - let expected = #"{"structuredQuery":{"from":[{"allDescendants":false,"collectionId":"orders"}],"limit":50,"offset":100,"# - + #""orderBy":[{"direction":"DESCENDING","field":{"fieldPath":"total"}}]}}"# - #expect(query.text() == expected) - } - - @Test("Filter values are typed by the field's type") - func typedValues() throws { - #expect(try filterJSON(FirebaseFilterSpec(column: "age", op: ">=", value: "30"), types: ["age": "integer"]) - == #"{"fieldFilter":{"field":{"fieldPath":"age"},"op":"GREATER_THAN_OR_EQUAL","value":{"integerValue":"30"}}}"#) - #expect(try filterJSON(FirebaseFilterSpec(column: "zip", op: "=", value: "02134"), types: ["zip": "string"]) - == #"{"fieldFilter":{"field":{"fieldPath":"zip"},"op":"EQUAL","value":{"stringValue":"02134"}}}"#) - #expect(try filterJSON(FirebaseFilterSpec(column: "score", op: "!=", value: "7")) - == #"{"fieldFilter":{"field":{"fieldPath":"score"},"op":"NOT_EQUAL","value":{"integerValue":"7"}}}"#) - } - - @Test("__name__ compares document references") - func nameFilter() throws { - #expect(try filterJSON(FirebaseFilterSpec(column: "__name__", op: "=", value: "alice")) - == #"{"fieldFilter":{"field":{"fieldPath":"__name__"},"op":"EQUAL","value":{"referenceValue":"projects/demo/databases/(default)/documents/users/alice"}}}"#) - #expect(try filterJSON(FirebaseFilterSpec(column: "__name__", op: "=", value: "users/a/orders/1"), table: "*/orders") - .contains("documents/users/a/orders/1")) - } - - @Test("Range, list and null operators map to Firestore operators") - func operators() throws { - let between = try filterJSON(FirebaseFilterSpec(column: "age", op: "BETWEEN", value: "10,20", secondValue: "20"), types: ["age": "integer"]) - #expect(between.contains(#""op":"GREATER_THAN_OR_EQUAL","value":{"integerValue":"10"}"#)) - #expect(between.contains(#""op":"LESS_THAN_OR_EQUAL","value":{"integerValue":"20"}"#)) - let prefix = try filterJSON(FirebaseFilterSpec(column: "name", op: "STARTS WITH", value: "Al")) - #expect(prefix.contains("\"Al\u{F8FF}\"")) - let list = try filterJSON(FirebaseFilterSpec(column: "tier", op: "NOT IN", value: "gold, silver"), types: ["tier": "string"]) - #expect(list.contains(#""op":"NOT_IN""#) && list.contains(#"{"stringValue":"silver"}"#)) - #expect(try filterJSON(FirebaseFilterSpec(column: "x", op: "IS NULL", value: "")).contains("IS_NULL")) - #expect(try filterJSON(FirebaseFilterSpec(column: "tags", op: "CONTAINS", value: "vip"), types: ["tags": "array"]) - .contains("ARRAY_CONTAINS")) - } - - @Test("Operators Firestore lacks are refused, not approximated") - func unsupported() { - #expect(throws: FirebaseError.self) { try filterJSON(FirebaseFilterSpec(column: "name", op: "CONTAINS", value: "x"), types: ["name": "string"]) } - #expect(throws: FirebaseError.self) { try filterJSON(FirebaseFilterSpec(column: "name", op: "ENDS WITH", value: "x")) } - #expect(throws: FirebaseError.self) { try filterJSON(FirebaseFilterSpec(column: "name", op: "REGEX", value: "x")) } - #expect(throws: FirebaseError.self) { try filterJSON(FirebaseFilterSpec(column: "age", op: "=", value: "old"), types: ["age": "integer"]) } - } - - @Test("Several filters combine with the chosen logic, and a raw filter passes through") - func combine() throws { - let spec = FirebaseBrowseSpec(table: "users", filters: [ - FirebaseFilterSpec(column: "a", op: "=", value: "x"), - FirebaseFilterSpec(column: "__RAW__", op: "", value: #"{"unaryFilter": {"field": {"fieldPath": "b.c"}, "op": "IS_NAN"}}"#) - ], logicMode: "OR", limit: 10, offset: 0) - let query = try FirestoreQueryBuilder.structuredQuery( - spec: spec, target: try FirestoreCollectionTarget(table: "users"), documentRoot: testDocumentRoot, typeOf: noTypes - ) - let filter = query["structuredQuery"]?["where"]?.text() ?? "" - #expect(filter.hasPrefix(#"{"compositeFilter":{"filters":["#)) - #expect(filter.contains(#""op":"OR""#) && filter.contains("IS_NAN")) - let bad = FirebaseBrowseSpec(table: "users", filters: [FirebaseFilterSpec(column: "__RAW__", op: "", value: "age > 3")], limit: 1, offset: 0) - #expect(throws: FirebaseError.self) { - try FirestoreQueryBuilder.structuredQuery(spec: bad, target: try FirestoreCollectionTarget(table: "users"), documentRoot: testDocumentRoot, typeOf: noTypes) - } - } - - @Test("A raw filter that is not a Filter object is refused with an example the raw filter row accepts") - func rawFilterExample() throws { - let raw = FirestoreQueryBuilder.rawFilterColumn - let example = FirestoreQueryBuilder.rawFilterExample - #expect(try filterJSON(FirebaseFilterSpec(column: raw, op: "", value: example)).contains("IS_NULL")) - do { - _ = try filterJSON(FirebaseFilterSpec(column: raw, op: "", value: #"{"age": 3}"#)) - Issue.record("A JSON object that is not a Filter was accepted") - } catch let FirebaseError.configuration(message) { - #expect(message.contains(example)) - } - } - - @Test("Counting drops the page but keeps the order, which leaves out documents without the field") - func count() throws { - let spec = FirebaseBrowseSpec(table: "*/orders", sorts: [FirebaseSortSpec(column: "a", ascending: true)], limit: 10, offset: 5) - let query = try FirestoreQueryBuilder.countQuery( - spec: spec, target: try FirestoreCollectionTarget(table: spec.table), documentRoot: testDocumentRoot, typeOf: noTypes - ) - let expected = #"{"structuredAggregationQuery":{"aggregations":[{"alias":"total","count":{}}],"# - + #""structuredQuery":{"from":[{"allDescendants":true,"collectionId":"orders"}],"# - + #""orderBy":[{"direction":"ASCENDING","field":{"fieldPath":"a"}}]}}}"# - #expect(query.text() == expected) - #expect(FirestoreQueryBuilder.sortedFields(spec) == ["a"]) - } - - @Test("BETWEEN splits the host's joined value by the upper bound, whatever commas the bounds hold") - func betweenBounds() { - let joined = FirebaseFilterSpec(column: "s", op: "BETWEEN", value: "a,b,c,d", secondValue: "c,d") - #expect(joined.betweenBounds?.lower == "a,b" && joined.betweenBounds?.upper == "c,d") - let bare = FirebaseFilterSpec(column: "s", op: "BETWEEN", value: "1,9") - #expect(bare.betweenBounds?.lower == "1" && bare.betweenBounds?.upper == "9") - #expect(FirebaseFilterSpec(column: "s", op: "BETWEEN", value: "1").betweenBounds == nil) - } - - @Test("Field paths are quoted when they need it") - func fieldPaths() { - #expect(FirestoreQueryBuilder.fieldPath("name") == "name") - #expect(FirestoreQueryBuilder.fieldPath("first-name") == "`first-name`") - #expect(FirestoreQueryBuilder.fieldPath("a`b") == "`a\\`b`") - #expect(FirestoreQueryBuilder.fieldPath("__name__") == "__name__") - } - - @Test("A table names a collection or a collection group, never a document") - func targets() throws { - #expect(try FirestoreCollectionTarget(table: "*/orders").isGroup) - #expect(throws: FirebaseError.self) { try FirestoreCollectionTarget(table: "users/alice") } - #expect(throws: FirebaseError.self) { try FirestoreCollectionTarget(table: "*/a/b") } - let group = try FirestoreCollectionTarget(table: "*/orders") - #expect(group.rowName(forDocumentPath: "users/a/orders/1") == "users/a/orders/1") - let collection = try FirestoreCollectionTarget(table: "users") - #expect(collection.rowName(forDocumentPath: "users/alice") == "alice") - #expect(collection.documentPath(forRowName: "alice") == "users/alice") - } -} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreValueTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreValueTests.swift deleted file mode 100644 index 5cbfc242c8..0000000000 --- a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/FirestoreValueTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Foundation -@testable import TableProFirebaseCore -import Testing - -@Suite("Firestore values") -struct FirestoreValueTests { - @Test("Every REST value type decodes") - func decode() throws { - let rest = json(""" - {"mapValue": {"fields": { - "s": {"stringValue": "hi"}, - "i": {"integerValue": "9007199254740993"}, - "d": {"doubleValue": 1.5}, - "nan": {"doubleValue": "NaN"}, - "b": {"booleanValue": true}, - "n": {"nullValue": null}, - "t": {"timestampValue": "2026-10-12T08:00:00.123456Z"}, - "r": {"referenceValue": "projects/demo/databases/(default)/documents/users/alice"}, - "g": {"geoPointValue": {"latitude": 45.5, "longitude": 9.25}}, - "y": {"bytesValue": "aGk="}, - "a": {"arrayValue": {"values": [{"integerValue": "1"}, {"stringValue": "x"}]}}, - "e": {"arrayValue": {}} - }}} - """) - guard case .map(let fields) = try FirestoreValue(rest: rest, documentRoot: testDocumentRoot) else { - Issue.record("Expected a map") - return - } - #expect(fields["s"] == .string("hi")) - #expect(fields["i"] == .integer(9_007_199_254_740_993)) - #expect(fields["d"] == .double(1.5)) - if case .double(let value) = fields["nan"] { #expect(value.isNaN) } else { Issue.record("NaN lost") } - #expect(fields["b"] == .boolean(true)) - #expect(fields["n"] == .null) - #expect(fields["t"] == .timestamp("2026-10-12T08:00:00.123456Z")) - #expect(fields["r"] == .reference("users/alice")) - #expect(fields["g"] == .geoPoint(latitude: 45.5, longitude: 9.25)) - #expect(fields["y"] == .bytes(Data("hi".utf8))) - #expect(fields["a"] == .array([.integer(1), .string("x")])) - #expect(fields["e"] == .array([])) - } - - @Test("Encoding is the inverse of decoding") - func roundTrip() throws { - let values: [FirestoreValue] = [ - .null, .boolean(false), .integer(-7), .double(2.25), .timestamp("2026-01-01T00:00:00Z"), - .string(""), .bytes(Data([0, 255])), .reference("a/b/c/d"), .geoPoint(latitude: -10, longitude: 20), - .array([.integer(1), .map(["k": .string("v")])]), .map([:]) - ] - for value in values { - let decoded = try FirestoreValue(rest: value.rest(documentRoot: testDocumentRoot), documentRoot: testDocumentRoot) - #expect(decoded == value) - } - } - - @Test("Integers go on the wire as strings, references in full") - func wireShapes() { - #expect(FirestoreValue.integer(5).rest(documentRoot: testDocumentRoot).text() == #"{"integerValue":"5"}"#) - #expect( - FirestoreValue.reference("users/alice").rest(documentRoot: testDocumentRoot).text() - == #"{"referenceValue":"projects/demo/databases/(default)/documents/users/alice"}"# - ) - #expect(FirestoreValue.double(.infinity).rest(documentRoot: testDocumentRoot).text() == #"{"doubleValue":"Infinity"}"#) - } - - @Test("A value with no type or an unknown type is refused") - func malformed() { - #expect(throws: FirebaseError.self) { try FirestoreValue(rest: json("{}"), documentRoot: testDocumentRoot) } - #expect(throws: FirebaseError.self) { try FirestoreValue(rest: json(#"{"fooValue": 1}"#), documentRoot: testDocumentRoot) } - #expect(throws: FirebaseError.self) { try FirestoreValue(rest: json(#"{"integerValue": "x"}"#), documentRoot: testDocumentRoot) } - } - - @Test("Paths split into collection and document") - func paths() { - #expect(FirestorePath.isDocument("users/alice")) - #expect(!FirestorePath.isDocument("users")) - #expect(!FirestorePath.isDocument("users/alice/orders")) - #expect(FirestorePath.split(collectionPath: "users/alice/orders") == ("users/alice", "orders")) - #expect(FirestorePath.split(collectionPath: "users") == ("", "users")) - #expect(FirestorePath.absolute("/users/alice/", documentRoot: testDocumentRoot) == testDocumentRoot + "/users/alice") - #expect(FirestorePath.absolute("", documentRoot: testDocumentRoot) == testDocumentRoot) - } -} - -@Suite("Firestore document JSON") -struct FirestoreDocumentJSONTests { - @Test("Types JSON lacks are tagged and read back") - func tags() throws { - let fields: [String: FirestoreValue] = [ - "when": .timestamp("2026-10-12T08:00:00Z"), - "owner": .reference("users/alice"), - "where": .geoPoint(latitude: 45.4, longitude: 9.2), - "raw": .bytes(Data("hi".utf8)), - "whole": .double(3), - "half": .double(0.5), - "count": .integer(3), - "tags": .array([.string("a")]), - "nested": .map(["x": .null]) - ] - let text = FirestoreDocumentJSON.text(fields: fields) - #expect(text.contains(#""$timestamp": "2026-10-12T08:00:00Z""#)) - #expect(text.contains(#""$double": 3.0"#)) - #expect(text.contains(#""half": 0.5"#)) - #expect(try FirestoreDocumentJSON.fields(from: text) == fields) - } - - @Test("A real map shaped like a tag survives inside $map") - func mapEscape() throws { - let value = FirestoreValue.map(["$ref": .string("not a reference")]) - let encoded = FirestoreDocumentJSON.json(value) - #expect(encoded.text() == #"{"$map":{"$ref":"not a reference"}}"#) - #expect(try FirestoreDocumentJSON.value(encoded) == value) - } - - @Test("Invalid tags explain what they need") - func invalidTags() { - #expect(throws: FirebaseError.self) { try FirestoreDocumentJSON.value(json(#"{"$timestamp": "yesterday"}"#)) } - #expect(throws: FirebaseError.self) { try FirestoreDocumentJSON.value(json(#"{"$ref": "users"}"#)) } - #expect(throws: FirebaseError.self) { try FirestoreDocumentJSON.value(json(#"{"$geo": {"latitude": 99, "longitude": 0}}"#)) } - #expect(throws: FirebaseError.self) { try FirestoreDocumentJSON.fields(from: "[1, 2]") } - } -} diff --git a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/RealtimeDatabaseTests.swift b/Packages/TableProCore/Tests/TableProFirebaseCoreTests/RealtimeDatabaseTests.swift deleted file mode 100644 index 72c1d63238..0000000000 --- a/Packages/TableProCore/Tests/TableProFirebaseCoreTests/RealtimeDatabaseTests.swift +++ /dev/null @@ -1,145 +0,0 @@ -import Foundation -@testable import TableProFirebaseCore -import Testing - -@Suite("Realtime Database query planning") -struct RealtimeDatabasePlannerTests { - private func items(_ spec: FirebaseBrowseSpec, types: [String: String] = [:]) throws -> [String] { - try RealtimeDatabaseQueryPlanner.plan(spec: spec, typeOf: { types[$0] }).queryItems.map { "\($0.name)=\($0.value ?? "")" } - } - - @Test("A plain page orders by key and reads offset plus limit") - func plainPage() throws { - let plan = try RealtimeDatabaseQueryPlanner.plan(spec: FirebaseBrowseSpec(table: "chats", limit: 50, offset: 100), typeOf: noTypes) - #expect(plan.dropCount == 100) - #expect(plan.queryItems.map { "\($0.name)=\($0.value ?? "")" } == [#"orderBy="$key""#, "limitToFirst=150"]) - } - - @Test("A descending sort reads from the end") - func descending() throws { - let spec = FirebaseBrowseSpec(table: "x", sorts: [FirebaseSortSpec(column: "score", ascending: false)], limit: 10, offset: 0) - #expect(try items(spec) == [#"orderBy="score""#, "limitToLast=10"]) - } - - @Test("One filter becomes bounds on its own field, typed by the column") - func filters() throws { - let equal = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "age", op: "=", value: "30")], limit: 5, offset: 0) - #expect(try items(equal, types: ["age": "number"]) == [#"orderBy="age""#, "equalTo=30", "limitToFirst=5"]) - let zip = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "zip", op: ">", value: "021")], limit: 5, offset: 0) - #expect(try items(zip, types: ["zip": "string"]) == [#"orderBy="zip""#, #"startAfter="021""#, "limitToFirst=5"]) - let key = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "$key", op: "BETWEEN", value: "a", secondValue: "m")], limit: 0, offset: 0) - #expect(try items(key) == [#"orderBy="$key""#, #"endAt="m""#, #"startAt="a""#]) - let prefix = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "name", op: "STARTS WITH", value: "Al")], limit: 0, offset: 0) - #expect(try items(prefix) == [#"orderBy="name""#, "endAt=\"Al\u{F8FF}\"", #"startAt="Al""#]) - } - - @Test("What the API cannot do is refused with the reason") - func refusals() { - let two = FirebaseBrowseSpec(table: "x", filters: [ - FirebaseFilterSpec(column: "a", op: "=", value: "1"), FirebaseFilterSpec(column: "b", op: "=", value: "2") - ], limit: 1, offset: 0) - #expect(throws: FirebaseError.self) { try RealtimeDatabaseQueryPlanner.plan(spec: two, typeOf: noTypes) } - let mixed = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "a", op: "=", value: "1")], - sorts: [FirebaseSortSpec(column: "b", ascending: true)], limit: 1, offset: 0) - #expect(throws: FirebaseError.self) { try RealtimeDatabaseQueryPlanner.plan(spec: mixed, typeOf: noTypes) } - let contains = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "a", op: "CONTAINS", value: "1")], limit: 1, offset: 0) - #expect(throws: FirebaseError.self) { try RealtimeDatabaseQueryPlanner.plan(spec: contains, typeOf: noTypes) } - } - - @Test("A raw filter is the query string as written, with the page limit added") - func raw() throws { - let spec = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "__RAW__", op: "", value: #"orderBy="age"&startAt=18"#)], limit: 20, offset: 0) - #expect(try items(spec) == [#"orderBy="age""#, "startAt=18", "limitToFirst=20"]) - } - - @Test("A raw filter's page is cut in the order it names, not by key") - func rawOrder() throws { - let spec = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "__RAW__", op: "", value: #"orderBy="age"&startAt=30"#)], limit: 2, offset: 1) - let plan = try RealtimeDatabaseQueryPlanner.plan(spec: spec, typeOf: noTypes) - #expect(plan.order == .child("age")) - let children: [(key: String, value: FirebaseJSON)] = [("a", json(#"{"age": 50}"#)), ("b", json(#"{"age": 30}"#)), ("c", json(#"{"age": 40}"#))] - let page = plan.window(RealtimeDatabaseOrdering.sort(children, order: plan.order, ascending: plan.ascending)) - #expect(page.map(\.key) == ["c", "a"]) - } - - @Test("A raw filter with no order gets a key order for its limit, and a shallow read gets neither") - func rawWithoutOrder() throws { - func raw(_ text: String) -> FirebaseBrowseSpec { - FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "__RAW__", op: "", value: text)], limit: 10, offset: 5) - } - #expect(try items(raw("print=pretty")) == ["print=pretty", #"orderBy="$key""#, "limitToFirst=15"]) - #expect(try items(raw("shallow=true")) == ["shallow=true"]) - let shallow = try RealtimeDatabaseQueryPlanner.plan(spec: raw("shallow=true"), typeOf: noTypes) - #expect(shallow.dropCount == 5 && shallow.keepCount == 10) - #expect(try items(raw(#"orderBy="n"&limitToLast=3"#)) == [#"orderBy="n""#, "limitToLast=3"]) - } - - @Test("BETWEEN reads the bounds the way the host sends them") - func betweenFromHost() throws { - let spec = FirebaseBrowseSpec(table: "x", filters: [FirebaseFilterSpec(column: "age", op: "BETWEEN", value: "10,20", secondValue: "20")], limit: 0, offset: 0) - #expect(try items(spec, types: ["age": "number"]) == [#"orderBy="age""#, "endAt=20", "startAt=10"]) - } -} - -@Suite("Realtime Database ordering and rows") -struct RealtimeDatabaseRowTests { - @Test("Integer keys sort numerically before string keys") - func keyOrder() { - let keys = ["b", "10", "2", "a", "-1", "007"].sorted(by: RealtimeDatabaseOrdering.keyPrecedes) - #expect(keys == ["-1", "2", "10", "007", "a", "b"]) - } - - @Test("Values sort null, false, true, numbers, strings, objects") - func valueOrder() { - let children: [(key: String, value: FirebaseJSON)] = [ - ("o", .object(["v": .object([:])])), ("s", .object(["v": .string("x")])), ("n", .object(["v": .int(2)])), - ("d", .object(["v": .double(1.5)])), ("t", .object(["v": .bool(true)])), ("f", .object(["v": .bool(false)])), - ("z", .object([:])) - ] - let sorted = RealtimeDatabaseOrdering.sort(children, order: .child("v"), ascending: true).map(\.key) - #expect(sorted == ["z", "f", "t", "d", "n", "s", "o"]) - } - - @Test("Object children become columns, plain children a $value column") - func rows() { - let rows = RealtimeDatabaseRowSet(children: [ - ("op_1", json(#"{"name": "Ann", "calls": 3, "meta": {"a": 1}}"#)), - ("op_2", json(#"{"name": "Bo"}"#)), - ("flag", .bool(true)) - ], nodePath: "/operators") - #expect(rows.columns == ["$key", "calls", "meta", "name", "$value"]) - #expect(rows.rows[0] == ["op_1", "3", #"{"a":1}"#, "Ann", nil]) - #expect(rows.rows[2] == ["flag", nil, nil, nil, "true"]) - #expect(rows.absentCells[1] == [1, 2, 4]) - #expect(rows.locators == ["operators/op_1", "operators/op_2", "operators/flag"]) - #expect(rows.columnTypes == ["string", "number", "object", "string", "boolean"]) - #expect(rows.cellTypes["operators/op_1"]?["calls"] == "number") - } - - @Test("A node holding a plain value is one row located at the node") - func plainNode() { - let rows = RealtimeDatabaseRowSet(children: [("", .bool(false))], nodePath: "config/maintenance") - #expect(rows.rows == [["/config/maintenance", "false"]]) - #expect(rows.locators == ["config/maintenance"]) - } - - @Test("Cell edits keep the cell's type") - func cellEdits() throws { - #expect(try RealtimeDatabaseCell.value(text: "42", typeName: "string") == .string("42")) - #expect(try RealtimeDatabaseCell.value(text: "42", typeName: "number") == .int(42)) - #expect(try RealtimeDatabaseCell.value(text: "4.5", typeName: "number") == .double(4.5)) - #expect(try RealtimeDatabaseCell.value(text: "true", typeName: "boolean") == .bool(true)) - #expect(try RealtimeDatabaseCell.value(text: #"{"a":1}"#, typeName: "object") == .object(["a": .int(1)])) - #expect(try RealtimeDatabaseCell.value(text: "hello", typeName: nil) == .string("hello")) - #expect(try RealtimeDatabaseCell.value(text: "7", typeName: nil) == .int(7)) - #expect(throws: FirebaseError.self) { try RealtimeDatabaseCell.value(text: "x", typeName: "number") } - } - - @Test("Keys with forbidden characters are refused") - func keys() throws { - try RealtimeDatabasePath.validateKey("operator_1") - for bad in ["", "a.b", "a/b", "a$", "a#", "[x]"] { - #expect(throws: FirebaseError.self) { try RealtimeDatabasePath.validateKey(bad) } - } - } -} diff --git a/Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleSQLLiteralTests.swift b/Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleSQLLiteralTests.swift deleted file mode 100644 index 7f1e20213c..0000000000 --- a/Packages/TableProCore/Tests/TableProGoogleCloudTests/GoogleSQLLiteralTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation -import TableProGoogleCloud -import Testing - -@Suite("GoogleSQLLiteral") -struct GoogleSQLLiteralTests { - @Test("An injection attempt stays inside the literal") - func injectionStaysQuoted() { - #expect(GoogleSQLLiteral.quotedString("x' OR TRUE --") == #"'x\' OR TRUE --'"#) - #expect(GoogleSQLLiteral.quotedString(#"x\' OR TRUE --"#) == #"'x\\\' OR TRUE --'"#) - } - - @Test("Doubled quotes are not the GoogleSQL escape") - func apostropheIsBackslashEscaped() { - #expect(GoogleSQLLiteral.quotedString("O'Brien") == #"'O\'Brien'"#) - #expect(GoogleSQLLiteral.quotedString("say \"hi\"") == #"'say \"hi\"'"#) - } - - @Test("Line breaks and tabs use their short escapes") - func whitespaceControls() { - #expect(GoogleSQLLiteral.quotedString("a\nb") == #"'a\nb'"#) - #expect(GoogleSQLLiteral.quotedString("a\r\nb") == #"'a\r\nb'"#) - #expect(GoogleSQLLiteral.quotedString("a\tb") == #"'a\tb'"#) - } - - @Test("NUL, other controls and DEL become two-digit hex escapes") - func hexEscapes() { - #expect(GoogleSQLLiteral.quotedString("a\u{0}b") == #"'a\x00b'"#) - #expect(GoogleSQLLiteral.quotedString("\u{1}\u{1F}") == #"'\x01\x1f'"#) - #expect(GoogleSQLLiteral.quotedString("x\u{7F}") == #"'x\x7f'"#) - } - - @Test("A trailing backslash cannot escape the closing quote") - func trailingBackslash() { - #expect(GoogleSQLLiteral.quotedString(#"C:\"#) == #"'C:\\'"#) - #expect(GoogleSQLLiteral.quotedIdentifier(#"odd\"#) == #"`odd\\`"#) - } - - @Test("Backticks are left alone in strings and escaped in identifiers") - func backticks() { - #expect(GoogleSQLLiteral.quotedString("a`b") == "'a`b'") - #expect(GoogleSQLLiteral.quotedIdentifier("a`b") == #"`a\`b`"#) - #expect(GoogleSQLLiteral.quotedIdentifier("x` OR TRUE --") == #"`x\` OR TRUE --`"#) - } - - @Test("Identifiers keep ordinary names intact") - func plainIdentifier() { - #expect(GoogleSQLLiteral.quotedIdentifier("Singers") == "`Singers`") - #expect(GoogleSQLLiteral.quotedIdentifier("select") == "`select`") - #expect(GoogleSQLLiteral.quotedIdentifier("it's") == #"`it\'s`"#) - #expect(GoogleSQLLiteral.quotedIdentifier("a\nb") == #"`a\nb`"#) - } - - @Test("Non-ASCII text is written as is") - func unicode() { - #expect(GoogleSQLLiteral.quotedString("café 日本 🙂") == "'café 日本 🙂'") - } - - @Test("The string body matches the quoted string without its quotes") - func bodyMatchesQuoted() { - let value = "it's\n\u{0}\\" - #expect("'" + GoogleSQLLiteral.escapedStringBody(value) + "'" == GoogleSQLLiteral.quotedString(value)) - } - - @Test("Bytes become a FROM_BASE64 call") - func bytes() { - #expect(GoogleSQLLiteral.bytesLiteral(Data([0, 0xFF, 0x10])) == "FROM_BASE64('AP8Q')") - #expect(GoogleSQLLiteral.bytesLiteral(Data()) == "FROM_BASE64('')") - } - - @Test("LIKE metacharacters are escaped with a backslash") - func likePattern() { - #expect(GoogleSQLLiteral.likePatternBody("50%") == #"50\%"#) - #expect(GoogleSQLLiteral.likePatternBody("a_b") == #"a\_b"#) - #expect(GoogleSQLLiteral.likePatternBody(#"C:\dir"#) == #"C:\\dir"#) - #expect(GoogleSQLLiteral.likePatternBody("plain") == "plain") - } -} diff --git a/Packages/TableProCore/Tests/TableProGoogleCloudTests/SQLPlaceholderRewriterTests.swift b/Packages/TableProCore/Tests/TableProGoogleCloudTests/SQLPlaceholderRewriterTests.swift deleted file mode 100644 index 054676aff0..0000000000 --- a/Packages/TableProCore/Tests/TableProGoogleCloudTests/SQLPlaceholderRewriterTests.swift +++ /dev/null @@ -1,137 +0,0 @@ -import Foundation -import TableProGoogleCloud -import Testing - -@Suite("SQLPlaceholderRewriter") -struct SQLPlaceholderRewriterTests { - private func google(_ sql: String, expected: Int? = nil) throws -> (sql: String, count: Int) { - try SQLPlaceholderRewriter.rewrite(sql, lexicon: .googleSQL, expectedCount: expected) { "@p\($0)" } - } - - private func postgres(_ sql: String, expected: Int? = nil) throws -> (sql: String, count: Int) { - try SQLPlaceholderRewriter.rewrite(sql, lexicon: .postgreSQL, expectedCount: expected) { "$\($0)" } - } - - @Test("Bare placeholders are numbered from one") - func numbersPlaceholders() throws { - let result = try google("UPDATE t SET a = ?, b = ? WHERE id = ?") - #expect(result.sql == "UPDATE t SET a = @p1, b = @p2 WHERE id = @p3") - #expect(result.count == 3) - #expect(try postgres("SELECT ?,?").sql == "SELECT $1,$2") - } - - @Test("No placeholder leaves the text unchanged") - func noPlaceholders() throws { - let (sql, placeholders) = try google("SELECT 1") - #expect(sql == "SELECT 1") - #expect(placeholders == 0) - } - - @Test("GoogleSQL strings in every quoting form hide their question marks") - func googleStrings() throws { - let sql = #"SELECT '?', "?", 'it\'s ?', "a\"?", '''multi ' ? line''', """x "" ? """, r'?\d', b"?", rb'?', BR'''?''', ?"# - let result = try google(sql) - #expect(result.count == 1) - #expect(result.sql.hasSuffix(", @p1")) - #expect(result.sql.hasPrefix(#"SELECT '?', "?", 'it\'s ?'"#)) - } - - @Test("A raw string ending in an escaped quote does not end early") - func rawStringBackslashQuote() throws { - let result = try google(#"SELECT r'\'?', ?"#) - #expect(result.sql == #"SELECT r'\'?', @p1"#) - } - - @Test("GoogleSQL backtick identifiers and comments hide their question marks") - func googleIdentifiersAndComments() throws { - let sql = "SELECT `col?`, `a\\`?` -- what?\n, # hash?\n /* block ? */ ? FROM t" - let result = try google(sql) - #expect(result.count == 1) - #expect(result.sql == "SELECT `col?`, `a\\`?` -- what?\n, # hash?\n /* block ? */ @p1 FROM t") - } - - @Test("GoogleSQL block comments do not nest") - func googleBlockCommentsDoNotNest() throws { - let result = try google("SELECT /* a /* b */ ? */ 1") - #expect(result.count == 1) - #expect(result.sql == "SELECT /* a /* b */ @p1 */ 1") - } - - @Test("A quote inside a comment does not open a string") - func quoteInComment() throws { - let result = try google("SELECT ? -- don't\n, ?") - #expect(result.sql == "SELECT @p1 -- don't\n, @p2") - } - - @Test("PostgreSQL doubled quotes and literal backslashes") - func postgresStrings() throws { - let result = try postgres(#"SELECT 'it''s ?', 'C:\', ?"#) - #expect(result.sql == #"SELECT 'it''s ?', 'C:\', $1"#) - } - - @Test("PostgreSQL escape strings honour backslash escapes") - func postgresEscapeStrings() throws { - let result = try postgres(#"SELECT E'a\'?', e'b''?', ?"#) - #expect(result.sql == #"SELECT E'a\'?', e'b''?', $1"#) - } - - @Test("A word ending in e is not an escape-string prefix") - func postgresWordEndingInE() throws { - let result = try postgres(#"SELECT name, 'C:\', ?"#) - #expect(result.sql == #"SELECT name, 'C:\', $1"#) - } - - @Test("PostgreSQL dollar quotes hide their contents") - func postgresDollarQuotes() throws { - let result = try postgres("SELECT $$it's ?$$, $tag$ $$ ? $tag$, $x1$?$x1$, ?") - #expect(result.sql == "SELECT $$it's ?$$, $tag$ $$ ? $tag$, $x1$?$x1$, $1") - } - - @Test("Positional parameters and identifiers with a dollar are not dollar quotes") - func postgresPositionalDollar() throws { - let result = try postgres("SELECT a$b, $1, ? FROM t$x") - #expect(result.sql == "SELECT a$b, $1, $1 FROM t$x") - #expect(result.count == 1) - } - - @Test("PostgreSQL identifiers and nested comments") - func postgresIdentifiersAndComments() throws { - let sql = "SELECT \"a\"\"?\" /* outer /* inner ? */ still ? */ -- line ?\n, ? # ?" - let result = try postgres(sql) - #expect(result.sql == "SELECT \"a\"\"?\" /* outer /* inner ? */ still ? */ -- line ?\n, $1 # $2") - #expect(result.count == 2) - } - - @Test("Backticks mean nothing in PostgreSQL") - func postgresBacktick() throws { - #expect(try postgres("SELECT `?`").sql == "SELECT `$1`") - } - - @Test("An unterminated string swallows the rest") - func unterminated() throws { - #expect(try google("SELECT 'abc ?").sql == "SELECT 'abc ?") - #expect(try postgres("SELECT $q$ ?").sql == "SELECT $q$ ?") - #expect(try google("SELECT /* ?").sql == "SELECT /* ?") - } - - @Test("Non-ASCII text around placeholders survives") - func unicodeSurvives() throws { - let result = try google("SELECT '日本?', ? AS `café🙂`") - #expect(result.sql == "SELECT '日本?', @p1 AS `café🙂`") - } - - @Test("A count mismatch throws") - func countMismatch() { - #expect(throws: SQLPlaceholderRewriteError.countMismatch(found: 1, expected: 2)) { - try google("SELECT ?, '?'", expected: 2) - } - #expect(throws: SQLPlaceholderRewriteError.countMismatch(found: 2, expected: 0)) { - try postgres("SELECT ?, ?", expected: 0) - } - } - - @Test("A matching count passes") - func countMatches() throws { - #expect(try google("SELECT ?", expected: 1).sql == "SELECT @p1") - } -} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift deleted file mode 100644 index 11cff6f8e2..0000000000 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLEnvelopeDecodingTests.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -@testable import TableProR2SQLCore -import Testing - -@Suite("R2 SQL envelope decoding") -struct R2SQLEnvelopeDecodingTests { - private func response(_ json: String, status: Int = 200) -> R2SQLHTTPResponse { - R2SQLHTTPResponse(statusCode: status, body: Data(json.utf8)) - } - - @Test("The live envelope carries each column's type under descriptor.type.name") - func descriptorEnvelope() throws { - let json = """ - {"result":{"request_id":"dqe-prod-01", - "schema":[{"name":"category","descriptor":{"type":{"name":"utf8"},"nullable":true}}, - {"name":"cnt","descriptor":{"type":{"name":"int64"},"nullable":false}}], - "rows":[{"category":"Electronics","cnt":12345}], - "metrics":{"r2_requests_count":5,"files_scanned":29,"bytes_scanned":12345678,"cache_hits":0}}, - "success":true,"errors":[]} - """ - let result = try R2SQLResponseDecoder.decode(response(json)) - - #expect(result.schema == [ - R2SQLField(name: "category", typeName: "utf8", isNullable: true), - R2SQLField(name: "cnt", typeName: "int64", isNullable: false) - ]) - #expect(result.rows.first?["cnt"] == .number(12_345)) - } - - @Test("A nested list of structs decodes its outer type and keeps the value tree") - func nestedDescriptor() throws { - let json = """ - {"success":true,"errors":[],"messages":[],"result":{"request_id":"dqe-prod-test", - "schema":[{"name":"approx_top_k(value, Int64(3))","descriptor":{"type":{"name":"list","item":{"type":{"name":"struct", - "fields":[{"type":{"name":"int64"},"nullable":true,"name":"value"},{"type":{"name":"uint64"},"nullable":false,"name":"count"}]}, - "nullable":true}},"nullable":true}}], - "rows":[{"approx_top_k(value, Int64(3))":[{"value":0,"count":961},{"value":2,"count":null}]}], - "metrics":{"r2_requests_count":6,"files_scanned":3,"bytes_scanned":62878}}} - """ - let result = try R2SQLResponseDecoder.decode(response(json)) - - #expect(result.schema.first?.typeName == "list") - let mapped = R2SQLRowMapper.map(result) - #expect(mapped.columnTypeNames == ["ARRAY"]) - #expect(mapped.rows == [[.text(#"[{"count":961,"value":0},{"count":null,"value":2}]"#)]]) - } - - @Test("A schema without a descriptor is a malformed response, not an empty success") - func legacyShapeIsRejected() { - let json = """ - {"result":{"schema":[{"name":"id","type":"Int64"}],"rows":[{"id":1}]},"success":true,"errors":[]} - """ - #expect(throws: R2SQLError.self) { try R2SQLResponseDecoder.decode(response(json)) } - } - - @Test("A body with no success flag is malformed") - func missingSuccessIsMalformed() { - #expect(throws: R2SQLError.malformedResponse(status: 200, detail: #"{"result":null}"#)) { - try R2SQLResponseDecoder.decode(response(#"{"result":null}"#)) - } - } - - @Test("A body that is not JSON is malformed and quotes the body") - func nonJSONIsMalformed() { - #expect(throws: R2SQLError.malformedResponse(status: 502, detail: "Bad gateway")) { - try R2SQLResponseDecoder.decode(response("Bad gateway", status: 502)) - } - } - - @Test("A success with a null result is an empty result") - func nullResultIsEmpty() throws { - let result = try R2SQLResponseDecoder.decode(response(#"{"result":null,"success":true,"errors":[]}"#)) - #expect(result.schema.isEmpty && result.rows.isEmpty) - } - - @Test("A failure under 401 or 403 is an authentication error", arguments: [401, 403]) - func authenticationFailure(status: Int) { - let json = #"{"result":null,"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}"# - #expect(throws: R2SQLError.authentication( - status: status, - errors: [R2SQLAPIError(code: 10_000, message: "Authentication error")] - )) { - try R2SQLResponseDecoder.decode(response(json, status: status)) - } - } - - @Test("Any other failure carries the server's errors, including under HTTP 200") - func queryFailure() { - let json = #"{"result":null,"success":false,"errors":[{"code":40003,"message":"syntax error at LIMIT"}]}"# - let expected = R2SQLError.api(status: 200, errors: [R2SQLAPIError(code: 40_003, message: "syntax error at LIMIT")]) - - #expect(throws: expected) { try R2SQLResponseDecoder.decode(response(json)) } - #expect(expected.errorDescription == "syntax error at LIMIT") - } -} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift deleted file mode 100644 index cd94df3089..0000000000 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLIntrospectionSQLTests.swift +++ /dev/null @@ -1,71 +0,0 @@ -@testable import TableProR2SQLCore -import Testing - -@Suite("R2 SQL catalog statements") -struct R2SQLIntrospectionSQLTests { - private func result(_ columns: [String], _ rows: [[String: R2SQLJSONValue]]) -> R2SQLResult { - R2SQLResult(schema: columns.map { R2SQLField(name: $0, typeName: "utf8") }, rows: rows) - } - - @Test("Statements quote the namespace and table as identifiers") - func statements() { - #expect(R2SQLIntrospectionSQL.showTables(namespace: "logs") == #"SHOW TABLES IN "logs""#) - #expect(R2SQLIntrospectionSQL.describe(namespace: "lo\"gs", table: "events") - == #"DESCRIBE "lo""gs"."events""#) - } - - @Test("A Spark-shaped SHOW TABLES reads the tableName column, not the namespace") - func sparkShape() throws { - let listing = result(["namespace", "tableName", "isTemporary"], [ - ["namespace": .string("logs"), "tableName": .string("zeta"), "isTemporary": .bool(false)], - ["namespace": .string("logs"), "tableName": .string("alpha"), "isTemporary": .bool(false)] - ]) - #expect(try R2SQLIntrospectionSQL.tables(from: listing) == ["alpha", "zeta"]) - } - - @Test("A DataFusion-shaped SHOW TABLES reads table_name, not the catalog") - func dataFusionShape() throws { - let listing = result(["table_catalog", "table_schema", "table_name", "table_type"], [ - ["table_catalog": .string("r2"), "table_schema": .string("logs"), - "table_name": .string("events"), "table_type": .string("BASE TABLE")] - ]) - #expect(try R2SQLIntrospectionSQL.tables(from: listing) == ["events"]) - } - - @Test("A single-column listing reads that column") - func singleColumn() throws { - let listing = result(["db"], [["db": .string("logs")], ["db": .string("default")]]) - #expect(try R2SQLIntrospectionSQL.namespaces(from: listing) == ["default", "logs"]) - } - - @Test("A listing with no recognizable name column is reported, not guessed") - func unknownShapeThrows() { - let listing = result(["a", "b"], [["a": .string("x"), "b": .string("y")]]) - #expect(throws: R2SQLError.unexpectedResult( - "SHOW TABLES returned columns TablePro does not recognize: a, b." - )) { - try R2SQLIntrospectionSQL.tables(from: listing) - } - } - - @Test("DESCRIBE reads columns by name: nullable is the inverse of required, and doc is the comment") - func describeByName() throws { - let description = result(["column_name", "type", "required", "initial_default", "write_default", "doc"], [ - ["column_name": .string("sale_id"), "type": .string("BIGINT"), "required": .bool(false), - "doc": .string("Unique identifier")], - ["column_name": .string("region"), "type": .string("TEXT"), "required": .string("true"), - "doc": .string("")] - ]) - #expect(try R2SQLIntrospectionSQL.columns(from: description) == [ - R2SQLColumnDescription(name: "sale_id", typeName: "BIGINT", isNullable: true, comment: "Unique identifier"), - R2SQLColumnDescription(name: "region", typeName: "TEXT", isNullable: false, comment: nil) - ]) - } - - @Test("A DESCRIBE result without column_name and type is reported") - func describeWithoutColumnsThrows() { - #expect(throws: R2SQLError.self) { - try R2SQLIntrospectionSQL.columns(from: result(["name", "kind"], [])) - } - } -} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift deleted file mode 100644 index 80a9027543..0000000000 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLJSONValueTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation -@testable import TableProR2SQLCore -import Testing - -@Suite("R2 SQL JSON values") -struct R2SQLJSONValueTests { - private func decode(_ json: String) throws -> R2SQLJSONValue { - try JSONDecoder().decode(R2SQLJSONValue.self, from: Data(json.utf8)) - } - - @Test("Numbers keep every digit", arguments: [ - "12345678901234567.89", - "99999999999999999999999999999999999999", - "18446744073709551615", - "9007199254740993", - "-9223372036854775808", - "0.1" - ]) - func exactNumbers(literal: String) throws { - #expect(try decode(literal).jsonText == literal) - } - - @Test("A number no Decimal can hold is a decoding error, not NULL") - func outOfRangeNumberThrows() { - #expect(throws: DecodingError.self) { try decode("1e400") } - } - - @Test("Booleans stay booleans and never read as numbers") - func booleans() throws { - #expect(try decode("true") == .bool(true)) - #expect(try decode("1") == .number(1)) - } - - @Test("Nested values render as JSON without re-encoding their numbers") - func nestedText() throws { - let value = try decode(#"{"y":0.1,"x":[1,"a\"b",null,false]}"#) - #expect(value.jsonText == #"{"x":[1,"a\"b",null,false],"y":0.1}"#) - } -} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift deleted file mode 100644 index cdc9364899..0000000000 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRequestBuilderTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation -@testable import TableProR2SQLCore -import Testing - -@Suite("R2 SQL request") -struct R2SQLRequestBuilderTests { - private let config = R2SQLConnectionConfig(accountId: " acc123 ", bucket: "my-bucket", token: " tok \n") - - @Test("The request posts the query alone to the account's bucket endpoint with a bearer token") - func request() throws { - let request = try R2SQLRequestBuilder.queryRequest(config: config, sql: "SELECT 1", timeoutInterval: 330) - let body = try #require(try JSONSerialization.jsonObject(with: request.body) as? [String: String]) - - #expect(request.url.absoluteString - == "https://api.sql.cloudflarestorage.com/api/v1/accounts/acc123/r2-sql/query/my-bucket") - #expect(body == ["query": "SELECT 1"]) - #expect(request.headers["Authorization"] == "Bearer tok") - #expect(request.headers["Content-Type"] == "application/json") - #expect(request.timeoutInterval == 330) - } - - @Test("A missing account, bucket or token fails before any request", arguments: [ - R2SQLConnectionConfig(accountId: "", bucket: "b", token: "t"), - R2SQLConnectionConfig(accountId: "a", bucket: " ", token: "t"), - R2SQLConnectionConfig(accountId: "a", bucket: "b", token: "") - ]) - func validation(config: R2SQLConnectionConfig) { - #expect(throws: R2SQLError.self) { - try R2SQLRequestBuilder.queryRequest(config: config, sql: "SELECT 1", timeoutInterval: 60) - } - } -} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift deleted file mode 100644 index 5664c53e2b..0000000000 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLRowMapperTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation -@testable import TableProR2SQLCore -import Testing - -@Suite("R2 SQL row mapping") -struct R2SQLRowMapperTests { - @Test("Rows follow the schema's order and a missing key is NULL") - func schemaOrder() { - let result = R2SQLResult( - schema: [R2SQLField(name: "b", typeName: "utf8"), R2SQLField(name: "a", typeName: "int64")], - rows: [["a": .number(1), "b": .string("x")], ["a": .number(2)]] - ) - let mapped = R2SQLRowMapper.map(result) - - #expect(mapped.columns == ["b", "a"]) - #expect(mapped.columnTypeNames == ["TEXT", "BIGINT"]) - #expect(mapped.rows == [[.text("x"), .text("1")], [.null, .text("2")]]) - } - - @Test("Wide integers and decimals keep every digit") - func exactNumbers() throws { - let value = try JSONDecoder().decode(R2SQLJSONValue.self, from: Data("12345678901234567.89".utf8)) - #expect(R2SQLTypeMapper.cell(value, kind: .decimal) == .text("12345678901234567.89")) - let unsignedMaximum = try #require(Decimal(string: "18446744073709551615")) - #expect(R2SQLTypeMapper.cell(.number(unsignedMaximum), kind: .integer) == .text("18446744073709551615")) - } - - @Test("A floating-point column keeps its fractional form") - func floatingPoint() throws { - #expect(R2SQLTypeMapper.cell(.number(1), kind: .floatingPoint) == .text("1.0")) - let tenth = try #require(Decimal(string: "0.1")) - #expect(R2SQLTypeMapper.cell(.number(tenth), kind: .floatingPoint) == .text("0.1")) - } - - @Test("A bytes column decodes base64, and text that is not base64 stays text") - func binary() { - #expect(R2SQLTypeMapper.cell(.string("AAEC/w=="), kind: .binary) == .bytes([0, 1, 2, 255])) - #expect(R2SQLTypeMapper.cell(.string("not base64!"), kind: .binary) == .text("not base64!")) - #expect(R2SQLTypeMapper.cell(.string("AAEC/w=="), kind: .text) == .text("AAEC/w==")) - } - - @Test("JSON null is NULL and booleans read as true or false") - func nullAndBoolean() { - #expect(R2SQLTypeMapper.cell(.null, kind: .text) == .null) - #expect(R2SQLTypeMapper.cell(nil, kind: .integer) == .null) - #expect(R2SQLTypeMapper.cell(.bool(false), kind: .boolean) == .text("false")) - } -} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift deleted file mode 100644 index 4b27e1aaaf..0000000000 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLTypeMapperTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -@testable import TableProR2SQLCore -import Testing - -@Suite("R2 SQL type names") -struct R2SQLTypeMapperTests { - @Test("Result schema type names map to SQL names the grid classifies", arguments: [ - ("int64", "BIGINT", R2SQLValueKind.integer), - ("uint64", "BIGINT UNSIGNED", .integer), - ("int32", "INT", .integer), - ("float64", "DOUBLE", .floatingPoint), - ("decimal128", "DECIMAL", .decimal), - ("bool", "BOOLEAN", .boolean), - ("utf8", "TEXT", .text), - ("bytes", "BINARY", .binary), - ("date32", "DATE", .text), - ("timestamp", "TIMESTAMP", .text), - ("list", "ARRAY", .nested), - ("struct", "STRUCT", .nested), - ("map", "MAP", .nested), - ("Int64", "BIGINT", .integer) - ]) - func knownTypes(raw: String, display: String, kind: R2SQLValueKind) { - #expect(R2SQLTypeMapper.displayTypeName(raw) == display) - #expect(R2SQLTypeMapper.valueKind(raw) == kind) - } - - @Test("An unknown type name passes through uppercased and reads as text") - func unknownType() { - #expect(R2SQLTypeMapper.displayTypeName("interval") == "INTERVAL") - #expect(R2SQLTypeMapper.valueKind("interval") == .text) - } -} diff --git a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLURLSessionTransportTests.swift b/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLURLSessionTransportTests.swift deleted file mode 100644 index 9300a413d9..0000000000 --- a/Packages/TableProCore/Tests/TableProR2SQLCoreTests/R2SQLURLSessionTransportTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -@testable import TableProR2SQLCore -import Testing - -/// A protocol that answers `/ok` at once and leaves every other request hanging until cancelled, -/// so a test can hold several requests in flight and watch what a cancel does to each. -private final class StubProtocol: URLProtocol, @unchecked Sendable { - nonisolated(unsafe) static var lastTimeout: TimeInterval? - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - Self.lastTimeout = request.timeoutInterval - guard request.url?.path == "/ok", let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) - else { return } - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data(#"{"success":true}"#.utf8)) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} -} - -@Suite("R2 SQL URLSession transport", .serialized) -struct R2SQLURLSessionTransportTests { - private func transport() -> URLSessionR2SQLTransport { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [StubProtocol.self] - return URLSessionR2SQLTransport(configuration: configuration, resourceTimeout: 3_600) - } - - private func request(_ path: String, timeout: TimeInterval = 60) throws -> R2SQLHTTPRequest { - R2SQLHTTPRequest( - url: try #require(URL(string: "https://r2.test\(path)")), - headers: [:], - body: Data(), - timeoutInterval: timeout - ) - } - - private func waitUntil(_ condition: () -> Bool) async { - for _ in 0 ..< 200 where !condition() { - try? await Task.sleep(for: .milliseconds(10)) - } - } - - @Test("A request carries its own timeout and returns the status and body") - func roundTrip() async throws { - let response = try await transport().send(try request("/ok", timeout: 330)) - - #expect(response.statusCode == 200) - #expect(String(bytes: response.body, encoding: .utf8) == #"{"success":true}"#) - #expect(StubProtocol.lastTimeout == 330) - } - - @Test("Cancelling everything stops every request in flight, not just the latest") - func cancelAllStopsEveryRequest() async throws { - let transport = transport() - let firstRequest = try request("/hang/1") - let secondRequest = try request("/hang/2") - let first = Task { try await transport.send(firstRequest) } - let second = Task { try await transport.send(secondRequest) } - await waitUntil { transport.inFlightCount == 2 } - - transport.cancelAll() - - for task in [first, second] { - await #expect(throws: R2SQLError.cancelled) { try await task.value } - } - } - - @Test("Cancelling the awaiting task cancels its request") - func taskCancellation() async throws { - let transport = transport() - let hanging = try request("/hang") - let task = Task { try await transport.send(hanging) } - await waitUntil { transport.inFlightCount == 1 } - - task.cancel() - - await #expect(throws: R2SQLError.cancelled) { try await task.value } - #expect(transport.inFlightCount == 0) - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerAPIErrorTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerAPIErrorTests.swift deleted file mode 100644 index b63ec8ee93..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerAPIErrorTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -import Foundation -import Testing - -@testable import TableProSpannerCore - -@Suite("SpannerAPIError") -struct SpannerAPIErrorTests { - private static let emulatorSessionNotFound = """ - {"code":5,"message":"Session not found: projects/proj/instances/inst/databases/gdb/sessions/nope",\ - "details":[{"@type":"type.googleapis.com/google.rpc.ResourceInfo",\ - "resourceType":"type.googleapis.com/google.spanner.v1.Session",\ - "resourceName":"projects/proj/instances/inst/databases/gdb/sessions/nope","description":"Session does not exist."}]} - """ - - private static let emulatorDatabaseNotFound = """ - {"code":5,"message":"Database not found: projects/proj/instances/inst/databases/nodb",\ - "details":[{"@type":"type.googleapis.com/google.rpc.ResourceInfo",\ - "resourceType":"type.googleapis.com/google.spanner.admin.database.v1.Database",\ - "resourceName":"projects/proj/instances/inst/databases/nodb","description":"Database does not exist."}]} - """ - - private static let productionSessionNotFound = """ - {"error":{"code":404,"message":"Session not found: projects/p/instances/i/databases/d/sessions/abc",\ - "status":"NOT_FOUND","details":[{"@type":"type.googleapis.com/google.rpc.ResourceInfo",\ - "resourceType":"type.googleapis.com/google.spanner.v1.Session","resourceName":"projects/p/instances/i/databases/d/sessions/abc"}]}} - """ - - private func decode(_ body: String, status: Int) -> SpannerAPIError { - SpannerAPIError.decode(httpStatus: status, body: Data(body.utf8)) - } - - @Test("The emulator's top-level Status decodes with its gRPC code and resource type") - func emulatorShape() { - let error = decode(Self.emulatorSessionNotFound, status: 404) - #expect(error.httpStatus == 404) - #expect(error.code == 5) - #expect(error.status == nil) - #expect(error.message.hasPrefix("Session not found: projects/proj")) - #expect(error.resourceTypes == ["type.googleapis.com/google.spanner.v1.Session"]) - #expect(error.isNotFound) - #expect(error.isSessionNotFound) - } - - @Test("A missing database is not a lost session") - func databaseNotFound() { - let error = decode(Self.emulatorDatabaseNotFound, status: 404) - #expect(error.isNotFound) - #expect(!error.isSessionNotFound) - #expect(error.resourceTypes == ["type.googleapis.com/google.spanner.admin.database.v1.Database"]) - } - - @Test("The production error wrapper decodes status name and HTTP code") - func productionShape() { - let error = decode(Self.productionSessionNotFound, status: 404) - #expect(error.code == 404) - #expect(error.status == "NOT_FOUND") - #expect(error.isNotFound) - #expect(error.isSessionNotFound) - } - - @Test("Session loss without details falls back to the message prefix") - func sessionNotFoundByMessage() { - let error = decode(#"{"error":{"code":404,"message":"Session not found: x","status":"NOT_FOUND"}}"#, status: 404) - #expect(error.isSessionNotFound) - let other = decode(#"{"code":5,"message":"Table not found: Singers"}"#, status: 404) - #expect(!other.isSessionNotFound) - } - - @Test("A streaming error array takes its first Status") - func arrayShape() { - let error = decode(#"[{"error":{"code":400,"message":"Syntax error","status":"INVALID_ARGUMENT"}}]"#, status: 400) - #expect(error.isInvalidArgument) - #expect(error.message == "Syntax error") - } - - @Test("An in-stream error without a status name still classifies by gRPC code") - func emulatorStreamError() { - let error = decode(#"{"error":{"code":11,"message":"division by zero: 1 / 0"}}"#, status: 400) - #expect(error.code == 11) - #expect(error.message == "division by zero: 1 / 0") - #expect(!error.isInvalidArgument) - } - - @Test("A body that is not a Status keeps only the HTTP status", arguments: ["", "oops", "[]", #"{"foo":1}"#]) - func unparsableBody(body: String) { - let error = decode(body, status: 502) - #expect(error.message == "HTTP 502") - #expect(error.code == nil) - #expect(error.status == nil) - #expect(error.resourceTypes.isEmpty) - } - - @Test("An empty message falls back to the HTTP status") - func emptyMessage() { - let error = decode(#"{"code":13,"message":""}"#, status: 500) - #expect(error.message == "HTTP 500") - #expect(error.code == 13) - } - - @Test("Classification by gRPC code, status name and HTTP status") - func classification() { - #expect(decode(#"{"code":10,"message":"Transaction aborted"}"#, status: 409).isAborted) - #expect(decode(#"{"error":{"code":409,"message":"x","status":"ABORTED"}}"#, status: 409).isAborted) - #expect(decode(#"{"code":14,"message":"x"}"#, status: 500).isUnavailable) - #expect(decode("", status: 503).isUnavailable) - #expect(decode(#"{"code":16,"message":"x"}"#, status: 500).isUnauthenticated) - #expect(decode("", status: 401).isUnauthenticated) - #expect(decode(#"{"error":{"code":403,"message":"x","status":"PERMISSION_DENIED"}}"#, status: 403).isPermissionDenied) - #expect(decode("", status: 403).isPermissionDenied) - #expect(decode(#"{"code":3,"message":"x"}"#, status: 400).isInvalidArgument) - let exhausted = decode(#"{"error":{"code":429,"message":"x","status":"RESOURCE_EXHAUSTED"}}"#, status: 429) - #expect(!exhausted.isUnavailable) - #expect(!exhausted.isAborted) - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerBrowseRendererTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerBrowseRendererTests.swift deleted file mode 100644 index 9f8cc74a6a..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerBrowseRendererTests.swift +++ /dev/null @@ -1,292 +0,0 @@ -import Foundation -import TableProSpannerCore -import Testing - -@Suite("SpannerBrowseRenderer") -struct SpannerBrowseRendererTests { - private func request( - table: String = "Singers", - schema: String = "", - filters: [SpannerBrowseFilter] = [], - matchAll: Bool = true, - sorts: [SpannerBrowseSort] = [], - limit: Int = 100, - offset: Int = 0 - ) -> SpannerBrowseRequest { - SpannerBrowseRequest( - table: table, schema: schema, columns: [], sorts: sorts, filters: filters, - matchAll: matchAll, limit: limit, offset: offset - ) - } - - private func whereSQL(_ filter: SpannerBrowseFilter, _ dialect: SpannerDialect) -> (sql: String, parameters: [SpannerCell]) { - let table = dialect == .googleSQL ? "Singers" : "singers" - let statement = SpannerBrowseRenderer.count(request(table: table, filters: [filter]), dialect: dialect) - let prefix = "SELECT COUNT(*) FROM \(dialect.quoteIdentifier(table)) WHERE (" - #expect(statement.sql.hasPrefix(prefix)) - #expect(statement.sql.hasSuffix(")")) - return (String(statement.sql.dropFirst(prefix.count).dropLast()), statement.parameters) - } - - @Test("An unfiltered browse has no parameters and known types") - func unfiltered() { - let statement = SpannerBrowseRenderer.select(request(), dialect: .googleSQL) - #expect(statement.sql == "SELECT * FROM `Singers` LIMIT 100 OFFSET 0") - #expect(statement.parameters.isEmpty) - #expect(statement.parameterTypes?.isEmpty == true) - let count = SpannerBrowseRenderer.count(request(), dialect: .postgreSQL) - #expect(count.sql == "SELECT COUNT(*) FROM \"Singers\"") - } - - @Test("Filters, sorts and paging in GoogleSQL") - func fullGoogleSQL() { - let statement = SpannerBrowseRenderer.select( - request( - filters: [SpannerBrowseFilter(column: "Name", op: "=", value: "bob"), SpannerBrowseFilter(column: "Age", op: ">", value: "60")], - matchAll: false, - sorts: [SpannerBrowseSort(column: "Active", ascending: false), SpannerBrowseSort(column: "Id", ascending: true)], - limit: 3, - offset: 1 - ), - dialect: .googleSQL - ) - #expect(statement.sql == "SELECT * FROM `Singers` WHERE (`Name` = @p1 OR `Age` > @p2) ORDER BY `Active` DESC, `Id` ASC LIMIT 3 OFFSET 1") - #expect(statement.parameters == [.text("bob"), .text("60")]) - #expect(statement.parameterTypes == nil) - } - - @Test("Filters, sorts and paging in PostgreSQL") - func fullPostgreSQL() { - let statement = SpannerBrowseRenderer.select( - request( - table: "orders", - schema: "sales", - filters: [SpannerBrowseFilter(column: "amt", op: ">=", value: "10"), SpannerBrowseFilter(column: "id", op: "!=", value: "3")], - sorts: [SpannerBrowseSort(column: "amt", ascending: true)], - limit: 50, - offset: 100 - ), - dialect: .postgreSQL - ) - #expect(statement.sql == "SELECT * FROM \"sales\".\"orders\" WHERE (\"amt\" >= $1 AND \"id\" != $2) ORDER BY \"amt\" ASC LIMIT 50 OFFSET 100") - #expect(statement.parameters == [.text("10"), .text("3")]) - } - - @Test("The count shares the filter and its parameters") - func countMatchesSelect() { - let filtered = request(filters: [SpannerBrowseFilter(column: "Name", op: "CONTAINS", value: "a", caseSensitive: false)]) - let select = SpannerBrowseRenderer.select(filtered, dialect: .googleSQL) - let count = SpannerBrowseRenderer.count(filtered, dialect: .googleSQL) - #expect(count.sql == "SELECT COUNT(*) FROM `Singers` WHERE (LOWER(CAST(`Name` AS STRING)) LIKE LOWER(@p1))") - #expect(count.parameters == select.parameters) - #expect(count.parameters == [.text("%a%")]) - } - - @Test("Negative paging is clamped") - func clampsPaging() { - let statement = SpannerBrowseRenderer.select(request(limit: -1, offset: -5), dialect: .googleSQL) - #expect(statement.sql == "SELECT * FROM `Singers` LIMIT 0 OFFSET 0") - } - - @Test("Comparison operators bind their value", arguments: ["=", "!=", ">", ">=", "<", "<="]) - func comparisons(op: String) { - let google = whereSQL(SpannerBrowseFilter(column: "Age", op: op, value: "30"), .googleSQL) - #expect(google.sql == "`Age` \(op) @p1") - #expect(google.parameters == [.text("30")]) - let postgres = whereSQL(SpannerBrowseFilter(column: "age", op: op, value: "30"), .postgreSQL) - #expect(postgres.sql == "\"age\" \(op) $1") - } - - @Test("<> is written as !=") - func notEqualSpelling() { - #expect(whereSQL(SpannerBrowseFilter(column: "Age", op: "<>", value: "1"), .googleSQL).sql == "`Age` != @p1") - } - - @Test("A NULL value turns equality into a null test") - func nullEquality() { - let equal = whereSQL(SpannerBrowseFilter(column: "Name", op: "=", value: "null"), .googleSQL) - #expect(equal.sql == "`Name` IS NULL") - #expect(equal.parameters.isEmpty) - #expect(whereSQL(SpannerBrowseFilter(column: "name", op: "<>", value: " NULL "), .postgreSQL).sql == "\"name\" IS NOT NULL") - } - - @Test("Case-insensitive equality lowers both sides") - func caseInsensitiveEquality() { - let google = whereSQL(SpannerBrowseFilter(column: "Name", op: "=", value: "alice", caseSensitive: false), .googleSQL) - #expect(google.sql == "LOWER(CAST(`Name` AS STRING)) = LOWER(@p1)") - let postgres = whereSQL(SpannerBrowseFilter(column: "name", op: "!=", value: "ALICE", caseSensitive: false), .postgreSQL) - #expect(postgres.sql == "LOWER(CAST(\"name\" AS TEXT)) != LOWER($1)") - } - - @Test("Pattern operators build an escaped LIKE pattern") - func patterns() { - let contains = whereSQL(SpannerBrowseFilter(column: "Name", op: "CONTAINS", value: "50%_\\"), .googleSQL) - #expect(contains.sql == "CAST(`Name` AS STRING) LIKE @p1") - #expect(contains.parameters == [.text(#"%50\%\_\\%"#)]) - let notContains = whereSQL(SpannerBrowseFilter(column: "Name", op: "NOT CONTAINS", value: "x"), .googleSQL) - #expect(notContains.sql == "CAST(`Name` AS STRING) NOT LIKE @p1") - #expect(notContains.parameters == [.text("%x%")]) - #expect(whereSQL(SpannerBrowseFilter(column: "Name", op: "STARTS WITH", value: "al"), .googleSQL).parameters == [.text("al%")]) - #expect(whereSQL(SpannerBrowseFilter(column: "Name", op: "ENDS WITH", value: "ce"), .googleSQL).parameters == [.text("%ce")]) - } - - @Test("PostgreSQL LIKE relies on the default backslash escape, which NOT LIKE ... ESCAPE cannot name") - func postgresLike() { - let sensitive = whereSQL(SpannerBrowseFilter(column: "name", op: "CONTAINS", value: "50%"), .postgreSQL) - #expect(sensitive.sql == #"CAST("name" AS TEXT) LIKE $1"#) - #expect(sensitive.parameters == [.text(#"%50\%%"#)]) - let insensitive = whereSQL(SpannerBrowseFilter(column: "name", op: "NOT CONTAINS", value: "li", caseSensitive: false), .postgreSQL) - #expect(insensitive.sql == #"LOWER(CAST("name" AS TEXT)) NOT LIKE LOWER($1)"#) - #expect(insensitive.parameters == [.text("%li%")]) - } - - @Test("A quote in a filter value never reaches the SQL text") - func injectionIsBound() { - let value = "x' OR TRUE --" - for dialect in [SpannerDialect.googleSQL, .postgreSQL] { - for op in ["=", "CONTAINS", "IN", "REGEX", ">"] { - let rendered = whereSQL(SpannerBrowseFilter(column: "Name", op: op, value: value), dialect) - #expect(!rendered.sql.contains("OR TRUE")) - #expect(rendered.parameters.contains { $0 == .text(value) || $0 == .text("%\(value)%") }) - } - } - } - - @Test("IN lists skip empty items and turn NULL into a null test") - func membershipEdges() { - let withNull = whereSQL(SpannerBrowseFilter(column: "Id", op: "IN", value: "1, , NULL,2"), .googleSQL) - #expect(withNull.sql == "(`Id` IN (@p1, @p2) OR `Id` IS NULL)") - #expect(withNull.parameters == [.text("1"), .text("2")]) - let notInNull = whereSQL(SpannerBrowseFilter(column: "Id", op: "NOT IN", value: "null,3"), .googleSQL) - #expect(notInNull.sql == "(`Id` NOT IN (@p1) AND `Id` IS NOT NULL)") - let onlyNull = whereSQL(SpannerBrowseFilter(column: "Id", op: "IN", value: "NULL"), .googleSQL) - #expect(onlyNull.sql == "`Id` IS NULL") - let empty = whereSQL(SpannerBrowseFilter(column: "Id", op: "IN", value: " , "), .googleSQL) - #expect(empty.sql == "FALSE") - #expect(empty.parameters.isEmpty) - } - - @Test("IN lists bind one parameter per trimmed value") - func membership() { - let inList = whereSQL(SpannerBrowseFilter(column: "Id", op: "IN", value: "1, 2 ,3"), .googleSQL) - #expect(inList.sql == "`Id` IN (@p1, @p2, @p3)") - #expect(inList.parameters == [.text("1"), .text("2"), .text("3")]) - let notIn = whereSQL(SpannerBrowseFilter(column: "id", op: "NOT IN", value: "1,2"), .postgreSQL) - #expect(notIn.sql == "\"id\" NOT IN ($1, $2)") - let insensitive = whereSQL(SpannerBrowseFilter(column: "Name", op: "IN", value: "alice, BOB", caseSensitive: false), .googleSQL) - #expect(insensitive.sql == "LOWER(CAST(`Name` AS STRING)) IN (LOWER(@p1), LOWER(@p2))") - #expect(insensitive.parameters == [.text("alice"), .text("BOB")]) - } - - @Test("BETWEEN prefers the second value and falls back to the first comma") - func between() { - let joined = whereSQL(SpannerBrowseFilter(column: "Age", op: "BETWEEN", value: "30,5,0", secondValue: "5,0"), .googleSQL) - #expect(joined.sql == "`Age` BETWEEN @p1 AND @p2") - #expect(joined.parameters == [.text("30"), .text("5,0")]) - let separate = whereSQL(SpannerBrowseFilter(column: "Age", op: "BETWEEN", value: "30", secondValue: "50"), .googleSQL) - #expect(separate.parameters == [.text("30"), .text("50")]) - let fallback = whereSQL(SpannerBrowseFilter(column: "age", op: "BETWEEN", value: "30, 50"), .postgreSQL) - #expect(fallback.sql == "\"age\" BETWEEN $1 AND $2") - #expect(fallback.parameters == [.text("30"), .text("50")]) - #expect(whereSQL(SpannerBrowseFilter(column: "Age", op: "BETWEEN", value: "30"), .googleSQL).sql == "FALSE") - } - - @Test("Null and emptiness tests bind nothing") - func nullAndEmpty() { - #expect(whereSQL(SpannerBrowseFilter(column: "Age", op: "IS NULL", value: ""), .googleSQL).sql == "`Age` IS NULL") - #expect(whereSQL(SpannerBrowseFilter(column: "Age", op: "IS NOT NULL", value: "x"), .googleSQL).sql == "`Age` IS NOT NULL") - let empty = whereSQL(SpannerBrowseFilter(column: "Name", op: "IS EMPTY", value: ""), .googleSQL) - #expect(empty.sql == "(`Name` IS NULL OR CAST(`Name` AS STRING) = '')") - #expect(empty.parameters.isEmpty) - let notEmpty = whereSQL(SpannerBrowseFilter(column: "name", op: "IS NOT EMPTY", value: ""), .postgreSQL) - #expect(notEmpty.sql == "(\"name\" IS NOT NULL AND CAST(\"name\" AS TEXT) != '')") - } - - @Test("REGEX uses each dialect's matcher and an inline flag for case") - func regex() { - #expect(whereSQL(SpannerBrowseFilter(column: "Name", op: "REGEX", value: "^A"), .googleSQL).sql - == "REGEXP_CONTAINS(CAST(`Name` AS STRING), @p1)") - #expect(whereSQL(SpannerBrowseFilter(column: "Name", op: "regex", value: "^a", caseSensitive: false), .googleSQL).sql - == "REGEXP_CONTAINS(CAST(`Name` AS STRING), CONCAT('(?i)', @p1))") - #expect(whereSQL(SpannerBrowseFilter(column: "name", op: "REGEX", value: "^A"), .postgreSQL).sql - == "CAST(\"name\" AS TEXT) ~ $1") - #expect(whereSQL(SpannerBrowseFilter(column: "name", op: "REGEX", value: "^a", caseSensitive: false), .postgreSQL).sql - == "CAST(\"name\" AS TEXT) ~ ('(?i)' || $1)") - } - - @Test("Raw SQL is parenthesized on its own lines so a trailing comment cannot swallow the rest") - func rawSQL() { - let raw = whereSQL(SpannerBrowseFilter(column: "__RAW__", op: "=", value: " Age > 40 -- note "), .googleSQL) - #expect(raw.sql == "(Age > 40 -- note\n)") - #expect(raw.parameters.isEmpty) - let select = SpannerBrowseRenderer.select( - request(filters: [SpannerBrowseFilter(column: "__RAW__", op: "=", value: "Age > 40 -- note")]), - dialect: .googleSQL - ) - #expect(select.sql == "SELECT * FROM `Singers` WHERE ((Age > 40 -- note\n)) LIMIT 100 OFFSET 0") - #expect(whereSQL(SpannerBrowseFilter(column: "__RAW__", op: "=", value: " "), .googleSQL).sql == "FALSE") - } - - @Test("An unknown operator matches nothing", arguments: ["LIKE", "~", "", "EXISTS"]) - func unknownOperator(op: String) { - let rendered = whereSQL(SpannerBrowseFilter(column: "Name", op: op, value: "x"), .googleSQL) - #expect(rendered.sql == "FALSE") - #expect(rendered.parameters.isEmpty) - } - - @Test("Operators match case-insensitively") - func operatorCase() { - #expect(whereSQL(SpannerBrowseFilter(column: "Name", op: "starts with", value: "a"), .googleSQL).sql - == "CAST(`Name` AS STRING) LIKE @p1") - #expect(whereSQL(SpannerBrowseFilter(column: "Name", op: " is null ", value: ""), .googleSQL).sql == "`Name` IS NULL") - } - - @Test("Identifiers in filters and sorts are quoted") - func quotedIdentifiers() { - let statement = SpannerBrowseRenderer.select( - request( - filters: [SpannerBrowseFilter(column: "a`b", op: "=", value: "1")], - sorts: [SpannerBrowseSort(column: "x` DESC, (SELECT 1)", ascending: true)] - ), - dialect: .googleSQL - ) - #expect(statement.sql == #"SELECT * FROM `Singers` WHERE (`a\`b` = @p1) ORDER BY `x\` DESC, (SELECT 1)` ASC LIMIT 100 OFFSET 0"#) - } -} - -@Suite("SpannerBrowseRequest") -struct SpannerBrowseRequestTests { - private let sample = SpannerBrowseRequest( - table: "Orders", - schema: "sales", - columns: ["Id", "Amt"], - sorts: [SpannerBrowseSort(column: "Amt", ascending: false)], - filters: [SpannerBrowseFilter(column: "Amt", op: "BETWEEN", value: "1", secondValue: "9", caseSensitive: false)], - matchAll: false, - limit: 200, - offset: 400 - ) - - @Test("The descriptor round-trips") - func roundTrip() { - let encoded = sample.encoded() - #expect(encoded.hasPrefix("SPANNER_BROWSE:")) - #expect(SpannerBrowseRequest.decode(encoded) == sample) - #expect(SpannerBrowseRequest.decode("\n " + encoded + " \n") == sample) - } - - @Test("Encoding is deterministic") - func deterministic() { - let decoded = SpannerBrowseRequest.decode(sample.encoded()) - #expect(decoded?.encoded() == sample.encoded()) - } - - @Test("Anything without the tag or with a broken payload is not a descriptor") - func rejects() { - #expect(SpannerBrowseRequest.tag == "SPANNER_BROWSE:") - #expect(SpannerBrowseRequest.decode("SELECT * FROM t") == nil) - #expect(SpannerBrowseRequest.decode("SPANNER_BROWSE:not base64!") == nil) - #expect(SpannerBrowseRequest.decode("SPANNER_BROWSE:" + Data("{}".utf8).base64EncodedString()) == nil) - #expect(SpannerBrowseRequest.decode("x" + sample.encoded()) == nil) - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerCatalogTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerCatalogTests.swift deleted file mode 100644 index 9bb0fd27a7..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerCatalogTests.swift +++ /dev/null @@ -1,298 +0,0 @@ -import Foundation -import TableProSpannerCore -import Testing - -private func catalogRow(_ values: String?...) -> [SpannerCell] { - values.map { $0.map(SpannerCell.text) ?? .null } -} - -@Suite("SpannerCatalogSQL") -struct SpannerCatalogSQLTests { - private let string = SpannerType(code: "STRING") - - @Test("Schemas need no parameters") - func schemas() { - let statement = SpannerCatalogSQL.schemas(dialect: .googleSQL) - #expect(statement.sql == "SELECT schema_name FROM information_schema.schemata ORDER BY schema_name") - #expect(statement.parameters.isEmpty) - #expect(statement.parameterTypes?.isEmpty == true) - } - - @Test("Tables bind the schema as a STRING") - func tables() { - let google = SpannerCatalogSQL.tables(schema: "", dialect: .googleSQL) - #expect(google.sql == "SELECT table_schema, table_name, table_type FROM information_schema.tables" - + " WHERE table_schema = @p1 AND table_type IN ('BASE TABLE', 'VIEW') ORDER BY table_name") - #expect(google.parameters == [.text("")]) - #expect(google.parameterTypes == [string]) - let postgres = SpannerCatalogSQL.tables(schema: "public", dialect: .postgreSQL) - #expect(postgres.sql.contains("WHERE table_schema = $1 AND")) - #expect(postgres.parameters == [.text("public")]) - } - - @Test("Columns select the hidden flag only where it exists and find the key through PRIMARY_KEY") - func columns() { - let bulk = SpannerCatalogSQL.columns(schema: "", table: nil, dialect: .googleSQL) - #expect(bulk.sql == "SELECT c.table_schema, c.table_name, c.column_name, c.spanner_type, c.is_nullable, c.column_default," - + " c.is_generated, c.generation_expression, c.is_stored, c.is_identity, c.identity_generation," - + " c.is_hidden, k.ordinal_position" - + " FROM information_schema.columns AS c" - + " LEFT JOIN information_schema.index_columns AS k" - + " ON k.table_schema = c.table_schema AND k.table_name = c.table_name" - + " AND k.column_name = c.column_name AND k.index_type = 'PRIMARY_KEY'" - + " WHERE c.table_schema = @p1" - + " ORDER BY c.table_name, c.ordinal_position") - #expect(bulk.parameters == [.text("")]) - let one = SpannerCatalogSQL.columns(schema: "public", table: "child", dialect: .postgreSQL) - #expect(one.sql.contains(" c.identity_generation, 'NO', k.ordinal_position")) - #expect(one.sql.contains("WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY")) - #expect(one.parameters == [.text("public"), .text("child")]) - #expect(one.parameterTypes == [string, string]) - } - - @Test("Indexes drop STORING columns in SQL") - func indexes() { - let statement = SpannerCatalogSQL.indexes(schema: "sales", table: "Orders", dialect: .googleSQL) - #expect(statement.sql == "SELECT i.table_schema, i.table_name, i.index_name, i.index_type, i.is_unique, i.spanner_is_managed," - + " ic.column_name, ic.ordinal_position" - + " FROM information_schema.indexes AS i" - + " JOIN information_schema.index_columns AS ic" - + " ON ic.table_schema = i.table_schema AND ic.table_name = i.table_name AND ic.index_name = i.index_name" - + " WHERE i.table_schema = @p1 AND i.table_name = @p2 AND ic.ordinal_position IS NOT NULL" - + " ORDER BY i.table_name, i.index_name, ic.ordinal_position") - #expect(statement.parameters == [.text("sales"), .text("Orders")]) - } - - @Test("Foreign keys pair columns by position in the referenced constraint") - func foreignKeys() { - let statement = SpannerCatalogSQL.foreignKeys(schema: "", table: nil, dialect: .googleSQL) - #expect(statement.sql == "SELECT c.table_schema, c.table_name, c.constraint_name, c.column_name," - + " p.table_schema, p.table_name, p.column_name, r.delete_rule" - + " FROM information_schema.key_column_usage AS c" - + " JOIN information_schema.referential_constraints AS r" - + " ON r.constraint_schema = c.constraint_schema AND r.constraint_name = c.constraint_name" - + " JOIN information_schema.key_column_usage AS p" - + " ON p.constraint_schema = r.unique_constraint_schema AND p.constraint_name = r.unique_constraint_name" - + " AND p.ordinal_position = c.position_in_unique_constraint" - + " WHERE c.table_schema = @p1" - + " ORDER BY c.table_name, c.constraint_name, c.ordinal_position") - } - - @Test("Interleave parents join the parent's primary key") - func interleave() { - let statement = SpannerCatalogSQL.interleaveParents(schema: "public", table: "albums", dialect: .postgreSQL) - #expect(statement.sql == "SELECT t.table_schema, t.table_name, t.parent_table_name, t.on_delete_action, t.interleave_type, k.column_name" - + " FROM information_schema.tables AS t" - + " JOIN information_schema.index_columns AS k" - + " ON k.table_schema = t.table_schema AND k.table_name = t.parent_table_name" - + " AND k.index_type = 'PRIMARY_KEY'" - + " WHERE t.table_schema = $1 AND t.table_name = $2 AND t.parent_table_name IS NOT NULL" - + " ORDER BY t.table_name, k.ordinal_position") - #expect(statement.parameterTypes == [string, string]) - } - - @Test("A view definition binds schema and name") - func viewDefinition() { - let statement = SpannerCatalogSQL.viewDefinition(schema: "", name: "x' OR TRUE --", dialect: .googleSQL) - #expect(statement.sql == "SELECT view_definition FROM information_schema.views WHERE table_schema = @p1 AND table_name = @p2") - #expect(statement.parameters == [.text(""), .text("x' OR TRUE --")]) - } -} - -@Suite("SpannerCatalogParser") -struct SpannerCatalogParserTests { - @Test("Schemas keep the nameless default and drop system schemas") - func schemas() { - let google = [catalogRow(""), catalogRow("INFORMATION_SCHEMA"), catalogRow("SPANNER_SYS"), catalogRow("sales")] - #expect(SpannerCatalogParser.schemas(google, dialect: .googleSQL) == ["", "sales"]) - let postgres = [catalogRow("information_schema"), catalogRow("pg_catalog"), catalogRow("public"), catalogRow("sales"), catalogRow("spanner_sys")] - #expect(SpannerCatalogParser.schemas(postgres, dialect: .postgreSQL) == ["public", "sales"]) - } - - @Test("Tables and views") - func tables() { - let rows = [catalogRow("", "Albums", "BASE TABLE"), catalogRow("", "SingerNames", "VIEW"), catalogRow(nil, "Broken", "VIEW")] - #expect(SpannerCatalogParser.tables(rows) == [ - SpannerTableInfo(schema: "", name: "Albums", isView: false), - SpannerTableInfo(schema: "", name: "SingerNames", isView: true) - ]) - } - - @Test("GoogleSQL columns: key, default, stored generation, hidden and identity") - func googleSQLColumns() { - let rows = [ - catalogRow("", "T2", "Id", "INT64", "NO", nil, "NEVER", nil, nil, "NO", nil, "false", "1"), - catalogRow("", "T2", "D", "STRING(MAX)", "YES", "'dflt'", "NEVER", nil, nil, "NO", nil, "false", nil), - catalogRow("", "T2", "G", "STRING(MAX)", "YES", nil, "ALWAYS", "CONCAT(D, '!')", "YES", "NO", nil, "false", nil), - catalogRow("", "T2", "H", "STRING(MAX)", "YES", nil, "NEVER", nil, nil, "NO", nil, "true", nil), - catalogRow("", "T3", "Id", "INT64", "NO", nil, "NEVER", nil, nil, "YES", "BY DEFAULT", "false", "1"), - catalogRow("", "Docs", "K", "BYTES(16)", "NO", nil, "NEVER", nil, nil, "NO", nil, "false", "2") - ] - #expect(SpannerCatalogParser.columns(rows) == [ - SpannerColumnInfo(schema: "", table: "T2", name: "Id", spannerType: "INT64", isNullable: false, isPrimaryKey: true), - SpannerColumnInfo(schema: "", table: "T2", name: "D", spannerType: "STRING(MAX)", isNullable: true, isPrimaryKey: false, - defaultExpression: "'dflt'"), - SpannerColumnInfo(schema: "", table: "T2", name: "G", spannerType: "STRING(MAX)", isNullable: true, isPrimaryKey: false, - isGenerated: true, generationExpression: "CONCAT(D, '!')", isStored: true), - SpannerColumnInfo(schema: "", table: "T2", name: "H", spannerType: "STRING(MAX)", isNullable: true, isPrimaryKey: false, - isHidden: true), - SpannerColumnInfo(schema: "", table: "T3", name: "Id", spannerType: "INT64", isNullable: false, isPrimaryKey: true, - identityGeneration: "BY DEFAULT"), - SpannerColumnInfo(schema: "", table: "Docs", name: "K", spannerType: "BYTES(16)", isNullable: false, isPrimaryKey: true) - ]) - } - - @Test("PostgreSQL columns report YES and NO strings") - func postgreSQLColumns() { - let rows = [ - catalogRow("public", "t2", "g", "character varying", "YES", nil, "ALWAYS", "(d || '!'::text)", "YES", "NO", nil, "NO", nil), - catalogRow("public", "t3", "id", "bigint", "NO", nil, "NEVER", nil, nil, "YES", "BY DEFAULT", "NO", "1"), - catalogRow("public", "t2", "d", "character varying", "YES", "'dflt'::text", "NEVER", nil, nil, nil, nil, "NO", nil) - ] - let columns = SpannerCatalogParser.columns(rows) - #expect(columns.count == 3) - #expect(columns[0].isGenerated && columns[0].isStored && columns[0].generationExpression == "(d || '!'::text)") - #expect(columns[1].isPrimaryKey && columns[1].identityGeneration == "BY DEFAULT" && !columns[1].isNullable) - #expect(columns[2].defaultExpression == "'dflt'::text" && columns[2].identityGeneration == nil && !columns[2].isHidden) - } - - @Test("Indexes group key columns in order, flag the primary key and managed indexes, and skip STORING") - func indexes() { - let rows = [ - catalogRow("", "Child", "IDX_Child_X_Y_N_6C2AD26B4F077B71", "INDEX", "false", "true", "Y", "2"), - catalogRow("", "Child", "IDX_Child_X_Y_N_6C2AD26B4F077B71", "INDEX", "false", "true", "X", "1"), - catalogRow("", "Child", "PRIMARY_KEY", "PRIMARY_KEY", "true", "false", "Id", "1"), - catalogRow("", "Singers", "SingersByAgeName", "INDEX", "true", "false", "Age", "1"), - catalogRow("", "Singers", "SingersByAgeName", "INDEX", "true", "false", "Name", "2"), - catalogRow("", "Singers", "SingersByName", "INDEX", "false", "false", "Age", nil), - catalogRow("", "Singers", "SingersByName", "INDEX", "false", "false", "Name", "1") - ] - #expect(SpannerCatalogParser.indexes(rows) == [ - SpannerIndexInfo(schema: "", table: "Child", name: "IDX_Child_X_Y_N_6C2AD26B4F077B71", columns: ["X", "Y"], - isUnique: false, isPrimaryKey: false, isManaged: true, type: "INDEX"), - SpannerIndexInfo(schema: "", table: "Child", name: "PRIMARY_KEY", columns: ["Id"], - isUnique: true, isPrimaryKey: true, isManaged: false, type: "PRIMARY_KEY"), - SpannerIndexInfo(schema: "", table: "Singers", name: "SingersByAgeName", columns: ["Age", "Name"], - isUnique: true, isPrimaryKey: false, isManaged: false, type: "INDEX"), - SpannerIndexInfo(schema: "", table: "Singers", name: "SingersByName", columns: ["Name"], - isUnique: false, isPrimaryKey: false, isManaged: false, type: "INDEX") - ]) - } - - @Test("PostgreSQL index flags arrive as YES and NO") - func postgresIndexes() { - let rows = [ - catalogRow("public", "child", "IDX_child_x_y_N_6E2D1530E12DA9EC", "INDEX", "NO", "YES", "x", "1"), - catalogRow("public", "singers", "singers_by_name", "INDEX", "NO", "NO", "age", nil), - catalogRow("public", "singers", "PRIMARY_KEY", "PRIMARY_KEY", "YES", "NO", "id", "1") - ] - let indexes = SpannerCatalogParser.indexes(rows) - #expect(indexes.map(\.name) == ["IDX_child_x_y_N_6E2D1530E12DA9EC", "PRIMARY_KEY"]) - #expect(indexes[0].isManaged && !indexes[0].isUnique) - #expect(indexes[1].isPrimaryKey && indexes[1].isUnique) - } - - @Test("A composite foreign key keeps its column pairs and an interleave parent becomes a relation") - func foreignKeys() { - let rows = [ - catalogRow("", "Child", "FK_XY", "X", "", "Parent", "A", "CASCADE"), - catalogRow("", "Child", "FK_XY", "Y", "", "Parent", "B", "CASCADE"), - catalogRow("sales", "Orders", "FK_OrderSinger", "SingerId", "", "Singers", "Id", "NO ACTION") - ] - let interleave = [ - catalogRow("", "Albums", "Singers", "CASCADE", "IN PARENT", "Id"), - catalogRow("", "Songs", "Albums", "NO ACTION", "IN PARENT", "Id"), - catalogRow("", "Songs", "Albums", "NO ACTION", "IN PARENT", "AlbumId"), - catalogRow("", "Loose", "Albums", nil, "IN", "Id") - ] - #expect(SpannerCatalogParser.foreignKeys(rows, interleaveRows: interleave) == [ - SpannerForeignKeyInfo(schema: "", table: "Child", name: "FK_XY", columns: ["X", "Y"], referencedSchema: "", - referencedTable: "Parent", referencedColumns: ["A", "B"], onDelete: "CASCADE", isInterleave: false), - SpannerForeignKeyInfo(schema: "sales", table: "Orders", name: "FK_OrderSinger", columns: ["SingerId"], referencedSchema: "", - referencedTable: "Singers", referencedColumns: ["Id"], onDelete: "NO ACTION", isInterleave: false), - SpannerForeignKeyInfo(schema: "", table: "Albums", name: "INTERLEAVE IN PARENT Singers", columns: ["Id"], referencedSchema: "", - referencedTable: "Singers", referencedColumns: ["Id"], onDelete: "CASCADE", isInterleave: true), - SpannerForeignKeyInfo(schema: "", table: "Songs", name: "INTERLEAVE IN PARENT Albums", columns: ["Id", "AlbumId"], - referencedSchema: "", referencedTable: "Albums", referencedColumns: ["Id", "AlbumId"], - onDelete: "NO ACTION", isInterleave: true), - SpannerForeignKeyInfo(schema: "", table: "Loose", name: "INTERLEAVE IN Albums", columns: ["Id"], referencedSchema: "", - referencedTable: "Albums", referencedColumns: ["Id"], onDelete: nil, isInterleave: true) - ]) - } - - @Test("PostgreSQL foreign keys across schemas") - func postgresForeignKeys() { - let rows = [catalogRow("sales", "orders", "fk_order_singer", "singer_id", "public", "singers", "id", "NO ACTION")] - let interleave = [catalogRow("sales", "items", "orders", "CASCADE", "IN PARENT", "id")] - let keys = SpannerCatalogParser.foreignKeys(rows, interleaveRows: interleave) - #expect(keys.count == 2) - #expect(keys[0].referencedSchema == "public" && keys[0].referencedTable == "singers") - #expect(keys[1].referencedSchema == "sales" && keys[1].name == "INTERLEAVE IN PARENT orders") - } - - @Test("Emulator responses decode and parse end to end") - func emulatorResponses() throws { - let indexesJSON = #""" - {"metadata": {"rowType": {"fields": [ - {"name":"table_schema","type":{"code":"STRING"}}, - {"name":"table_name","type":{"code":"STRING"}}, - {"name":"index_name","type":{"code":"STRING"}}, - {"name":"index_type","type":{"code":"STRING"}}, - {"name":"is_unique","type":{"code":"BOOL"}}, - {"name":"spanner_is_managed","type":{"code":"BOOL"}}, - {"name":"column_name","type":{"code":"STRING"}}, - {"name":"ordinal_position","type":{"code":"INT64"}} - ]}}, "rows": [ - ["","Singers","PRIMARY_KEY","PRIMARY_KEY",true,false,"Id","1"], - ["","Singers","SingersByAgeName","INDEX",true,false,"Age","1"], - ["","Singers","SingersByAgeName","INDEX",true,false,"Name","2"], - ["","Singers","SingersByName","INDEX",false,false,"Name","1"] - ]} - """# - let columnsJSON = #""" - {"metadata": {"rowType": {"fields": [ - {"name":"table_schema","type":{"code":"STRING"}}, - {"name":"table_name","type":{"code":"STRING"}}, - {"name":"column_name","type":{"code":"STRING"}}, - {"name":"spanner_type","type":{"code":"STRING"}}, - {"name":"is_nullable","type":{"code":"STRING"}}, - {"name":"column_default","type":{"code":"STRING"}}, - {"name":"is_generated","type":{"code":"STRING"}}, - {"name":"generation_expression","type":{"code":"STRING"}}, - {"name":"is_stored","type":{"code":"STRING"}}, - {"name":"is_identity","type":{"code":"STRING"}}, - {"name":"identity_generation","type":{"code":"STRING"}}, - {"name":"?column?","type":{"code":"STRING"}}, - {"name":"ordinal_position","type":{"code":"INT64"}} - ]}}, "rows": [ - ["public","t2","id","bigint","NO",null,"NEVER",null,null,"NO",null,"NO","1"], - ["public","t2","d","character varying","YES","'dflt'::text","NEVER",null,null,"NO",null,"NO",null], - ["public","t2","g","character varying","YES",null,"ALWAYS","(d || '!'::text)","YES","NO",null,"NO",null], - ["public","t3","id","bigint","NO",null,"NEVER",null,null,"YES","BY DEFAULT","NO","1"], - ["public","t3","v","character varying","YES",null,"NEVER",null,null,"NO",null,"NO",null] - ]} - """# - let indexes = try decodedRows(indexesJSON) - #expect(SpannerCatalogParser.indexes(indexes).map(\.columns) == [["Id"], ["Age", "Name"], ["Name"]]) - #expect(SpannerCatalogParser.indexes(indexes).map(\.isUnique) == [true, true, false]) - let columns = SpannerCatalogParser.columns(try decodedRows(columnsJSON)) - #expect(columns.map(\.name) == ["id", "d", "g", "id", "v"]) - #expect(columns.map(\.isPrimaryKey) == [true, false, false, true, false]) - #expect(columns[1].defaultExpression == "'dflt'::text") - #expect(columns[2].isGenerated && columns[2].isStored) - #expect(columns[3].identityGeneration == "BY DEFAULT") - } - - private func decodedRows(_ json: String) throws -> [[SpannerCell]] { - let resultSet = try JSONDecoder().decode(SpannerResultSet.self, from: Data(json.utf8)) - return SpannerValueDecoder.rows(resultSet.rows, fields: resultSet.metadata?.fields ?? []) - } - - @Test("View definition") - func viewDefinition() { - #expect(SpannerCatalogParser.viewDefinition([catalogRow("SELECT Singers.Id, Singers.Name FROM Singers")]) - == "SELECT Singers.Id, Singers.Name FROM Singers") - #expect(SpannerCatalogParser.viewDefinition([]) == nil) - #expect(SpannerCatalogParser.viewDefinition([catalogRow(nil)]) == nil) - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerConnectionSettingsTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerConnectionSettingsTests.swift deleted file mode 100644 index cda07bef83..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerConnectionSettingsTests.swift +++ /dev/null @@ -1,174 +0,0 @@ -import Foundation -import Testing - -@testable import TableProSpannerCore - -@Suite("SpannerConnectionSettings") -struct SpannerConnectionSettingsTests { - private typealias Key = SpannerConnectionSettings.FieldKey - - private func fields( - project: String = "proj", - instance: String = "inst", - database: String = "gdb", - endpoint: String? = nil, - method: String? = nil - ) -> [String: String] { - var fields = [Key.projectId: project, Key.instanceId: instance, Key.databaseId: database] - fields[Key.endpoint] = endpoint - fields[Key.authMethod] = method - return fields - } - - @Test("Defaults to the service account method and the production endpoint") - func defaults() throws { - let settings = try SpannerConnectionSettings.parse(fields: fields()) - #expect(settings.authMethod == .serviceAccount) - #expect(settings.endpoint == SpannerConnectionSettings.productionEndpoint) - #expect(settings.endpoint.absoluteString == "https://spanner.googleapis.com") - #expect(settings.databasePath == "projects/proj/instances/inst/databases/gdb") - } - - @Test("Identifiers are trimmed") - func trimsIdentifiers() throws { - let settings = try SpannerConnectionSettings.parse(fields: fields(project: " proj \n", database: "\tgdb ")) - #expect(settings.projectId == "proj") - #expect(settings.databaseId == "gdb") - } - - @Test("Domain-scoped project ids with a colon and dots are accepted") - func domainScopedProject() throws { - let settings = try SpannerConnectionSettings.parse(fields: fields(project: "example.com:my-project_1")) - #expect(settings.projectId == "example.com:my-project_1") - } - - @Test("Every auth method raw value parses", arguments: SpannerAuthMethod.allCases) - func authMethods(method: SpannerAuthMethod) throws { - let endpoint = method == .emulator ? "http://localhost:9020" : nil - let settings = try SpannerConnectionSettings.parse(fields: fields(endpoint: endpoint, method: method.rawValue)) - #expect(settings.authMethod == method) - } - - @Test("The adc raw value names application default credentials") - func adcRawValue() throws { - let settings = try SpannerConnectionSettings.parse(fields: fields(method: "adc")) - #expect(settings.authMethod == .applicationDefault) - } - - @Test("An unknown auth method is rejected") - func unknownMethod() { - #expect(throws: SpannerConfigurationError.unknownAuthMethod("kerberos")) { - try SpannerConnectionSettings.parse(fields: fields(method: "kerberos")) - } - } - - @Test("Missing identifiers name their field", arguments: [Key.projectId, Key.instanceId, Key.databaseId]) - func missingIdentifier(key: String) { - var values = fields() - values[key] = " " - #expect(throws: SpannerConfigurationError.missingField(key)) { - try SpannerConnectionSettings.parse(fields: values) - } - } - - @Test( - "Identifiers that could escape their path segment are rejected", - arguments: ["a/b", "..", "a..b", "db?x=1", "db#frag", "-lead", ".lead", "a b", "db%2F", "ü", "a\\b"] - ) - func rejectsUnsafeIdentifiers(value: String) { - #expect(throws: SpannerConfigurationError.invalidIdentifier(Key.databaseId)) { - try SpannerConnectionSettings.parse(fields: fields(database: value)) - } - #expect(throws: SpannerConfigurationError.invalidIdentifier(Key.projectId)) { - try SpannerConnectionSettings.parse(fields: fields(project: value)) - } - } - - @Test("A regional Google endpoint is trusted", arguments: [ - "https://spanner.us-central1.rep.googleapis.com", - "https://SPANNER.GOOGLEAPIS.COM/", - "spanner.googleapis.com" - ]) - func trustedEndpoints(endpoint: String) throws { - let settings = try SpannerConnectionSettings.parse(fields: fields(endpoint: endpoint)) - #expect(settings.endpoint.scheme == "https") - } - - @Test("Production auth refuses endpoints that are not https googleapis.com", arguments: [ - "http://spanner.googleapis.com", - "https://spanner.googleapis.com.evil.example", - "https://evilgoogleapis.com", - "https://localhost:9020", - "http://localhost:9020" - ]) - func untrustedEndpoints(endpoint: String) { - #expect(throws: SpannerConfigurationError.untrustedEndpoint) { - try SpannerConnectionSettings.parse(fields: fields(endpoint: endpoint, method: "oauth")) - } - } - - @Test("Credentials, queries, fragments and paths in the endpoint are refused", arguments: [ - "https://user:pw@spanner.googleapis.com", - "https://spanner.googleapis.com?x=1", - "https://spanner.googleapis.com#x", - "https://spanner.googleapis.com/v1/projects", - "ftp://spanner.googleapis.com", - "https://" - ]) - func malformedEndpoints(endpoint: String) { - #expect(throws: SpannerConfigurationError.invalidEndpoint) { - try SpannerConnectionSettings.parse(fields: fields(endpoint: endpoint)) - } - } - - @Test("The emulator accepts loopback endpoints over http or https", arguments: [ - "http://localhost:9020", - "https://127.0.0.1:9020", - "http://[::1]:9020", - "localhost:9020" - ]) - func emulatorLoopback(endpoint: String) throws { - let settings = try SpannerConnectionSettings.parse(fields: fields(endpoint: endpoint, method: "emulator")) - #expect(settings.authMethod == .emulator) - #expect(settings.endpoint.host == "localhost" || settings.endpoint.host == "127.0.0.1" || settings.endpoint.host == "::1") - } - - @Test("The emulator refuses a remote endpoint", arguments: [ - "https://spanner.googleapis.com", - "http://10.0.0.5:9020", - "http://localhost.evil.example:9020" - ]) - func emulatorRemote(endpoint: String) { - #expect(throws: SpannerConfigurationError.emulatorRequiresLoopback) { - try SpannerConnectionSettings.parse(fields: fields(endpoint: endpoint, method: "emulator")) - } - } - - @Test("The emulator needs an endpoint") - func emulatorNeedsEndpoint() { - #expect(throws: SpannerConfigurationError.missingField(Key.endpoint)) { - try SpannerConnectionSettings.parse(fields: fields(method: "emulator")) - } - } - - @Test("Resource URLs percent-encode each segment and keep the custom verb") - func resourceURLs() throws { - let settings = try SpannerConnectionSettings.parse(fields: fields(project: "example.com:proj")) - let database = try settings.databaseURL(suffix: "sessions") - #expect(database.absoluteString - == "https://spanner.googleapis.com/v1/projects/example.com%3Aproj/instances/inst/databases/gdb/sessions") - let session = try settings.resourceURL("projects/p/instances/i/databases/d/sessions/a b", verb: "executeSql") - #expect(session.absoluteString - == "https://spanner.googleapis.com/v1/projects/p/instances/i/databases/d/sessions/a%20b:executeSql") - } - - @Test("Server resource names with dot segments or empty segments are refused", arguments: [ - "projects/p/../x", "projects/p/./x", "projects//p", "", "/projects/p" - ]) - func rejectsDotSegments(name: String) throws { - let settings = try SpannerConnectionSettings.parse(fields: fields()) - #expect(throws: SpannerTransportError.invalidResponse) { - try settings.resourceURL(name) - } - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerDDLCatalogTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerDDLCatalogTests.swift deleted file mode 100644 index bc5497256b..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerDDLCatalogTests.swift +++ /dev/null @@ -1,256 +0,0 @@ -import Foundation -import TableProSpannerCore -import Testing - -@Suite("SpannerDDLCatalog") -struct SpannerDDLCatalogTests { - private static let googleSQLDatabase = [ - "CREATE SCHEMA sales", - "CREATE SEQUENCE seq OPTIONS (\n sequence_kind = 'bit_reversed_positive')", - """ - CREATE TABLE Singers ( - Id INT64 NOT NULL, - Name STRING(MAX), - Age INT64, - Active BOOL, - Photo BYTES(MAX), - Tags ARRAY, - Born DATE, - Updated TIMESTAMP, - Score NUMERIC, - Doc JSON, - Flags ARRAY, - ) PRIMARY KEY(Id) - """, - "CREATE INDEX SingersByName ON Singers(Name) STORING (Age)", - "CREATE TABLE Docs (\n Tenant STRING(36) NOT NULL,\n K BYTES(16) NOT NULL,\n V STRING(MAX),\n) PRIMARY KEY(Tenant, K)", - "CREATE TABLE Parent (\n A INT64 NOT NULL,\n B INT64 NOT NULL,\n) PRIMARY KEY(A, B)", - """ - CREATE TABLE Child ( - Id INT64 NOT NULL, - X INT64, - Y INT64, - CONSTRAINT FK_XY FOREIGN KEY(X, Y) REFERENCES Parent(A, B), - ) PRIMARY KEY(Id) - """, - """ - CREATE TABLE Albums ( - Id INT64 NOT NULL, - AlbumId INT64 NOT NULL, - Title STRING(MAX), - ) PRIMARY KEY(Id, AlbumId), - INTERLEAVE IN PARENT Singers ON DELETE CASCADE - """, - "CREATE TABLE sales.Orders (\n Id INT64 NOT NULL,\n Amt FLOAT64,\n) PRIMARY KEY(Id)", - """ - CREATE TABLE T2 ( - Id INT64 NOT NULL, - F FLOAT64, - AI ARRAY, - AB ARRAY, - B BYTES(MAX), - D STRING(MAX) DEFAULT ('dflt'), - G STRING(MAX) AS (CONCAT(D, '!')) STORED, - J JSON, - ) PRIMARY KEY(Id) - """, - "CREATE TABLE T3 (\n Id INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE),\n V STRING(MAX),\n) PRIMARY KEY(Id)", - "CREATE VIEW SingerNames SQL SECURITY INVOKER AS SELECT Singers.Id, Singers.Name FROM Singers" - ] - - private static let postgreSQLDatabase = [ - "CREATE SCHEMA sales", - """ - CREATE TABLE singers ( - id bigint NOT NULL, - name character varying, - active boolean, - photo bytea, - tags character varying[], - doc jsonb, - score numeric, - updated timestamp with time zone, - born date, - PRIMARY KEY(id) - ) - """, - "CREATE TABLE sales.orders (\n id bigint NOT NULL,\n amt double precision,\n PRIMARY KEY(id)\n)", - "CREATE TABLE parent (\n a bigint NOT NULL,\n b bigint NOT NULL,\n PRIMARY KEY(a, b)\n)", - """ - CREATE TABLE child ( - id bigint NOT NULL, - x bigint, - y bigint, - PRIMARY KEY(id), - CONSTRAINT fk_xy FOREIGN KEY (x, y) REFERENCES parent(a, b) - ) - """ - ] - - private static let richGoogleSQLDatabase = [ - "CREATE SCHEMA sales", - """ - CREATE TABLE Singers ( - Id INT64 NOT NULL, - Name STRING(MAX), - Age INT64, - Active BOOL, - Photo BYTES(MAX), - Tags ARRAY, - Born DATE, - Updated TIMESTAMP, - Score NUMERIC, - Doc JSON, - F FLOAT64, - ) PRIMARY KEY(Id) - """, - "CREATE UNIQUE NULL_FILTERED INDEX SingersByAgeName ON Singers(Age DESC, Name)", - "CREATE INDEX SingersByName ON Singers(Name) STORING (Age)", - """ - CREATE TABLE Songs ( - Id INT64 NOT NULL, - AlbumId INT64 NOT NULL, - TrackId INT64 NOT NULL, - Name STRING(MAX), - ) PRIMARY KEY(Id, AlbumId, TrackId), - INTERLEAVE IN PARENT Albums ON DELETE NO ACTION - """, - """ - CREATE TABLE sales.Orders ( - Id INT64 NOT NULL, - Amt FLOAT64, - SingerId INT64, - CONSTRAINT FK_OrderSinger FOREIGN KEY(SingerId) REFERENCES Singers(Id), - ) PRIMARY KEY(Id) - """, - "CREATE INDEX sales.OrdersByAmt ON sales.Orders(Amt)", - """ - CREATE TABLE sales.Items ( - Id INT64 NOT NULL, - Line INT64 NOT NULL, - Sku STRING(MAX), - ) PRIMARY KEY(Id, Line), - INTERLEAVE IN PARENT sales.Orders ON DELETE CASCADE - """, - "CREATE VIEW SingerNames SQL SECURITY INVOKER AS SELECT Singers.Id, Singers.Name FROM Singers", - "CREATE VIEW sales.BigOrders SQL SECURITY INVOKER AS SELECT o.Id FROM sales.Orders AS o WHERE o.Amt > 100" - ] - - private static let richPostgreSQLDatabase = [ - "CREATE SCHEMA sales", - """ - CREATE TABLE singers ( - id bigint NOT NULL, - name character varying, - age bigint, - active boolean, - photo bytea, - tags character varying[], - doc jsonb, - score numeric, - updated timestamp with time zone, - born date, - f double precision, - PRIMARY KEY(id) - ) - """, - """ - CREATE TABLE sales.orders ( - id bigint NOT NULL, - amt double precision, - singer_id bigint, - PRIMARY KEY(id), - CONSTRAINT fk_order_singer FOREIGN KEY (singer_id) REFERENCES singers(id) - ) - """, - "CREATE TABLE \"MixedCase\" (\n \"Id\" bigint NOT NULL,\n \"Val\" character varying,\n PRIMARY KEY(\"Id\")\n)", - "CREATE UNIQUE INDEX singers_by_age_name ON singers (age DESC, name) WHERE (age IS NOT NULL AND name IS NOT NULL)", - "CREATE INDEX singers_by_name ON singers (name) INCLUDE (age)", - "CREATE INDEX orders_by_amt ON sales.orders (amt)", - "CREATE VIEW singer_names WITH (security_invoker) AS SELECT id, name FROM singers" - ] - - @Test("GoogleSQL tables resolve by exact schema and name") - func googleSQLTables() { - let catalog = SpannerDDLCatalog(statements: Self.googleSQLDatabase, dialect: .googleSQL) - #expect(catalog.tableDDL(schema: "", name: "Singers") == Self.googleSQLDatabase[2]) - #expect(catalog.tableDDL(schema: "", name: "Child") == Self.googleSQLDatabase[6]) - #expect(catalog.tableDDL(schema: "", name: "Albums") == Self.googleSQLDatabase[7]) - #expect(catalog.tableDDL(schema: "sales", name: "Orders") == Self.googleSQLDatabase[8]) - #expect(catalog.tableDDL(schema: "", name: "Orders") == nil) - #expect(catalog.tableDDL(schema: "", name: "singers") == nil) - #expect(catalog.tableDDL(schema: "", name: "Sing") == nil) - #expect(catalog.tableDDL(schema: "", name: "SingerNames") == nil) - } - - @Test("GoogleSQL indexes and views") - func googleSQLIndexesAndViews() { - let catalog = SpannerDDLCatalog(statements: Self.googleSQLDatabase, dialect: .googleSQL) - #expect(catalog.indexDDL(schema: "", table: "Singers") == ["CREATE INDEX SingersByName ON Singers(Name) STORING (Age)"]) - #expect(catalog.indexDDL(schema: "", table: "Child").isEmpty) - #expect(catalog.viewDDL(schema: "", name: "SingerNames") == Self.googleSQLDatabase[11]) - #expect(catalog.viewDDL(schema: "", name: "Singers") == nil) - } - - @Test("PostgreSQL tables live in public unless qualified") - func postgreSQLTables() { - let catalog = SpannerDDLCatalog(statements: Self.postgreSQLDatabase, dialect: .postgreSQL) - #expect(catalog.tableDDL(schema: "public", name: "singers") == Self.postgreSQLDatabase[1]) - #expect(catalog.tableDDL(schema: "sales", name: "orders") == Self.postgreSQLDatabase[2]) - #expect(catalog.tableDDL(schema: "public", name: "child") == Self.postgreSQLDatabase[4]) - #expect(catalog.tableDDL(schema: "", name: "singers") == nil) - #expect(catalog.tableDDL(schema: "public", name: "orders") == nil) - } - - @Test("Every index modifier and a named schema") - func richGoogleSQL() { - let catalog = SpannerDDLCatalog(statements: Self.richGoogleSQLDatabase, dialect: .googleSQL) - #expect(catalog.indexDDL(schema: "", table: "Singers") == [ - "CREATE UNIQUE NULL_FILTERED INDEX SingersByAgeName ON Singers(Age DESC, Name)", - "CREATE INDEX SingersByName ON Singers(Name) STORING (Age)" - ]) - #expect(catalog.indexDDL(schema: "sales", table: "Orders") == ["CREATE INDEX sales.OrdersByAmt ON sales.Orders(Amt)"]) - #expect(catalog.tableDDL(schema: "sales", name: "Items") == Self.richGoogleSQLDatabase[7]) - #expect(catalog.tableDDL(schema: "", name: "Songs") == Self.richGoogleSQLDatabase[4]) - #expect(catalog.viewDDL(schema: "sales", name: "BigOrders") == Self.richGoogleSQLDatabase[9]) - } - - @Test("PostgreSQL quoted names, trailing indexes and view options") - func richPostgreSQL() { - let catalog = SpannerDDLCatalog(statements: Self.richPostgreSQLDatabase, dialect: .postgreSQL) - #expect(catalog.tableDDL(schema: "public", name: "MixedCase") == Self.richPostgreSQLDatabase[3]) - #expect(catalog.tableDDL(schema: "public", name: "mixedcase") == nil) - #expect(catalog.indexDDL(schema: "public", table: "singers") == [Self.richPostgreSQLDatabase[4], Self.richPostgreSQLDatabase[5]]) - #expect(catalog.indexDDL(schema: "sales", table: "orders") == ["CREATE INDEX orders_by_amt ON sales.orders (amt)"]) - #expect(catalog.viewDDL(schema: "public", name: "singer_names") == Self.richPostgreSQLDatabase[7]) - } - - @Test("Quoted GoogleSQL paths, IF NOT EXISTS and OR REPLACE") - func quotedPaths() { - let statements = [ - "CREATE TABLE IF NOT EXISTS `my table` (\n a INT64,\n) PRIMARY KEY(a)", - "CREATE TABLE `sales`.`Or\\`ders` (\n Id INT64,\n) PRIMARY KEY(Id)", - "CREATE INDEX IF NOT EXISTS Idx ON `my table`(a)", - "CREATE OR REPLACE VIEW `sales`.V SQL SECURITY INVOKER AS SELECT 1 AS x", - "-- leading comment\nCREATE TABLE Commented (\n Id INT64,\n) PRIMARY KEY(Id)" - ] - let catalog = SpannerDDLCatalog(statements: statements, dialect: .googleSQL) - #expect(catalog.tableDDL(schema: "", name: "my table") == statements[0]) - #expect(catalog.tableDDL(schema: "sales", name: "Or`ders") == statements[1]) - #expect(catalog.indexDDL(schema: "", table: "my table") == [statements[2]]) - #expect(catalog.viewDDL(schema: "sales", name: "V") == statements[3]) - #expect(catalog.tableDDL(schema: "", name: "Commented") == statements[4]) - } - - @Test("Statements that are not tables, indexes or views are ignored") - func ignoresOtherStatements() { - let catalog = SpannerDDLCatalog( - statements: ["CREATE SCHEMA s", "CREATE SEQUENCE q OPTIONS (sequence_kind = 'bit_reversed_positive')", - "ALTER TABLE t ADD COLUMN c INT64", "CREATE ROLE r", ""], - dialect: .googleSQL - ) - #expect(catalog.tableDDL(schema: "", name: "t") == nil) - #expect(catalog.tableDDL(schema: "", name: "s") == nil) - #expect(catalog.viewDDL(schema: "", name: "q") == nil) - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerDialectTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerDialectTests.swift deleted file mode 100644 index ccdcd8c8be..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerDialectTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Foundation -import TableProGoogleCloud -import TableProSpannerCore -import Testing - -@Suite("SpannerDialect") -struct SpannerDialectTests { - @Test("The database dialect string picks the dialect") - func fromDatabaseDialect() { - #expect(SpannerDialect(databaseDialect: "POSTGRESQL") == .postgreSQL) - #expect(SpannerDialect(databaseDialect: "postgresql") == .postgreSQL) - #expect(SpannerDialect(databaseDialect: "GOOGLE_STANDARD_SQL") == .googleSQL) - #expect(SpannerDialect(databaseDialect: "DATABASE_DIALECT_UNSPECIFIED") == .googleSQL) - #expect(SpannerDialect(databaseDialect: nil) == .googleSQL) - } - - @Test("Per-dialect constants") - func constants() { - #expect(SpannerDialect.googleSQL.defaultSchema == "") - #expect(SpannerDialect.postgreSQL.defaultSchema == "public") - #expect(SpannerDialect.googleSQL.textCastType == "STRING") - #expect(SpannerDialect.postgreSQL.textCastType == "TEXT") - #expect(SpannerDialect.googleSQL.placeholder(3) == "@p3") - #expect(SpannerDialect.postgreSQL.placeholder(3) == "$3") - #expect(SpannerDialect.googleSQL.placeholderLexicon == .googleSQL) - #expect(SpannerDialect.postgreSQL.placeholderLexicon == .postgreSQL) - } - - @Test("GoogleSQL identifiers use backticks with backslash escapes") - func googleIdentifiers() { - let dialect = SpannerDialect.googleSQL - #expect(dialect.quoteIdentifier("Singers") == "`Singers`") - #expect(dialect.quoteIdentifier("a`b") == #"`a\`b`"#) - #expect(dialect.quoteIdentifier(#"trailing\"#) == #"`trailing\\`"#) - #expect(dialect.quoteIdentifier("x` OR TRUE --") == #"`x\` OR TRUE --`"#) - } - - @Test("PostgreSQL identifiers use doubled double quotes") - func postgresIdentifiers() { - let dialect = SpannerDialect.postgreSQL - #expect(dialect.quoteIdentifier("singers") == "\"singers\"") - #expect(dialect.quoteIdentifier("a\"b") == "\"a\"\"b\"") - #expect(dialect.quoteIdentifier(#"trailing\"#) == #""trailing\""#) - #expect(dialect.quoteIdentifier("a`b") == "\"a`b\"") - } - - @Test("GoogleSQL strings use backslash escapes") - func googleStrings() { - let dialect = SpannerDialect.googleSQL - #expect(dialect.quotedString(#"x\' OR TRUE --"#) == #"'x\\\' OR TRUE --'"#) - #expect(dialect.quotedString("a\nb\u{0}") == #"'a\nb\x00'"#) - #expect(dialect.escapedStringBody("O'Brien") == #"O\'Brien"#) - } - - @Test("PostgreSQL strings double quotes, keep backslashes and drop NUL") - func postgresStrings() { - let dialect = SpannerDialect.postgreSQL - #expect(dialect.quotedString(#"O'Brien\"#) == #"'O''Brien\'"#) - #expect(dialect.quotedString(#"x\' OR TRUE --"#) == #"'x\'' OR TRUE --'"#) - #expect(dialect.quotedString("a\u{0}b") == "'ab'") - #expect(dialect.quotedString("a\nb") == "'a\nb'") - #expect(dialect.escapedStringBody("it's") == "it''s") - } - - @Test("The default schema is written unqualified") - func qualifiedNames() { - #expect(SpannerDialect.googleSQL.qualifiedName(schema: "", name: "Singers") == "`Singers`") - #expect(SpannerDialect.googleSQL.qualifiedName(schema: "sales", name: "Orders") == "`sales`.`Orders`") - #expect(SpannerDialect.postgreSQL.qualifiedName(schema: "", name: "singers") == "\"singers\"") - #expect(SpannerDialect.postgreSQL.qualifiedName(schema: "public", name: "singers") == "\"singers\"") - #expect(SpannerDialect.postgreSQL.qualifiedName(schema: "sales", name: "orders") == "\"sales\".\"orders\"") - } - - @Test("System schemas match case-insensitively") - func systemSchemas() { - for dialect in [SpannerDialect.googleSQL, .postgreSQL] { - #expect(dialect.isSystemSchema("INFORMATION_SCHEMA")) - #expect(dialect.isSystemSchema("information_schema")) - #expect(dialect.isSystemSchema("SPANNER_SYS")) - #expect(dialect.isSystemSchema("spanner_sys")) - #expect(dialect.isSystemSchema("pg_catalog")) - #expect(!dialect.isSystemSchema("")) - #expect(!dialect.isSystemSchema("public")) - #expect(!dialect.isSystemSchema("sales")) - } - } - - @Test("The dialect round-trips through Codable") - func codable() throws { - let data = try JSONEncoder().encode(SpannerDialect.postgreSQL) - #expect(try JSONDecoder().decode(SpannerDialect.self, from: data) == .postgreSQL) - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerExecutorEmulatorTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerExecutorEmulatorTests.swift deleted file mode 100644 index aec0fcc951..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerExecutorEmulatorTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation -@testable import TableProSpannerCore -import Testing - -@Suite( - "SpannerExecutor against the emulator", - .enabled(if: SpannerEmulatorEnvironment.host != nil), - .serialized -) -struct SpannerExecutorEmulatorTests { - private func makeExecutor(database: String) async throws -> SpannerExecutor { - let settings = try SpannerConnectionSettings.parse(fields: [ - SpannerConnectionSettings.FieldKey.projectId: SpannerEmulatorEnvironment.value("SPANNER_EMULATOR_PROJECT") ?? "proj", - SpannerConnectionSettings.FieldKey.instanceId: SpannerEmulatorEnvironment.value("SPANNER_EMULATOR_INSTANCE") ?? "inst", - SpannerConnectionSettings.FieldKey.databaseId: database, - SpannerConnectionSettings.FieldKey.endpoint: SpannerEmulatorEnvironment.host ?? "", - SpannerConnectionSettings.FieldKey.authMethod: SpannerAuthMethod.emulator.rawValue - ]) - let transport = URLSessionSpannerTransport(requestTimeout: { 60 }) - let client = SpannerRESTClient(settings: settings, transport: transport, tokenProvider: nil) - let dialect = SpannerDialect(databaseDialect: try await client.databaseDialect()) - return SpannerExecutor(client: client, dialect: dialect, ddlDeadline: { .seconds(60) }) - } - - @Test("GoogleSQL: DDL, typed DML through placeholders, transactions, EXPLAIN and reads") - func googleSQLRoundTrip() async throws { - let executor = try await makeExecutor(database: "gdb") - defer { Task { await executor.shutdown() } } - _ = try? await executor.run("DROP TABLE exec_probe_g", hostParameters: nil) - _ = try await executor.run( - """ - CREATE TABLE exec_probe_g (Id INT64 NOT NULL, Name STRING(MAX), Active BOOL, Score FLOAT64, - Tags ARRAY, Photo BYTES(MAX), Note STRING(MAX)) PRIMARY KEY (Id) - """, - hostParameters: nil - ) - - let inserted = try await executor.run( - "INSERT INTO exec_probe_g (Id, Name, Active, Score, Tags, Photo, Note) VALUES (?, ?, ?, ?, ?, ?, ?)", - hostParameters: [ - .text("1"), .text("O'Brien \\ 'quoted' ?"), .text("true"), .text("1.5"), .text("[1, 2]"), - .bytes(Data([0, 255])), .null - ] - ) - #expect(inserted.rowsAffected == 1) - - let read = try await executor.run( - "SELECT Name, Active, Score, Tags, Photo, Note FROM exec_probe_g WHERE Id = ?", - hostParameters: [.text("1")] - ) - #expect(read.rows == [[ - .text("O'Brien \\ 'quoted' ?"), .text("true"), .text("1.5"), .text("[1,2]"), .bytes(Data([0, 255])), .null - ]]) - - _ = try await executor.run("BEGIN", hostParameters: nil) - _ = try await executor.run("UPDATE exec_probe_g SET Name = ? WHERE Id = ?", hostParameters: [.text("rolled"), .text("1")]) - _ = try await executor.run("ROLLBACK", hostParameters: nil) - let afterRollback = try await executor.run("SELECT Name FROM exec_probe_g WHERE Id = 1", hostParameters: nil) - #expect(afterRollback.rows == [[.text("O'Brien \\ 'quoted' ?")]]) - - try await executor.beginTransaction() - _ = try await executor.run("UPDATE exec_probe_g SET Name = ? WHERE Id = ?", hostParameters: [.text("kept"), .text("1")]) - try await executor.commitTransaction() - - let plan = try await executor.run("EXPLAIN DELETE FROM exec_probe_g WHERE TRUE", hostParameters: nil) - #expect(plan.fields.map(\.name) == [SpannerPlanRenderer.columnName]) - await #expect(throws: SpannerExecutionError.self) { - _ = try await executor.run("EXPLAIN DROP TABLE exec_probe_g", hostParameters: nil) - } - let remaining = try await executor.read(SpannerRenderedStatement(sql: "SELECT Name FROM exec_probe_g")) - #expect(remaining == [[.text("kept")]]) - - _ = try await executor.run("DROP TABLE exec_probe_g", hostParameters: nil) - } - - @Test("PostgreSQL dialect: $n placeholders, typed DML and EXPLAIN") - func postgreSQLRoundTrip() async throws { - let executor = try await makeExecutor(database: "pg") - defer { Task { await executor.shutdown() } } - #expect(executor.dialect == .postgreSQL) - _ = try? await executor.run("DROP TABLE exec_probe_p", hostParameters: nil) - _ = try await executor.run( - "CREATE TABLE exec_probe_p (id bigint NOT NULL PRIMARY KEY, name varchar, active boolean, doc jsonb)", - hostParameters: nil - ) - - _ = try await executor.run( - "INSERT INTO exec_probe_p (id, name, active, doc) VALUES (?, ?, ?, ?)", - hostParameters: [.text("7"), .text("O'Brien\\"), .text("false"), .text(#"{"a": 1}"#)] - ) - let read = try await executor.run("SELECT name, active FROM exec_probe_p WHERE id = ?", hostParameters: [.text("7")]) - #expect(read.rows == [[.text("O'Brien\\"), .text("false")]]) - - let plan = try await executor.run("EXPLAIN DELETE FROM exec_probe_p", hostParameters: nil) - #expect(!plan.rows.isEmpty) - let count = try await executor.read(SpannerRenderedStatement(sql: "SELECT COUNT(*) FROM exec_probe_p")) - #expect(count == [[.text("1")]]) - - _ = try await executor.run("DROP TABLE exec_probe_p", hostParameters: nil) - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerExecutorTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerExecutorTests.swift deleted file mode 100644 index 29725c59fb..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerExecutorTests.swift +++ /dev/null @@ -1,425 +0,0 @@ -import Foundation -@testable import TableProSpannerCore -import Testing - -@Suite("Spanner executor EXPLAIN") -struct SpannerExecutorExplainTests { - @Test("EXPLAIN DELETE plans in a throwaway transaction, rolls it back and never commits") - func explainDeleteNeverCommits() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - let outcome = try await executor.run("EXPLAIN DELETE FROM Singers WHERE TRUE", hostParameters: nil) - - let plans = server.calls(verb: "executeSql") - #expect(plans.count == 1) - #expect(plans.first?.queryMode == "PLAN") - #expect(plans.first?.transaction == "begin") - #expect(plans.first?.seqno == "1") - #expect(plans.first?.sql == "DELETE FROM Singers WHERE TRUE") - #expect(server.calls(verb: "rollback").count == 1) - #expect(server.calls(verb: "commit").isEmpty) - #expect(outcome.fields.map(\.name) == [SpannerPlanRenderer.columnName]) - #expect(outcome.rows == [[.text("Distributed Union")]]) - } - - @Test("EXPLAIN of DML inside an open transaction uses its id and the next seqno") - func explainInsideTransaction() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - try await executor.beginTransaction() - _ = try await executor.run("UPDATE Singers SET Age = 1 WHERE Id = 1", hostParameters: nil) - _ = try await executor.run("EXPLAIN UPDATE Singers SET Age = 2 WHERE Id = 1", hostParameters: nil) - - let calls = server.calls(verb: "executeSql") - #expect(calls.map(\.transaction) == ["tx1", "tx1"]) - #expect(calls.map(\.seqno) == ["1", "2"]) - #expect(calls.last?.queryMode == "PLAN") - #expect(server.calls(verb: "rollback").isEmpty) - } - - @Test("EXPLAIN of DDL or transaction control is refused before any request") - func explainDDLRefused() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - for sql in ["EXPLAIN DROP TABLE Singers", "EXPLAIN CREATE INDEX i ON t(c)", "EXPLAIN BEGIN", "EXPLAIN COMMIT"] { - await #expect(throws: SpannerExecutionError.self) { - _ = try await executor.run(sql, hostParameters: nil) - } - } - #expect(server.recorded.isEmpty) - } - - @Test("EXPLAIN ANALYZE is refused because it would run the statement") - func explainAnalyzeRefused() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - await #expect(throws: SpannerExecutionError.explainAnalyzeNotSupported) { - _ = try await executor.run("EXPLAIN ANALYZE DELETE FROM Singers WHERE TRUE", hostParameters: nil) - } - #expect(server.recorded.isEmpty) - } - - @Test("EXPLAIN of a query plans on the read session with a single-use read") - func explainQuery() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - _ = try await executor.run("-- note\nEXPLAIN SELECT * FROM Singers", hostParameters: nil) - - let call = try #require(server.calls(verb: "executeSql").first) - #expect(call.queryMode == "PLAN") - #expect(call.transaction == "singleUse") - #expect(call.seqno == nil) - } -} - -@Suite("Spanner executor transactions") -struct SpannerExecutorTransactionTests { - @Test("BEGIN twice is refused") - func beginTwice() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - _ = try await executor.run("BEGIN", hostParameters: nil) - await #expect(throws: SpannerExecutionError.transactionAlreadyOpen) { - try await executor.beginTransaction() - } - #expect(server.calls(verb: "beginTransaction").count == 1) - } - - @Test("COMMIT without a transaction throws and ROLLBACK is a no-op") - func commitWithoutTransaction() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - await #expect(throws: SpannerExecutionError.noTransactionOpen) { - _ = try await executor.run("COMMIT", hostParameters: nil) - } - _ = try await executor.run("ROLLBACK", hostParameters: nil) - #expect(server.recorded.isEmpty) - } - - @Test("A failed COMMIT leaves no transaction behind") - func failedCommitLeavesIdle() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - server.failNext("commit", with: .json(#"{"error":{"code":500,"status":"INTERNAL","message":"boom"}}"#, status: 500)) - - try await executor.beginTransaction() - _ = try await executor.run("UPDATE Singers SET Age = 1 WHERE Id = 1", hostParameters: nil) - await #expect(throws: SpannerAPIError.self) { - try await executor.commitTransaction() - } - #expect(await executor.hasOpenTransaction == false) - - _ = try await executor.run("UPDATE Singers SET Age = 2 WHERE Id = 1", hostParameters: nil) - #expect(server.calls(verb: "executeSql").last?.transaction == "begin") - } - - @Test("ABORTED inside a transaction refuses later statements until ROLLBACK") - func abortedRefusesUntilRollback() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - server.failNext("executeSql", with: .json(SpannerFakeServer.abortedBody, status: 409)) - - try await executor.beginTransaction() - await #expect(throws: SpannerAPIError.self) { - _ = try await executor.run("UPDATE Singers SET Age = 1 WHERE Id = 1", hostParameters: nil) - } - await #expect(throws: SpannerExecutionError.transactionAborted) { - _ = try await executor.run("UPDATE Singers SET Age = 2 WHERE Id = 1", hostParameters: nil) - } - await #expect(throws: SpannerExecutionError.transactionAborted) { - try await executor.commitTransaction() - } - try await executor.rollbackTransaction() - _ = try await executor.run("UPDATE Singers SET Age = 3 WHERE Id = 1", hostParameters: nil) - #expect(server.calls(verb: "executeSql").last?.transaction == "begin") - } - - @Test("Autocommit DML retries the whole unit when the commit is aborted") - func autocommitRetriesAborted() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - server.failNext("commit", with: .json(SpannerFakeServer.abortedBody, status: 409)) - - let outcome = try await executor.run("DELETE FROM Singers WHERE Id = 1", hostParameters: nil) - - #expect(outcome.rowsAffected == 3) - #expect(server.calls(verb: "commit").count == 2) - #expect(server.calls(verb: "executeSql").count == 2) - } - - @Test("Concurrent statements in a transaction get strictly increasing seqnos in send order") - func seqnosStayOrdered() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - try await executor.beginTransaction() - - try await withThrowingTaskGroup(of: Void.self) { group in - for index in 0..<20 { - group.addTask { - _ = try await executor.run("UPDATE Singers SET Age = \(index) WHERE Id = 1", hostParameters: nil) - } - } - try await group.waitForAll() - } - - let seqnos = server.calls(verb: "executeSql").compactMap { $0.seqno.flatMap(Int.init) } - #expect(seqnos == Array(1...20)) - } - - @Test("BEGIN retries on a new session when the leased one was deleted") - func beginRetriesLostSession() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - server.failNext("beginTransaction", with: .json(SpannerFakeServer.sessionNotFoundBody, status: 404)) - - try await executor.beginTransaction() - - #expect(server.calls(verb: "beginTransaction").count == 2) - #expect(server.calls(verb: "createSession").count == 2) - #expect(await executor.hasOpenTransaction) - } -} - -@Suite("Spanner executor reads and parameters") -struct SpannerExecutorReadTests { - @Test("A read recreates the read session once when the server deleted it") - func readSessionRecreated() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - server.failNext("executeStreamingSql", with: .json(SpannerFakeServer.sessionNotFoundBody, status: 404)) - - let outcome = try await executor.run("SELECT v FROM t", hostParameters: nil) - - #expect(outcome.rows == [[.text("1")]]) - #expect(server.calls(verb: "createSession").count == 2) - #expect(server.calls(verb: "executeStreamingSql").count == 2) - } - - @Test("Parameter types are discovered once per statement text and always sent") - func discoveryIsMemoized() async throws { - let server = SpannerFakeServer() - server.undeclaredParameters = [("p1", "INT64")] - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - for _ in 0..<2 { - _ = try await executor.run("SELECT v FROM t WHERE v = ?", hostParameters: [.text("1")]) - } - - let plans = server.calls(verb: "executeSql").filter { $0.queryMode == "PLAN" } - #expect(plans.count == 1) - let reads = server.calls(verb: "executeStreamingSql") - #expect(reads.count == 2) - #expect(reads.allSatisfy { $0.paramTypeCodes == ["p1": "INT64"] && $0.sql == "SELECT v FROM t WHERE v = @p1" }) - } - - @Test("A schema change clears the parameter types learned before it") - func ddlClearsMemo() async throws { - let server = SpannerFakeServer() - server.undeclaredParameters = [("p1", "INT64")] - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - _ = try await executor.run("SELECT v FROM t WHERE v = ?", hostParameters: [.text("1")]) - _ = try await executor.run("ALTER TABLE t ADD COLUMN w INT64", hostParameters: nil) - _ = try await executor.run("SELECT v FROM t WHERE v = ?", hostParameters: [.text("1")]) - - #expect(server.calls(verb: "executeSql").filter { $0.queryMode == "PLAN" }.count == 2) - } - - @Test("Autocommit DML with parameters discovers types inside the transaction it commits") - func autocommitDiscovery() async throws { - let server = SpannerFakeServer() - server.undeclaredParameters = [("p1", "BOOL"), ("p2", "INT64")] - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - _ = try await executor.run( - "UPDATE Singers SET Active = ? WHERE Id = ?", - hostParameters: [.text("true"), .text("5")] - ) - - let calls = server.calls(verb: "executeSql") - #expect(calls.map(\.queryMode) == ["PLAN", "NORMAL"]) - #expect(calls.map(\.transaction) == ["begin", "tx1"]) - #expect(calls.map(\.seqno) == ["1", "2"]) - #expect(calls.last?.paramTypeCodes == ["p1": "BOOL", "p2": "INT64"]) - #expect(server.calls(verb: "commit").count == 1) - } - - @Test("A placeholder count that does not match the values is refused") - func placeholderMismatch() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - await #expect(throws: SpannerExecutionError.parameterCount(found: 2, expected: 1)) { - _ = try await executor.run("SELECT ? , ?", hostParameters: [.text("1")]) - } - #expect(server.recorded.isEmpty) - } - - @Test("Streaming a statement that is not a query runs it and emits an empty header") - func streamNonQuery() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - var outputs: [SpannerStreamOutput] = [] - for try await output in executor.stream("DELETE FROM Singers WHERE Id = 1", hostParameters: nil) { - outputs.append(output) - } - #expect(outputs == [.header([])]) - #expect(server.calls(verb: "commit").count == 1) - } -} - -@Suite("Spanner executor schema changes and shutdown") -struct SpannerExecutorLifecycleTests { - @Test("A schema change past its deadline reports it is still running") - func ddlDeadline() async throws { - let server = SpannerFakeServer() - server.operationNeverFinishes = true - let (executor, _) = try SpannerTestFixtures.executor(server: server, ddlDeadline: { .milliseconds(20) }) - - await #expect(throws: SpannerExecutionError.self) { - _ = try await executor.run("CREATE INDEX i ON t(c)", hostParameters: nil) - } - } - - @Test("Without a deadline the schema change is polled until done") - func ddlWithoutDeadline() async throws { - let server = SpannerFakeServer() - server.operationPollsUntilDone = 3 - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - _ = try await executor.run("CREATE INDEX i ON t(c)", hostParameters: nil) - - #expect(server.calls(verb: "operation").count == 3) - } - - @Test("Shutdown deletes regular sessions, keeps the multiplexed one, closes the client, then refuses work") - func shutdownOrder() async throws { - let server = SpannerFakeServer() - let (executor, transport) = try SpannerTestFixtures.executor(server: server) - _ = try await executor.run("SELECT 1", hostParameters: nil) - _ = try await executor.run("DELETE FROM t WHERE TRUE", hostParameters: nil) - - await executor.shutdown() - await executor.shutdown() - - let deletes = server.calls(verb: "deleteSession") - #expect(deletes.count == 1) - #expect(deletes.first?.path.hasSuffix("/sessions/s2") == true) - #expect(transport.closeCount == 1) - await #expect(throws: SpannerExecutionError.closed) { - _ = try await executor.run("SELECT 1", hostParameters: nil) - } - } -} - -@Suite("Spanner executor transaction ownership and unknown outcomes") -struct SpannerExecutorOwnershipTests { - @Test("A grid save during an editor BEGIN is refused and its rollback leaves the editor transaction open") - func hostCannotEndEditorTransaction() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - _ = try await executor.run("BEGIN", hostParameters: nil) - _ = try await executor.run("DELETE FROM Singers WHERE Id = 1", hostParameters: nil) - await #expect(throws: SpannerExecutionError.transactionAlreadyOpen) { - try await executor.beginTransaction() - } - try await executor.rollbackTransaction() - await #expect(throws: SpannerExecutionError.noTransactionOpen) { - try await executor.commitTransaction() - } - - #expect(await executor.hasOpenTransaction) - #expect(server.calls(verb: "rollback").isEmpty) - _ = try await executor.run("COMMIT", hostParameters: nil) - #expect(server.calls(verb: "commit").count == 1) - } - - @Test("An editor ROLLBACK can end a transaction the host began") - func statementEndsHostTransaction() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - - try await executor.beginTransaction() - _ = try await executor.run("ROLLBACK", hostParameters: nil) - - #expect(await executor.hasOpenTransaction == false) - #expect(server.calls(verb: "rollback").count == 1) - } - - @Test("A commit lost in transit reports an unknown outcome, and the rollback after it does not claim success") - func commitOutcomeUnknown() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - server.failNext("commit", with: .failing(.timedOut)) - server.failNext("commit", with: .failing(.timedOut)) - server.failNext("commit", with: .failing(.timedOut)) - server.failNext("commit", with: .failing(.timedOut)) - - try await executor.beginTransaction() - _ = try await executor.run("UPDATE Singers SET Age = 1 WHERE Id = 1", hostParameters: nil) - await #expect(throws: SpannerExecutionError.commitOutcomeUnknown) { - try await executor.commitTransaction() - } - await #expect(throws: SpannerExecutionError.commitOutcomeUnknown) { - try await executor.rollbackTransaction() - } - try await executor.rollbackTransaction() - #expect(await executor.hasOpenTransaction == false) - } - - @Test("A DML statement that times out inside a transaction aborts it, so COMMIT cannot save an unknown result") - func timedOutStatementAborts() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - for _ in 0..<4 { - server.failNext("executeSql", with: .failing(.timedOut)) - } - - try await executor.beginTransaction() - await #expect(throws: SpannerTransportError.timedOut) { - _ = try await executor.run("UPDATE Singers SET Age = 1 WHERE Id = 1", hostParameters: nil) - } - await #expect(throws: SpannerExecutionError.transactionAborted) { - try await executor.commitTransaction() - } - #expect(server.calls(verb: "commit").isEmpty) - } - - @Test("A lost pooled session discards every idle session so the retry runs on a new one") - func expiredPoolIsDropped() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - async let first = executor.run("DELETE FROM a WHERE TRUE", hostParameters: nil) - async let second = executor.run("DELETE FROM b WHERE TRUE", hostParameters: nil) - _ = try await (first, second) - let createdBefore = server.calls(verb: "createSession").count - server.failNext("executeSql", with: .json(SpannerFakeServer.sessionNotFoundBody, status: 404)) - - _ = try await executor.run("DELETE FROM c WHERE TRUE", hostParameters: nil) - - #expect(server.calls(verb: "createSession").count == createdBefore + 1) - } - - @Test("Cancelling a read does not turn it into a successful partial result") - func cancelledReadThrows() async throws { - let server = SpannerFakeServer() - let (executor, _) = try SpannerTestFixtures.executor(server: server) - let task = Task { - try await Task.sleep(for: .milliseconds(50)) - return try await executor.run("SELECT v FROM t", hostParameters: nil) - } - task.cancel() - await #expect(throws: (any Error).self) { - _ = try await task.value - } - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerFakeServer.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerFakeServer.swift deleted file mode 100644 index b36006a720..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerFakeServer.swift +++ /dev/null @@ -1,187 +0,0 @@ -import Foundation -@testable import TableProSpannerCore - -struct RecordedSpannerCall: Sendable { - let verb: String - let path: String - let body: [String: String] - let sql: String? - let queryMode: String? - let seqno: String? - let transaction: String - let paramTypeCodes: [String: String] -} - -final class SpannerFakeServer: @unchecked Sendable { - private let lock = NSLock() - private var calls: [RecordedSpannerCall] = [] - private var sessionCounter = 0 - private var transactionCounter = 0 - private var pendingFailures: [String: [StubSpannerResponse]] = [:] - private var operationPolls = 0 - - var undeclaredParameters: [(name: String, code: String)] = [] - var queryFields: [(name: String, code: String)] = [("v", "INT64")] - var queryRows: [[String]] = [["1"]] - var operationPollsUntilDone = 1 - var operationNeverFinishes = false - - var recorded: [RecordedSpannerCall] { - lock.withLock { calls } - } - - func calls(verb: String) -> [RecordedSpannerCall] { - recorded.filter { $0.verb == verb } - } - - func failNext(_ verb: String, with response: StubSpannerResponse) { - lock.withLock { pendingFailures[verb, default: []].append(response) } - } - - func makeTransport() -> StubSpannerTransport { - StubSpannerTransport(responder: { [self] request in self.respond(to: request) }) - } - - static let abortedBody = #"{"error":{"code":409,"status":"ABORTED","message":"Transaction was aborted."}}"# - static let sessionNotFoundBody = """ - {"code":5,"message":"Session not found: projects/proj/instances/inst/databases/gdb/sessions/9",\ - "details":[{"@type":"type.googleapis.com/google.rpc.ResourceInfo",\ - "resourceType":"type.googleapis.com/google.spanner.v1.Session"}]} - """ - - private func respond(to request: URLRequest) -> StubSpannerResponse { - let call = Self.record(request) - lock.withLock { calls.append(call) } - if let failure = lock.withLock({ () -> StubSpannerResponse? in - guard var queue = pendingFailures[call.verb], !queue.isEmpty else { return nil } - let next = queue.removeFirst() - pendingFailures[call.verb] = queue - return next - }) { - return failure - } - switch call.verb { - case "createSession": - return .json(#"{"name":"projects/proj/instances/inst/databases/gdb/sessions/\#(nextSession())"}"#) - case "executeSql": - return .json(executeSqlBody(call)) - case "executeStreamingSql": - return .stream([streamBody()]) - case "beginTransaction": - return .json(#"{"id":"\#(nextTransaction())"}"#) - case "updateDdl": - return .json(#"{"name":"projects/proj/instances/inst/databases/gdb/operations/op1","done":false}"#) - case "operation": - return .json(operationBody()) - default: - return .json("{}") - } - } - - private func nextSession() -> String { - lock.withLock { - sessionCounter += 1 - return "s\(sessionCounter)" - } - } - - private func nextTransaction() -> String { - lock.withLock { - transactionCounter += 1 - return "tx\(transactionCounter)" - } - } - - private func executeSqlBody(_ call: RecordedSpannerCall) -> String { - let transaction = call.transaction == "begin" ? #","transaction":{"id":"\#(nextTransaction())"}"# : "" - let undeclared = undeclaredParameters.map { #"{"name":"\#($0.name)","type":{"code":"\#($0.code)"}}"# } - .joined(separator: ",") - if call.queryMode == "PLAN" { - return """ - {"metadata":{"rowType":{"fields":[]}\(transaction),"undeclaredParameters":{"fields":[\(undeclared)]}},\ - "stats":{"queryPlan":{"planNodes":[{"index":0,"kind":"RELATIONAL","displayName":"Distributed Union"}]}}} - """ - } - return #"{"metadata":{"rowType":{"fields":[]}\#(transaction)},"stats":{"rowCountExact":"3"}}"# - } - - private func streamBody() -> String { - let fields = queryFields.map { #"{"name":"\#($0.name)","type":{"code":"\#($0.code)"}}"# }.joined(separator: ",") - let values = queryRows.flatMap { $0 }.map { #""\#($0)""# }.joined(separator: ",") - return #"{"result":{"metadata":{"rowType":{"fields":[\#(fields)]}},"values":[\#(values)]}}"# - } - - private func operationBody() -> String { - let done = lock.withLock { () -> Bool in - operationPolls += 1 - return !operationNeverFinishes && operationPolls >= operationPollsUntilDone - } - return #"{"name":"projects/proj/instances/inst/databases/gdb/operations/op1","done":\#(done)}"# - } - - private static func record(_ request: URLRequest) -> RecordedSpannerCall { - let path = request.url?.path ?? "" - let body = request.jsonBody ?? [:] - let transaction = body["transaction"] as? [String: Any] ?? [:] - let selector: String - if transaction["begin"] != nil { - selector = "begin" - } else if let identifier = transaction["id"] as? String { - selector = identifier - } else if transaction["singleUse"] != nil { - selector = "singleUse" - } else { - selector = "" - } - let paramTypes = (body["paramTypes"] as? [String: [String: Any]] ?? [:]).compactMapValues { $0["code"] as? String } - return RecordedSpannerCall( - verb: verb(method: request.httpMethod ?? "GET", path: path), - path: path, - body: body.compactMapValues { $0 as? String }, - sql: body["sql"] as? String, - queryMode: body["queryMode"] as? String, - seqno: body["seqno"] as? String, - transaction: selector, - paramTypeCodes: paramTypes - ) - } - - private static func verb(method: String, path: String) -> String { - if let colon = path.lastIndex(of: ":") { - return String(path[path.index(after: colon)...]) - } - switch method { - case "DELETE": - return "deleteSession" - case "PATCH": - return "updateDdl" - case "POST" where path.hasSuffix("/sessions"): - return "createSession" - case "GET" where path.contains("/operations/"): - return "operation" - case "GET" where path.hasSuffix("/ddl"): - return "getDdl" - default: - return method - } - } -} - -extension SpannerTestFixtures { - static func executor( - server: SpannerFakeServer, - dialect: SpannerDialect = .googleSQL, - ddlDeadline: @escaping @Sendable () -> Duration? = { nil } - ) throws -> (SpannerExecutor, StubSpannerTransport) { - let transport = server.makeTransport() - let client = try emulatorClient(transport: transport) - let executor = SpannerExecutor( - client: client, - dialect: dialect, - ddlDeadline: ddlDeadline, - abortedRetryDelays: [.zero, .zero, .zero, .zero], - schemaChangePollInterval: .milliseconds(1) - ) - return (executor, transport) - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerParameterEncoderTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerParameterEncoderTests.swift deleted file mode 100644 index b392e0787a..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerParameterEncoderTests.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Foundation -import Testing - -@testable import TableProSpannerCore - -@Suite("SpannerParameterEncoder") -struct SpannerParameterEncoderTests { - private static let bool = SpannerType(code: "BOOL") - private static let float64 = SpannerType(code: "FLOAT64") - private static let float32 = SpannerType(code: "FLOAT32") - private static let int64 = SpannerType(code: "INT64") - private static let bytes = SpannerType(code: "BYTES") - private static let json = SpannerType(code: "JSON") - - private static func array(_ element: SpannerType) -> SpannerType { - SpannerType(code: "ARRAY", arrayElementType: element) - } - - private func encode(_ cell: SpannerCell, _ type: SpannerType, index: Int = 1) throws -> SpannerJSONValue { - try SpannerParameterEncoder.encode(cell, as: type, index: index) - } - - @Test("NULL is sent as JSON null for every type", arguments: [ - SpannerType(code: "BOOL"), SpannerType(code: "STRUCT"), SpannerType(code: "ARRAY") - ]) - func nulls(type: SpannerType) throws { - #expect(try encode(.null, type) == .null) - } - - @Test("BOOL accepts true, false, 1 and 0 in any case") - func booleans() throws { - #expect(try encode(.text("TRUE"), Self.bool) == .bool(true)) - #expect(try encode(.text(" false "), Self.bool) == .bool(false)) - #expect(try encode(.text("1"), Self.bool) == .bool(true)) - #expect(try encode(.text("0"), Self.bool) == .bool(false)) - } - - @Test("BOOL refuses anything else and names the parameter") - func notBoolean() { - #expect(throws: SpannerParameterEncodingError.notBoolean(index: 3)) { - try encode(.text("yes"), Self.bool, index: 3) - } - } - - @Test("Floats are JSON numbers, and the special values are their canonical strings") - func floats() throws { - #expect(try encode(.text("1.5"), Self.float64) == .number(1.5)) - #expect(try encode(.text(" -2e3 "), Self.float32) == .number(-2_000)) - #expect(try encode(.text("nan"), Self.float64) == .string("NaN")) - #expect(try encode(.text("Infinity"), Self.float64) == .string("Infinity")) - #expect(try encode(.text("-inf"), Self.float32) == .string("-Infinity")) - } - - @Test("A float that does not parse is refused", arguments: ["abc", "", "1.5.5", "1e999"]) - func notNumber(text: String) { - #expect(throws: SpannerParameterEncodingError.notNumber(index: 2)) { - try encode(.text(text), Self.float64, index: 2) - } - } - - @Test("Exact and textual types are JSON strings", arguments: ["INT64", "NUMERIC", "STRING", "DATE", "TIMESTAMP", "JSON", "UUID"]) - func strings(code: String) throws { - #expect(try encode(.text("12"), SpannerType(code: code)) == .string("12")) - } - - @Test("Bytes are base64, and text for a BYTES column is taken as base64 as given") - func bytesValues() throws { - #expect(try encode(.bytes(Data([0x00, 0xFF])), Self.bytes) == .string("AP8=")) - #expect(try encode(.text("AP8="), Self.bytes) == .string("AP8=")) - } - - @Test("Bytes for a text column are sent as their UTF-8 text") - func bytesForText() throws { - #expect(try encode(.bytes(Data("hi".utf8)), SpannerType(code: "STRING")) == .string("hi")) - #expect(throws: SpannerParameterEncodingError.unsupportedType("STRING")) { - try encode(.bytes(Data([0xFF, 0xFE])), SpannerType(code: "STRING")) - } - } - - @Test("ARRAY numbers become decimal strings, strings stay strings, nulls stay null") - func int64Array() throws { - let encoded = try encode(.text(#"[1, "2", null, 9007199254740993]"#), Self.array(Self.int64)) - #expect(encoded == .list([.string("1"), .string("2"), .null, .string("9007199254740993")])) - } - - @Test("ARRAY and ARRAY coerce their elements") - func typedArrays() throws { - #expect(try encode(.text(#"[true, "false", 1]"#), Self.array(Self.bool)) == .list([.bool(true), .bool(false), .bool(true)])) - #expect(try encode(.text(#"[1.5, "NaN", 2]"#), Self.array(Self.float64)) == .list([.number(1.5), .string("NaN"), .number(2)])) - #expect(throws: SpannerParameterEncodingError.notBoolean(index: 4)) { - try encode(.text(#"["maybe"]"#), Self.array(Self.bool), index: 4) - } - #expect(throws: SpannerParameterEncodingError.notNumber(index: 4)) { - try encode(.text("[true]"), Self.array(Self.float64), index: 4) - } - } - - @Test("ARRAY strings are sent as given base64") - func bytesArray() throws { - #expect(try encode(.text(#"["YWI=", null]"#), Self.array(Self.bytes)) == .list([.string("YWI="), .null])) - } - - @Test("ARRAY sends each element as JSON text") - func jsonArray() throws { - let encoded = try encode(.text(#"[{"b":1,"a":[2]}, "x", 3]"#), Self.array(Self.json)) - #expect(encoded == .list([.string(#"{"a":[2],"b":1}"#), .string("x"), .string("3")])) - } - - @Test("Text that is not a JSON array is refused for an ARRAY", arguments: ["1,2", "[1,2", "\"[1]\"", "{\"a\":1}", ""]) - func notJSONArray(text: String) { - #expect(throws: SpannerParameterEncodingError.notJSONArray(index: 5)) { - try encode(.text(text), Self.array(Self.int64), index: 5) - } - } - - @Test("A nested value where a scalar element belongs is refused") - func nestedElement() { - #expect(throws: SpannerParameterEncodingError.notJSONArray(index: 1)) { - try encode(.text("[[1]]"), Self.array(Self.int64)) - } - } - - @Test("STRUCT parameters and arrays of structs are unsupported") - func structs() { - let structType = SpannerType(code: "STRUCT", structFields: [SpannerField(name: "a", type: Self.int64)]) - #expect(throws: SpannerParameterEncodingError.unsupportedType("STRUCT")) { - try encode(.text("{}"), structType) - } - #expect(throws: SpannerParameterEncodingError.unsupportedType("STRUCT")) { - try encode(.text("[{}]"), Self.array(structType)) - } - } - - @Test("An array type with no element type is unsupported") - func arrayWithoutElement() { - #expect(throws: SpannerParameterEncodingError.unsupportedType("ARRAY")) { - try encode(.text("[1]"), SpannerType(code: "ARRAY")) - } - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerPartialResultAssemblerTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerPartialResultAssemblerTests.swift deleted file mode 100644 index cb8eb4a24d..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerPartialResultAssemblerTests.swift +++ /dev/null @@ -1,152 +0,0 @@ -import Foundation -import Testing - -@testable import TableProSpannerCore - -@Suite("SpannerPartialResultAssembler") -struct SpannerPartialResultAssemblerTests { - private static func metadata(_ names: [String]) -> SpannerResultSetMetadata { - SpannerResultSetMetadata(fields: names.map { SpannerField(name: $0, type: SpannerType(code: "STRING")) }) - } - - @Test("Values are cut into rows of the field count across messages") - func rowCutting() throws { - var assembler = SpannerPartialResultAssembler() - let first = try assembler.consume(SpannerPartialResultSet( - metadata: Self.metadata(["a", "b"]), - values: [.string("1"), .string("x"), .string("2")] - )) - #expect(first == [[.string("1"), .string("x")]]) - let second = try assembler.consume(SpannerPartialResultSet(values: [.string("y")])) - #expect(second == [[.string("2"), .string("y")]]) - try assembler.finish() - #expect(assembler.metadata?.fields.count == 2) - } - - @Test("A chunked string is concatenated with the first value of the next message") - func chunkedString() throws { - var assembler = SpannerPartialResultAssembler() - let first = try assembler.consume(SpannerPartialResultSet( - metadata: Self.metadata(["id", "s"]), - values: [.string("1"), .string("hel")], - chunkedValue: true - )) - #expect(first.isEmpty) - let second = try assembler.consume(SpannerPartialResultSet(values: [.string("lo"), .string("2"), .string("z")])) - #expect(second == [[.string("1"), .string("hello")], [.string("2"), .string("z")]]) - try assembler.finish() - } - - @Test("A value chunked across three messages is merged each time") - func threeWayString() throws { - var assembler = SpannerPartialResultAssembler() - _ = try assembler.consume(SpannerPartialResultSet(metadata: Self.metadata(["s"]), values: [.string("a")], chunkedValue: true)) - _ = try assembler.consume(SpannerPartialResultSet(values: [.string("b")], chunkedValue: true)) - let rows = try assembler.consume(SpannerPartialResultSet(values: [.string("c")])) - #expect(rows == [[.string("abc")]]) - } - - @Test("The emulator's chunked array whose last element is a partial string merges element-wise") - func emulatorArrayShape() throws { - var assembler = SpannerPartialResultAssembler() - var rows = try assembler.consume(SpannerPartialResultSet( - metadata: Self.metadata(["id", "arr", "s2", "tail"]), - values: [.string("1"), .list([.string("aaaa"), .string("b")])], - chunkedValue: true - )) - rows += try assembler.consume(SpannerPartialResultSet( - values: [.list([.string("bbb"), .string("cc")])], - chunkedValue: true - )) - rows += try assembler.consume(SpannerPartialResultSet( - values: [.list([.string("cc")]), .string("zz")], - chunkedValue: true - )) - rows += try assembler.consume(SpannerPartialResultSet(values: [.string("zz"), .string("7")])) - #expect(rows == [[ - .string("1"), - .list([.string("aaaa"), .string("bbbb"), .string("cccc")]), - .string("zzzz"), - .string("7") - ]]) - try assembler.finish() - } - - @Test("Lists whose boundary elements are not chunkable are concatenated") - func nonChunkableBoundary() throws { - let merged = try SpannerPartialResultAssembler.merge( - .list([.number(2), .number(3)]), - .list([.number(4)]) - ) - #expect(merged == .list([.number(2), .number(3), .number(4)])) - let mixed = try SpannerPartialResultAssembler.merge(.list([.string("a")]), .list([.null, .string("b")])) - #expect(mixed == .list([.string("a"), .null, .string("b")])) - } - - @Test("Nested lists merge recursively at the boundary") - func nestedLists() throws { - let merged = try SpannerPartialResultAssembler.merge( - .list([.string("a"), .list([.string("b"), .string("c")])]), - .list([.list([.string("d")]), .string("e")]) - ) - #expect(merged == .list([.string("a"), .list([.string("b"), .string("cd")]), .string("e")])) - } - - @Test("An empty list on either side merges to the other") - func emptyListMerge() throws { - #expect(try SpannerPartialResultAssembler.merge(.list([]), .list([.string("a")])) == .list([.string("a")])) - #expect(try SpannerPartialResultAssembler.merge(.list([.string("a")]), .list([])) == .list([.string("a")])) - } - - @Test("Merging values that cannot be chunked throws", arguments: [ - (SpannerJSONValue.number(1), SpannerJSONValue.number(2)), - (.bool(true), .bool(false)), - (.string("a"), .list([])), - (.null, .string("a")), - (.object([:]), .object([:])) - ]) - func unmergeable(head: SpannerJSONValue, tail: SpannerJSONValue) { - #expect(throws: SpannerTransportError.invalidResponse) { - try SpannerPartialResultAssembler.merge(head, tail) - } - } - - @Test("A chunk still pending at the end fails the stream") - func pendingAtFinish() throws { - var assembler = SpannerPartialResultAssembler() - _ = try assembler.consume(SpannerPartialResultSet(metadata: Self.metadata(["s"]), values: [.string("a")], chunkedValue: true)) - #expect(throws: SpannerTransportError.invalidResponse) { - try assembler.finish() - } - } - - @Test("Values that do not fill a whole row fail the stream") - func partialRowAtFinish() throws { - var assembler = SpannerPartialResultAssembler() - _ = try assembler.consume(SpannerPartialResultSet(metadata: Self.metadata(["a", "b"]), values: [.string("1")])) - #expect(throws: SpannerTransportError.invalidResponse) { - try assembler.finish() - } - } - - @Test("A result with no fields yields no rows and keeps its stats") - func noFields() throws { - var assembler = SpannerPartialResultAssembler() - let rows = try assembler.consume(SpannerPartialResultSet( - metadata: SpannerResultSetMetadata(fields: []), - values: [], - stats: SpannerResultSetStats(rowCountExact: 4) - )) - #expect(rows.isEmpty) - #expect(assembler.stats?.rowCountExact == 4) - try assembler.finish() - } - - @Test("A chunked message with no values is invalid") - func chunkedWithoutValues() { - var assembler = SpannerPartialResultAssembler() - #expect(throws: SpannerTransportError.invalidResponse) { - _ = try assembler.consume(SpannerPartialResultSet(metadata: Self.metadata(["s"]), values: [], chunkedValue: true)) - } - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerPlanRendererTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerPlanRendererTests.swift deleted file mode 100644 index 6d4c95f857..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerPlanRendererTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation -import TableProSpannerCore -import Testing - -@Suite("SpannerPlanRenderer") -struct SpannerPlanRendererTests { - private typealias Link = SpannerPlanNode.ChildLink - - @Test("The column is named QUERY PLAN") - func columnName() { - #expect(SpannerPlanRenderer.columnName == "QUERY PLAN") - } - - @Test("No plan, an empty plan and the emulator placeholder all say so") - func noPlan() throws { - #expect(SpannerPlanRenderer.lines(nil) == ["No query plan returned"]) - #expect(SpannerPlanRenderer.lines(SpannerQueryPlan(nodes: [])) == ["No query plan returned"]) - let emulator = try JSONDecoder().decode( - SpannerQueryPlan.self, - from: Data(#"{"planNodes":[{"displayName":"No query plan"}]}"#.utf8) - ) - #expect(SpannerPlanRenderer.lines(emulator) == ["No query plan returned"]) - } - - @Test("Relational children are indented and scalar children annotate their parent") - func rendersTree() throws { - let json = #""" - {"planNodes":[ - {"kind":"RELATIONAL","displayName":"Distributed Union","childLinks":[{"childIndex":1},{"childIndex":6,"type":"Split Range"}]}, - {"index":1,"kind":"RELATIONAL","displayName":"Serialize Result","childLinks":[{"childIndex":2},{"childIndex":5}]}, - {"index":2,"kind":"RELATIONAL","displayName":"Filter Scan","childLinks":[{"childIndex":3},{"childIndex":4,"type":"Seek Condition"}]}, - {"index":3,"kind":"RELATIONAL","displayName":"Scan","metadata":{"scan_target":"Singers","scan_type":"TableScan"}}, - {"index":4,"kind":"SCALAR","displayName":"Function","shortRepresentation":{"description":"($Age > 3)"}}, - {"index":5,"kind":"SCALAR","displayName":"Reference","shortRepresentation":{"description":"$Id"}}, - {"index":6,"kind":"SCALAR","displayName":"Constant","shortRepresentation":{"description":"true"}} - ]} - """# - let plan = try JSONDecoder().decode(SpannerQueryPlan.self, from: Data(json.utf8)) - #expect(SpannerPlanRenderer.lines(plan) == [ - "Distributed Union [Split Range: true]", - " Serialize Result [Reference: $Id]", - " Filter Scan [Seek Condition: ($Age > 3)]", - " Scan (TableScan: Singers)" - ]) - } - - @Test("Siblings keep their order and a short description follows the name") - func siblings() { - let plan = SpannerQueryPlan(nodes: [ - SpannerPlanNode(index: 0, kind: "RELATIONAL", displayName: "Cross Apply", - childLinks: [Link(childIndex: 1, type: "Input"), Link(childIndex: 2, type: "Map")]), - SpannerPlanNode(index: 1, kind: "RELATIONAL", displayName: "Scan", shortDescription: "Albums"), - SpannerPlanNode(index: 2, displayName: "Scan", metadata: ["scan_target": .string("Songs")]) - ]) - #expect(SpannerPlanRenderer.lines(plan) == ["Cross Apply", " Scan (Albums)", " Scan (Songs)"]) - } - - @Test("Cycles and missing children do not loop or crash") - func cycles() { - let plan = SpannerQueryPlan(nodes: [ - SpannerPlanNode(index: 0, kind: "RELATIONAL", displayName: "A", childLinks: [Link(childIndex: 1), Link(childIndex: 9)]), - SpannerPlanNode(index: 1, kind: "RELATIONAL", displayName: "B", childLinks: [Link(childIndex: 0), Link(childIndex: 1)]) - ]) - #expect(SpannerPlanRenderer.lines(plan) == ["A", " B"]) - } - - @Test("A scalar child without a description adds nothing") - func scalarWithoutDescription() { - let plan = SpannerQueryPlan(nodes: [ - SpannerPlanNode(index: 0, kind: "RELATIONAL", displayName: "Scan", childLinks: [Link(childIndex: 1, variable: "Id")]), - SpannerPlanNode(index: 1, kind: "SCALAR", displayName: "Reference") - ]) - #expect(SpannerPlanRenderer.lines(plan) == ["Scan"]) - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRESTClientEmulatorTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRESTClientEmulatorTests.swift deleted file mode 100644 index d27e4967c7..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRESTClientEmulatorTests.swift +++ /dev/null @@ -1,161 +0,0 @@ -import Foundation -import Testing - -@testable import TableProSpannerCore - -@Suite( - "SpannerRESTClient against the emulator", - .enabled(if: SpannerEmulatorEnvironment.host != nil), - .serialized -) -struct SpannerRESTClientEmulatorTests { - private static func environment(_ key: String, default fallback: String) -> String { - SpannerEmulatorEnvironment.value(key) ?? fallback - } - - private func makeClient() throws -> (SpannerRESTClient, URLSessionSpannerTransport) { - let settings = try SpannerConnectionSettings.parse(fields: [ - SpannerConnectionSettings.FieldKey.projectId: Self.environment("SPANNER_EMULATOR_PROJECT", default: "proj"), - SpannerConnectionSettings.FieldKey.instanceId: Self.environment("SPANNER_EMULATOR_INSTANCE", default: "inst"), - SpannerConnectionSettings.FieldKey.databaseId: Self.environment("SPANNER_EMULATOR_DATABASE", default: "gdb"), - SpannerConnectionSettings.FieldKey.endpoint: SpannerEmulatorEnvironment.host ?? "", - SpannerConnectionSettings.FieldKey.authMethod: SpannerAuthMethod.emulator.rawValue - ]) - let transport = URLSessionSpannerTransport(requestTimeout: { 30 }) - return (SpannerRESTClient(settings: settings, transport: transport, tokenProvider: nil), transport) - } - - @Test("Session, executeSql, streaming and teardown work on the real wire") - func smoke() async throws { - let (client, _) = try makeClient() - #expect(try await client.databaseDialect() == "GOOGLE_STANDARD_SQL") - - let session = try await client.createSession(multiplexed: false) - #expect(session.contains("/sessions/")) - - let single = try await client.executeSql( - session: session, - SpannerExecuteSqlRequest(sql: "SELECT 1", transaction: .singleUseStrongReadOnly) - ) - let fields = try #require(single.metadata?.fields) - #expect(fields.map(\.name) == [""]) - #expect(SpannerValueDecoder.rows(single.rows, fields: fields) == [[.text("1")]]) - - let planned = try await client.executeSql( - session: session, - SpannerExecuteSqlRequest(sql: "SELECT @p1 + 1 AS x", transaction: .singleUseStrongReadOnly, queryMode: .plan) - ) - #expect(planned.metadata?.undeclaredParameters == [SpannerField(name: "p1", type: SpannerType(code: "INT64"))]) - - let bound = try await client.executeSql( - session: session, - SpannerExecuteSqlRequest( - sql: "SELECT @p1 + 1 AS x, @p2 AS flag, @p3 AS list", - transaction: .singleUseStrongReadOnly, - params: [ - "p1": try SpannerParameterEncoder.encode(.text("41"), as: SpannerType(code: "INT64"), index: 1), - "p2": try SpannerParameterEncoder.encode(.text("TRUE"), as: SpannerType(code: "BOOL"), index: 2), - "p3": try SpannerParameterEncoder.encode( - .text("[1.5, \"NaN\"]"), - as: SpannerType(code: "ARRAY", arrayElementType: SpannerType(code: "FLOAT64")), - index: 3 - ) - ], - paramTypes: [ - "p1": SpannerType(code: "INT64"), - "p2": SpannerType(code: "BOOL"), - "p3": SpannerType(code: "ARRAY", arrayElementType: SpannerType(code: "FLOAT64")) - ] - ) - ) - let boundFields = try #require(bound.metadata?.fields) - #expect(SpannerValueDecoder.rows(bound.rows, fields: boundFields) == [[.text("42"), .text("true"), .text(#"[1.5,"NaN"]"#)]]) - - let stream = try await client.executeStreamingSql( - session: session, - SpannerExecuteSqlRequest( - sql: "SELECT n AS id, 'r' || CAST(n AS STRING) AS label FROM UNNEST(GENERATE_ARRAY(1, 2500)) AS n ORDER BY n", - transaction: .singleUseStrongReadOnly - ) - ) - let events = try await collectEvents(stream) - let rows = streamedRows(events) - #expect(rows.count == 2_500) - #expect(rows.first == [.string("1"), .string("r1")]) - #expect(rows.last == [.string("2500"), .string("r2500")]) - - try await client.deleteSession(session) - do { - _ = try await client.executeSql( - session: session, - SpannerExecuteSqlRequest(sql: "SELECT 1", transaction: .singleUseStrongReadOnly) - ) - Issue.record("A deleted session must be reported as not found") - } catch let error as SpannerAPIError { - #expect(error.isSessionNotFound) - } - await client.close() - } - - @Test("A stream whose values are chunked across messages reassembles on the real wire") - func chunkedStream() async throws { - let (client, _) = try makeClient() - let session = try await client.createSession(multiplexed: false) - let sql = """ - SELECT 1 AS id, [REPEAT('a', 900000), REPEAT('b', 900000), REPEAT('c', 900000)] AS arr, \ - REPEAT('z', 900000) AS s2, 7 AS tail - """ - let stream = try await client.executeStreamingSql( - session: session, - SpannerExecuteSqlRequest(sql: sql, transaction: .singleUseStrongReadOnly) - ) - let rows = streamedRows(try await collectEvents(stream)) - #expect(rows.count == 1) - let row = try #require(rows.first) - #expect(row.count == 4) - guard case .list(let elements) = row[1], case .string(let tail) = row[2] else { - Issue.record("Unexpected shapes for the chunked columns") - return - } - let lengths = elements.map { element -> Int in - guard case .string(let text) = element else { return -1 } - return (text as NSString).length - } - #expect(lengths == [900_000, 900_000, 900_000]) - #expect(elements.map { element -> Bool in - guard case .string(let text) = element, let first = text.first else { return false } - return text.allSatisfy { $0 == first } - } == [true, true, true]) - #expect((tail as NSString).length == 900_000) - #expect(row[3] == .string("7")) - try await client.deleteSession(session) - await client.close() - } - - @Test("The emulator's in-stream error decodes to its gRPC code") - func streamError() async throws { - let (client, _) = try makeClient() - let session = try await client.createSession(multiplexed: false) - do { - let stream = try await client.executeStreamingSql( - session: session, - SpannerExecuteSqlRequest(sql: "SELECT 1/0", transaction: .singleUseStrongReadOnly) - ) - _ = try await collectEvents(stream) - Issue.record("Division by zero must fail") - } catch let error as SpannerAPIError { - #expect(error.message.contains("division by zero")) - } - try await client.deleteSession(session) - await client.close() - } - - @Test("A closed transport refuses new requests") - func closedTransport() async throws { - let (client, _) = try makeClient() - await client.close() - await #expect(throws: SpannerTransportError.closed) { - try await client.createSession(multiplexed: false) - } - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRESTClientTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRESTClientTests.swift deleted file mode 100644 index 8f02584127..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRESTClientTests.swift +++ /dev/null @@ -1,484 +0,0 @@ -import Foundation -import Testing - -@testable import TableProSpannerCore - -@Suite("SpannerRESTClient") -struct SpannerRESTClientTests { - private static let session = SpannerTestFixtures.sessionName - private static let productionBase = "https://spanner.googleapis.com/v1/" - private static let unavailable = #"{"error":{"code":503,"message":"The service is unavailable.","status":"UNAVAILABLE"}}"# - private static let exhausted = #"{"error":{"code":429,"message":"Quota exceeded.","status":"RESOURCE_EXHAUSTED"}}"# - - private func productionClient( - _ transport: StubSpannerTransport, - tokens: StubAccessTokenProvider? = StubAccessTokenProvider(tokens: ["tok"]) - ) throws -> SpannerRESTClient { - SpannerTestFixtures.client(transport: transport, settings: try SpannerTestFixtures.settings(), tokenProvider: tokens) - } - - @Test("Database dialect comes from GET on the database") - func dialect() async throws { - let transport = StubSpannerTransport([.json(#"{"name":"x","state":"READY","databaseDialect":"POSTGRESQL"}"#)]) - let dialect = try await productionClient(transport).databaseDialect() - #expect(dialect == "POSTGRESQL") - let request = try #require(transport.requests.first) - #expect(request.httpMethod == "GET") - #expect(request.absoluteURL == Self.productionBase + SpannerTestFixtures.databasePath) - #expect(request.httpBody == nil) - } - - @Test("Create session posts labels and the multiplexed flag, and returns the full name") - func createSession() async throws { - let transport = StubSpannerTransport([ - .json(#"{"name":"projects/proj/instances/inst/databases/gdb/sessions/18","multiplexed":true}"#), - .json(#"{"name":"projects/proj/instances/inst/databases/gdb/sessions/19"}"#) - ]) - let client = try productionClient(transport) - #expect(try await client.createSession(multiplexed: true) == "projects/proj/instances/inst/databases/gdb/sessions/18") - #expect(try await client.createSession(multiplexed: false) == "projects/proj/instances/inst/databases/gdb/sessions/19") - let requests = transport.requests - #expect(requests[0].httpMethod == "POST") - #expect(requests[0].absoluteURL == Self.productionBase + SpannerTestFixtures.databasePath + "/sessions") - let multiplexed = try #require(requests[0].jsonBody?["session"] as? [String: Any]) - #expect(multiplexed["multiplexed"] as? Bool == true) - #expect(multiplexed["labels"] as? [String: String] == ["app": "tablepro"]) - let regular = try #require(requests[1].jsonBody?["session"] as? [String: Any]) - #expect(regular["multiplexed"] == nil) - } - - @Test("A session response without a name is invalid") - func createSessionWithoutName() async throws { - let transport = StubSpannerTransport([.json("{}")]) - await #expect(throws: SpannerTransportError.invalidResponse) { - try await productionClient(transport).createSession(multiplexed: true) - } - } - - @Test("Session endpoints append the verb to the session resource") - func sessionEndpoints() async throws { - let transport = StubSpannerTransport([ - .json(#"{"id":"TXN"}"#), - .json(#"{"commitTimestamp":"2026-01-01T00:00:00Z"}"#), - .json("{}"), - .json("{}") - ]) - let client = try productionClient(transport) - #expect(try await client.beginTransaction(session: Self.session) == "TXN") - try await client.commit(session: Self.session, transactionId: "TXN") - try await client.rollback(session: Self.session, transactionId: "TXN2") - try await client.deleteSession(Self.session) - let requests = transport.requests - #expect(requests.map(\.absoluteURL) == [ - Self.productionBase + Self.session + ":beginTransaction", - Self.productionBase + Self.session + ":commit", - Self.productionBase + Self.session + ":rollback", - Self.productionBase + Self.session - ]) - #expect(requests.map(\.httpMethod) == ["POST", "POST", "POST", "DELETE"]) - let options = try #require(requests[0].jsonBody?["options"] as? [String: Any]) - #expect(options["readWrite"] is [String: Any]) - #expect(requests[1].jsonBody?["transactionId"] as? String == "TXN") - #expect(requests[2].jsonBody?["transactionId"] as? String == "TXN2") - } - - @Test("executeSql decodes the result set") - func executeSql() async throws { - let transport = StubSpannerTransport([ - .json(#"{"metadata":{"rowType":{"fields":[{"type":{"code":"INT64"}}]}},"rows":[["1"]]}"#) - ]) - let result = try await productionClient(transport).executeSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT 1", transaction: .singleUseStrongReadOnly) - ) - #expect(result.rows == [[.string("1")]]) - let request = try #require(transport.requests.first) - #expect(request.absoluteURL == Self.productionBase + Self.session + ":executeSql") - #expect(request.jsonBody?["sql"] as? String == "SELECT 1") - #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") - } - - @Test("A malformed success body is an invalid response") - func malformedSuccess() async throws { - let transport = StubSpannerTransport([.json("not json")]) - await #expect(throws: SpannerTransportError.invalidResponse) { - try await productionClient(transport).executeSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT 1", transaction: .singleUseStrongReadOnly) - ) - } - } - - @Test("DDL, operations and the DDL listing use the database admin paths") - func adminEndpoints() async throws { - let operationName = "projects/proj/instances/inst/databases/gdb/operations/_auto7" - let transport = StubSpannerTransport([ - .json(#"{"name":"\#(operationName)","done":false}"#), - .json(#"{"name":"\#(operationName)","done":true,"error":{"code":5,"message":"Table not found: x"}}"#), - .json(#"{"statements":["CREATE SCHEMA sales","CREATE TABLE t (id INT64) PRIMARY KEY(id)"]}"#) - ]) - let client = try productionClient(transport) - let started = try await client.updateDdl(["CREATE TABLE t (id INT64) PRIMARY KEY(id)"]) - #expect(started == SpannerOperation(name: operationName, done: false, error: nil)) - let finished = try await client.operation(named: operationName) - #expect(finished.done) - #expect(finished.error?.isNotFound == true) - #expect(finished.error?.message == "Table not found: x") - #expect(try await client.databaseDDL().count == 2) - let requests = transport.requests - #expect(requests[0].httpMethod == "PATCH") - #expect(requests[0].absoluteURL == Self.productionBase + SpannerTestFixtures.databasePath + "/ddl") - #expect(requests[0].jsonBody?["statements"] as? [String] == ["CREATE TABLE t (id INT64) PRIMARY KEY(id)"]) - #expect(requests[1].httpMethod == "GET") - #expect(requests[1].absoluteURL == Self.productionBase + operationName) - #expect(requests[2].absoluteURL == Self.productionBase + SpannerTestFixtures.databasePath + "/ddl") - } - - @Test("A bearer token goes to a trusted Google endpoint") - func bearerForGoogle() async throws { - let transport = StubSpannerTransport([.json(#"{"databaseDialect":"GOOGLE_STANDARD_SQL"}"#)]) - _ = try await productionClient(transport).databaseDialect() - #expect(transport.requests.first?.authorization == "Bearer tok") - } - - @Test("No Authorization header reaches a loopback endpoint even with a provider") - func noBearerForLoopback() async throws { - let transport = StubSpannerTransport([.json(#"{"databaseDialect":"GOOGLE_STANDARD_SQL"}"#)]) - let tokens = StubAccessTokenProvider(tokens: ["tok"]) - let client = SpannerTestFixtures.client( - transport: transport, - settings: try SpannerTestFixtures.emulatorSettings(), - tokenProvider: tokens - ) - _ = try await client.databaseDialect() - #expect(transport.requests.first?.authorization == nil) - #expect(transport.requests.first?.absoluteURL == "http://localhost:9020/v1/" + SpannerTestFixtures.databasePath) - #expect(await tokens.accessTokenCalls == 0) - } - - @Test("Without a provider no Authorization header is sent") - func noProvider() async throws { - let transport = StubSpannerTransport([.json("{}")]) - _ = try await productionClient(transport, tokens: nil).databaseDialect() - #expect(transport.requests.first?.authorization == nil) - } - - @Test("A 401 invalidates the cached token and retries once with a fresh one") - func unauthorizedRetriesOnce() async throws { - let transport = StubSpannerTransport([ - .json(#"{"error":{"code":401,"message":"expired","status":"UNAUTHENTICATED"}}"#, status: 401), - .json(#"{"databaseDialect":"POSTGRESQL"}"#) - ]) - let tokens = StubAccessTokenProvider(tokens: ["old", "new"]) - let dialect = try await productionClient(transport, tokens: tokens).databaseDialect() - #expect(dialect == "POSTGRESQL") - #expect(transport.requests.map(\.authorization) == ["Bearer old", "Bearer new"]) - #expect(await tokens.invalidations == 1) - } - - @Test("A second 401 is surfaced as unauthenticated") - func unauthorizedTwice() async throws { - let body = #"{"error":{"code":401,"message":"bad","status":"UNAUTHENTICATED"}}"# - let transport = StubSpannerTransport([.json(body, status: 401), .json(body, status: 401)]) - let tokens = StubAccessTokenProvider(tokens: ["a", "b"]) - do { - _ = try await productionClient(transport, tokens: tokens).databaseDialect() - Issue.record("Expected an unauthenticated error") - } catch let error as SpannerAPIError { - #expect(error.isUnauthenticated) - } - #expect(transport.requests.count == 2) - #expect(await tokens.invalidations == 1) - } - - @Test("UNAVAILABLE is retried for a replay-safe request") - func unavailableRetried() async throws { - let transport = StubSpannerTransport([ - .json(Self.unavailable, status: 503), - .json(Self.unavailable, status: 503), - .json(#"{"metadata":{"rowType":{"fields":[]}}}"#) - ]) - _ = try await productionClient(transport).executeSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT 1", transaction: .singleUseStrongReadOnly) - ) - #expect(transport.requests.count == 3) - } - - @Test("A network failure is retried for a replay-safe request") - func networkRetried() async throws { - let transport = StubSpannerTransport([ - .failing(.network("networkConnectionLost")), - .json(#"{"name":"projects/proj/instances/inst/databases/gdb/sessions/1"}"#) - ]) - _ = try await productionClient(transport).createSession(multiplexed: true) - #expect(transport.requests.count == 2) - } - - @Test("Retries stop after the backoff schedule is spent") - func retriesBounded() async throws { - let transport = StubSpannerTransport(Array(repeating: .json(Self.unavailable, status: 503), count: 10)) - do { - _ = try await productionClient(transport).databaseDDL() - Issue.record("Expected UNAVAILABLE") - } catch let error as SpannerAPIError { - #expect(error.isUnavailable) - } - #expect(transport.requests.count == 4) - } - - @Test("A request that is not replay-safe is never retried") - func notReplaySafe() async throws { - let transport = StubSpannerTransport([ - .json(Self.unavailable, status: 503), - .json(Self.unavailable, status: 503), - .json("{}") - ]) - await #expect(throws: SpannerAPIError.self) { - try await productionClient(transport).executeSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT 1", transaction: .id("TXN")) - ) - } - await #expect(throws: SpannerAPIError.self) { - try await productionClient(transport).updateDdl(["DROP TABLE t"]) - } - #expect(transport.requests.count == 2) - } - - @Test("DML carrying a seqno is replay-safe") - func dmlWithSeqnoRetried() async throws { - let transport = StubSpannerTransport([ - .json(Self.unavailable, status: 503), - .json(#"{"metadata":{"rowType":{}},"stats":{"rowCountExact":"1"}}"#) - ]) - let result = try await productionClient(transport).executeSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "DELETE FROM t WHERE TRUE", transaction: .id("TXN"), seqno: 4) - ) - #expect(result.stats?.rowCountExact == 1) - #expect(transport.requests.count == 2) - } - - @Test("RESOURCE_EXHAUSTED (429) is never retried") - func exhaustedNotRetried() async throws { - let transport = StubSpannerTransport([.json(Self.exhausted, status: 429), .json("{}")]) - do { - _ = try await productionClient(transport).executeSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT 1", transaction: .singleUseStrongReadOnly) - ) - Issue.record("Expected RESOURCE_EXHAUSTED") - } catch let error as SpannerAPIError { - #expect(error.httpStatus == 429) - #expect(error.status == "RESOURCE_EXHAUSTED") - } - #expect(transport.requests.count == 1) - } - - @Test("Cancellation and timeouts are not retried") - func cancellationNotRetried() async throws { - let transport = StubSpannerTransport([.failing(.cancelled), .failing(.timedOut), .json("{}")]) - let client = try productionClient(transport) - await #expect(throws: SpannerTransportError.cancelled) { - try await client.databaseDialect() - } - await #expect(throws: SpannerTransportError.timedOut) { - try await client.databaseDialect() - } - #expect(transport.requests.count == 2) - } - - @Test("A streaming query yields metadata once, merged rows and final stats") - func streamingQuery() async throws { - let transport = StubSpannerTransport([.stream([ - #"[{"metadata":{"rowType":{"fields":[{"name":"s","type":{"code":"STRING"}}]}},"values":["ab"],"chunkedValue":true}"#, - #",{"values":["c","d"]},{"values":[],"stats":{"rowCountExact":"2"}}]"# - ])]) - let stream = try await productionClient(transport).executeStreamingSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT s FROM t", transaction: .singleUseStrongReadOnly) - ) - let events = try await collectEvents(stream) - #expect(transport.requests.first?.absoluteURL == Self.productionBase + Self.session + ":executeStreamingSql") - guard case .metadata(let metadata) = events.first else { - Issue.record("Metadata must come first") - return - } - #expect(metadata.fields.map(\.name) == ["s"]) - #expect(events.filter { if case .metadata = $0 { return true } else { return false } }.count == 1) - #expect(streamedRows(events) == [[.string("abc")], [.string("d")]]) - guard case .stats(let stats) = events.last else { - Issue.record("Stats must come last") - return - } - #expect(stats.rowCountExact == 2) - } - - @Test("The emulator's NDJSON stream split at every byte decodes the same rows") - func streamingNDJSONByteSplit() async throws { - let body = """ - {"result":{"metadata":{"rowType":{"fields":[{"name":"a","type":{"code":"INT64"}},{"name":"b","type":{"code":"STRING"}}]}},"values":["2","y","1","x"]}} - - """ - let transport = StubSpannerTransport([.stream(body.map { String($0) })]) - let stream = try await SpannerTestFixtures.emulatorClient(transport: transport).executeStreamingSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT", transaction: .singleUseStrongReadOnly) - ) - let events = try await collectEvents(stream) - #expect(streamedRows(events) == [[.string("2"), .string("y")], [.string("1"), .string("x")]]) - } - - @Test("An in-stream error throws after the rows before it") - func streamingInStreamError() async throws { - let transport = StubSpannerTransport([.stream([ - #"{"result":{"metadata":{"rowType":{"fields":[{"name":"a","type":{"code":"INT64"}}]}},"values":["1"]}}"#, - "\n", - #"{"error":{"code":10,"message":"Transaction was aborted.","status":"ABORTED"}}"# - ])]) - let stream = try await productionClient(transport).executeStreamingSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT a FROM t", transaction: .id("TXN")) - ) - var rows: [[SpannerJSONValue]] = [] - do { - for try await event in stream { - if case .rows(let batch) = event { - rows += batch - } - } - Issue.record("Expected the in-stream error") - } catch let error as SpannerAPIError { - #expect(error.isAborted) - } - #expect(rows == [[.string("1")]]) - } - - @Test("A non-2xx stream head throws the decoded error before any event") - func streamingErrorHead() async throws { - let transport = StubSpannerTransport([ - .stream([#"{"error":{"code":11,"message":"Output of REPEAT exceeds max allowed output size of 1MB"}}"#], status: 400) - ]) - do { - _ = try await productionClient(transport).executeStreamingSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT", transaction: .singleUseStrongReadOnly) - ) - Issue.record("Expected the head error") - } catch let error as SpannerAPIError { - #expect(error.httpStatus == 400) - #expect(error.code == 11) - } - } - - @Test("A stream head with an empty error body keeps the HTTP status") - func streamingEmptyErrorBody() async throws { - let transport = StubSpannerTransport([.stream([], status: 400)]) - do { - _ = try await productionClient(transport).executeStreamingSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT", transaction: .singleUseStrongReadOnly) - ) - Issue.record("Expected the head error") - } catch let error as SpannerAPIError { - #expect(error.message == "HTTP 400") - } - } - - @Test("A streaming 401 refreshes the token once before any event") - func streamingUnauthorized() async throws { - let transport = StubSpannerTransport([ - .stream([#"{"error":{"code":401,"message":"expired","status":"UNAUTHENTICATED"}}"#], status: 401), - .stream([#"[{"metadata":{"rowType":{"fields":[{"name":"a","type":{"code":"INT64"}}]}},"values":["1"]}]"#]) - ]) - let tokens = StubAccessTokenProvider(tokens: ["old", "new"]) - let stream = try await productionClient(transport, tokens: tokens).executeStreamingSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT", transaction: .singleUseStrongReadOnly) - ) - #expect(streamedRows(try await collectEvents(stream)) == [[.string("1")]]) - #expect(transport.requests.map(\.authorization) == ["Bearer old", "Bearer new"]) - #expect(await tokens.invalidations == 1) - } - - @Test("A streaming UNAVAILABLE head is not retried") - func streamingNotRetried() async throws { - let transport = StubSpannerTransport([.stream([Self.unavailable], status: 503)]) - await #expect(throws: SpannerAPIError.self) { - try await productionClient(transport).executeStreamingSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT", transaction: .singleUseStrongReadOnly) - ) - } - #expect(transport.requests.count == 1) - } - - @Test("A truncated stream fails instead of dropping the partial row") - func streamingTruncated() async throws { - let transport = StubSpannerTransport([.stream([ - #"[{"metadata":{"rowType":{"fields":[{"name":"a","type":{"code":"STRING"}}]}},"values":["ab"],"chunkedValue":true}"# - ])]) - let stream = try await productionClient(transport).executeStreamingSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT", transaction: .singleUseStrongReadOnly) - ) - await #expect(throws: SpannerTransportError.invalidResponse) { - _ = try await collectEvents(stream) - } - } - - @Test("A transport failure mid-stream surfaces from the event stream") - func streamingTransportFailure() async throws { - let transport = StubSpannerTransport([.stream( - [#"[{"metadata":{"rowType":{"fields":[{"name":"a","type":{"code":"INT64"}}]}},"values":["1"]}"#], - trailingFailure: .network("networkConnectionLost") - )]) - let stream = try await productionClient(transport).executeStreamingSql( - session: Self.session, - SpannerExecuteSqlRequest(sql: "SELECT", transaction: .singleUseStrongReadOnly) - ) - await #expect(throws: SpannerTransportError.network("networkConnectionLost")) { - _ = try await collectEvents(stream) - } - } - - @Test("Closing the client closes its transport") - func closeClosesTransport() async throws { - let transport = StubSpannerTransport() - try await productionClient(transport).close() - #expect(transport.closeCount == 1) - } - - @Test("The URLSession transport refuses work once closed") - func urlSessionClosed() async throws { - let transport = URLSessionSpannerTransport(requestTimeout: { 5 }) - await transport.close() - await transport.close() - let request = URLRequest(url: try #require(URL(string: "http://127.0.0.1:9/v1/x"))) - await #expect(throws: SpannerTransportError.closed) { - try await transport.send(request) - } - await #expect(throws: SpannerTransportError.closed) { - try await transport.stream(request) - } - } - - @Test("A cancelled caller never starts a URLSession request") - func urlSessionCancelledBeforeStart() async throws { - let transport = URLSessionSpannerTransport(requestTimeout: { 5 }) - let request = URLRequest(url: try #require(URL(string: "http://127.0.0.1:9/v1/x"))) - let task = Task { - withUnsafeCurrentTask { $0?.cancel() } - return try await transport.send(request) - } - let result = await task.result - switch result { - case .success: - Issue.record("A cancelled send must not succeed") - case .failure(let error): - #expect(error is CancellationError || (error as? SpannerTransportError) == .cancelled) - } - await transport.close() - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRowEditSQLTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRowEditSQLTests.swift deleted file mode 100644 index 4810d9d014..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRowEditSQLTests.swift +++ /dev/null @@ -1,231 +0,0 @@ -import Foundation -import TableProSpannerCore -import Testing - -@Suite("SpannerRowEditSQL") -struct SpannerRowEditSQLTests { - private typealias Cell = SpannerRowChange.CellChange - - private func generate( - schema: String = "", - table: String = "Singers", - columns: [String] = ["Id", "Name", "Photo"], - primaryKey: [String] = ["Id"], - changes: [SpannerRowChange], - insertedRows: [Int: [SpannerCell]] = [:], - deletedRows: Set = [], - insertedRowIndices: Set = [], - dialect: SpannerDialect = .googleSQL - ) -> [SpannerRenderedStatement]? { - SpannerRowEditSQL.statements( - schema: schema, table: table, columns: columns, primaryKey: primaryKey, changes: changes, - insertedRows: insertedRows, deletedRows: deletedRows, insertedRowIndices: insertedRowIndices, dialect: dialect - ) - } - - @Test("The sentinel is __DEFAULT__") - func sentinel() { - #expect(SpannerRowEditSQL.defaultSentinel == "__DEFAULT__") - } - - @Test("An insert skips sentinel cells and binds every other value, NULL included") - func insertSkipsSentinel() throws { - let statements = try #require(generate( - table: "T2", columns: ["Id", "D", "G", "N"], - changes: [SpannerRowChange(rowIndex: 0, kind: .insert)], - insertedRows: [0: [.text("900"), .text("__DEFAULT__"), .text("__DEFAULT__"), .null]], - insertedRowIndices: [0] - )) - #expect(statements == [SpannerRenderedStatement( - sql: "INSERT INTO `T2` (`Id`, `N`) VALUES (?, ?)", - parameters: [.text("900"), .null] - )]) - #expect(statements.first?.parameterTypes == nil) - } - - @Test("An insert whose every cell is the sentinel writes DEFAULT into the first column") - func allSentinelInsert() throws { - let statements = try #require(generate( - table: "T3", columns: ["Id", "V"], - changes: [SpannerRowChange(rowIndex: 4, kind: .insert)], - insertedRows: [4: [.text("__DEFAULT__"), .text("__DEFAULT__")]], - insertedRowIndices: [4] - )) - #expect(statements == [SpannerRenderedStatement(sql: "INSERT INTO `T3` (`Id`) VALUES (DEFAULT)")]) - } - - @Test("An insert without row data uses its cell changes") - func insertFromCellChanges() throws { - let statements = try #require(generate( - changes: [SpannerRowChange(rowIndex: 0, kind: .insert, cellChanges: [ - Cell(column: "Id", value: .text("1")), Cell(column: "Name", value: .text("a")), - Cell(column: "Name", value: .text("b")), Cell(column: "Photo", value: .text("__DEFAULT__")) - ])], - insertedRowIndices: [0] - )) - #expect(statements == [SpannerRenderedStatement( - sql: "INSERT INTO `Singers` (`Id`, `Name`) VALUES (?, ?)", - parameters: [.text("1"), .text("b")] - )]) - } - - @Test("Inserts and deletes outside their index sets are ignored") - func respectsIndexSets() throws { - let statements = try #require(generate( - changes: [ - SpannerRowChange(rowIndex: 0, kind: .insert, cellChanges: [Cell(column: "Id", value: .text("1"))]), - SpannerRowChange(rowIndex: 1, kind: .delete, originalRow: [.text("2"), .null, .null]) - ] - )) - #expect(statements.isEmpty) - } - - @Test("An update binds values, writes DEFAULT for the sentinel and keys on the original row") - func update() throws { - let statements = try #require(generate( - changes: [SpannerRowChange(rowIndex: 3, kind: .update, cellChanges: [ - Cell(column: "Name", value: .text("x' OR TRUE --")), - Cell(column: "Photo", value: .bytes(Data([0, 1, 2]))), - Cell(column: "Name", value: .text("It's \\ new")), - Cell(column: "Age", value: .text("__DEFAULT__")), - Cell(column: "Active", value: .null) - ], originalRow: [.text("7"), .text("old"), .null])] - )) - #expect(statements == [SpannerRenderedStatement( - sql: "UPDATE `Singers` SET `Name` = ?, `Photo` = ?, `Age` = DEFAULT, `Active` = ? WHERE `Id` = ?", - parameters: [.text("It's \\ new"), .bytes(Data([0, 1, 2])), .null, .text("7")] - )]) - } - - @Test("A composite key with a bytes column binds every key part") - func compositeKey() throws { - let statements = try #require(generate( - table: "Docs", columns: ["Tenant", "K", "V"], primaryKey: ["Tenant", "K"], - changes: [SpannerRowChange(rowIndex: 0, kind: .update, cellChanges: [Cell(column: "V", value: .text("v2b"))], - originalRow: [.text("t1"), .bytes(Data([0, 2])), .text("v2")])] - )) - #expect(statements == [SpannerRenderedStatement( - sql: "UPDATE `Docs` SET `V` = ? WHERE `Tenant` = ? AND `K` = ?", - parameters: [.text("v2b"), .text("t1"), .bytes(Data([0, 2]))] - )]) - } - - @Test("A NULL key part matches with IS NULL") - func nullKey() throws { - let statements = try #require(generate( - table: "NK", columns: ["K", "V"], primaryKey: ["K"], - changes: [ - SpannerRowChange(rowIndex: 0, kind: .update, cellChanges: [Cell(column: "V", value: .text("changed"))], - originalRow: [.null, .text("nullkey")]), - SpannerRowChange(rowIndex: 1, kind: .delete, originalRow: [.null, .text("x")]) - ], - deletedRows: [1] - )) - #expect(statements == [ - SpannerRenderedStatement(sql: "UPDATE `NK` SET `V` = ? WHERE `K` IS NULL", parameters: [.text("changed")]), - SpannerRenderedStatement(sql: "DELETE FROM `NK` WHERE `K` IS NULL", parameters: []) - ]) - } - - @Test("A delete keys on the original row") - func delete() throws { - let statements = try #require(generate( - changes: [SpannerRowChange(rowIndex: 2, kind: .delete, originalRow: [.text("901"), .text("n"), .null])], - deletedRows: [2] - )) - #expect(statements == [SpannerRenderedStatement(sql: "DELETE FROM `Singers` WHERE `Id` = ?", parameters: [.text("901")])]) - } - - struct UnkeyableCase: Sendable, CustomTestStringConvertible { - let label: String - let primaryKey: [String] - let originalRow: [SpannerCell]? - var testDescription: String { label } - } - - @Test("An update or delete that cannot be keyed refuses the whole batch", arguments: [ - UnkeyableCase(label: "key column not in the result", primaryKey: ["Other"], originalRow: [.text("1"), .null, .null]), - UnkeyableCase(label: "no original row", primaryKey: ["Id"], originalRow: nil), - UnkeyableCase(label: "no primary key", primaryKey: [], originalRow: [.text("1"), .null, .null]), - UnkeyableCase(label: "original row too short", primaryKey: ["Photo"], originalRow: [.text("1")]) - ]) - func unkeyable(_ testCase: UnkeyableCase) { - let primaryKey = testCase.primaryKey - let update = SpannerRowChange(rowIndex: 0, kind: .update, cellChanges: [Cell(column: "Name", value: .text("x"))], - originalRow: testCase.originalRow) - let delete = SpannerRowChange(rowIndex: 1, kind: .delete, originalRow: testCase.originalRow) - let fine = SpannerRowChange(rowIndex: 2, kind: .insert, cellChanges: [Cell(column: "Id", value: .text("9"))]) - #expect(generate(primaryKey: primaryKey, changes: [fine, update], insertedRowIndices: [2]) == nil) - #expect(generate(primaryKey: primaryKey, changes: [fine, delete], deletedRows: [1], insertedRowIndices: [2]) == nil) - } - - @Test("The ownership probe gets an empty batch") - func ownershipProbe() { - let probe = SpannerRowChange(rowIndex: 0, kind: .update) - #expect(generate(primaryKey: [], changes: [probe])?.isEmpty == true) - #expect(generate(changes: [])?.isEmpty == true) - } - - @Test("Statement order follows the change order") - func order() throws { - let statements = try #require(generate( - changes: [ - SpannerRowChange(rowIndex: 5, kind: .delete, originalRow: [.text("5"), .null, .null]), - SpannerRowChange(rowIndex: 6, kind: .insert, cellChanges: [Cell(column: "Id", value: .text("6"))]), - SpannerRowChange(rowIndex: 1, kind: .update, cellChanges: [Cell(column: "Name", value: .text("n"))], - originalRow: [.text("1"), .null, .null]) - ], - deletedRows: [5], - insertedRowIndices: [6] - )) - #expect(statements.map(\.sql) == [ - "DELETE FROM `Singers` WHERE `Id` = ?", - "INSERT INTO `Singers` (`Id`) VALUES (?)", - "UPDATE `Singers` SET `Name` = ? WHERE `Id` = ?" - ]) - } - - @Test("PostgreSQL quoting and the unqualified public schema") - func postgres() throws { - let statements = try #require(generate( - schema: "public", table: "t2", columns: ["id", "d"], primaryKey: ["id"], - changes: [SpannerRowChange(rowIndex: 0, kind: .update, cellChanges: [Cell(column: "d", value: .text("__DEFAULT__"))], - originalRow: [.text("900"), .text("x")])], - dialect: .postgreSQL - )) - #expect(statements == [SpannerRenderedStatement(sql: "UPDATE \"t2\" SET \"d\" = DEFAULT WHERE \"id\" = ?", parameters: [.text("900")])]) - let named = try #require(generate( - schema: "sales", table: "orders", columns: ["id"], primaryKey: ["id"], - changes: [SpannerRowChange(rowIndex: 0, kind: .delete, originalRow: [.text("1")])], - deletedRows: [0], dialect: .postgreSQL - )) - #expect(named.map(\.sql) == ["DELETE FROM \"sales\".\"orders\" WHERE \"id\" = ?"]) - } - - @Test("A named GoogleSQL schema qualifies the table and hostile names stay quoted") - func namedSchemaAndHostileNames() throws { - let statements = try #require(generate( - schema: "sales", table: "Or`ders", columns: ["Id", "a`b"], primaryKey: ["Id"], - changes: [SpannerRowChange(rowIndex: 0, kind: .update, cellChanges: [Cell(column: "a`b", value: .text("v"))], - originalRow: [.text("1"), .null])] - )) - #expect(statements.map(\.sql) == [#"UPDATE `sales`.`Or\`ders` SET `a\`b` = ? WHERE `Id` = ?"#]) - } - - @Test("An identity-preserving insert writes every column, sentinel text and NULL included") - func identityPreserving() { - let statements = SpannerRowEditSQL.identityPreservingInserts( - schema: "", table: "T3", columns: ["Id", "V"], - rows: [[.text("777"), .text("restored")], [.text("778"), .null], []], - dialect: .googleSQL - ) - #expect(statements == [ - SpannerRenderedStatement(sql: "INSERT INTO `T3` (`Id`, `V`) VALUES (?, ?)", parameters: [.text("777"), .text("restored")]), - SpannerRenderedStatement(sql: "INSERT INTO `T3` (`Id`, `V`) VALUES (?, ?)", parameters: [.text("778"), .null]) - ]) - let postgres = SpannerRowEditSQL.identityPreservingInserts( - schema: "sales", table: "orders", columns: ["id"], rows: [[.bytes(Data([1]))]], dialect: .postgreSQL - ) - #expect(postgres == [SpannerRenderedStatement(sql: "INSERT INTO \"sales\".\"orders\" (\"id\") VALUES (?)", parameters: [.bytes(Data([1]))])]) - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerSchemaNameTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerSchemaNameTests.swift deleted file mode 100644 index 5a01b5706c..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerSchemaNameTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation -import TableProSpannerCore -import Testing - -@Suite("SpannerSchemaName") -struct SpannerSchemaNameTests { - @Test("GoogleSQL maps the default token to the nameless schema") - func googleSQLName() { - #expect(SpannerSchemaName.defaultToken == "(default)") - #expect(SpannerSchemaName.sqlName(nil, dialect: .googleSQL) == "") - #expect(SpannerSchemaName.sqlName("", dialect: .googleSQL) == "") - #expect(SpannerSchemaName.sqlName("(default)", dialect: .googleSQL) == "") - #expect(SpannerSchemaName.sqlName("sales", dialect: .googleSQL) == "sales") - } - - @Test("GoogleSQL presents the nameless schema as the token") - func googleSQLPresented() { - #expect(SpannerSchemaName.presentedName("", dialect: .googleSQL) == "(default)") - #expect(SpannerSchemaName.presentedName("sales", dialect: .googleSQL) == "sales") - } - - @Test("PostgreSQL keeps public") - func postgres() { - #expect(SpannerSchemaName.sqlName(nil, dialect: .postgreSQL) == "public") - #expect(SpannerSchemaName.sqlName("", dialect: .postgreSQL) == "public") - #expect(SpannerSchemaName.sqlName("(default)", dialect: .postgreSQL) == "public") - #expect(SpannerSchemaName.sqlName("sales", dialect: .postgreSQL) == "sales") - #expect(SpannerSchemaName.presentedName("public", dialect: .postgreSQL) == "public") - #expect(SpannerSchemaName.presentedName("sales", dialect: .postgreSQL) == "sales") - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerStatementClassifierTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerStatementClassifierTests.swift deleted file mode 100644 index a1609a2ba4..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerStatementClassifierTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation -import TableProSpannerCore -import Testing - -@Suite("SpannerStatementClassifier") -struct SpannerStatementClassifierTests { - private func kind(_ sql: String) -> SpannerStatementKind { - SpannerStatementClassifier.classify(sql) - } - - @Test("Queries", arguments: [ - "SELECT 1", "select * from t", "WITH x AS (SELECT 1) SELECT * FROM x", "(SELECT 1)", - "GRAPH FinGraph MATCH (n) RETURN n", "CALL cancel_query('1')", "SHOW VARIABLE READ_ONLY_STALENESS", - "", " ", "VALUES (1)" - ]) - func queries(sql: String) { - #expect(kind(sql) == .query) - } - - @Test("DML", arguments: ["INSERT INTO t (a) VALUES (1)", "update t set a = 1 where true", "DELETE FROM t WHERE TRUE", - "INSERT OR UPDATE INTO t (a) VALUES (1)"]) - func dml(sql: String) { - #expect(kind(sql) == .dml) - } - - @Test("DDL keywords", arguments: [ - "CREATE TABLE t (a INT64) PRIMARY KEY (a)", "ALTER TABLE t ADD COLUMN b STRING(MAX)", "DROP TABLE t", - "RENAME TABLE a TO b", "GRANT SELECT ON TABLE t TO ROLE r", "REVOKE SELECT ON TABLE t FROM ROLE r", "ANALYZE", - "create index i on t(a)" - ]) - func ddl(sql: String) { - #expect(kind(sql) == .ddl) - } - - @Test("Leading comments are skipped") - func leadingComments() { - #expect(kind("-- note\nDELETE FROM t WHERE TRUE") == .dml) - #expect(kind("# note\n UPDATE t SET a = 1 WHERE TRUE") == .dml) - #expect(kind("/* SELECT */ DROP TABLE t") == .ddl) - #expect(kind("/* a /* b */ DELETE FROM t WHERE TRUE") == .dml) - #expect( - SpannerStatementClassifier.classify( - "/* outer /* inner */ still comment */ INSERT INTO t (a) VALUES (1)", dialect: .postgreSQL - ) == .dml - ) - #expect(SpannerStatementClassifier.classify("/* a /* b */ DELETE FROM t", dialect: .postgreSQL) == .query) - #expect(kind("\n\t /* a */ -- b\n /* c */ SELECT 1") == .query) - } - - @Test("Statement hints are skipped") - func hints() { - #expect(kind("@{PDML_MAX_PARALLELISM=4} DELETE FROM t WHERE TRUE") == .dml) - #expect(kind("@{USE_ADDITIONAL_PARALLELISM=TRUE} SELECT 1") == .query) - #expect(kind("/* c */ @{a=b} @{c=d} UPDATE t SET a = 1 WHERE TRUE") == .dml) - } - - @Test("Stripping leading noise keeps the statement text") - func stripping() { - #expect(SpannerStatementClassifier.strippingLeadingNoise(" -- x\n/* y */ @{h=1} SELECT 1 ") == "SELECT 1 ") - #expect(SpannerStatementClassifier.strippingLeadingNoise("SELECT 1") == "SELECT 1") - #expect(SpannerStatementClassifier.strippingLeadingNoise("-- only a comment") == "") - } - - @Test("EXPLAIN carries the inner statement") - func explain() { - #expect(kind("EXPLAIN SELECT 1") == .explain(statement: "SELECT 1", analyze: false)) - #expect(kind("explain DELETE FROM t WHERE TRUE ") == .explain(statement: "DELETE FROM t WHERE TRUE", analyze: false)) - #expect(kind("-- c\nEXPLAIN DROP TABLE t") == .explain(statement: "DROP TABLE t", analyze: false)) - #expect(kind("EXPLAIN (SELECT 1)") == .explain(statement: "(SELECT 1)", analyze: false)) - } - - @Test("EXPLAIN ANALYZE is flagged") - func explainAnalyze() { - #expect(kind("EXPLAIN ANALYZE SELECT 1") == .explain(statement: "SELECT 1", analyze: true)) - #expect(kind("explain /* x */ analyze DELETE FROM t") == .explain(statement: "DELETE FROM t", analyze: true)) - } - - @Test("A PostgreSQL option list is read for ANALYZE") - func explainOptionList() { - #expect(kind("EXPLAIN (ANALYZE) DELETE FROM t") == .explain(statement: "DELETE FROM t", analyze: true)) - #expect(kind("EXPLAIN (VERBOSE, ANALYZE true) SELECT 1") == .explain(statement: "SELECT 1", analyze: true)) - #expect(kind("EXPLAIN (FORMAT JSON) SELECT 1") == .explain(statement: "SELECT 1", analyze: false)) - } - - @Test("Transaction control", arguments: [ - ("BEGIN", SpannerStatementKind.begin), ("begin transaction", .begin), ("BEGIN WORK;", .begin), - ("START TRANSACTION", .begin), ("start transaction ;", .begin), ("COMMIT", .commit), ("COMMIT TRANSACTION", .commit), - ("commit work", .commit), ("ROLLBACK", .rollback), ("rollback transaction;", .rollback), - ("-- c\nBEGIN /* x */", .begin) - ]) - func transactionControl(sql: String, expected: SpannerStatementKind) { - #expect(kind(sql) == expected) - } - - @Test("Transaction options are not silently dropped", arguments: [ - "BEGIN READ ONLY", "BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE", "START TRANSACTION READ WRITE", - "BEGIN READ WRITE", "SET TRANSACTION READ ONLY", "SAVEPOINT a", "ROLLBACK TO SAVEPOINT a", "RELEASE SAVEPOINT a", - "COMMIT AND CHAIN" - ]) - func unsupportedTransactionControl(sql: String) { - #expect(kind(sql) == .unsupportedTransactionControl(sql)) - } - - @Test("SET without TRANSACTION and START without TRANSACTION are left to the server") - func otherSetAndStart() { - #expect(kind("SET STATEMENT_TIMEOUT = '10s'") == .query) - #expect(kind("START BATCH DDL") == .query) - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerStreamFramerTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerStreamFramerTests.swift deleted file mode 100644 index 1a69726009..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerStreamFramerTests.swift +++ /dev/null @@ -1,125 +0,0 @@ -import Foundation -import Testing - -@testable import TableProSpannerCore - -@Suite("SpannerStreamFramer") -struct SpannerStreamFramerTests { - private static let first = #"{"metadata":{"rowType":{"fields":[{"name":"a","type":{"code":"STRING"}}]}},"values":["x{\"}]["]}"# - private static let second = #"{"values":["a\\"],"chunkedValue":true}"# - private static let third = #"{"values":["}\u007b"]}"# - - private static let jsonArrayStream = "[\(first)\n,\r\n\(second),\t\(third)]" - private static let ndjsonStream = """ - {"result":\(first)} - {"result": \(second) } - { "result" :\(third)} - - """ - - private func frames(of text: String, splitEvery size: Int) throws -> [String] { - var framer = SpannerStreamFramer() - let bytes = Array(text.utf8) - var output: [Data] = [] - var start = 0 - while start < bytes.count { - let end = min(start + size, bytes.count) - output.append(contentsOf: try framer.append(Data(bytes[start..".utf8)) - } - } - - @Test("An empty JSON array yields nothing and finishes cleanly") - func emptyArray() throws { - #expect(try frames(of: "[]\n", splitEvery: 1).isEmpty) - } - - @Test("Frames decode into partial results and in-stream failures") - func messageDecoding() throws { - let partial = try SpannerStreamMessage.decode(Data(Self.second.utf8), httpStatus: 200) - guard case .partial(let set) = partial else { - Issue.record("Expected a partial result set") - return - } - #expect(set.values == [.string("a\\")]) - #expect(set.chunkedValue) - - let wrapped = try SpannerStreamMessage.decode( - Data(#"{"error":{"code":10,"message":"Transaction was aborted.","status":"ABORTED"}}"#.utf8), - httpStatus: 200 - ) - guard case .failure(let error) = wrapped else { - Issue.record("Expected an in-stream failure") - return - } - #expect(error.isAborted) - #expect(error.httpStatus == 200) - - let bare = try SpannerStreamMessage.decode(Data(#"{"code":14,"message":"unavailable"}"#.utf8), httpStatus: 200) - guard case .failure(let bareError) = bare else { - Issue.record("Expected a bare Status failure") - return - } - #expect(bareError.isUnavailable) - } - - @Test("A frame that is not a partial result set is an invalid response") - func undecodableFrame() { - #expect(throws: SpannerTransportError.invalidResponse) { - try SpannerStreamMessage.decode(Data(#"{"values":"nope"}"#.utf8), httpStatus: 200) - } - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerTestSupport.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerTestSupport.swift deleted file mode 100644 index 47ae24b895..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerTestSupport.swift +++ /dev/null @@ -1,210 +0,0 @@ -import Foundation -import TableProGoogleCloud -@testable import TableProSpannerCore - -struct StubSpannerResponse: Sendable { - let status: Int - let chunks: [Data] - let requestFailure: SpannerTransportError? - let trailingFailure: SpannerTransportError? - - init( - status: Int = 200, - chunks: [Data] = [], - requestFailure: SpannerTransportError? = nil, - trailingFailure: SpannerTransportError? = nil - ) { - self.status = status - self.chunks = chunks - self.requestFailure = requestFailure - self.trailingFailure = trailingFailure - } - - var body: Data { - chunks.reduce(into: Data()) { $0.append($1) } - } - - static func json(_ text: String, status: Int = 200) -> StubSpannerResponse { - StubSpannerResponse(status: status, chunks: [Data(text.utf8)]) - } - - static func stream(_ chunks: [String], status: Int = 200, trailingFailure: SpannerTransportError? = nil) -> StubSpannerResponse { - StubSpannerResponse(status: status, chunks: chunks.map { Data($0.utf8) }, trailingFailure: trailingFailure) - } - - static func failing(_ error: SpannerTransportError) -> StubSpannerResponse { - StubSpannerResponse(requestFailure: error) - } -} - -final class StubSpannerTransport: SpannerTransport, @unchecked Sendable { - typealias Responder = @Sendable (URLRequest) async throws -> StubSpannerResponse? - - private let lock = NSLock() - private let responder: Responder? - private var queue: [StubSpannerResponse] - private var log: [URLRequest] = [] - private var closes = 0 - - init(_ responses: [StubSpannerResponse] = [], responder: Responder? = nil) { - self.queue = responses - self.responder = responder - } - - var requests: [URLRequest] { - lock.withLock { log } - } - - var closeCount: Int { - lock.withLock { closes } - } - - func enqueue(_ responses: StubSpannerResponse...) { - lock.withLock { queue.append(contentsOf: responses) } - } - - func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { - let response = try await nextResponse(for: request) - if let failure = response.requestFailure { - throw failure - } - return (response.body, try Self.httpResponse(for: request, status: response.status)) - } - - func stream(_ request: URLRequest) async throws -> (HTTPURLResponse, AsyncThrowingStream) { - let response = try await nextResponse(for: request) - if let failure = response.requestFailure { - throw failure - } - let body = AsyncThrowingStream { continuation in - for chunk in response.chunks { - continuation.yield(chunk) - } - continuation.finish(throwing: response.trailingFailure) - } - return (try Self.httpResponse(for: request, status: response.status), body) - } - - func close() async { - lock.withLock { closes += 1 } - } - - private func nextResponse(for request: URLRequest) async throws -> StubSpannerResponse { - lock.withLock { log.append(request) } - if let responder, let answer = try await responder(request) { - return answer - } - return lock.withLock { () -> StubSpannerResponse in - guard !queue.isEmpty else { return .json("{}") } - return queue.removeFirst() - } - } - - private static func httpResponse(for request: URLRequest, status: Int) throws -> HTTPURLResponse { - guard let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil) - else { - throw SpannerTransportError.invalidResponse - } - return response - } -} - -actor StubAccessTokenProvider: GoogleAccessTokenProviding { - private var tokens: [String] - private(set) var accessTokenCalls = 0 - private(set) var invalidations = 0 - - init(tokens: [String]) { - self.tokens = tokens - } - - func accessToken() async throws -> String { - accessTokenCalls += 1 - guard tokens.count > 1 else { return tokens.first ?? "" } - return tokens.removeFirst() - } - - func invalidateCachedToken() async { - invalidations += 1 - } -} - -enum SpannerEmulatorEnvironment { - static var host: String? { - value("SPANNER_EMULATOR_HOST") - } - - static func value(_ key: String) -> String? { - ProcessInfo.processInfo.environment[key].flatMap { $0.isEmpty ? nil : $0 } - } -} - -enum SpannerTestFixtures { - static let databasePath = "projects/proj/instances/inst/databases/gdb" - static let sessionName = "projects/proj/instances/inst/databases/gdb/sessions/s1" - - static func settings( - endpoint: String = "", - authMethod: SpannerAuthMethod = .serviceAccount - ) throws -> SpannerConnectionSettings { - try SpannerConnectionSettings.parse(fields: [ - SpannerConnectionSettings.FieldKey.projectId: "proj", - SpannerConnectionSettings.FieldKey.instanceId: "inst", - SpannerConnectionSettings.FieldKey.databaseId: "gdb", - SpannerConnectionSettings.FieldKey.endpoint: endpoint, - SpannerConnectionSettings.FieldKey.authMethod: authMethod.rawValue - ]) - } - - static func emulatorSettings() throws -> SpannerConnectionSettings { - try settings(endpoint: "http://localhost:9020", authMethod: .emulator) - } - - static func client( - transport: StubSpannerTransport, - settings: SpannerConnectionSettings, - tokenProvider: (any GoogleAccessTokenProviding)? = nil - ) -> SpannerRESTClient { - SpannerRESTClient( - settings: settings, - transport: transport, - tokenProvider: tokenProvider, - retryDelays: [.zero, .zero, .zero] - ) - } - - static func emulatorClient(transport: StubSpannerTransport) throws -> SpannerRESTClient { - client(transport: transport, settings: try emulatorSettings()) - } -} - -extension URLRequest { - var jsonBody: [String: Any]? { - guard let httpBody else { return nil } - return (try? JSONSerialization.jsonObject(with: httpBody)) as? [String: Any] - } - - var authorization: String? { - value(forHTTPHeaderField: "Authorization") - } - - var absoluteURL: String { - url?.absoluteString ?? "" - } -} - -func collectEvents(_ stream: AsyncThrowingStream) async throws -> [SpannerStreamEvent] { - var events: [SpannerStreamEvent] = [] - for try await event in stream { - events.append(event) - } - return events -} - -func streamedRows(_ events: [SpannerStreamEvent]) -> [[SpannerJSONValue]] { - events.flatMap { event -> [[SpannerJSONValue]] in - guard case .rows(let rows) = event else { return [] } - return rows - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerValueDecoderTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerValueDecoderTests.swift deleted file mode 100644 index c594eb164f..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerValueDecoderTests.swift +++ /dev/null @@ -1,124 +0,0 @@ -import Foundation -import Testing - -@testable import TableProSpannerCore - -@Suite("SpannerValueDecoder") -struct SpannerValueDecoderTests { - private static let int64 = SpannerType(code: "INT64") - private static let string = SpannerType(code: "STRING") - private static let float64 = SpannerType(code: "FLOAT64") - private static let float32 = SpannerType(code: "FLOAT32") - private static let bytes = SpannerType(code: "BYTES") - private static let bool = SpannerType(code: "BOOL") - private static let numeric = SpannerType(code: "NUMERIC") - private static let json = SpannerType(code: "JSON") - - private static func array(_ element: SpannerType) -> SpannerType { - SpannerType(code: "ARRAY", arrayElementType: element) - } - - private func text(_ value: SpannerJSONValue, _ type: SpannerType) -> String? { - guard case .text(let text) = SpannerValueDecoder.cell(value, type: type) else { return nil } - return text - } - - @Test("NULL of any type is a null cell") - func nulls() { - #expect(SpannerValueDecoder.cell(.null, type: Self.int64) == .null) - #expect(SpannerValueDecoder.cell(.null, type: Self.bytes) == .null) - #expect(SpannerValueDecoder.cell(.null, type: Self.array(Self.int64)) == .null) - } - - @Test("Exact types keep their server text verbatim") - func verbatimStrings() { - #expect(text(.string("9223372036854775807"), Self.int64) == "9223372036854775807") - #expect(text(.string("1.50"), Self.numeric) == "1.50") - #expect(text(.string("2024-01-01"), SpannerType(code: "DATE")) == "2024-01-01") - #expect(text(.string("2024-01-01T00:00:00.123456Z"), SpannerType(code: "TIMESTAMP")) == "2024-01-01T00:00:00.123456Z") - #expect(text(.string("null"), Self.string) == "null") - #expect(text(.string(""), Self.string) == "") - #expect(text(.string("P1Y2M3D"), SpannerType(code: "INTERVAL")) == "P1Y2M3D") - #expect(text(.string(#"{"a":1}"#), Self.json) == #"{"a":1}"#) - } - - @Test("Booleans are true and false") - func booleans() { - #expect(text(.bool(true), Self.bool) == "true") - #expect(text(.bool(false), Self.bool) == "false") - } - - @Test("Floats print their shortest round-trip text without a trailing .0") - func floats() { - #expect(text(.number(1.5), Self.float64) == "1.5") - #expect(text(.number(3), Self.float64) == "3") - #expect(text(.number(1e300), Self.float64) == "1e+300") - #expect(text(.number(1e20), Self.float64) == "1e+20") - #expect(text(.number(-0.25), Self.float64) == "-0.25") - #expect(text(.number(0.10000000149011612), Self.float32) == "0.1") - #expect(text(.string("NaN"), Self.float64) == "NaN") - #expect(text(.string("-Infinity"), Self.float32) == "-Infinity") - } - - @Test("Bytes decode from base64, and bad base64 stays text") - func bytesCells() { - #expect(SpannerValueDecoder.cell(.string("AAE="), type: Self.bytes) == .bytes(Data([0x00, 0x01]))) - #expect(SpannerValueDecoder.cell(.string("not base64!"), type: Self.bytes) == .text("not base64!")) - } - - @Test("Arrays render as compact JSON with typed elements") - func arrays() { - #expect(text(.list([.string("1"), .string("2"), .null]), Self.array(Self.int64)) == "[1,2,null]") - #expect(text(.list([.string("a\"b"), .string("line\nbreak")]), Self.array(Self.string)) == #"["a\"b","line\nbreak"]"#) - #expect(text(.list([.bool(true), .bool(false)]), Self.array(Self.bool)) == "[true,false]") - #expect(text(.list([.number(1.5), .string("NaN"), .number(2)]), Self.array(Self.float64)) == #"[1.5,"NaN",2]"#) - #expect(text(.list([.string("YWI=")]), Self.array(Self.bytes)) == #"["YWI="]"#) - #expect(text(.list([.string("1.50")]), Self.array(Self.numeric)) == "[1.50]") - #expect(text(.list([.string(#"{"k":[1]}"#)]), Self.array(Self.json)) == #"[{"k":[1]}]"#) - #expect(text(.list([]), Self.array(Self.int64)) == "[]") - } - - @Test("A PG numeric NaN inside an array stays a JSON string") - func pgNumericNaN() { - let type = Self.array(SpannerType(code: "NUMERIC", typeAnnotation: "PG_NUMERIC")) - #expect(text(.list([.string("NaN"), .string("-3")]), type) == #"["NaN",-3]"#) - } - - @Test("Structs render as objects keyed by field name, unnamed fields by position") - func structs() { - let type = Self.array(SpannerType(code: "STRUCT", structFields: [ - SpannerField(name: "x", type: Self.int64), - SpannerField(name: "", type: Self.string), - SpannerField(name: "tags", type: Self.array(Self.string)) - ])) - let value = SpannerJSONValue.list([.list([.string("1"), .string("y"), .list([.string("t")])])]) - #expect(text(value, type) == #"[{"x":1,"_1":"y","tags":["t"]}]"#) - } - - @Test("Control characters and quotes in keys and strings are escaped") - func escaping() { - let type = SpannerType(code: "STRUCT", structFields: [SpannerField(name: "a\"b", type: Self.string)]) - #expect(text(.list([.string("\u{01}\t\\")]), type) == "{\"a\\\"b\":\"\\u0001\\t\\\\\"}") - } - - @Test("Rows decode per field, padding a short row with nulls") - func rows() { - let fields = [SpannerField(name: "", type: Self.int64), SpannerField(name: "b", type: Self.bytes)] - let rows = SpannerValueDecoder.rows([[.string("1"), .string("AAE=")], [.string("2")]], fields: fields) - #expect(rows == [[.text("1"), .bytes(Data([0x00, 0x01]))], [.text("2"), .null]]) - } - - @Test("Display type names are canonical") - func displayNames() { - #expect(SpannerValueDecoder.displayTypeName(Self.int64) == "INT64") - #expect(SpannerValueDecoder.displayTypeName(Self.array(Self.int64)) == "ARRAY") - #expect(SpannerValueDecoder.displayTypeName(SpannerType(code: "JSON", typeAnnotation: "PG_JSONB")) == "JSONB") - #expect(SpannerValueDecoder.displayTypeName(SpannerType(code: "NUMERIC", typeAnnotation: "PG_NUMERIC")) == "NUMERIC") - let structType = SpannerType(code: "STRUCT", structFields: [ - SpannerField(name: "a", type: Self.int64), - SpannerField(name: "", type: Self.array(Self.string)) - ]) - #expect(SpannerValueDecoder.displayTypeName(structType) == "STRUCT>") - #expect(SpannerValueDecoder.displayTypeName(Self.array(structType)) == "ARRAY>>") - } -} diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerWireDecodingTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerWireDecodingTests.swift deleted file mode 100644 index 62d06936bc..0000000000 --- a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerWireDecodingTests.swift +++ /dev/null @@ -1,222 +0,0 @@ -import Foundation -import Testing - -@testable import TableProSpannerCore - -@Suite("Spanner wire decoding") -struct SpannerWireDecodingTests { - private func decode(_ type: Value.Type, _ json: String) throws -> Value { - try JSONDecoder().decode(type, from: Data(json.utf8)) - } - - private func encodedObject(_ value: Value) throws -> [String: Any] { - let data = try JSONEncoder().encode(value) - return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - } - - @Test("The emulator's typed result decodes, including a field with no name") - func emulatorResultSet() throws { - let json = """ - {"metadata":{"rowType":{"fields":[{"type":{"code":"INT64"}},{"name":"arr","type":{"code":"ARRAY",\ - "arrayElementType":{"code":"STRUCT","structType":{"fields":[{"name":"x","type":{"code":"INT64"}},\ - {"name":"y","type":{"code":"STRING"}}]}}}},{"name":"j","type":{"code":"JSON","typeAnnotation":"PG_JSONB"}}]}},\ - "rows":[["1",[["1","y"]],"{\\"a\\":1}"]]} - """ - let result = try decode(SpannerResultSet.self, json) - let fields = try #require(result.metadata?.fields) - #expect(fields.count == 3) - #expect(fields[0].name == "") - #expect(fields[0].type.code == "INT64") - let element = try #require(fields[1].type.arrayElementType) - #expect(element.code == "STRUCT") - #expect(element.structFields.map(\.name) == ["x", "y"]) - #expect(fields[2].type.typeAnnotation == "PG_JSONB") - #expect(result.rows == [[.string("1"), .list([.list([.string("1"), .string("y")])]), .string(#"{"a":1}"#)]]) - #expect(result.stats == nil) - } - - @Test("A result with no rows key has no rows") - func missingRows() throws { - let result = try decode(SpannerResultSet.self, #"{"metadata":{"rowType":{"fields":[]}}}"#) - #expect(result.rows.isEmpty) - #expect(result.metadata?.fields.isEmpty == true) - } - - @Test("DML stats carry int64 counts as strings and the begun transaction id") - func dmlStats() throws { - let json = """ - {"metadata":{"rowType":{},"transaction":{"id":"MTc4OQ=="}},"stats":{"rowCountExact":"3"}} - """ - let result = try decode(SpannerResultSet.self, json) - #expect(result.metadata?.transactionId == "MTc4OQ==") - #expect(result.stats?.rowCountExact == 3) - #expect(result.stats?.rowCountLowerBound == nil) - } - - @Test("Row counts also decode from JSON numbers") - func numericCounts() throws { - let stats = try decode(SpannerResultSetStats.self, #"{"rowCountLowerBound":7}"#) - #expect(stats.rowCountLowerBound == 7) - } - - @Test("Undeclared parameters from a PLAN request decode") - func undeclaredParameters() throws { - let json = """ - {"metadata":{"rowType":{"fields":[{"name":"x","type":{"code":"INT64"}}]},\ - "undeclaredParameters":{"fields":[{"name":"p1","type":{"code":"INT64"}}]}},\ - "stats":{"queryPlan":{"planNodes":[{"displayName":"No query plan"}]}}} - """ - let result = try decode(SpannerResultSet.self, json) - #expect(result.metadata?.undeclaredParameters == [SpannerField(name: "p1", type: SpannerType(code: "INT64"))]) - let nodes = try #require(result.stats?.queryPlan?.nodes) - #expect(nodes == [SpannerPlanNode(index: 0, displayName: "No query plan")]) - } - - @Test("Plan nodes decode child links, short representations and metadata") - func planNodes() throws { - let json = """ - {"planNodes":[{"kind":"RELATIONAL","displayName":"Distributed Union","childLinks":[{"childIndex":1},\ - {"childIndex":"2","type":"Split Range"}],"metadata":{"subquery_cluster_node":"1"}},\ - {"index":1,"kind":"RELATIONAL","displayName":"Scan","childLinks":[]},\ - {"index":2,"kind":"SCALAR","displayName":"Function","shortRepresentation":{"description":"($x > 1)"}}]} - """ - let plan = try decode(SpannerQueryPlan.self, json) - #expect(plan.nodes.count == 3) - #expect(plan.nodes[0].index == 0) - #expect(plan.nodes[0].childLinks == [ - SpannerPlanNode.ChildLink(childIndex: 1), - SpannerPlanNode.ChildLink(childIndex: 2, type: "Split Range") - ]) - #expect(plan.nodes[0].metadata == ["subquery_cluster_node": .string("1")]) - #expect(plan.nodes[2].shortDescription == "($x > 1)") - #expect(plan.nodes[2].kind == "SCALAR") - } - - @Test("A partial result set defaults its optional parts") - func partialDefaults() throws { - let partial = try decode(SpannerPartialResultSet.self, #"{"values":["1",null,true,1.5]}"#) - #expect(partial.values == [.string("1"), .null, .bool(true), .number(1.5)]) - #expect(partial.chunkedValue == false) - #expect(partial.metadata == nil) - #expect(partial.resumeToken == nil) - } - - @Test("The Foundation decoding path the client uses agrees with the Codable path") - func foundationPathParity() throws { - let resultJSON = """ - {"metadata":{"rowType":{"fields":[{"name":"a","type":{"code":"STRING"}}]},"transaction":{"id":"T"}},\ - "rows":[["1",true,false,0,1,1.5,1e+300,-0.25,null,"NaN",["a",["b"],{"k":"v"}],{"x":[1,"2"]}]],\ - "stats":{"rowCountExact":"12"}} - """ - let codable = try decode(SpannerResultSet.self, resultJSON) - let foundation = try SpannerResultSet(foundationObject: SpannerFoundationJSON.object(Data(resultJSON.utf8))) - #expect(foundation == codable) - #expect(foundation.rows.first?[1] == .bool(true)) - #expect(foundation.rows.first?[3] == .number(0)) - - let partialJSON = #"{"values":["a",1,true,[null]],"chunkedValue":true,"resumeToken":"cmVz"}"# - let partialCodable = try decode(SpannerPartialResultSet.self, partialJSON) - let partialFoundation = try SpannerPartialResultSet( - foundationObject: SpannerFoundationJSON.object(Data(partialJSON.utf8)) - ) - #expect(partialFoundation == partialCodable) - #expect(partialFoundation.chunkedValue) - #expect(partialFoundation.resumeToken == "cmVz") - } - - @Test("A result whose rows are not a list is an invalid response") - func rowsNotAList() { - #expect(throws: SpannerTransportError.invalidResponse) { - try SpannerResultSet(foundationObject: SpannerFoundationJSON.object(Data(#"{"rows":{"a":1}}"#.utf8))) - } - } - - @Test("A JSON value keeps strings, booleans, numbers, lists and objects apart") - func jsonValueRoundTrip() throws { - let value = try decode(SpannerJSONValue.self, #"{"a":[1,"1",true,null,{"b":"c"}]}"#) - #expect(value == .object(["a": .list([.number(1), .string("1"), .bool(true), .null, .object(["b": .string("c")])])])) - let data = try JSONEncoder().encode(value) - #expect(try JSONDecoder().decode(SpannerJSONValue.self, from: data) == value) - } - - @Test("A recursive type encodes element, struct fields and annotation") - func typeEncoding() throws { - let type = SpannerType( - code: "ARRAY", - arrayElementType: SpannerType( - code: "STRUCT", - structFields: [SpannerField(name: "n", type: SpannerType(code: "NUMERIC", typeAnnotation: "PG_NUMERIC"))] - ) - ) - let object = try encodedObject(type) - #expect(object["code"] as? String == "ARRAY") - #expect(object["structType"] == nil) - let element = try #require(object["arrayElementType"] as? [String: Any]) - let structType = try #require(element["structType"] as? [String: Any]) - let fields = try #require(structType["fields"] as? [[String: Any]]) - #expect(fields.first?["name"] as? String == "n") - let fieldType = try #require(fields.first?["type"] as? [String: Any]) - #expect(fieldType["typeAnnotation"] as? String == "PG_NUMERIC") - let decoded = try JSONDecoder().decode(SpannerType.self, from: JSONEncoder().encode(type)) - #expect(decoded == type) - } - - @Test("A single-use strong read encodes its selector and leaves params out when empty") - func singleUseRequest() throws { - let object = try encodedObject(SpannerExecuteSqlRequest(sql: "SELECT 1", transaction: .singleUseStrongReadOnly)) - #expect(object["sql"] as? String == "SELECT 1") - let transaction = try #require(object["transaction"] as? [String: Any]) - let singleUse = try #require(transaction["singleUse"] as? [String: Any]) - let readOnly = try #require(singleUse["readOnly"] as? [String: Any]) - #expect(readOnly["strong"] as? Bool == true) - #expect(object["params"] == nil) - #expect(object["paramTypes"] == nil) - #expect(object["seqno"] == nil) - #expect(object["queryMode"] as? String == "NORMAL") - } - - @Test("An inline begin encodes a read-write option and the seqno as a JSON string") - func beginRequest() throws { - let request = SpannerExecuteSqlRequest( - sql: "DELETE FROM t WHERE TRUE", - transaction: .beginReadWrite, - queryMode: .plan, - seqno: 1 - ) - let object = try encodedObject(request) - let transaction = try #require(object["transaction"] as? [String: Any]) - let begin = try #require(transaction["begin"] as? [String: Any]) - #expect(begin["readWrite"] is [String: Any]) - #expect(object["seqno"] as? String == "1") - #expect(object["queryMode"] as? String == "PLAN") - } - - @Test("A transaction id selector carries params and their types") - func idRequest() throws { - let request = SpannerExecuteSqlRequest( - sql: "UPDATE t SET a = @p1 WHERE k = @p2", - transaction: .id("TXN"), - params: ["p1": .bool(true), "p2": .string("9")], - paramTypes: ["p1": SpannerType(code: "BOOL"), "p2": SpannerType(code: "INT64")], - seqno: 42 - ) - let object = try encodedObject(request) - let transaction = try #require(object["transaction"] as? [String: Any]) - #expect(transaction["id"] as? String == "TXN") - let params = try #require(object["params"] as? [String: Any]) - #expect(params["p1"] as? Bool == true) - #expect(params["p2"] as? String == "9") - let types = try #require(object["paramTypes"] as? [String: Any]) - #expect((types["p2"] as? [String: Any])?["code"] as? String == "INT64") - #expect(object["seqno"] as? String == "42") - } - - @Test("Replay safety follows the selector, the seqno and the query mode") - func replaySafety() { - #expect(SpannerExecuteSqlRequest(sql: "", transaction: .singleUseStrongReadOnly).isReplaySafe) - #expect(SpannerExecuteSqlRequest(sql: "", transaction: .id("t"), seqno: 3).isReplaySafe) - #expect(SpannerExecuteSqlRequest(sql: "", transaction: .beginReadWrite, queryMode: .plan).isReplaySafe) - #expect(!SpannerExecuteSqlRequest(sql: "", transaction: .id("t")).isReplaySafe) - #expect(!SpannerExecuteSqlRequest(sql: "", transaction: .beginReadWrite).isReplaySafe) - } -} diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncMapperFieldAccessTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncMapperFieldAccessTests.swift deleted file mode 100644 index d69826c049..0000000000 --- a/Packages/TableProCore/Tests/TableProSyncTests/SyncMapperFieldAccessTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation -import Testing - -@Suite("Sync mappers only reach CKRecord fields through the schema gate") -struct SyncMapperFieldAccessTests { - private static let repositoryRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - - private static let mapperPaths = [ - "TablePro/Core/Sync/SyncRecordMapper.swift", - "Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift" - ] - - @Test("Every mapper the check covers is where the check expects it") - func mappersExist() { - for path in Self.mapperPaths { - let url = Self.repositoryRoot.appendingPathComponent(path) - #expect(FileManager.default.fileExists(atPath: url.path), """ - \(path) has moved, so this check would pass vacuously. Update mapperPaths. - """) - } - } - - @Test("No mapper writes a CKRecord field by raw string", arguments: mapperPaths) - func mappersUseTypedFields(path: String) throws { - let url = Self.repositoryRoot.appendingPathComponent(path) - guard let source = try? String(contentsOf: url, encoding: .utf8) else { return } - - let offenders = source - .components(separatedBy: .newlines) - .enumerated() - .filter { $0.element.contains("record[\"") } - .map { "\(path):\($0.offset + 1): \($0.element.trimmingCharacters(in: .whitespaces))" } - - #expect(offenders.isEmpty, """ - Raw-string CKRecord field access bypasses the production-schema gate, which is exactly \ - how FavoriteTable, SQLFavorite, SQLFavoriteFolder and SSHProfile.jumpHostsJson reached \ - users without ever being deployed. Declare the field in its SyncSchemaField enum and go \ - through record.fields(_:) instead. - \(offenders.joined(separator: "\n")) - """) - } -} diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordNameConstructionTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordNameConstructionTests.swift deleted file mode 100644 index e4e0fd3cc3..0000000000 --- a/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordNameConstructionTests.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -import Testing - -@Suite("Record names are only built where CloudKit's length limit is enforced") -struct SyncRecordNameConstructionTests { - private static let repositoryRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - - private static let sourceRoots = [ - "TablePro", - "TableProMobile/TableProMobile", - "Packages/TableProCore/Sources" - ] - - private static let permittedPaths: Set = [ - "TablePro/Core/Sync/SyncRecordMapper.swift", - "Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift" - ] - - @Test("Every source root the check covers is where the check expects it") - func sourceRootsExist() { - for path in Self.sourceRoots { - let url = Self.repositoryRoot.appendingPathComponent(path) - #expect(FileManager.default.fileExists(atPath: url.path), """ - \(path) has moved, so this check would pass vacuously. Update sourceRoots. - """) - } - } - - @Test("Every mapper the check permits is where the check expects it") - func permittedMappersExist() { - for path in Self.permittedPaths { - let url = Self.repositoryRoot.appendingPathComponent(path) - #expect(FileManager.default.fileExists(atPath: url.path), """ - \(path) has moved. Update permittedPaths. - """) - } - } - - @Test("No shipping source constructs a CKRecord.ID outside the mappers") - func recordIdsComeFromTheMappers() { - var offenders: [String] = [] - - for root in Self.sourceRoots { - let rootURL = Self.repositoryRoot.appendingPathComponent(root) - guard let files = FileManager.default.enumerator( - at: rootURL, - includingPropertiesForKeys: nil - ) else { continue } - - for case let url as URL in files where url.pathExtension == "swift" { - let path = url.path.replacingOccurrences(of: Self.repositoryRoot.path + "/", with: "") - guard !Self.permittedPaths.contains(path), - let source = try? String(contentsOf: url, encoding: .utf8) else { continue } - - for (offset, line) in source.components(separatedBy: .newlines).enumerated() { - let code = line.trimmingCharacters(in: .whitespaces) - guard !code.hasPrefix("//"), code.contains("CKRecord.ID(recordName:") else { continue } - offenders.append("\(path):\(offset + 1): \(code)") - } - } - } - - #expect(offenders.isEmpty, """ - CKRecord.ID(recordName:) raises CKException past 255 UTF-16 code units, and an \ - Objective-C exception raised inside a Swift task crashes the app from an unrelated call \ - site seconds later. SyncRecordType.recordName(for:) is the only thing that bounds the \ - name, so go through SyncRecordMapper.recordID(type:id:in:). - \(offenders.joined(separator: "\n")) - """) - } -} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/BigUIntTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/BigUIntTests.swift deleted file mode 100644 index a5ec9f441f..0000000000 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/BigUIntTests.swift +++ /dev/null @@ -1,72 +0,0 @@ -@testable import TableProTeradataCore -import XCTest - -final class BigUIntTests: XCTestCase { - private func bigUInt(_ hex: String, file: StaticString = #filePath, line: UInt = #line) throws -> BigUInt { - try XCTUnwrap(BigUInt(hex: hex), file: file, line: line) - } - - func testRoundTripBytes() throws { - XCTAssertEqual(try bigUInt("").description, "0") - XCTAssertEqual(try bigUInt("00").description, "0") - XCTAssertEqual(try bigUInt("01").description, "01") - XCTAssertEqual(try bigUInt("ff").description, "ff") - XCTAssertEqual(try bigUInt("0100").description, "0100") - XCTAssertEqual(try bigUInt("deadbeef").description, "deadbeef") - let prime = try bigUInt(DHVectors.primeHex) - XCTAssertEqual(prime.byteCount, 256) - XCTAssertEqual(prime.description, DHVectors.primeHex.lowercased()) - XCTAssertEqual(BigUInt(bytesBE: prime.bytesBE()), prime) - } - - func testComparisonAndSubtraction() throws { - let a = try bigUInt("1000000000000000") - let b = try bigUInt("ffffffff") - XCTAssertEqual(BigUInt.compare(a.limbs, b.limbs), 1) - XCTAssertEqual(BigUInt.compare(b.limbs, a.limbs), -1) - XCTAssertEqual(BigUInt.compare(a.limbs, a.limbs), 0) - XCTAssertEqual(a.subtracting(b).description, "0fffffff00000001") - } - - func testModSmall() throws { - XCTAssertEqual(try bigUInt("64").mod(bigUInt("0a")).description, "0") - XCTAssertEqual(try bigUInt("65").mod(bigUInt("0a")).description, "01") - XCTAssertEqual(try bigUInt("deadbeefcafe").mod(bigUInt("010000")).description, "cafe") - XCTAssertEqual(try bigUInt("123456789abcdef0").mod(bigUInt("0100000000")).description, - "9abcdef0") - } - - func testModPowSmallVectors() throws { - for entry in DHVectors.small { - let base = try bigUInt(entry.base) - let exp = try bigUInt(entry.exp) - let mod = try bigUInt(entry.mod) - let expected = try bigUInt(entry.result) - XCTAssertEqual(base.modPow(exp, modulus: mod), expected, - "\(entry.base)^\(entry.exp) mod \(entry.mod)") - } - } - - func testModPowDHVectors() throws { - let prime = try bigUInt(DHVectors.primeHex) - let generator = try bigUInt(DHVectors.generatorHex) - for pair in DHVectors.pairs { - let exp = try bigUInt(pair.x) - let expected = try bigUInt(pair.y) - XCTAssertEqual(generator.modPow(exp, modulus: prime), expected, "g^\(pair.x)") - } - } - - func testDiffieHellmanSharedSecretConsistency() throws { - let prime = try bigUInt(DHVectors.primeHex) - let generator = try bigUInt(DHVectors.generatorHex) - let privateA = try bigUInt("7f3c91aa20d5e6b8c4d1f0937755dd11abcdef0123456789fedcba9876543210") - let privateB = try bigUInt("95e42b7d1c0a8f6e5d4c3b2a19087f6e5d4c3b2a1908f7e6d5c4b3a2918070605") - let publicA = generator.modPow(privateA, modulus: prime) - let publicB = generator.modPow(privateB, modulus: prime) - let sharedFromA = publicB.modPow(privateA, modulus: prime) - let sharedFromB = publicA.modPow(privateB, modulus: prime) - XCTAssertEqual(sharedFromA, sharedFromB) - XCTAssertFalse(sharedFromA.isZero) - } -} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/DHVectors.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/DHVectors.swift deleted file mode 100644 index 2997e1de23..0000000000 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/DHVectors.swift +++ /dev/null @@ -1,26 +0,0 @@ -// Auto-generated DH test vectors (RFC 3526 Group 14 prime, g=2). Do not edit. -enum DHVectors { - // swiftlint:disable:next line_length - static let primeHex = "ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff" - static let generatorHex = "02" - static let pairs: [(x: String, y: String)] = [ - ("01", "02"), - ("02", "04"), - ("05", "20"), - // swiftlint:disable line_length - ("1234", "841ad311a237c5b4417619073dc277d3324a355324082383e5a4b75dfceb099532e57984cb67bcbd0ae924e2f20df4ef35049c4b8d3173b6fdfb2594410072a9e63ad88797cff1393abcb9a5505513fd76b6525508b5256cabc88e0f8adf7c49bf98ca32e1d5dd40b8a9685b84f5fc7f26277837f8bcad0de6483c3988efd54523fcd6d1c568e800b63ef592c3d9e3972dc060129ba73a669bb5d5ff3063e76def06113da4395e3ae0b4080647f17fe9adfddc42d5d241c7507478753109e15719e318ff0b5b00f5d1e6931c7e138d4043515122197d2a4962216b948630b2e5bb8589dd35a1d1b2e78967adba9ec8ca68e761ca29c6b881ad783b6ed7507346"), - ("deadbeef", "8cf8f34206ef1c591b3fe2c5ddec332fd79b3ade33f2bf2fd291993006cd0225023a12eb285d28930ae32e0deb8b3156d9fef7bf20f52f144356b0d6951a864fc1b548a52964ad09729cf5dc683b4a4acc6dbeb8855e4f3d8ec9e2efa6d6b2d312db86b01381a31ce47b0e3c0e90877cda66ff85465963f867ee020bd647cbb4e6023726ecfcf67da8e93116e0c1aded2c60d7a802c55fa133be6dee49d70e33d739acb7860884fb25e02a67e980197021315098addb221c9256015071dafd7a1d9f53353b86999f3830a16f9d2304eb2b7005698d590b5daa5030cd5b46119b47d1150b64dfb39edc50e5048a486c882885cbb2c4c21ec3e7744f54481afab3"), - ("01000000", "53649e1f1959dae3b9d253e56134dcd2fab6ba41cd89f43a32c2bfe2d8442e957ed83073a4d0893a757bbf9fce2e93772525f51256b3162bf1942173e426e680d8e649ae118e4a10321013390e742e27e23a5c4cc0a485b2959fdb7dec5297a0cc4b513477bcabcda08270198c10d75e5f62a26adafa40bf7b024928cdfb53703fcc651698c5c0f7571d12f5976f525883faf6fe6353badb980a076621ec27701935d2e35d2d02a73a78bdb193c0e894342f7478df47baa1f32c37420b1afd9c6e1edb8a57aeaf2dc0d42fff01f67f53ac56b55aa62796e0622a23a40b81330df927525dc8cc4bedf182fff210615b245dfd0059dfb9f26fe6f969a596503b17"), - ("010000000000000000000000000000000000000000000000000000000000000007", "906f66166f41a25762ef3cf4de3e8b7108529e83a6994a7a3d7287719c72145dd899301a2471fe1035ad595f7cf701b6188d7093dcdc806577255b33b837d41281e3d27418fd478b83ba8828db9ee171f2abc805a877252b87e48c45d3b99541ecc52ee4956fdc71b3e920e75a53733dad00b91d9c70a306faa2e6b28314867f84235ed6428a0a6746b0faeffdd66481ffe32cbee48fb15fdccc582d37510936c2218620f320dcc93c14591c697e6d45fdce883c4ba5689710541328f5af663b83eb55741cde466fd022dfc1bdbe4006f75d0a2d61dbc4a5018e89cd25d7e6ae90f739813e3581aad83c97188a0a4795a73c7b57ece5a8c621648104f4204477"), - ("8f7ea53c0563fb20b2597bc5f6b67fb13cdce41001871be1f77c2bc3021c50ce8cfbc7c83cb2b4ce025ed03069aef471ca13d5d7b09fdd473e5a137a973318fb", "27aff6769a2705b5a162fcce8443b30d78315a1b82d59a2a59523e9c09d1fcde349af7807dcfe24138df3582d32fc7c38328af642261e458d568b6cf8470fca079f0ee8b9ee0a184a160c19e69d21bc269bb0e0f542b95d8d018d0de98352bc847964d2ec67a2c060163fef35aa2326e499433504621cd776d4e4b2a055870408f44dd400c11b53ad8a2eca69e36e8cdae4f7c6bf1d14f721c183d5488f4e6bb585dea0bb6648e1e45ba553974d9d782293c2616705dfeffaaab881d91bba6614a94f9ebc9608b32123d2eb4d27783938e9a278c2c82aa2806439c2ed754faf6ac7158da01bafe6a95ddeb3d3cc871b841f9943820f5a5f59a2bc9b012c1c7d0"), - ("445384194288abce1c17ac5ee458c8a3d73727c861ba079c54b138fbc0150f610f645f30bc4e7072a67751bb37a32311038b743c82e584f098e2967f42a896bd", "f7cbf6e39f08ae044424374ac5b2e42bc6d0ffe2aec5ac40b35fef75dac0b6e18fc3a9d54d83f49f5023bb28f8029964f389bd2909c958549fb2de01c4c320f8bb2744fe36b487293032c06f1fb85436cf9360f3b349984010453a85eff7b49e3dc8f19a28819afd2f00d8e5ba485abb72d4b82470e08a7310bac89c70cdfabe55dc0786a49a3460fe2d110a27e341cb4c7d93981d06f4e35858c036a38a97c8c036292d4ae26878283cd8dbadc5594c1db877fdaea93666f47b3fbf2ec936b6cc3254b1984c01fab38d4864f895c30ec08c00badf13b05a90abf0e06421c15af985051b0df1646226c7858a8600854c64bfa07dab10ae287e18868552fe4edf"), - ("07484e34cf328f0a82649aaf6873c40ecb6c03e09c48cf9708c7bd4afb6963601bdb409fbb33bd8cd53ab94af4e0ec96d783b78bc43f4e2f988ec825693072f3d1c438dd766172659ba2e747cf1b4bcdc2490ca21d325269bf54899daafa99dbc2aee220cd50bd5616f4d4bacd79b731de8daf91ff964dc9f82854529d8b6c36cedcaca521e4e0bb62141ccde825bb5fe31fbf26a1b43c2b3b8348b58690123f7a62122974f46e1bbc4f9465edebf9ba6de1c14779f8f0eeb34d644b0fe983abad8a708007e83000d60909c9bb6349590cb040104d7aec2ed5e8b1be389a3d8eb3338ba86188522ed360edeeeb6c747628ee6257abe36622a0aca5483ba77de1", "5a2f0d41f941033743b985df48371103fad67269a7daf2390e8c928b02d24546344eea38aeac2005c6bdd3ece343fb96068f93dede0933ceee3f720bd98ad594deb9b11e9a6d952f303bf1d5186967fa59ed478effa33a0b0a7edadacc0bc91a87a3604a016792020ab8f8f9f46367b3a70c8e7fc2062b22b9e503b433a5778eefa9b8ea324715731ba223bd10861a4619a44208f59bb0546424cb36367452019c7520ab887b801e6931de2e311300cde74d3207407c9b1cc48870d679f7962fefc713949bfe851f27711da41ed2c6d1d43c7c89cf171fe82cff8b9a8311b2f46254cb1985ea22377a3bbe3f1f41a064734d9f99bf964e986cdeddd89f23679f"), - // swiftlint:enable line_length - ] - static let small: [(base: String, exp: String, mod: String, result: String)] = [ - ("05", "75", "f1", "1b"), - ("07", "00", "f1", "01"), - ("03", "03e8", "0f4243", "011e00"), - ("02", "0fff", "fffffffb", "73cdcb4d"), - ] -} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/DerTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/DerTests.swift deleted file mode 100644 index 65a8c02978..0000000000 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/DerTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -@testable import TableProTeradataCore -import XCTest - -final class DerTests: XCTestCase { - func testTd2MechanismOid() throws { - let expected: [UInt8] = [0x06, 0x0D, 0x2B, 0x06, 0x01, 0x04, 0x01, 0x81, 0x3F, - 0x01, 0x87, 0x74, 0x01, 0x01, 0x09] - let encoded = try Der.encodeObjectIdentifier("1.3.6.1.4.1.191.1.1012.1.1.9") - XCTAssertEqual(encoded, expected) - XCTAssertEqual(try Der.decodeObjectIdentifier(encoded), "1.3.6.1.4.1.191.1.1012.1.1.9") - } - - func testLongLength() { - XCTAssertEqual(Der.encodeLength(0x7F), [0x7F]) - XCTAssertEqual(Der.encodeLength(0x80), [0x81, 0x80]) - XCTAssertEqual(Der.encodeLength(300), [0x82, 0x01, 0x2C]) - } -} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/DiffieHellmanTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/DiffieHellmanTests.swift deleted file mode 100644 index 9eb1f3fd49..0000000000 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/DiffieHellmanTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -@testable import TableProTeradataCore -import XCTest - -final class DiffieHellmanTests: XCTestCase { - private func bytesBE(ofHex hex: String, file: StaticString = #filePath, line: UInt = #line) throws -> [UInt8] { - try XCTUnwrap(BigUInt(hex: hex), file: file, line: line).bytesBE() - } - - private func primeBytes(file: StaticString = #filePath, line: UInt = #line) throws -> [UInt8] { - try bytesBE(ofHex: DHVectors.primeHex, file: file, line: line) - } - - func testSharedSecretMatches() throws { - let prime = try primeBytes() - let exponentA = try bytesBE(ofHex: "a1b2c3d4e5f6071829") - let exponentB = try bytesBE(ofHex: "0f1e2d3c4b5a69788796a5b4c3d2e1f0") - let dhA = DiffieHellman(primeBytes: prime, generatorBytes: [0x02], privateExponentBytes: exponentA) - let dhB = DiffieHellman(primeBytes: prime, generatorBytes: [0x02], privateExponentBytes: exponentB) - let sharedA = dhA.sharedSecret(peerPublicKeyBytes: dhB.publicKeyBytes()) - let sharedB = dhB.sharedSecret(peerPublicKeyBytes: dhA.publicKeyBytes()) - XCTAssertEqual(sharedA, sharedB) - XCTAssertEqual(dhA.publicKeyBytes().count, 256) - XCTAssertEqual(sharedA.count, 256) - } - - func testRandomExponentIsUnique() throws { - let dh1 = DiffieHellman(primeBytes: try primeBytes(), generatorBytes: [0x02]) - let dh2 = DiffieHellman(primeBytes: try primeBytes(), generatorBytes: [0x02]) - XCTAssertNotEqual(dh1.publicKeyBytes(), dh2.publicKeyBytes()) - } - - func testDeriveKeySlice() { - let secret = (0..<32).map { UInt8($0) } - XCTAssertEqual(DiffieHellman.deriveKey(fromSharedSecret: secret, offset: 0, length: 16), - Array(0..<16).map(UInt8.init)) - XCTAssertEqual(DiffieHellman.deriveKey(fromSharedSecret: secret, offset: 16, length: 8), - Array(16..<24).map(UInt8.init)) - } -} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/GcmCipherTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/GcmCipherTests.swift deleted file mode 100644 index 9c539768ff..0000000000 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/GcmCipherTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -@testable import TableProTeradataCore -import XCTest - -final class GcmCipherTests: XCTestCase { - private let key = [UInt8](repeating: 0x2B, count: 16) - private let nonce = [UInt8](repeating: 0x11, count: 12) - - func testRoundTrip() throws { - let cipher = GcmCipher(keyBytes: key) - let plaintext: [UInt8] = Array("username,password,account".utf8) - let sealed = try cipher.seal(plaintext, nonce: nonce, aad: [0x01, 0x02]) - let opened = try cipher.open(ciphertext: sealed.ciphertext, nonce: nonce, tag: sealed.tag, aad: [0x01, 0x02]) - XCTAssertEqual(opened, plaintext) - XCTAssertEqual(sealed.tag.count, GcmCipher.tagLength) - } - - func testTamperRejected() throws { - let cipher = GcmCipher(keyBytes: key) - var sealed = try cipher.seal([0xDE, 0xAD, 0xBE, 0xEF], nonce: nonce) - sealed.ciphertext[0] ^= 0xFF - XCTAssertThrowsError(try cipher.open(ciphertext: sealed.ciphertext, nonce: nonce, tag: sealed.tag)) - } -} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/LanMessageTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/LanMessageTests.swift deleted file mode 100644 index 2b38c87b18..0000000000 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/LanMessageTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -@testable import TableProTeradataCore -import XCTest - -final class LanMessageTests: XCTestCase { - func testHeaderLayout() { - let message = LanMessage(kind: .start, body: [0xAA, 0xBB], sessionNumber: 0x01020304, - requestNumber: 0x11223344, hostCharSet: 0x7F) - let encoded = message.encoded() - XCTAssertEqual(encoded.count, LanMessage.headerLength + 2) - XCTAssertEqual(encoded[0], 3) - XCTAssertEqual(encoded[1], LanMessage.requestClass) - XCTAssertEqual(encoded[2], MessageKind.start.rawValue) - XCTAssertEqual(Array(encoded[20..<24]), [0x01, 0x02, 0x03, 0x04]) - XCTAssertEqual(Array(encoded[32..<36]), [0x11, 0x22, 0x33, 0x44]) - XCTAssertEqual(encoded[37], 0x7F) - XCTAssertEqual(Array(encoded[52..<54]), [0xAA, 0xBB]) - } - - func testSplitBodyLength() { - let body = [UInt8](repeating: 0x5A, count: 70_000) - let encoded = LanMessage(kind: .cont, body: body).encoded() - let header = Array(encoded[0.. TeradataConnectionConfig { - TeradataConnectionConfig(host: "127.0.0.1", username: "u", password: "p", logMech: mech) - } - - func testUnsupportedMechanismsRejectedBeforeNetwork() { - for mech in [TeradataLogMech.ldap, .krb5, .jwt] { - XCTAssertThrowsError(try TeradataConnection(config: config(mech)).connect()) { error in - guard case TeradataWireError.unsupported(let detail) = error else { - return XCTFail("expected .unsupported for \(mech.rawValue), got \(error)") - } - XCTAssertTrue(detail.contains(mech.rawValue)) - } - } - } - - func testTd2AndTdnegoPassMechanismValidation() { - for mech in [TeradataLogMech.td2, .tdnego] { - XCTAssertThrowsError(try TeradataConnection(config: config(mech)).connect()) { error in - if case TeradataWireError.unsupported = error { - XCTFail("\(mech.rawValue) must pass mechanism validation, was rejected as unsupported") - } - } - } - } -} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift deleted file mode 100644 index 0ca8521ee2..0000000000 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataSchemaQueriesTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -@testable import TableProTeradataCore -import XCTest - -final class TeradataSchemaQueriesTests: XCTestCase { - func testQuoteIdentifierDoublesInternalQuotes() { - XCTAssertEqual(TeradataSchemaQueries.quoteIdentifier("Sales"), "\"Sales\"") - XCTAssertEqual(TeradataSchemaQueries.quoteIdentifier("My\"Col"), "\"My\"\"Col\"") - } - - func testQualifiedName() { - XCTAssertEqual(TeradataSchemaQueries.qualifiedName(database: "Retail", table: "Orders"), - "\"Retail\".\"Orders\"") - XCTAssertEqual(TeradataSchemaQueries.qualifiedName(database: nil, table: "Orders"), "\"Orders\"") - XCTAssertEqual(TeradataSchemaQueries.qualifiedName(database: "", table: "Orders"), "\"Orders\"") - } - - func testListTablesUsesLiteralDatabase() { - let sql = TeradataSchemaQueries.listTables(database: "Re'tail") - XCTAssertTrue(sql.contains("FROM DBC.TablesV")) - XCTAssertTrue(sql.contains("DatabaseName = 'Re''tail'")) - } - - func testListTablesExcludesProceduresMacrosAndFunctions() { - let sql = TeradataSchemaQueries.listTables(database: "demo_user") - XCTAssertTrue(sql.contains("TableKind IN ('T', 'O', 'Q', 'V')")) - for kind in ["'M'", "'P'", "'E'", "'F'", "'R'", "'G'"] { - XCTAssertFalse(sql.contains(kind), "browsable-object list must not include \(kind)") - } - } - - func testBrowseFirstPageUsesTop() { - let sql = TeradataSchemaQueries.browse( - database: "Retail", table: "Orders", columns: nil, - sortColumns: [], limit: 100, offset: 0) - XCTAssertEqual(sql, "SELECT TOP 100 * FROM \"Retail\".\"Orders\"") - } - - func testBrowseFirstPageWithSort() { - let sql = TeradataSchemaQueries.browse( - database: "Retail", table: "Orders", columns: nil, - sortColumns: [("Total", false)], limit: 50, offset: 0) - XCTAssertEqual(sql, "SELECT TOP 50 * FROM \"Retail\".\"Orders\" ORDER BY \"Total\" DESC") - } - - func testBrowseOffsetPageUsesQualifyRowNumber() { - let sql = TeradataSchemaQueries.browse( - database: "Retail", table: "Orders", columns: ["Id", "Total"], - sortColumns: [("Id", true)], limit: 100, offset: 200) - XCTAssertEqual(sql, - "SELECT \"Id\", \"Total\" FROM \"Retail\".\"Orders\" " - + "QUALIFY ROW_NUMBER() OVER (ORDER BY \"Id\" ASC) BETWEEN 201 AND 300") - } - - func testBrowseOffsetPageFallsBackToOrderByOne() { - let sql = TeradataSchemaQueries.browse( - database: nil, table: "Orders", columns: nil, - sortColumns: [], limit: 25, offset: 25) - XCTAssertTrue(sql.contains("QUALIFY ROW_NUMBER() OVER (ORDER BY 1) BETWEEN 26 AND 50")) - } - - func testColumnsQueryTargetsColumnsV() { - let sql = TeradataSchemaQueries.columns(database: "Retail", table: "Orders") - XCTAssertTrue(sql.contains("FROM DBC.ColumnsV")) - XCTAssertTrue(sql.contains("ORDER BY ColumnId")) - XCTAssertTrue(sql.contains("TableName = 'Orders'")) - } - - func testStatisticsRowCountReadsTableStatsV() { - let sql = TeradataSchemaQueries.statisticsRowCount(database: "Re'tail", table: "Orders") - XCTAssertEqual( - sql, - "SELECT CAST(MAX(RowCount) AS BIGINT) FROM DBC.TableStatsV WHERE DatabaseName = 'Re''tail' AND TableName = 'Orders'" - ) - } - - func testTableCommentReadsCommentStringFromTablesV() { - let sql = TeradataSchemaQueries.tableComment(database: "Re'tail", table: "Orders") - XCTAssertEqual( - sql, - "SELECT CommentString FROM DBC.TablesV WHERE DatabaseName = 'Re''tail' AND TableName = 'Orders'" - ) - } - - func testCommentStatementSetsAndClearsTableAndViewComments() { - let target = TeradataSchemaQueries.qualifiedName(database: "Retail", table: "Orders") - XCTAssertEqual( - TeradataSchemaQueries.commentStatement(objectType: "TABLE", qualifiedName: target, comment: "it's"), - "COMMENT ON TABLE \"Retail\".\"Orders\" IS 'it''s'" - ) - XCTAssertEqual( - TeradataSchemaQueries.commentStatement(objectType: "VIEW", qualifiedName: target, comment: "x"), - "COMMENT ON VIEW \"Retail\".\"Orders\" IS 'x'" - ) - XCTAssertEqual( - TeradataSchemaQueries.commentStatement(objectType: "TABLE", qualifiedName: target, comment: nil), - "COMMENT ON TABLE \"Retail\".\"Orders\" IS ''" - ) - XCTAssertEqual( - TeradataSchemaQueries.commentStatement(objectType: "VIEW", qualifiedName: target, comment: ""), - "COMMENT ON VIEW \"Retail\".\"Orders\" IS ''" - ) - } - - func testCommentStatementRefusesKindsTeradataCannotComment() { - for kind in ["MATERIALIZED VIEW", "SEQUENCE", "SYSTEM TABLE", "FOREIGN TABLE"] { - XCTAssertNil(TeradataSchemaQueries.commentStatement(objectType: kind, qualifiedName: "\"t\"", comment: nil), kind) - } - } - - func testColumnCommentStatementsSkipColumnsWithoutAComment() { - let statements = TeradataSchemaQueries.columnCommentStatements( - qualifiedTable: "\"Retail\".\"Orders\"", - comments: [(column: "Id", comment: nil), (column: "Total", comment: "net's"), (column: "Note", comment: "")] - ) - XCTAssertEqual(statements, ["COMMENT ON COLUMN \"Retail\".\"Orders\".\"Total\" IS 'net''s'"]) - } - - func testQuotesSurviveAFollowingCombiningMark() { - XCTAssertEqual(TeradataSchemaQueries.quoteLiteral("a'\u{0301}b"), "'a''\u{0301}b'") - XCTAssertEqual(TeradataSchemaQueries.quoteIdentifier("a\"\u{0301}b"), "\"a\"\"\u{0301}b\"") - } - - func testTableKindReadsOneObject() { - let sql = TeradataSchemaQueries.tableKind(database: "Retail", table: "o'v") - XCTAssertTrue(sql.hasPrefix("SELECT TableKind FROM DBC.TablesV")) - XCTAssertTrue(sql.contains("DatabaseName = 'Retail'")) - XCTAssertTrue(sql.contains("TableName = 'o''v'")) - } -} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataTLSTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataTLSTests.swift deleted file mode 100644 index df21549892..0000000000 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/TeradataTLSTests.swift +++ /dev/null @@ -1,155 +0,0 @@ -@testable import TableProTeradataCore -import XCTest - -final class TeradataTLSTests: XCTestCase { - func testDisabledOptionsDoNotEnableTLS() { - XCTAssertFalse(TeradataTLSOptions.disabled.enabled) - XCTAssertFalse(TeradataTLSOptions.disabled.verifiesCertificate) - } - - func testClientAttributesEmbedSSLMode() throws { - let parcel = TeradataMessages.clientAttributesParcel( - username: "u", session: 1, charset: 0xBF, serverIP: "10.0.0.1", - logMech: "TD2", transactionMode: "ANSI", sslMode: "REQUIRE", database: "db") - let body = try XCTUnwrap(String(bytes: parcel.body, encoding: .isoLatin1)) - XCTAssertTrue(body.contains("SSLM=REQUIRE"), "attributes must carry the negotiated SSL mode") - XCTAssertTrue(body.contains("LM=TD2")) - XCTAssertTrue(body.contains("TM=ANSI")) - } - - func testVerifyCAWithoutACARefusesTheTrustPolicy() { - for path in ["", " "] { - let options = verifyingOptions(hostname: false, caCertificatePath: path) - assertRefusal(containing: "no CA certificate") { - _ = try TeradataTLSTransport.trustPolicy(for: options, host: "db.example.com") - } - } - } - - func testVerifyCAWithoutACARefusesBeforeOpeningASocket() { - let options = verifyingOptions(hostname: false, caCertificatePath: "") - assertRefusal(containing: "no CA certificate") { - _ = try TeradataTLSTransport( - host: "127.0.0.1", - options: options, - deadline: TeradataConnectDeadline(milliseconds: 5_000) - ) - } - } - - func testUnreadableCAFileRefusesInBothVerifyModes() throws { - let notACertificate = FileManager.default.temporaryDirectory - .appendingPathComponent("tablepro-teradata-not-a-certificate-\(UUID().uuidString).pem") - try Data("not a certificate".utf8).write(to: notACertificate) - defer { try? FileManager.default.removeItem(at: notACertificate) } - - for path in ["/nonexistent/tablepro-teradata-tests/ca.pem", notACertificate.path] { - for verifiesHostname in [false, true] { - let options = verifyingOptions(hostname: verifiesHostname, caCertificatePath: path) - assertRefusal(containing: "could not be read") { - _ = try TeradataTLSTransport.trustPolicy(for: options, host: "db.example.com") - } - } - } - } - - func testUnreadableCAFileRefusesBeforeOpeningASocket() { - let options = verifyingOptions(hostname: true, caCertificatePath: "/nonexistent/tablepro-teradata-tests/ca.pem") - assertRefusal(containing: "could not be read") { - _ = try TeradataTLSTransport( - host: "127.0.0.1", - options: options, - deadline: TeradataConnectDeadline(milliseconds: 5_000) - ) - } - } - - func testVerifyIdentityWithoutACAChecksTheSystemTrustStoreAndTheHostname() throws { - let options = verifyingOptions(hostname: true, caCertificatePath: "") - let policy = try TeradataTLSTransport.trustPolicy(for: options, host: "db.example.com") - guard case .systemTrust(let hostname) = policy else { - return XCTFail("expected the system trust store, got \(policy)") - } - XCTAssertEqual(hostname, "db.example.com") - } - - func testReadableCAFileAnchorsBothVerifyModes() throws { - let caFile = FileManager.default.temporaryDirectory - .appendingPathComponent("tablepro-teradata-ca-\(UUID().uuidString).pem") - try Data(Self.testAuthorityPEM.utf8).write(to: caFile) - defer { try? FileManager.default.removeItem(at: caFile) } - - let verifyCA = try TeradataTLSTransport.trustPolicy( - for: verifyingOptions(hostname: false, caCertificatePath: caFile.path), host: "db.example.com") - guard case .anchors(let caAnchors, let caHostname) = verifyCA else { - return XCTFail("expected the CA file as the only anchor, got \(verifyCA)") - } - XCTAssertEqual(caAnchors.count, 1) - XCTAssertNil(caHostname) - - let verifyIdentity = try TeradataTLSTransport.trustPolicy( - for: verifyingOptions(hostname: true, caCertificatePath: caFile.path), host: "db.example.com") - guard case .anchors(let identityAnchors, let identityHostname) = verifyIdentity else { - return XCTFail("expected the CA file as the only anchor, got \(verifyIdentity)") - } - XCTAssertEqual(identityAnchors.count, 1) - XCTAssertEqual(identityHostname, "db.example.com") - } - - func testRequiredModeNeverReadsTheCAFile() throws { - let options = TeradataTLSOptions( - enabled: true, caCertificatePath: "/nonexistent/tablepro-teradata-tests/ca.pem", modeLabel: "REQUIRE") - let policy = try TeradataTLSTransport.trustPolicy(for: options, host: "db.example.com") - guard case .acceptAny = policy else { - return XCTFail("expected no certificate check, got \(policy)") - } - } - - func testPlaintextConnectIgnoresTLSFallbackFlag() { - let config = TeradataConnectionConfig( - host: "127.0.0.1", port: 9, username: "u", password: "p", - tls: TeradataTLSOptions(enabled: false, allowPlaintextFallback: true), - connectTimeoutSeconds: 1) - XCTAssertThrowsError(try TeradataConnection(config: config).connect()) { error in - guard case TeradataWireError.connectionFailed = error else { - if case TeradataWireError.truncated = error { return } - return XCTFail("expected a transport error, got \(error)") - } - } - } - - private static let testAuthorityPEM = """ - -----BEGIN CERTIFICATE----- - MIIBJzCBzgIJAMLYxH2Rh5LAMAoGCCqGSM49BAMCMBsxGTAXBgNVBAMMEFRhYmxl - UHJvIFRlc3QgQ0EwIBcNMjYwOTI4MTUwMDE2WhgPMjEyNjA5MDQxNTAwMTZaMBsx - GTAXBgNVBAMMEFRhYmxlUHJvIFRlc3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMB - BwNCAATJmp4qF8ZPLELdv7zhRF+UgFduM9tQS5IR2CV3lcsn7mqhM2pOB7AULRWY - lLOmxbRu13doRzengY46xrjP2qucMAoGCCqGSM49BAMCA0gAMEUCIQDaf/ow8D8x - nBCmT33zqUHRFbg3uZyuI62VfVapXunZzgIgN5lVItMifnoHMg8Ls8eqf5TkK13C - zKfWtkCRv0GhTSQ= - -----END CERTIFICATE----- - """ - - private func verifyingOptions(hostname: Bool, caCertificatePath: String) -> TeradataTLSOptions { - TeradataTLSOptions( - enabled: true, - verifiesCertificate: true, - verifiesHostname: hostname, - caCertificatePath: caCertificatePath, - modeLabel: hostname ? "VERIFY-FULL" : "VERIFY-CA") - } - - private func assertRefusal( - containing fragment: String, - file: StaticString = #filePath, - line: UInt = #line, - _ body: () throws -> Void - ) { - XCTAssertThrowsError(try body(), file: file, line: line) { error in - guard case TeradataWireError.connectionFailed(let detail) = error else { - return XCTFail("expected a refusal, got \(error)", file: file, line: line) - } - XCTAssertTrue(detail.contains(fragment), detail, file: file, line: line) - } - } -} diff --git a/Packages/TableProCore/Tests/TableProTeradataCoreTests/WebSocketFrameTests.swift b/Packages/TableProCore/Tests/TableProTeradataCoreTests/WebSocketFrameTests.swift deleted file mode 100644 index c1c58bf5b8..0000000000 --- a/Packages/TableProCore/Tests/TableProTeradataCoreTests/WebSocketFrameTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -@testable import TableProTeradataCore -import XCTest - -final class WebSocketFrameTests: XCTestCase { - func testAcceptKeyMatchesRFC6455Vector() { - XCTAssertEqual(WebSocketFrame.acceptKey(for: "dGhlIHNhbXBsZSBub25jZQ=="), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") - } - - func testBinaryFramesAreUnmaskedWithFinBit() { - let frame = WebSocketFrame.encodeBinary([0xAA, 0xBB, 0xCC]) - XCTAssertEqual(frame[0], 0x82) - XCTAssertEqual(frame[1], 3) - XCTAssertEqual(Array(frame[2...]), [0xAA, 0xBB, 0xCC]) - } - - func testExtendedLengthEncoding() { - let payload = [UInt8](repeating: 0x5A, count: 300) - let frame = WebSocketFrame.encodeBinary(payload) - XCTAssertEqual(frame[0], 0x82) - XCTAssertEqual(frame[1], 126) - XCTAssertEqual(Int(frame[2]) << 8 | Int(frame[3]), 300) - XCTAssertEqual(Array(frame[4...]), payload) - } - - func testDecodeUnmaskedServerFrame() throws { - var buffer: [UInt8] = [0x82, 0x03, 0x01, 0x02, 0x03, 0xFF] - let frame = try WebSocketFrame.decode(&buffer) - XCTAssertEqual(frame?.opcode, .binary) - XCTAssertEqual(frame?.payload, [0x01, 0x02, 0x03]) - XCTAssertEqual(buffer, [0xFF]) - } - - func testDecodeMaskedFrameUnmasksPayload() throws { - let mask: [UInt8] = [0x10, 0x20, 0x30, 0x40] - let plain: [UInt8] = [0x41, 0x42, 0x43] - var buffer: [UInt8] = [0x82, 0x83] + mask + plain.enumerated().map { $0.element ^ mask[$0.offset % 4] } - let frame = try WebSocketFrame.decode(&buffer) - XCTAssertEqual(frame?.payload, plain) - XCTAssertTrue(buffer.isEmpty) - } - - func testDecodeReturnsNilOnIncompleteFrame() throws { - var buffer: [UInt8] = [0x82, 0x05, 0x01, 0x02] - XCTAssertNil(try WebSocketFrame.decode(&buffer)) - XCTAssertEqual(buffer.count, 4) - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoClientConfigTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoClientConfigTests.swift deleted file mode 100644 index adbcc782c8..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoClientConfigTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation -@testable import TableProTrinoCore -import Testing - -@Suite("TrinoClientConfig") -struct TrinoClientConfigTests { - @Test("The TLS setting alone picks the scheme, so an explicit SSL off on 443 stays plain HTTP") - func statementURLScheme() { - let plainHTTP = TrinoClientConfig(host: "trino.example.com", port: 443, user: "tablepro") - let https = TrinoClientConfig(host: "trino.example.com", port: 443, useTLS: true, user: "tablepro") - - #expect(plainHTTP.statementURL?.absoluteString == "http://trino.example.com:443/v1/statement") - #expect(https.statementURL?.absoluteString == "https://trino.example.com:443/v1/statement") - } - - @Test("A password or an access token is a plaintext credential only when TLS is off") - func plaintextCredential() { - func config(useTLS: Bool, auth: TrinoAuth) -> TrinoClientConfig { - TrinoClientConfig(host: "trino.example.com", useTLS: useTLS, user: "tablepro", auth: auth) - } - - #expect(config(useTLS: false, auth: .basic(password: "secret")).plaintextCredential == .password) - #expect(config(useTLS: false, auth: .jwt(token: "t")).plaintextCredential == .accessToken) - #expect(config(useTLS: false, auth: .none).plaintextCredential == nil) - #expect(config(useTLS: true, auth: .basic(password: "secret")).plaintextCredential == nil) - #expect(config(useTLS: true, auth: .jwt(token: "t")).plaintextCredential == nil) - } - - @Test("The refusal names the credential and what to change, and never the secret") - func credentialRefusalText() { - #expect(TrinoError.credentialsRequireTLS(.password).errorDescription - == "A password is sent only over TLS, and SSL Mode is Disabled. Set SSL Mode to Verify Identity, " - + "or clear the password if the cluster has no authentication.") - #expect(TrinoError.credentialsRequireTLS(.accessToken).errorDescription - == "An access token is sent only over TLS, and SSL Mode is Disabled. Set SSL Mode to Verify " - + "Identity, or clear the Access Token if the cluster has no authentication.") - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoDDLSQLTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoDDLSQLTests.swift deleted file mode 100644 index 31e999bc9d..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoDDLSQLTests.swift +++ /dev/null @@ -1,90 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoDDLSQLTests: XCTestCase { - private let target = "\"hive\".\"sales\".\"orders\"" - - func testColumnDefinitionWithComment() { - let spec = TrinoColumnSpec(name: "amount", type: "decimal(10,2)", nullable: false, comment: "the total") - XCTAssertEqual( - TrinoDDLSQL.columnDefinition(spec), - "\"amount\" decimal(10,2) NOT NULL COMMENT 'the total'" - ) - } - - func testCreateTable() { - let columns = [ - TrinoColumnSpec(name: "id", type: "bigint", nullable: false, comment: nil), - TrinoColumnSpec(name: "name", type: "varchar(50)", nullable: true, comment: nil), - ] - let sql = TrinoDDLSQL.createTable(qualifiedTable: target, columns: columns, tableComment: "orders", ifNotExists: true) - XCTAssertEqual( - sql, - "CREATE TABLE IF NOT EXISTS \(target) (\n \"id\" bigint NOT NULL,\n \"name\" varchar(50)\n) COMMENT 'orders'" - ) - } - - func testCreateTableEmptyColumnsReturnsNil() { - XCTAssertNil(TrinoDDLSQL.createTable(qualifiedTable: target, columns: [], tableComment: nil, ifNotExists: false)) - } - - func testAddDropRenameRetype() { - XCTAssertEqual( - TrinoDDLSQL.addColumn(qualifiedTable: target, column: TrinoColumnSpec(name: "c", type: "varchar", nullable: true, comment: nil)), - "ALTER TABLE \(target) ADD COLUMN \"c\" varchar" - ) - XCTAssertEqual(TrinoDDLSQL.dropColumn(qualifiedTable: target, name: "c"), "ALTER TABLE \(target) DROP COLUMN \"c\"") - XCTAssertEqual( - TrinoDDLSQL.renameColumn(qualifiedTable: target, from: "a", to: "b"), - "ALTER TABLE \(target) RENAME COLUMN \"a\" TO \"b\"" - ) - XCTAssertEqual( - TrinoDDLSQL.setColumnType(qualifiedTable: target, name: "a", type: "bigint"), - "ALTER TABLE \(target) ALTER COLUMN \"a\" SET DATA TYPE bigint" - ) - } - - func testComments() { - XCTAssertEqual( - TrinoDDLSQL.setColumnComment(qualifiedTable: target, name: "a", comment: "note"), - "COMMENT ON COLUMN \(target).\"a\" IS 'note'" - ) - XCTAssertEqual( - TrinoDDLSQL.setColumnComment(qualifiedTable: target, name: "a", comment: nil), - "COMMENT ON COLUMN \(target).\"a\" IS NULL" - ) - XCTAssertEqual( - TrinoDDLSQL.setTableComment(qualifiedTable: target, comment: "hi"), - "COMMENT ON TABLE \(target) IS 'hi'" - ) - XCTAssertEqual( - TrinoDDLSQL.setTableComment(qualifiedTable: target, comment: ""), - "COMMENT ON TABLE \(target) IS NULL" - ) - XCTAssertEqual( - TrinoDDLSQL.setViewComment(qualifiedView: target, comment: "it's"), - "COMMENT ON VIEW \(target) IS 'it''s'" - ) - XCTAssertEqual( - TrinoDDLSQL.setViewComment(qualifiedView: target, comment: nil), - "COMMENT ON VIEW \(target) IS NULL" - ) - } - - func testObjectCommentPicksTheKeywordForTheKind() { - XCTAssertEqual( - TrinoDDLSQL.objectComment(qualifiedName: target, objectType: "TABLE", comment: "a"), - "COMMENT ON TABLE \(target) IS 'a'" - ) - XCTAssertEqual( - TrinoDDLSQL.objectComment(qualifiedName: target, objectType: "VIEW", comment: nil), - "COMMENT ON VIEW \(target) IS NULL" - ) - } - - func testObjectCommentRefusesKindsTrinoCannotComment() { - for kind in ["MATERIALIZED VIEW", "SEQUENCE", "SYSTEM TABLE", "FOREIGN TABLE"] { - XCTAssertNil(TrinoDDLSQL.objectComment(qualifiedName: target, objectType: kind, comment: nil), kind) - } - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift deleted file mode 100644 index 0eb7c9b6b7..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoIntrospectionSQLTests.swift +++ /dev/null @@ -1,96 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoIntrospectionSQLTests: XCTestCase { - func testQuoteIdentifierEscapesQuotes() { - XCTAssertEqual(TrinoIntrospectionSQL.quoteIdentifier("name"), "\"name\"") - XCTAssertEqual(TrinoIntrospectionSQL.quoteIdentifier("we\"ird"), "\"we\"\"ird\"") - } - - func testQuoteLiteralEscapesApostrophes() { - XCTAssertEqual(TrinoIntrospectionSQL.quoteLiteral("O'Brien"), "'O''Brien'") - } - - func testQualifiedNameJoinsAvailableParts() { - XCTAssertEqual( - TrinoIntrospectionSQL.qualifiedName(catalog: "hive", schema: "sales", table: "orders"), - "\"hive\".\"sales\".\"orders\"" - ) - XCTAssertEqual( - TrinoIntrospectionSQL.qualifiedName(catalog: nil, schema: "sales", table: "orders"), - "\"sales\".\"orders\"" - ) - XCTAssertEqual( - TrinoIntrospectionSQL.qualifiedName(catalog: nil, schema: nil, table: "orders"), - "\"orders\"" - ) - } - - func testShowCatalogsAndSchemas() { - XCTAssertEqual(TrinoIntrospectionSQL.showCatalogs(), "SHOW CATALOGS") - XCTAssertEqual(TrinoIntrospectionSQL.showSchemas(catalog: "hive"), "SHOW SCHEMAS FROM \"hive\"") - } - - func testListTablesQueriesInformationSchema() { - let sql = TrinoIntrospectionSQL.listTables(catalog: "hive", schema: "sales") - XCTAssertTrue(sql.contains("\"hive\".information_schema.tables")) - XCTAssertTrue(sql.contains("table_schema = 'sales'")) - XCTAssertTrue(sql.contains("ORDER BY table_name")) - } - - func testListColumnsQueriesInformationSchema() { - let sql = TrinoIntrospectionSQL.listColumns(catalog: "hive", schema: "sales", table: "orders") - XCTAssertTrue(sql.contains("\"hive\".information_schema.columns")) - XCTAssertTrue(sql.contains("table_schema = 'sales'")) - XCTAssertTrue(sql.contains("table_name = 'orders'")) - XCTAssertTrue(sql.contains("comment")) - XCTAssertTrue(sql.contains("ORDER BY ordinal_position")) - } - - func testTableCommentQueriesSystemMetadata() { - let sql = TrinoIntrospectionSQL.tableComment(catalog: "hive", schema: "sales", table: "orders") - XCTAssertTrue(sql.contains("system.metadata.table_comments")) - XCTAssertTrue(sql.contains("catalog_name = 'hive'")) - XCTAssertTrue(sql.contains("schema_name = 'sales'")) - XCTAssertTrue(sql.contains("table_name = 'orders'")) - } - - func testListMaterializedViewsQueriesSystemMetadata() { - let sql = TrinoIntrospectionSQL.listMaterializedViews(catalog: "hive", schema: "sales") - XCTAssertTrue(sql.contains("system.metadata.materialized_views")) - XCTAssertTrue(sql.contains("catalog_name = 'hive'")) - XCTAssertTrue(sql.contains("schema_name = 'sales'")) - } - - func testShowCreateTableAndView() { - XCTAssertEqual( - TrinoIntrospectionSQL.showCreateTable(catalog: "hive", schema: "sales", table: "orders"), - "SHOW CREATE TABLE \"hive\".\"sales\".\"orders\"" - ) - XCTAssertEqual( - TrinoIntrospectionSQL.showCreateView(catalog: "hive", schema: "sales", view: "v"), - "SHOW CREATE VIEW \"hive\".\"sales\".\"v\"" - ) - } - - /// Trino refuses `SHOW CREATE VIEW` on a materialized view, which has its own statement. - func testShowCreateMaterializedView() { - XCTAssertEqual( - TrinoIntrospectionSQL.showCreateMaterializedView(catalog: "hive", schema: "sales", view: "mv"), - "SHOW CREATE MATERIALIZED VIEW \"hive\".\"sales\".\"mv\"" - ) - } - - func testRelationTypeReadsOneRelation() { - let sql = TrinoIntrospectionSQL.relationType(catalog: "hive", schema: "sales", table: "o'v") - XCTAssertTrue(sql.hasPrefix("SELECT table_type FROM \"hive\".information_schema.tables")) - XCTAssertTrue(sql.contains("table_schema = 'sales'")) - XCTAssertTrue(sql.contains("table_name = 'o''v'")) - } - - func testMaterializedViewNarrowsTheListingToOneName() { - let sql = TrinoIntrospectionSQL.materializedView(catalog: "hive", schema: "sales", name: "mv") - XCTAssertTrue(sql.hasPrefix(TrinoIntrospectionSQL.listMaterializedViews(catalog: "hive", schema: "sales"))) - XCTAssertTrue(sql.hasSuffix(" AND name = 'mv'")) - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoJSONValueTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoJSONValueTests.swift deleted file mode 100644 index 1806335ef6..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoJSONValueTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoJSONValueTests: XCTestCase { - private func decode(_ json: String) throws -> TrinoJSONValue { - try JSONDecoder().decode(TrinoJSONValue.self, from: Data(json.utf8)) - } - - func testDecodesNull() throws { - XCTAssertEqual(try decode("null"), .null) - } - - func testDecodesBool() throws { - XCTAssertEqual(try decode("true"), .bool(true)) - XCTAssertEqual(try decode("false"), .bool(false)) - } - - func testBigintKeepsFullPrecision() throws { - let value = try decode("9007199254740993") - XCTAssertEqual(value, .int(9_007_199_254_740_993)) - XCTAssertEqual(value.scalarText, "9007199254740993") - } - - func testInt64MaxRoundTrips() throws { - let value = try decode("9223372036854775807") - XCTAssertEqual(value, .int(9_223_372_036_854_775_807)) - XCTAssertEqual(value.scalarText, "9223372036854775807") - } - - func testDecimalStaysString() throws { - let value = try decode("\"12345678901234567890.99\"") - XCTAssertEqual(value, .string("12345678901234567890.99")) - XCTAssertEqual(value.scalarText, "12345678901234567890.99") - } - - func testDoubleSpecialValuesArriveAsStrings() throws { - XCTAssertEqual(try decode("\"NaN\""), .string("NaN")) - XCTAssertEqual(try decode("\"Infinity\""), .string("Infinity")) - XCTAssertEqual(try decode("\"-Infinity\""), .string("-Infinity")) - } - - func testArrayJsonText() throws { - let value = try decode("[1,2,3]") - XCTAssertEqual(value.jsonText(), "[1,2,3]") - } - - func testObjectJsonTextSortsKeys() throws { - let value = try decode("{\"b\":2,\"a\":1}") - XCTAssertEqual(value.jsonText(), "{\"a\":1,\"b\":2}") - } - - func testNestedArrayOfObjectsSerializes() throws { - let value = try decode("[{\"k\":\"v\"},{\"k\":\"w\"}]") - XCTAssertEqual(value.jsonText(), "[{\"k\":\"v\"},{\"k\":\"w\"}]") - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoLiteralTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoLiteralTests.swift deleted file mode 100644 index 787d896c8d..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoLiteralTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoLiteralTests: XCTestCase { - func testNull() { - XCTAssertEqual(TrinoLiteral.render(.null, typeName: "varchar(10)"), "NULL") - } - - func testVarcharQuotesNumericLookingText() { - XCTAssertEqual(TrinoLiteral.render(.text("123"), typeName: "varchar(10)"), "'123'") - } - - func testVarcharEscapesApostrophe() { - XCTAssertEqual(TrinoLiteral.render(.text("O'Brien"), typeName: "varchar(20)"), "'O''Brien'") - } - - func testNumericColumnEmitsBareNumber() { - XCTAssertEqual(TrinoLiteral.render(.text("123"), typeName: "bigint"), "123") - XCTAssertEqual(TrinoLiteral.render(.text("-4.5"), typeName: "double"), "-4.5") - XCTAssertEqual(TrinoLiteral.render(.text("10.25"), typeName: "decimal(10,2)"), "10.25") - } - - func testNumericColumnQuotesNonNumericText() { - XCTAssertEqual(TrinoLiteral.render(.text("NaN"), typeName: "double"), "'NaN'") - } - - func testBoolean() { - XCTAssertEqual(TrinoLiteral.render(.text("true"), typeName: "boolean"), "true") - XCTAssertEqual(TrinoLiteral.render(.text("FALSE"), typeName: "boolean"), "false") - } - - func testTemporalLiterals() { - XCTAssertEqual(TrinoLiteral.render(.text("2020-01-02"), typeName: "date"), "DATE '2020-01-02'") - XCTAssertEqual(TrinoLiteral.render(.text("12:00:00"), typeName: "time(3)"), "TIME '12:00:00'") - XCTAssertEqual( - TrinoLiteral.render(.text("2020-01-02 03:04:05"), typeName: "timestamp(3)"), - "TIMESTAMP '2020-01-02 03:04:05'" - ) - XCTAssertEqual( - TrinoLiteral.render(.text("12:00:00 +00:00"), typeName: "time(3) with time zone"), - "TIME '12:00:00 +00:00'" - ) - } - - func testJsonUuidIpAddress() { - XCTAssertEqual(TrinoLiteral.render(.text("{\"a\":1}"), typeName: "json"), "JSON '{\"a\":1}'") - XCTAssertEqual( - TrinoLiteral.render(.text("12151fd2-7586-11e9-8f9e-2a86e4085a59"), typeName: "uuid"), - "UUID '12151fd2-7586-11e9-8f9e-2a86e4085a59'" - ) - XCTAssertEqual(TrinoLiteral.render(.text("10.0.0.1"), typeName: "ipaddress"), "CAST('10.0.0.1' AS ipaddress)") - } - - func testVarbinaryFromBytes() { - XCTAssertEqual(TrinoLiteral.render(.bytes([104, 105]), typeName: "varbinary"), "X'6869'") - } - - func testStructuredCastsFromJson() { - XCTAssertEqual( - TrinoLiteral.render(.text("[1,2,3]"), typeName: "array(integer)"), - "CAST(json_parse('[1,2,3]') AS array(integer))" - ) - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoMutualTLSTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoMutualTLSTests.swift deleted file mode 100644 index c025b29342..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoMutualTLSTests.swift +++ /dev/null @@ -1,369 +0,0 @@ -import Foundation -import Network -import Security -import TableProTLSClientIdentity -import TableProTLSTestFixtures -@testable import TableProTrinoCore -import Testing - -private final class MutualTLSServerState: @unchecked Sendable { - private let lock = NSLock() - private var presented: [String] = [] - private var ready = false - - var isReady: Bool { - lock.withLock { ready } - } - - var presentedCommonNames: [String] { - lock.withLock { presented } - } - - func markReady() { - lock.withLock { ready = true } - } - - func record(leafOf trust: SecTrust) { - guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], let leaf = chain.first else { return } - var name: CFString? - SecCertificateCopyCommonName(leaf, &name) - lock.withLock { presented.append((name as String?) ?? "") } - } -} - -private final class MutualTLSServer: @unchecked Sendable { - enum Version { - case tls12 - case tls13 - } - - enum Reply { - case complete - case truncated - - var bytes: Data { - switch self { - case .complete: - return Data("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".utf8) - case .truncated: - return Data("HTTP/1.1 200 OK\r\nContent-Length: 4096\r\nConnection: close\r\n\r\n{\"id\"".utf8) - } - } - } - - private let listener: NWListener - private let state: MutualTLSServerState - - init(requiresClientCertificate: Bool, version: Version = .tls13, reply: Reply = .complete) throws { - let serverCredential = try TLSClientIdentity.credential( - certificate: TLSTestFixtures.data(TLSTestFixtures.serverCertificate), - privateKey: TLSTestFixtures.data(TLSTestFixtures.serverKey) - ) - let identity = try #require(serverCredential.identity.flatMap { sec_identity_create($0) }) - let authority = try #require( - SecCertificateCreateWithData(nil, TLSTestFixtures.der(TLSTestFixtures.caCertificate) as CFData) - ) - let state = MutualTLSServerState() - let tls = NWProtocolTLS.Options() - let options = tls.securityProtocolOptions - sec_protocol_options_set_local_identity(options, identity) - sec_protocol_options_set_peer_authentication_required(options, requiresClientCertificate) - if version == .tls12 { - sec_protocol_options_set_max_tls_protocol_version(options, .TLSv12) - } - sec_protocol_options_set_verify_block(options, { _, peerTrust, complete in - let trust = sec_trust_copy_ref(peerTrust).takeRetainedValue() - SecTrustSetAnchorCertificates(trust, [authority] as CFArray) - SecTrustSetAnchorCertificatesOnly(trust, true) - SecTrustSetPolicies(trust, SecPolicyCreateBasicX509()) - state.record(leafOf: trust) - complete(SecTrustEvaluateWithError(trust, nil)) - }, DispatchQueue.global()) - let listener = try NWListener(using: NWParameters(tls: tls), on: .any) - listener.stateUpdateHandler = { listenerState in - guard case .ready = listenerState else { return } - state.markReady() - } - listener.newConnectionHandler = { connection in - connection.start(queue: .global()) - connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { _, _, _, error in - guard error == nil else { - connection.cancel() - return - } - connection.send(content: reply.bytes, completion: .contentProcessed { _ in connection.cancel() }) - } - } - self.listener = listener - self.state = state - listener.start(queue: .global()) - } - - deinit { - listener.cancel() - } - - var presentedCommonNames: [String] { - state.presentedCommonNames - } - - func statementURL() async throws -> URL { - for _ in 0 ..< 500 where !state.isReady { - try await Task.sleep(for: .milliseconds(10)) - } - let port = try #require(listener.port?.rawValue) - return try #require(URL(string: "https://localhost:\(port)/v1/statement")) - } -} - -private final class CertificateRequestingProtocol: URLProtocol, URLAuthenticationChallengeSender, @unchecked Sendable { - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - let space = URLProtectionSpace( - host: request.url?.host ?? "", - port: 443, - protocol: NSURLProtectionSpaceHTTPS, - realm: nil, - authenticationMethod: NSURLAuthenticationMethodClientCertificate - ) - client?.urlProtocol(self, didReceive: URLAuthenticationChallenge( - protectionSpace: space, - proposedCredential: nil, - previousFailureCount: 0, - failureResponse: nil, - error: nil, - sender: self - )) - } - - override func stopLoading() {} - - func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) { - finish() - } - - func continueWithoutCredential(for challenge: URLAuthenticationChallenge) { - finish() - } - - func performDefaultHandling(for challenge: URLAuthenticationChallenge) { - finish() - } - - func cancel(_ challenge: URLAuthenticationChallenge) { - client?.urlProtocol(self, didFailWithError: URLError(.cancelled)) - } - - private func finish() { - guard request.url?.path != "/drop" else { - client?.urlProtocol(self, didFailWithError: URLError(.networkConnectionLost)) - return - } - guard let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: 401, httpVersion: "HTTP/1.1", headerFields: [:]) - else { return } - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data("Unauthorized".utf8)) - client?.urlProtocolDidFinishLoading(self) - } -} - -private final class IgnoringChallengeSender: NSObject, URLAuthenticationChallengeSender { - func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {} - func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {} - func cancel(_ challenge: URLAuthenticationChallenge) {} -} - -struct TrinoMutualTLSTests { - private func clientCredential() throws -> URLCredential { - try TLSClientIdentity.credential( - certificate: TLSTestFixtures.data(TLSTestFixtures.clientCertificate), - privateKey: TLSTestFixtures.data(TLSTestFixtures.clientKeyPKCS1) - ) - } - - private func send(to server: MutualTLSServer, tls: TrinoTLSOptions) async throws -> TrinoHTTPResponse { - let request = TrinoHTTPRequest( - method: .post, - url: try await server.statementURL(), - headers: [:], - body: Data("SELECT 1".utf8), - timeoutSeconds: 10 - ) - return try await URLSessionTrinoTransport(tls: tls).send(request) - } - - private func failure(to server: MutualTLSServer, tls: TrinoTLSOptions) async throws -> TrinoError? { - do { - _ = try await send(to: server, tls: tls) - return nil - } catch let error as TrinoError { - return error - } - } - - @Test("A configured client certificate is presented, and the server sees the client it names") - func certificateIsPresented() async throws { - let server = try MutualTLSServer(requiresClientCertificate: true) - - let response = try await send(to: server, tls: TrinoTLSOptions(mode: .insecure, clientCredential: try clientCredential())) - - #expect(response.statusCode == 200) - #expect(response.clientCertificateRequest == .answered) - #expect(server.presentedCommonNames == ["probe-client"]) - } - - @Test("An EC client key held only in memory signs the handshake") - func ellipticCurveCertificateIsPresented() async throws { - let server = try MutualTLSServer(requiresClientCertificate: true) - let credential = try TLSClientIdentity.credential( - certificate: TLSTestFixtures.data(TLSTestFixtures.ecClientCertificate), - privateKey: TLSTestFixtures.data(TLSTestFixtures.ecKeySEC1) - ) - - let response = try await send(to: server, tls: TrinoTLSOptions(mode: .insecure, clientCredential: credential)) - - #expect(response.statusCode == 200) - #expect(server.presentedCommonNames == ["probe-ec-client"]) - } - - @Test("A connection dropped after the server began answering is a dropped connection, not a rejected certificate") - func droppedMidResponseIsTransport() async throws { - for version in [MutualTLSServer.Version.tls12, .tls13] { - let server = try MutualTLSServer(requiresClientCertificate: true, version: version, reply: .truncated) - - let error = try await failure(to: server, tls: TrinoTLSOptions(mode: .insecure, clientCredential: try clientCredential())) - - guard case .transport? = error else { - Issue.record("Expected a dropped connection over \(version), got \(String(describing: error))") - continue - } - #expect(server.presentedCommonNames == ["probe-client"]) - } - } - - @Test("With no certificate, a TLS 1.2 server that requires one reports the missing certificate") - func missingCertificateOverTLS12() async throws { - let server = try MutualTLSServer(requiresClientCertificate: true, version: .tls12) - - let error = try await failure(to: server, tls: TrinoTLSOptions(mode: .insecure)) - - guard case .tlsHandshakeFailed(.clientCertificateRequired, _)? = error else { - Issue.record("Expected the missing client certificate, got \(String(describing: error))") - return - } - } - - private func requestingTransport(_ tls: TrinoTLSOptions) -> URLSessionTrinoTransport { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [CertificateRequestingProtocol.self] - return URLSessionTrinoTransport(tls: tls, configuration: configuration) - } - - private func request(_ path: String) throws -> TrinoHTTPRequest { - TrinoHTTPRequest(method: .post, url: try #require(URL(string: "https://trino.example.com\(path)")), headers: [:]) - } - - @Test("A server that only asks for a certificate, gets none and drops the connection reads as a dropped connection") - func askedThenDroppedIsTransport() async throws { - let transport = requestingTransport(TrinoTLSOptions(mode: .insecure)) - let dropping = try request("/drop") - - await #expect(throws: TrinoError.transport(URLError(.networkConnectionLost).localizedDescription)) { - try await transport.send(dropping) - } - } - - @Test("A connection dropped after the certificate was sent reads as the certificate being rejected") - func sentThenDroppedIsRejection() async throws { - let transport = requestingTransport(TrinoTLSOptions(mode: .insecure, clientCredential: try clientCredential())) - let dropping = try request("/drop") - - await #expect(throws: TrinoError.tlsHandshakeFailed( - kind: .clientCertificateRejected, - serverMessage: URLError(.networkConnectionLost).localizedDescription - )) { - try await transport.send(dropping) - } - } - - @Test("A coordinator that asked for a certificate and answered 401 is reported as missing the certificate") - func unauthorizedAfterUnansweredRequest() async throws { - let transport = requestingTransport(TrinoTLSOptions(mode: .insecure)) - - let response = try await transport.send(try request("/unauthorized")) - let client = TrinoStatementClient( - transport: transport, - config: TrinoClientConfig(host: "trino.example.com", port: 443, useTLS: true, user: "u"), - session: TrinoSessionState() - ) - - #expect(response.statusCode == 401) - #expect(response.clientCertificateRequest == .unanswered) - await #expect(throws: TrinoError.tlsHandshakeFailed(kind: .clientCertificateRequired, serverMessage: "Unauthorized")) { - try await client.execute("SELECT 1") - } - } - - @Test("A certificate from another CA is reported as rejected over TLS 1.2 and TLS 1.3") - func foreignCertificateIsRejected() async throws { - let rogue = try TLSClientIdentity.credential( - certificate: TLSTestFixtures.data(TLSTestFixtures.rogueCertificate), - privateKey: TLSTestFixtures.data(TLSTestFixtures.rogueKey) - ) - for version in [MutualTLSServer.Version.tls12, .tls13] { - let server = try MutualTLSServer(requiresClientCertificate: true, version: version) - - let error = try await failure(to: server, tls: TrinoTLSOptions(mode: .insecure, clientCredential: rogue)) - - guard case .tlsHandshakeFailed(.clientCertificateRejected, _)? = error else { - Issue.record("Expected a rejected client certificate, got \(String(describing: error))") - continue - } - #expect(server.presentedCommonNames.contains("rogue")) - } - } - - @Test("Verify CA with no CA certificate refuses the server instead of checking it against the system roots") - func verifyCAWithoutAnchorRefuses() async throws { - let server = try MutualTLSServer(requiresClientCertificate: false) - - let error = try await failure(to: server, tls: TrinoTLSOptions(mode: .caOnly)) - - #expect(error == .tlsHandshakeFailed( - kind: .untrustedCertificate, - serverMessage: "Verify CA has no CA certificate to check the server against." - )) - } - - @Test("A certificate challenge is answered with the configured credential, or recorded as unanswered") - func clientCertificateChallenge() throws { - let challenge = URLAuthenticationChallenge( - protectionSpace: URLProtectionSpace( - host: "trino.example.com", - port: 443, - protocol: NSURLProtectionSpaceHTTPS, - realm: nil, - authenticationMethod: NSURLAuthenticationMethodClientCertificate - ), - proposedCredential: nil, - previousFailureCount: 0, - failureResponse: nil, - error: nil, - sender: IgnoringChallengeSender() - ) - let credential = try clientCredential() - - let answered = TrinoTLSChallengeHandler(tls: TrinoTLSOptions(clientCredential: credential)).answer(challenge) - let unanswered = TrinoTLSChallengeHandler(tls: TrinoTLSOptions()).answer(challenge) - - #expect(answered.disposition == .useCredential) - #expect(answered.credential === credential) - #expect(answered.clientCertificateRequest == .answered) - #expect(unanswered.disposition == .performDefaultHandling) - #expect(unanswered.credential == nil) - #expect(unanswered.clientCertificateRequest == .unanswered) - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoQueryResultsDecodingTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoQueryResultsDecodingTests.swift deleted file mode 100644 index 0768fc37ad..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoQueryResultsDecodingTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoQueryResultsDecodingTests: XCTestCase { - private func decode(_ json: String) throws -> TrinoQueryResults { - try JSONDecoder().decode(TrinoQueryResults.self, from: Data(json.utf8)) - } - - func testDecodesInitialResponseWithoutColumns() throws { - let json = #"{"id":"q1","infoUri":"http://h/ui/q1","nextUri":"http://h/v1/statement/q1/1","stats":{"state":"QUEUED"}}"# - let results = try decode(json) - XCTAssertEqual(results.id, "q1") - XCTAssertEqual(results.nextUri, "http://h/v1/statement/q1/1") - XCTAssertNil(results.columns) - XCTAssertNil(results.data) - XCTAssertNil(results.error) - XCTAssertEqual(results.stats?.state, "QUEUED") - } - - func testDecodesColumnsAndData() throws { - let json = #""" - {"id":"q1","columns":[{"name":"n","type":"bigint","typeSignature":{"rawType":"bigint"}}, - {"name":"s","type":"varchar(3)","typeSignature":{"rawType":"varchar"}}], - "data":[[1,"abc"],[2,"xyz"]],"stats":{"state":"FINISHED"}} - """# - let results = try decode(json) - XCTAssertEqual(results.columns?.count, 2) - XCTAssertEqual(results.columns?.first?.name, "n") - XCTAssertEqual(results.columns?.first?.rawTypeName, "bigint") - XCTAssertEqual(results.columns?.last?.type, "varchar(3)") - XCTAssertEqual(results.data?.count, 2) - } - - func testDecodesErrorObject() throws { - let json = #""" - {"id":"q1","error":{"message":"line 1:1: Table not found","errorCode":46, - "errorName":"TABLE_NOT_FOUND","errorType":"USER_ERROR"}} - """# - let results = try decode(json) - XCTAssertEqual(results.error?.errorName, "TABLE_NOT_FOUND") - XCTAssertEqual(results.error?.errorType, "USER_ERROR") - XCTAssertEqual(results.error?.errorCode, 46) - XCTAssertEqual(results.error?.message, "line 1:1: Table not found") - } - - func testDecodesUpdateTypeAndCount() throws { - let json = #"{"id":"q1","updateType":"INSERT","updateCount":7,"stats":{"state":"FINISHED"}}"# - let results = try decode(json) - XCTAssertEqual(results.updateType, "INSERT") - XCTAssertEqual(results.updateCount, 7) - XCTAssertNil(results.nextUri) - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoRedirectPolicyTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoRedirectPolicyTests.swift deleted file mode 100644 index 6155b7d575..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoRedirectPolicyTests.swift +++ /dev/null @@ -1,143 +0,0 @@ -import Foundation -@testable import TableProTrinoCore -import Testing - -struct TrinoRedirectPolicyTests { - private func refusal( - _ statusCode: Int, - location: String?, - from request: String, - useTLS: Bool = false - ) throws -> TrinoError { - let response = TrinoHTTPResponse( - statusCode: statusCode, - headers: TrinoHeaderFields(location.map { ["Location": $0] } ?? [:]), - body: Data("Moved".utf8) - ) - return TrinoRedirectPolicy.refusal( - for: response, - requestURL: try #require(URL(string: request)), - useTLS: useTLS - ) - } - - @Test("Plain HTTP redirected to HTTPS on the same host and port is the plaintext rejection") - func sameHostAndPortUpgrade() throws { - let error = try refusal( - 301, - location: "https://trino.example.com/v1/statement", - from: "http://trino.example.com:443/v1/statement" - ) - - #expect(error == .tlsHandshakeFailed( - kind: .serverRejectedPlaintext, - serverMessage: "301 redirect to https://trino.example.com/v1/statement" - )) - } - - @Test("An HTTPS redirect to another port names the port") - func upgradeToAnotherPort() throws { - let error = try refusal( - 308, - location: "https://trino.example.com/v1/statement", - from: "http://trino.example.com:8080/v1/statement" - ) - - #expect(error == .redirected( - statusCode: 308, - location: "https://trino.example.com/v1/statement", - advice: .turnOnTLS(port: 443) - )) - #expect(error.errorDescription?.contains("Set Port to 443 and SSL Mode to Verify Identity.") == true) - } - - @Test("A host compared case-insensitively still counts as the same host") - func hostCaseIsIgnored() throws { - let error = try refusal( - 301, - location: "https://Trino.Example.com:8443/v1/statement", - from: "http://trino.example.com:8443/v1/statement" - ) - - guard case .tlsHandshakeFailed(.serverRejectedPlaintext, _) = error else { - Issue.record("Expected the plaintext rejection, got \(error)") - return - } - } - - @Test("A redirect from HTTPS to plain HTTP is never advice to turn TLS on") - func downgradeIsCheckAddress() throws { - let error = try refusal( - 307, - location: "http://trino.example.com/v1/statement", - from: "https://trino.example.com/v1/statement", - useTLS: true - ) - - #expect(error == .redirected( - statusCode: 307, - location: "http://trino.example.com/v1/statement", - advice: .checkAddress - )) - } - - @Test("A redirect to another host, or to another path on the same origin, asks to check the address") - func otherTargetsAreCheckAddress() throws { - let otherHost = try refusal( - 302, - location: "https://gateway.example.com/v1/statement", - from: "http://trino.example.com:8080/v1/statement" - ) - let samePath = try refusal( - 307, - location: "https://trino.example.com/moved/v1/statement", - from: "https://trino.example.com/v1/statement", - useTLS: true - ) - - #expect(otherHost == .redirected( - statusCode: 302, - location: "https://gateway.example.com/v1/statement", - advice: .checkAddress - )) - #expect(samePath == .redirected( - statusCode: 307, - location: "https://trino.example.com/moved/v1/statement", - advice: .checkAddress - )) - } - - @Test("A relative Location resolves against the request and drops its query") - func relativeLocation() throws { - let error = try refusal(302, location: "/login?next=%2Fv1", from: "http://trino.example.com:8080/v1/statement") - - #expect(error == .redirected( - statusCode: 302, - location: "http://trino.example.com:8080/login", - advice: .checkAddress - )) - } - - @Test("Credentials and tokens in a Location never reach the message") - func secretsAreDropped() throws { - let error = try refusal( - 302, - location: "https://u:p@sso.example.com/auth?state=abc&token=xyz#f", - from: "https://trino.example.com/v1/statement", - useTLS: true - ) - - #expect(error == .redirected(statusCode: 302, location: "https://sso.example.com/auth", advice: .checkAddress)) - let description = try #require(error.errorDescription) - #expect(!description.contains("token")) - #expect(!description.contains("u:p")) - } - - @Test("A 3xx with no Location says so") - func missingLocation() throws { - let error = try refusal(300, location: nil, from: "http://trino.example.com:8080/v1/statement") - - #expect(error == .redirected(statusCode: 300, location: nil, advice: .checkAddress)) - #expect(error.errorDescription?.hasPrefix("The server answered with HTTP 300 and no address.") == true) - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoRowEditSQLTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoRowEditSQLTests.swift deleted file mode 100644 index a4a5a8f1fc..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoRowEditSQLTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoRowEditSQLTests: XCTestCase { - private let target = "\"hive\".\"sales\".\"orders\"" - - func testInsert() { - let columns = [ - TrinoColumnValue(name: "id", value: .text("7"), typeName: "bigint"), - TrinoColumnValue(name: "name", value: .text("Ann"), typeName: "varchar(20)"), - ] - XCTAssertEqual( - TrinoRowEditSQL.insert(qualifiedTable: target, columns: columns), - "INSERT INTO \(target) (\"id\", \"name\") VALUES (7, 'Ann')" - ) - } - - func testUpdateWithKey() { - let sql = TrinoRowEditSQL.update( - qualifiedTable: target, - assignments: [TrinoColumnValue(name: "name", value: .text("Bob"), typeName: "varchar(20)")], - keyColumns: [TrinoColumnValue(name: "id", value: .text("7"), typeName: "bigint")] - ) - XCTAssertEqual(sql, "UPDATE \(target) SET \"name\" = 'Bob' WHERE \"id\" = 7") - } - - func testDeleteWithNullKey() { - let sql = TrinoRowEditSQL.delete( - qualifiedTable: target, - keyColumns: [TrinoColumnValue(name: "id", value: .null, typeName: "bigint")] - ) - XCTAssertEqual(sql, "DELETE FROM \(target) WHERE \"id\" IS NULL") - } - - func testPredicateSkipsStructuredColumns() { - let sql = TrinoRowEditSQL.delete( - qualifiedTable: target, - keyColumns: [ - TrinoColumnValue(name: "tags", value: .text("[1]"), typeName: "array(integer)"), - TrinoColumnValue(name: "id", value: .text("7"), typeName: "bigint"), - ] - ) - XCTAssertEqual(sql, "DELETE FROM \(target) WHERE \"id\" = 7") - } - - func testDeleteWithOnlyStructuredColumnsReturnsNil() { - XCTAssertNil(TrinoRowEditSQL.delete( - qualifiedTable: target, - keyColumns: [TrinoColumnValue(name: "tags", value: .text("[1]"), typeName: "array(integer)")] - )) - } - - func testUpdateWithoutKeyReturnsNil() { - XCTAssertNil(TrinoRowEditSQL.update( - qualifiedTable: target, - assignments: [TrinoColumnValue(name: "name", value: .text("Bob"), typeName: "varchar(20)")], - keyColumns: [] - )) - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoSessionStateTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoSessionStateTests.swift deleted file mode 100644 index 57222bb55c..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoSessionStateTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoSessionStateTests: XCTestCase { - func testAppliesSetCatalogAndSchema() { - let session = TrinoSessionState(catalog: "old", schema: "public") - let headers = TrinoHeaderFields([ - "X-Trino-Set-Catalog": "hive", - "X-Trino-Set-Schema": "analytics" - ]) - session.apply(responseHeaders: headers, protocolHeaders: .trino) - XCTAssertEqual(session.catalog, "hive") - XCTAssertEqual(session.schema, "analytics") - } - - func testAppliesSetAndClearSession() { - let session = TrinoSessionState(sessionProperties: ["stale": "1"]) - let setHeaders = TrinoHeaderFields([ - "X-Trino-Set-Session": "query_max_memory=1GB", - "X-Trino-Clear-Session": "stale" - ]) - session.apply(responseHeaders: setHeaders, protocolHeaders: .trino) - XCTAssertEqual(session.sessionProperties["query_max_memory"], "1GB") - XCTAssertNil(session.sessionProperties["stale"]) - } - - func testDecodesPercentEncodedSessionValue() { - let session = TrinoSessionState() - let headers = TrinoHeaderFields(["X-Trino-Set-Session": "note=hello%20world"]) - session.apply(responseHeaders: headers, protocolHeaders: .trino) - XCTAssertEqual(session.sessionProperties["note"], "hello world") - } - - func testAppliesTransactionLifecycle() { - let session = TrinoSessionState() - session.apply( - responseHeaders: TrinoHeaderFields(["X-Trino-Started-Transaction-Id": "tx-123"]), - protocolHeaders: .trino - ) - XCTAssertEqual(session.transactionId, "tx-123") - session.apply( - responseHeaders: TrinoHeaderFields(["X-Trino-Clear-Transaction-Id": "true"]), - protocolHeaders: .trino - ) - XCTAssertNil(session.transactionId) - } - - func testSessionPropertyHeaderValueEncodesAndSorts() { - let session = TrinoSessionState(sessionProperties: ["b": "2", "a": "hello world"]) - XCTAssertEqual(session.sessionPropertyHeaderValue(), "a=hello%20world,b=2") - } - - func testPrestoProtocolHeadersApply() { - let session = TrinoSessionState() - session.apply( - responseHeaders: TrinoHeaderFields(["X-Presto-Set-Catalog": "tpch"]), - protocolHeaders: .presto - ) - XCTAssertEqual(session.catalog, "tpch") - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementClientTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementClientTests.swift deleted file mode 100644 index 9f4fa33db7..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementClientTests.swift +++ /dev/null @@ -1,242 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoStatementClientTests: XCTestCase { - private func makeClient(_ transport: StubTransport, session: TrinoSessionState = TrinoSessionState(catalog: "c", schema: "s")) -> TrinoStatementClient { - let config = TrinoClientConfig(host: "h", port: 8_080, user: "u") - return TrinoStatementClient(transport: transport, config: config, session: session) - } - - private let bigintColumn = #"{"name":"n","type":"bigint","typeSignature":{"rawType":"bigint"}}"# - - func testPollsNextUriUntilAbsentAndAccumulatesRows() async throws { - let transport = StubTransport([ - canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/1"}"#), - canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/2","columns":[\#(bigintColumn)]}"#), - canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/3","columns":[\#(bigintColumn)],"data":[[9007199254740993]]}"#), - canned(#"{"id":"q1","columns":[\#(bigintColumn)],"data":[[42]]}"#) - ]) - let client = makeClient(transport) - - let result = try await client.execute("SELECT n FROM t") - - XCTAssertEqual(result.columns.map(\.name), ["n"]) - XCTAssertEqual(result.columns.first?.category, .scalar) - XCTAssertEqual(result.rows, [[.text("9007199254740993")], [.text("42")]]) - XCTAssertEqual(transport.requests.count, 4) - XCTAssertEqual(transport.requests.first?.method, .post) - XCTAssertEqual(transport.requests.dropFirst().map(\.method), [.get, .get, .get]) - } - - func testRequestsUseTheLatestConnectDeadlineBudget() async throws { - let transport = StubTransport([ - canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/1"}"#), - canned(#"{"id":"q1"}"#) - ]) - let timeouts = TrinoTimeoutSequence([2.5, 1.25]) - let config = TrinoClientConfig(host: "h", port: 8_080, user: "u") - let client = TrinoStatementClient( - transport: transport, - config: config, - session: TrinoSessionState(), - requestTimeout: { timeouts.next() } - ) - - _ = try await client.execute("SELECT 1") - - XCTAssertEqual(transport.requests.map(\.timeoutInterval), [2.5, 1.25]) - } - - func testInitialPostCarriesSessionHeaders() async throws { - let transport = StubTransport([canned(#"{"id":"q1"}"#)]) - let client = makeClient(transport) - - _ = try await client.execute("SELECT 1") - - let post = try XCTUnwrap(transport.requests.first) - XCTAssertEqual(post.headers["X-Trino-User"], "u") - XCTAssertEqual(post.headers["X-Trino-Catalog"], "c") - XCTAssertEqual(post.headers["X-Trino-Schema"], "s") - XCTAssertEqual(post.headers["X-Trino-Source"], "TablePro") - XCTAssertEqual(post.headers["Content-Type"], "text/plain; charset=utf-8") - XCTAssertEqual(post.body, Data("SELECT 1".utf8)) - } - - func testThrowsOnQueryError() async throws { - let transport = StubTransport([ - canned(#"{"id":"q1","error":{"message":"Table not found","errorName":"TABLE_NOT_FOUND","errorType":"USER_ERROR","errorCode":46}}"#) - ]) - let client = makeClient(transport) - - do { - _ = try await client.execute("SELECT * FROM missing") - XCTFail("Expected a query error") - } catch let error as TrinoError { - guard case .query(let queryError) = error else { - return XCTFail("Expected TrinoError.query, got \(error)") - } - XCTAssertEqual(queryError.errorName, "TABLE_NOT_FOUND") - XCTAssertEqual(error.errorDescription, "TABLE_NOT_FOUND: Table not found") - } - } - - func testRetriesOnServiceUnavailable() async throws { - let transport = StubTransport([ - canned("upstream unavailable", status: 503), - canned(#"{"id":"q1","data":[]}"#) - ]) - let client = makeClient(transport) - - _ = try await client.execute("SELECT 1") - - XCTAssertEqual(transport.requests.count, 2) - XCTAssertEqual(transport.requests.map(\.method), [.post, .post]) - } - - func testThrowsAuthenticationFailedOn401() async throws { - let transport = StubTransport([canned("Unauthorized", status: 401)]) - let client = makeClient(transport) - - do { - _ = try await client.execute("SELECT 1") - XCTFail("Expected an authentication error") - } catch let error as TrinoError { - guard case .authenticationFailed = error else { - return XCTFail("Expected authenticationFailed, got \(error)") - } - } - } - - func testReportsUpdateTypeAndCountForDML() async throws { - let transport = StubTransport([canned(#"{"id":"q1","updateType":"INSERT","updateCount":5}"#)]) - let client = makeClient(transport) - - let result = try await client.execute("INSERT INTO t VALUES (1)") - - XCTAssertEqual(result.updateType, "INSERT") - XCTAssertEqual(result.updateCount, 5) - XCTAssertTrue(result.columns.isEmpty) - XCTAssertTrue(result.rows.isEmpty) - } - - func testAppliesResponseSessionHeadersToState() async throws { - let session = TrinoSessionState(catalog: "c", schema: "s") - let transport = StubTransport([ - canned( - #"{"id":"q1","updateType":"SET SESSION"}"#, - headers: ["X-Trino-Set-Catalog": "hive", "X-Trino-Set-Schema": "analytics"] - ) - ]) - let client = makeClient(transport, session: session) - - _ = try await client.execute("USE hive.analytics") - - XCTAssertEqual(session.catalog, "hive") - XCTAssertEqual(session.schema, "analytics") - } - - func testCancelStopsPolling() async throws { - let box = ClientBox() - let transport = StubTransport([ - canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/1"}"#), - canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/2"}"#) - ]) - transport.onSend = { _, index in - if index == 1 { - box.client?.cancel() - } - } - let client = makeClient(transport) - box.client = client - - do { - _ = try await client.execute("SELECT 1") - XCTFail("Expected cancellation") - } catch let error as TrinoError { - XCTAssertEqual(error, .cancelled) - } - XCTAssertEqual(transport.cancelAllCount, 1) - } - - private func plainHTTPClient(_ transport: StubTransport, auth: TrinoAuth) -> TrinoStatementClient { - let config = TrinoClientConfig(host: "h", port: 8_080, useTLS: false, user: "u", auth: auth) - return TrinoStatementClient(transport: transport, config: config, session: TrinoSessionState()) - } - - func testPasswordOverPlainHTTPIsRefusedBeforeAnyRequest() async { - let transport = StubTransport([canned(#"{"id":"q1"}"#)]) - - do { - _ = try await plainHTTPClient(transport, auth: .basic(password: "secret")).execute("SELECT 1") - XCTFail("Expected the password to be refused over plain HTTP") - } catch { - XCTAssertEqual(error as? TrinoError, .credentialsRequireTLS(.password)) - } - XCTAssertTrue(transport.requests.isEmpty) - } - - func testAccessTokenOverPlainHTTPIsRefusedBeforeAnyRequest() async { - let transport = StubTransport([canned(#"{"id":"q1"}"#)]) - - do { - _ = try await plainHTTPClient(transport, auth: .jwt(token: "t")).execute("SELECT 1") - XCTFail("Expected the access token to be refused over plain HTTP") - } catch { - XCTAssertEqual(error as? TrinoError, .credentialsRequireTLS(.accessToken)) - } - XCTAssertTrue(transport.requests.isEmpty) - } - - func testStreamedPasswordOverPlainHTTPIsRefusedBeforeAnyRequest() async { - let transport = StubTransport([canned(#"{"id":"q1","columns":[\#(bigintColumn)],"data":[[1]]}"#)]) - var elements = 0 - - do { - for try await _ in plainHTTPClient(transport, auth: .basic(password: "secret")).executeStreamed("SELECT 1") { - elements += 1 - } - XCTFail("Expected the password to be refused over plain HTTP") - } catch { - XCTAssertEqual(error as? TrinoError, .credentialsRequireTLS(.password)) - } - XCTAssertEqual(elements, 0) - XCTAssertTrue(transport.requests.isEmpty) - } - - func testAnonymousOverPlainHTTPStillPostsWithoutAuthorization() async throws { - let transport = StubTransport([canned(#"{"id":"q1"}"#)]) - - _ = try await plainHTTPClient(transport, auth: .none).execute("SELECT 1") - - let post = try XCTUnwrap(transport.requests.first) - XCTAssertEqual(post.headers["X-Trino-User"], "u") - XCTAssertNil(post.headers["Authorization"]) - } - - func testPasswordOverTLSIsSentOnThePostAndEveryPoll() async throws { - let transport = StubTransport([ - canned(#"{"id":"q1","nextUri":"https://h:8443/v1/statement/executing/q1/1"}"#), - canned(#"{"id":"q1"}"#) - ]) - let config = TrinoClientConfig(host: "h", port: 8_443, useTLS: true, user: "u", auth: .basic(password: "secret")) - let client = TrinoStatementClient(transport: transport, config: config, session: TrinoSessionState()) - - _ = try await client.execute("SELECT 1") - - XCTAssertEqual(transport.requests.map(\.method), [.post, .get]) - XCTAssertEqual(transport.requests.map { $0.headers["Authorization"] }, ["Basic dTpzZWNyZXQ=", "Basic dTpzZWNyZXQ="]) - } -} - -private final class TrinoTimeoutSequence: @unchecked Sendable { - private let lock = NSLock() - private var values: [TimeInterval] - - init(_ values: [TimeInterval]) { - self.values = values - } - - func next() -> TimeInterval { - lock.withLock { values.removeFirst() } - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementSplitterTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementSplitterTests.swift deleted file mode 100644 index fb63c5afa6..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStatementSplitterTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoStatementSplitterTests: XCTestCase { - func testSingleStatementNoTrailingSemicolon() { - XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1"), ["SELECT 1"]) - } - - func testSingleStatementTrailingSemicolon() { - XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1;"), ["SELECT 1"]) - } - - func testSplitsTwoStatements() { - XCTAssertEqual( - TrinoStatementSplitter.split("ALTER TABLE t RENAME COLUMN a TO b;\nALTER TABLE t ALTER COLUMN b SET DATA TYPE bigint"), - ["ALTER TABLE t RENAME COLUMN a TO b", "ALTER TABLE t ALTER COLUMN b SET DATA TYPE bigint"] - ) - } - - func testIgnoresSemicolonInsideSingleQuotes() { - XCTAssertEqual(TrinoStatementSplitter.split("SELECT 'a;b'"), ["SELECT 'a;b'"]) - } - - func testIgnoresSemicolonInsideDoubleQuotedIdentifier() { - XCTAssertEqual(TrinoStatementSplitter.split("SELECT \"we;ird\" FROM t"), ["SELECT \"we;ird\" FROM t"]) - } - - func testHandlesEscapedSingleQuote() { - XCTAssertEqual(TrinoStatementSplitter.split("SELECT 'O''Brien;x'; SELECT 2"), ["SELECT 'O''Brien;x'", "SELECT 2"]) - } - - func testIgnoresSemicolonInLineComment() { - XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1 -- a;b\n; SELECT 2"), ["SELECT 1 -- a;b", "SELECT 2"]) - } - - func testIgnoresSemicolonInBlockComment() { - XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1 /* a;b */; SELECT 2"), ["SELECT 1 /* a;b */", "SELECT 2"]) - } - - func testDropsEmptyStatements() { - XCTAssertEqual(TrinoStatementSplitter.split(";;SELECT 1;;"), ["SELECT 1"]) - } - - func testDropsTrailingCommentOnlySegment() { - XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1; -- done"), ["SELECT 1"]) - } - - func testDropsCommentOnlyInput() { - XCTAssertEqual(TrinoStatementSplitter.split("-- just a comment"), []) - XCTAssertEqual(TrinoStatementSplitter.split("/* block */"), []) - } - - func testKeepsTrailingCommentOnRealStatement() { - XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1 -- trailing"), ["SELECT 1 -- trailing"]) - } - - func testKeepsLeadingCommentWithFollowingStatement() { - XCTAssertEqual(TrinoStatementSplitter.split("SELECT 1;\n-- note\nSELECT 2"), ["SELECT 1", "-- note\nSELECT 2"]) - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStreamingTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStreamingTests.swift deleted file mode 100644 index 4a42401440..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoStreamingTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoStreamingTests: XCTestCase { - private let bigintColumn = #"{"name":"n","type":"bigint","typeSignature":{"rawType":"bigint"}}"# - - private func makeClient(_ transport: StubTransport) -> TrinoStatementClient { - let config = TrinoClientConfig(host: "h", port: 8_080, user: "u") - return TrinoStatementClient(transport: transport, config: config, session: TrinoSessionState(catalog: "c", schema: "s")) - } - - func testStreamsColumnsOnceThenEachPageSeparately() async throws { - let transport = StubTransport([ - canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/1"}"#), - canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/2","columns":[\#(bigintColumn)]}"#), - canned(#"{"id":"q1","nextUri":"http://h:8080/v1/statement/q1/3","columns":[\#(bigintColumn)],"data":[[1],[2]]}"#), - canned(#"{"id":"q1","columns":[\#(bigintColumn)],"data":[[3]]}"#), - ]) - let client = makeClient(transport) - - var columnBatches: [[TrinoColumnDescriptor]] = [] - var pages: [[[TrinoValue]]] = [] - for try await element in client.executeStreamed("SELECT n FROM t") { - switch element { - case .columns(let columns): - columnBatches.append(columns) - case .rows(let page): - pages.append(page) - } - } - - XCTAssertEqual(columnBatches.count, 1) - XCTAssertEqual(columnBatches.first?.map(\.name), ["n"]) - XCTAssertEqual(pages, [[[.text("1")], [.text("2")]], [[.text("3")]]]) - } - - func testStreamPropagatesQueryError() async throws { - let transport = StubTransport([ - canned(#"{"id":"q1","error":{"message":"boom","errorName":"GENERIC_INTERNAL_ERROR","errorType":"INTERNAL_ERROR"}}"#), - ]) - let client = makeClient(transport) - - do { - for try await _ in client.executeStreamed("SELECT 1") {} - XCTFail("Expected an error") - } catch let error as TrinoError { - guard case .query = error else { - return XCTFail("Expected TrinoError.query, got \(error)") - } - } - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTLSFailureTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTLSFailureTests.swift deleted file mode 100644 index 96964c8179..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTLSFailureTests.swift +++ /dev/null @@ -1,316 +0,0 @@ -import Foundation -@testable import TableProTrinoCore -import Testing - -private let nginxPlaintextRejection = """ - -400 The plain HTTP request was sent to HTTPS port - -

400 Bad Request

-
The plain HTTP request was sent to HTTPS port
-
nginx
- - -""" - -private let goPlaintextRejection = "Client sent an HTTP request to an HTTPS server.\n" - -private func client(_ transport: StubTransport, useTLS: Bool) -> TrinoStatementClient { - let config = TrinoClientConfig(host: "trino.example.com", port: 443, useTLS: useTLS, user: "u") - return TrinoStatementClient(transport: transport, config: config, session: TrinoSessionState()) -} - -private func failure(of transport: StubTransport, useTLS: Bool) async -> TrinoError? { - do { - _ = try await client(transport, useTLS: useTLS).execute("SELECT version()") - return nil - } catch let error as TrinoError { - return error - } catch { - return nil - } -} - -struct TrinoResponseTextTests { - @Test("An HTML error page reads as its title") - func htmlReadsAsTitle() { - #expect(TrinoResponseText.readable(nginxPlaintextRejection) == "400 The plain HTTP request was sent to HTTPS port") - } - - @Test("A body that is not HTML is kept as it is") - func plainBodyIsKept() { - #expect(TrinoResponseText.readable("Query exceeded limit") == "Query exceeded limit") - #expect(TrinoResponseText.readable(goPlaintextRejection) == goPlaintextRejection) - } - - @Test("An HTML page with no title reads as its visible text rather than as markup") - func untitledHTMLReadsAsVisibleText() { - let page = "

413 Request Entity Too Large


nginx
" - #expect(TrinoResponseText.readable(page) == "413 Request Entity Too Large nginx") - } - - @Test("A body that only mentions HTML is not treated as a page") - func bodyQuotingHTMLIsKept() { - let body = "Unsupported content type: is not accepted" - #expect(TrinoResponseText.readable(body) == body) - } - - @Test("nginx's and Go's replies to plain HTTP on a TLS port are recognised, and only as a 400") - func plaintextRejectionMarkers() { - #expect(TrinoResponseText.isPlaintextRejection(statusCode: 400, body: nginxPlaintextRejection)) - #expect(TrinoResponseText.isPlaintextRejection(statusCode: 400, body: goPlaintextRejection)) - #expect(!TrinoResponseText.isPlaintextRejection(statusCode: 404, body: nginxPlaintextRejection)) - #expect(!TrinoResponseText.isPlaintextRejection(statusCode: 400, body: "line 1:8: mismatched input")) - } -} - -struct TrinoTLSFailureTests { - @Test("A plain HTTP request refused by a TLS port reports the TLS failure, not the page (#3166)") - func plaintextRejectionIsATLSFailure() async { - let transport = StubTransport([canned(nginxPlaintextRejection, status: 400)]) - - let error = await failure(of: transport, useTLS: false) - - #expect(error == .tlsHandshakeFailed( - kind: .serverRejectedPlaintext, - serverMessage: "400 The plain HTTP request was sent to HTTPS port" - )) - #expect(error?.errorDescription?.contains("") == false) - } - - @Test("Go's reply to plain HTTP on a TLS port reports the same TLS failure") - func goPlaintextRejectionIsATLSFailure() async { - let transport = StubTransport([canned(goPlaintextRejection, status: 400)]) - - let error = await failure(of: transport, useTLS: false) - - guard case .tlsHandshakeFailed(.serverRejectedPlaintext, _)? = error else { - Issue.record("Expected a plaintext rejection, got \(String(describing: error))") - return - } - } - - @Test("With TLS already on, the same page never blames the SSL mode") - func tlsOnDoesNotBlameTheSSLMode() async { - let transport = StubTransport([canned(nginxPlaintextRejection, status: 400)]) - - let error = await failure(of: transport, useTLS: true) - - #expect(error == .httpStatus(code: 400, body: "400 The plain HTTP request was sent to HTTPS port")) - } - - @Test("An HTML sign-in page from a proxy reads as its title") - func htmlAuthenticationFailureReadsAsTitle() async { - let page = "401 Authorization Requirednginx" - let transport = StubTransport([canned(page, status: 401)]) - - let error = await failure(of: transport, useTLS: true) - - #expect(error == .authenticationFailed("401 Authorization Required")) - } - - @Test("An http:// nextUri on an HTTPS connection is refused before any credential is sent over it") - func downgradedNextUriIsNotFollowed() async { - let transport = StubTransport([ - canned(#"{"id":"q1","nextUri":"http://trino.example.com:443/v1/statement/executing/q1/1"}"#) - ]) - let config = TrinoClientConfig( - host: "trino.example.com", port: 443, useTLS: true, user: "u", auth: .basic(password: "secret") - ) - let client = TrinoStatementClient(transport: transport, config: config, session: TrinoSessionState()) - - do { - _ = try await client.execute("SELECT 1") - Issue.record("Expected the downgraded nextUri to be refused") - } catch let error as TrinoError { - guard case .invalidResponse(let detail) = error else { - Issue.record("Expected invalidResponse, got \(error)") - return - } - #expect(detail.contains("http-server.process-forwarded")) - } catch { - Issue.record("Unexpected error \(error)") - } - #expect(transport.requests.allSatisfy { $0.url.scheme == "https" }) - } - - @Test("An https:// nextUri on a plain HTTP connection is followed") - func upgradedNextUriIsFollowed() async throws { - let transport = StubTransport([ - canned(#"{"id":"q1","nextUri":"https://trino.example.com/v1/statement/executing/q1/1"}"#), - canned(#"{"id":"q1"}"#) - ]) - _ = try await client(transport, useTLS: false).execute("SELECT 1") - #expect(transport.requests.count == 2) - } - - @Test("A certificate the request refused reports as a TLS failure, not as a cancel") - func refusedTrustIsNotACancel() { - let refusal = TrinoTrustRefusal(kind: .hostnameMismatch, message: "certificate is for otherhost") - - #expect(URLSessionTrinoTransport.failure(for: URLError(.cancelled), refusedTrust: refusal, clientCertificateRequest: nil) - == .tlsHandshakeFailed(kind: .hostnameMismatch, serverMessage: "certificate is for otherhost")) - #expect(URLSessionTrinoTransport.failure(for: URLError(.cancelled), refusedTrust: nil, clientCertificateRequest: nil) - == .cancelled) - } - - @Test("A certificate URLSession itself rejected reports as an untrusted certificate") - func systemTrustFailureIsUntrusted() { - for code in [ - URLError.Code.serverCertificateUntrusted, - .serverCertificateHasBadDate, - .serverCertificateHasUnknownRoot, - .serverCertificateNotYetValid - ] { - guard case .tlsHandshakeFailed(.untrustedCertificate, _) = - URLSessionTrinoTransport.failure(for: URLError(code), refusedTrust: nil, clientCertificateRequest: nil) else { - Issue.record("\(code.rawValue) should be an untrusted certificate") - continue - } - } - guard case .transport = URLSessionTrinoTransport.failure( - for: URLError(.timedOut), - refusedTrust: nil, - clientCertificateRequest: nil - ) else { - Issue.record("A timeout is not a TLS failure") - return - } - } - - @Test("An unanswered certificate request reports the missing certificate only on the codes that say so") - func unansweredRequestNeedsACertificateCode() { - for code in [URLError.Code.clientCertificateRequired, .secureConnectionFailed] { - let error = URLError(code) - #expect(URLSessionTrinoTransport.failure(for: error, refusedTrust: nil, clientCertificateRequest: .unanswered) - == .tlsHandshakeFailed(kind: .clientCertificateRequired, serverMessage: error.localizedDescription)) - } - } - - @Test("A connection dropped after an unanswered certificate request is still a dropped connection") - func unansweredRequestThenDropIsTransport() { - let error = URLError(.networkConnectionLost) - - #expect(URLSessionTrinoTransport.failure(for: error, refusedTrust: nil, clientCertificateRequest: .unanswered) - == .transport(error.localizedDescription)) - #expect(URLSessionTrinoTransport.failure(for: error, refusedTrust: nil, clientCertificateRequest: nil) - == .transport(error.localizedDescription)) - } - - @Test("A handshake that ends after the certificate was sent reports the certificate as rejected") - func answeredRequestThenFailureIsRejection() { - for code in [ - URLError.Code.networkConnectionLost, - .secureConnectionFailed, - .clientCertificateRejected, - .clientCertificateRequired - ] { - let error = URLError(code) - #expect(URLSessionTrinoTransport.failure(for: error, refusedTrust: nil, clientCertificateRequest: .answered) - == .tlsHandshakeFailed(kind: .clientCertificateRejected, serverMessage: error.localizedDescription)) - } - } - - @Test("A connection that fails after the server began answering is a transport failure, certificate or not") - func failureAfterAnswerIsTransport() { - for request in [TrinoClientCertificateRequest.answered, .unanswered] { - let error = URLError(.networkConnectionLost) - #expect(URLSessionTrinoTransport.failure( - for: error, - refusedTrust: nil, - clientCertificateRequest: request, - serverAnswered: true - ) == .transport(error.localizedDescription)) - } - } - - @Test("A server certificate the request refused is reported as that refusal after a certificate request too") - func refusedTrustWinsOverCertificateRequest() { - let refusal = TrinoTrustRefusal(kind: .untrustedCertificate, message: "not trusted") - - #expect(URLSessionTrinoTransport.failure(for: URLError(.cancelled), refusedTrust: refusal, clientCertificateRequest: .answered) - == .tlsHandshakeFailed(kind: .untrustedCertificate, serverMessage: "not trusted")) - } - - @Test("A 401 after an unanswered certificate request, with no password or token, says the certificate is missing") - func unauthorizedWithoutCertificate() async { - let transport = StubTransport([canned("Unauthorized", status: 401, clientCertificateRequest: .unanswered)]) - - let error = await failure(of: transport, useTLS: true) - - #expect(error == .tlsHandshakeFailed(kind: .clientCertificateRequired, serverMessage: "Unauthorized")) - } - - @Test("A 401 to a password stays the password's failure when the coordinator also asked for a certificate") - func unauthorizedPasswordStaysAnAuthenticationFailure() async { - let transport = StubTransport([ - canned("Access Denied: Invalid credentials", status: 401, clientCertificateRequest: .unanswered) - ]) - let config = TrinoClientConfig( - host: "trino.example.com", port: 443, useTLS: true, user: "u", auth: .basic(password: "secret") - ) - let client = TrinoStatementClient(transport: transport, config: config, session: TrinoSessionState()) - - await #expect(throws: TrinoError.authenticationFailed("Access Denied: Invalid credentials")) { - try await client.execute("SELECT 1") - } - } - - @Test("A 401 on a connection that sent its certificate is an authentication failure") - func unauthorizedAfterCertificateWasSent() async { - let transport = StubTransport([canned("Unauthorized", status: 401, clientCertificateRequest: .answered)]) - - let error = await failure(of: transport, useTLS: true) - - #expect(error == .authenticationFailed("Unauthorized")) - } - - @Test("A redirect on the statement POST to HTTPS on the same host and port is the plaintext rejection") - func redirectedPostIsPlaintextRejection() async { - let transport = StubTransport([ - canned( - "301 Moved Permanently", - status: 301, - headers: ["Location": "https://trino.example.com/v1/statement"] - ) - ]) - - let error = await failure(of: transport, useTLS: false) - - #expect(error == .tlsHandshakeFailed( - kind: .serverRejectedPlaintext, - serverMessage: "301 redirect to https://trino.example.com/v1/statement" - )) - } - - @Test("A redirect on a nextUri poll stops the statement and names the address") - func redirectedPollStopsTheStatement() async { - let transport = StubTransport([ - canned(#"{"id":"q1","nextUri":"https://trino.example.com/v1/statement/executing/q1/1"}"#), - canned("", status: 307, headers: ["Location": "https://elsewhere.example.com/x"]) - ]) - let config = TrinoClientConfig( - host: "trino.example.com", port: 443, useTLS: true, user: "u", auth: .basic(password: "secret") - ) - let client = TrinoStatementClient(transport: transport, config: config, session: TrinoSessionState()) - - await #expect(throws: TrinoError.redirected( - statusCode: 307, - location: "https://elsewhere.example.com/x", - advice: .checkAddress - )) { - try await client.execute("SELECT 1") - } - } - - @Test("Verify CA with no CA certificate refuses the server, and no other setting does") - func missingAnchorRefusal() { - #expect(TrinoTLSOptions(mode: .caOnly).missingAnchorRefusal == TrinoTrustRefusal( - kind: .untrustedCertificate, - message: "Verify CA has no CA certificate to check the server against." - )) - #expect(TrinoTLSOptions(mode: .caOnly, anchorCertificate: Data([0x30])).missingAnchorRefusal == nil) - #expect(TrinoTLSOptions(mode: .full).missingAnchorRefusal == nil) - #expect(TrinoTLSOptions(mode: .insecure).missingAnchorRefusal == nil) - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift deleted file mode 100644 index 4aee234379..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTestSupport.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation -@testable import TableProTrinoCore - -final class StubTransport: TrinoTransport, @unchecked Sendable { - struct Canned { - let statusCode: Int - let headers: [String: String] - let body: Data - var clientCertificateRequest: TrinoClientCertificateRequest? - } - - private let lock = NSLock() - private var queue: [Canned] - private var recorded: [TrinoHTTPRequest] = [] - private var cancelAllCalls = 0 - var onSend: ((TrinoHTTPRequest, Int) -> Void)? - - init(_ responses: [Canned]) { - self.queue = responses - } - - var requests: [TrinoHTTPRequest] { - lock.withLock { recorded } - } - - var cancelAllCount: Int { - lock.withLock { cancelAllCalls } - } - - func cancelAll() { - lock.withLock { cancelAllCalls += 1 } - } - - func send(_ request: TrinoHTTPRequest) async throws -> TrinoHTTPResponse { - let index = lock.withLock { () -> Int in - recorded.append(request) - return recorded.count - 1 - } - onSend?(request, index) - /// A `DELETE` is the client releasing a statement it cancelled, fired from a detached task - /// with its response discarded. Served from the queue it raced the statement's own in-flight - /// `GET` for the next canned page: on CI it won, the `GET` got the empty default, read it - /// as the last page and returned, and `testCancelStopsPolling` failed with "Expected - /// cancellation" although the cancel had landed. - guard request.method != .delete else { - return TrinoHTTPResponse(statusCode: 204, headers: TrinoHeaderFields([:]), body: Data()) - } - let canned = lock.withLock { () -> Canned in - guard !queue.isEmpty else { - return Canned(statusCode: 200, headers: [:], body: Data(#"{"id":"empty"}"#.utf8)) - } - return queue.removeFirst() - } - return TrinoHTTPResponse( - statusCode: canned.statusCode, - headers: TrinoHeaderFields(canned.headers), - body: canned.body, - clientCertificateRequest: canned.clientCertificateRequest - ) - } -} - -func canned( - _ json: String, - status: Int = 200, - headers: [String: String] = [:], - clientCertificateRequest: TrinoClientCertificateRequest? = nil -) -> StubTransport.Canned { - StubTransport.Canned( - statusCode: status, - headers: headers, - body: Data(json.utf8), - clientCertificateRequest: clientCertificateRequest - ) -} - -/// Hands the client to an `onSend` hook that runs on whatever thread the transport was resumed on, -/// so the store is locked: the write happens on the test's thread and the read inside `send`. -final class ClientBox: @unchecked Sendable { - private let lock = NSLock() - private var stored: TrinoStatementClient? - - var client: TrinoStatementClient? { - get { lock.withLock { stored } } - set { lock.withLock { stored = newValue } } - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTypeMapperTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTypeMapperTests.swift deleted file mode 100644 index c16cc95fcb..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoTypeMapperTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoTypeMapperTests: XCTestCase { - func testBaseTypeStripsParameters() { - XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "varchar(255)"), "varchar") - XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "decimal(10,2)"), "decimal") - XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "array(varchar)"), "array") - XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "row(x integer, y varchar)"), "row") - XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "bigint"), "bigint") - XCTAssertEqual(TrinoTypeMapper.baseType(fromDisplayType: "timestamp(3) with time zone"), "timestamp") - } - - func testScalarCategories() { - XCTAssertEqual(TrinoTypeMapper.category(forRawType: "bigint"), .scalar) - XCTAssertEqual(TrinoTypeMapper.category(forRawType: "varchar"), .scalar) - XCTAssertEqual(TrinoTypeMapper.category(forRawType: "decimal"), .scalar) - XCTAssertEqual(TrinoTypeMapper.category(forRawType: "uuid"), .scalar) - XCTAssertEqual(TrinoTypeMapper.category(forRawType: "timestamp with time zone"), .scalar) - } - - func testBinaryCategories() { - XCTAssertEqual(TrinoTypeMapper.category(forRawType: "varbinary"), .binary) - XCTAssertEqual(TrinoTypeMapper.category(forRawType: "hyperloglog"), .binary) - XCTAssertEqual(TrinoTypeMapper.category(forRawType: "qdigest"), .binary) - } - - func testStructuredCategories() { - XCTAssertEqual(TrinoTypeMapper.category(forRawType: "array"), .structured) - XCTAssertEqual(TrinoTypeMapper.category(forRawType: "map"), .structured) - XCTAssertEqual(TrinoTypeMapper.category(forRawType: "row"), .structured) - } - - func testColumnCategoryUsesTypeSignatureRawType() { - let column = TrinoColumn( - name: "c", - type: "array(varchar)", - typeSignature: TrinoTypeSignature(rawType: "array") - ) - XCTAssertEqual(column.category, .structured) - XCTAssertEqual(column.rawTypeName, "array") - } - - func testColumnCategoryFallsBackToDisplayType() { - let column = TrinoColumn(name: "c", type: "varbinary", typeSignature: nil) - XCTAssertEqual(column.category, .binary) - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoURLSessionTransportTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoURLSessionTransportTests.swift deleted file mode 100644 index d7fd1d4339..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoURLSessionTransportTests.swift +++ /dev/null @@ -1,275 +0,0 @@ -import Foundation -@testable import TableProTrinoCore -import Testing - -/// Plays a Trino coordinator: a statement POST answers at once, `SELECT hang` hands back a nextUri -/// whose GET never answers until cancelled, and every DELETE is recorded, so a test can hold -/// several statements in flight and watch what a cancel does to each. -private final class TrinoStubProtocol: URLProtocol, @unchecked Sendable { - static let origin = "https://h:8443" - - private static let lock = NSLock() - nonisolated(unsafe) private static var startedRequests: [String] = [] - nonisolated(unsafe) private static var queryCount = 0 - nonisolated(unsafe) private static var timeout: TimeInterval? - - static func reset() { - lock.withLock { - startedRequests = [] - queryCount = 0 - timeout = nil - } - } - - static var started: [String] { - lock.withLock { startedRequests } - } - - static var lastTimeout: TimeInterval? { - lock.withLock { timeout } - } - - static var parkedPolls: [String] { - started.filter { $0.hasPrefix("GET /v1/statement/executing/") }.map { String($0.dropFirst("GET ".count)) } - } - - static var deletes: [String] { - started.filter { $0.hasPrefix("DELETE ") }.map { String($0.dropFirst("DELETE ".count)) } - } - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - let method = request.httpMethod ?? "GET" - let path = request.url?.path ?? "" - Self.lock.withLock { - Self.startedRequests.append("\(method) \(path)") - Self.timeout = request.timeoutInterval - } - - switch (method, path) { - case ("POST", "/v1/statement"): - respond(body: statementReply(sql: Self.bodyText(of: request))) - case ("DELETE", "/slow"): - DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { self.respond(status: 204, body: "") } - case ("DELETE", _): - respond(status: 204, body: "") - case (_, "/ok"): - respond(body: #"{"id":"ok"}"#, headers: ["X-Trino-Set-Schema": "analytics"]) - case ("POST", "/redirect"): - redirect(to: "https://other:9443/redirected") - case (_, "/redirected"): - respond(body: #"{"id":"followed"}"#) - default: - return - } - } - - override func stopLoading() {} - - private func statementReply(sql: String) -> String { - let queryId = Self.lock.withLock { () -> String in - Self.queryCount += 1 - return "q\(Self.queryCount)" - } - guard sql == "SELECT hang" else { - let column = #"{"name":"n","type":"bigint","typeSignature":{"rawType":"bigint"}}"# - return #"{"id":"\#(queryId)","columns":[\#(column)],"data":[[1]]}"# - } - return #"{"id":"\#(queryId)","nextUri":"\#(Self.origin)/v1/statement/executing/\#(queryId)/1"}"# - } - - private func respond(status: Int = 200, body: String, headers: [String: String] = [:]) { - guard let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: headers) - else { return } - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data(body.utf8)) - client?.urlProtocolDidFinishLoading(self) - } - - private func redirect(to target: String) { - guard let url = request.url, - let targetURL = URL(string: target), - let response = HTTPURLResponse( - url: url, - statusCode: 307, - httpVersion: "HTTP/1.1", - headerFields: ["Location": target] - ) - else { return } - var redirected = request - redirected.url = targetURL - client?.urlProtocol(self, wasRedirectedTo: redirected, redirectResponse: response) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data("307 Temporary Redirect".utf8)) - client?.urlProtocolDidFinishLoading(self) - } - - private static func bodyText(of request: URLRequest) -> String { - if let body = request.httpBody { - return String(bytes: body, encoding: .utf8) ?? "" - } - guard let stream = request.httpBodyStream else { return "" } - stream.open() - defer { stream.close() } - var data = Data() - var buffer = [UInt8](repeating: 0, count: 1_024) - while stream.hasBytesAvailable { - let read = stream.read(&buffer, maxLength: buffer.count) - guard read > 0 else { break } - data.append(buffer, count: read) - } - return String(bytes: data, encoding: .utf8) ?? "" - } -} - -@Suite("Trino URLSession transport", .serialized) -struct TrinoURLSessionTransportTests { - init() { - TrinoStubProtocol.reset() - } - - private func transport() -> URLSessionTrinoTransport { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [TrinoStubProtocol.self] - return URLSessionTrinoTransport(tls: .systemDefault, configuration: configuration) - } - - private func client(_ transport: URLSessionTrinoTransport) -> TrinoStatementClient { - TrinoStatementClient( - transport: transport, - config: TrinoClientConfig(host: "h", port: 8_443, useTLS: true, user: "u"), - session: TrinoSessionState(catalog: "c", schema: "s") - ) - } - - private func request(_ method: TrinoHTTPRequest.Method, _ path: String, timeout: Int = 60) throws -> TrinoHTTPRequest { - TrinoHTTPRequest( - method: method, - url: try #require(URL(string: TrinoStubProtocol.origin + path)), - headers: [:], - timeoutSeconds: timeout - ) - } - - private func waitUntil(_ condition: () -> Bool) async { - for _ in 0 ..< 300 where !condition() { - try? await Task.sleep(for: .milliseconds(10)) - } - } - - @Test("A request carries its own timeout and returns the status, headers and body") - func roundTrip() async throws { - let response = try await transport().send(try request(.get, "/ok", timeout: 330)) - - #expect(response.statusCode == 200) - #expect(response.headers.first("x-trino-set-schema") == "analytics") - #expect(String(bytes: response.body, encoding: .utf8) == #"{"id":"ok"}"#) - #expect(TrinoStubProtocol.lastTimeout == 330) - } - - @Test("A request preserves a subsecond timeout") - func subsecondTimeout() async throws { - let url = try #require(URL(string: TrinoStubProtocol.origin + "/ok")) - let request = TrinoHTTPRequest( - method: .get, - url: url, - headers: [:], - timeoutInterval: 0.125 - ) - - _ = try await transport().send(request) - - #expect(TrinoStubProtocol.lastTimeout == 0.125) - } - - @Test("A redirect is handed back to the caller, never followed") - func redirectIsNotFollowed() async throws { - let response = try await transport().send(try request(.post, "/redirect")) - - #expect(response.statusCode == 307) - #expect(response.headers.first("Location") == "https://other:9443/redirected") - #expect(TrinoStubProtocol.started == ["POST /redirect"]) - } - - @Test("Cancelling everything stops every request in flight, not just the latest") - func cancelAllStopsEveryRequest() async throws { - let transport = transport() - let firstRequest = try request(.get, "/hang/1") - let secondRequest = try request(.get, "/hang/2") - let first = Task { try await transport.send(firstRequest) } - let second = Task { try await transport.send(secondRequest) } - await waitUntil { transport.inFlightCount == 2 } - - transport.cancelAll() - - for task in [first, second] { - await #expect(throws: TrinoError.cancelled) { try await task.value } - } - #expect(transport.inFlightCount == 0) - } - - @Test("Cancelling the awaiting task cancels its request") - func taskCancellation() async throws { - let transport = transport() - let hanging = try request(.get, "/hang") - let task = Task { try await transport.send(hanging) } - await waitUntil { transport.inFlightCount == 1 } - - task.cancel() - - await #expect(throws: TrinoError.cancelled) { try await task.value } - #expect(transport.inFlightCount == 0) - } - - @Test("Cancelling everything leaves a DELETE to finish telling Trino to stop") - func cancelAllSparesDelete() async throws { - let transport = transport() - let deleteRequest = try request(.delete, "/slow") - let pollRequest = try request(.get, "/hang") - let delete = Task { try await transport.send(deleteRequest) } - let poll = Task { try await transport.send(pollRequest) } - await waitUntil { transport.inFlightCount == 1 && TrinoStubProtocol.started.contains("DELETE /slow") } - - transport.cancelAll() - - await #expect(throws: TrinoError.cancelled) { try await poll.value } - #expect(try await delete.value.statusCode == 204) - } - - @Test("Cancel stops every running statement and deletes each on the server") - func cancelStopsEveryStatement() async throws { - let transport = transport() - let client = client(transport) - let first = Task { try await client.execute("SELECT hang") } - let second = Task { try await client.execute("SELECT hang") } - await waitUntil { TrinoStubProtocol.parkedPolls.count == 2 } - let parked = TrinoStubProtocol.parkedPolls - - let finished = try await client.execute("SELECT 1") - client.cancel() - - #expect(finished.rows == [[.text("1")]]) - for task in [first, second] { - await #expect(throws: TrinoError.cancelled) { try await task.value } - } - await waitUntil { TrinoStubProtocol.deletes.count == 2 } - #expect(Set(TrinoStubProtocol.deletes) == Set(parked)) - } - - @Test("Cancelling the task running a statement cancels its poll and deletes it on the server") - func taskCancellationReleasesStatement() async throws { - let client = client(transport()) - let task = Task { try await client.execute("SELECT hang") } - await waitUntil { TrinoStubProtocol.parkedPolls.count == 1 } - let parked = TrinoStubProtocol.parkedPolls - - task.cancel() - - await #expect(throws: TrinoError.cancelled) { try await task.value } - await waitUntil { TrinoStubProtocol.deletes.count == 1 } - #expect(TrinoStubProtocol.deletes == parked) - } -} diff --git a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoValueDecoderTests.swift b/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoValueDecoderTests.swift deleted file mode 100644 index 838e90857a..0000000000 --- a/Packages/TableProCore/Tests/TableProTrinoCoreTests/TrinoValueDecoderTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -@testable import TableProTrinoCore -import XCTest - -final class TrinoValueDecoderTests: XCTestCase { - func testScalarIntBecomesText() { - let value = TrinoValueDecoder.decode(.int(42), category: .scalar) - XCTAssertEqual(value, .text("42")) - } - - func testScalarBoolBecomesText() { - XCTAssertEqual(TrinoValueDecoder.decode(.bool(true), category: .scalar), .text("true")) - XCTAssertEqual(TrinoValueDecoder.decode(.bool(false), category: .scalar), .text("false")) - } - - func testNullStaysNull() { - XCTAssertEqual(TrinoValueDecoder.decode(.null, category: .scalar), .null) - XCTAssertEqual(TrinoValueDecoder.decode(.null, category: .binary), .null) - XCTAssertEqual(TrinoValueDecoder.decode(.null, category: .structured), .null) - } - - func testVarbinaryBase64DecodesToBytes() { - let value = TrinoValueDecoder.decode(.string("aGVsbG8="), category: .binary) - XCTAssertEqual(value, .bytes([104, 101, 108, 108, 111])) - } - - func testInvalidBase64FallsBackToText() { - let value = TrinoValueDecoder.decode(.string("not base64!!!"), category: .binary) - XCTAssertEqual(value, .text("not base64!!!")) - } - - func testStructuredArrayBecomesJsonText() { - let value = TrinoValueDecoder.decode(.array([.int(1), .string("x")]), category: .structured) - XCTAssertEqual(value, .text("[1,\"x\"]")) - } - - func testStructuredObjectBecomesJsonText() { - let value = TrinoValueDecoder.decode(.object(["a": .int(1)]), category: .structured) - XCTAssertEqual(value, .text("{\"a\":1}")) - } -} diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateAuthAndQueryTests.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateAuthAndQueryTests.swift deleted file mode 100644 index 2bd548ca24..0000000000 --- a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateAuthAndQueryTests.swift +++ /dev/null @@ -1,195 +0,0 @@ -import Foundation -@testable import TableProWeaviateCore -import Testing - -@Suite("Weaviate auth and settings") -struct WeaviateAuthTests { - @Test("Field ids stay Weaviate-prefixed") - func fieldIdsArePrefixed() { - #expect(WeaviateFieldID.authMethod == "wvAuthMethod") - #expect(WeaviateFieldID.apiKey == "wvApiKey") - #expect(WeaviateFieldID.skipTLSVerify == "wvSkipTLSVerify") - #expect(WeaviateFieldID.authMethod != "esAuthMethod") - #expect(WeaviateFieldID.apiKey != "esApiKey") - } - - @Test("API key mode without a key is refused") - func apiKeyRequiresValue() { - #expect(throws: WeaviateError.configuration("Enter a Weaviate API key.")) { - _ = try WeaviateConnectionSettings.parse( - host: "localhost", - port: 8_080, - usesTLS: false, - fields: [WeaviateFieldID.authMethod: "apiKey"] - ) - } - } - - @Test("A pasted key is not sent when Auth Method is None") - func noneModeIgnoresAPastedKey() throws { - let settings = try WeaviateConnectionSettings.parse( - host: "localhost", - port: 8_080, - usesTLS: false, - fields: [ - WeaviateFieldID.authMethod: "none", - WeaviateFieldID.apiKey: "wv-secret" - ] - ) - #expect(settings.auth.authorizationHeader == nil) - } - - @Test("None mode does not require a key") - func noneModeConnects() throws { - let settings = try WeaviateConnectionSettings.parse( - host: "localhost", - port: 0, - usesTLS: false, - fields: [WeaviateFieldID.authMethod: "none"] - ) - #expect(settings.port == 8_080) - #expect(settings.auth.authorizationHeader == nil) - #expect(try settings.baseURL().absoluteString == "http://localhost:8080") - } - - @Test("TLS uses https") - func tlsUsesHTTPS() throws { - let settings = try WeaviateConnectionSettings.parse( - host: "example.weaviate.cloud", - port: 443, - usesTLS: true, - fields: [ - WeaviateFieldID.authMethod: "apiKey", - WeaviateFieldID.apiKey: "key" - ] - ) - #expect(try settings.baseURL().absoluteString == "https://example.weaviate.cloud:443") - #expect(settings.auth.authorizationHeader == "Bearer key") - } -} - -@Suite("Weaviate query tags") -struct WeaviateQueryTests { - @Test("Browse tags round-trip") - func browseRoundTrip() throws { - let encoded = WeaviateBrowseQuery.encode( - collection: "Article", - offset: 10, - limit: 25, - sorts: [WeaviateSortSpec(column: "title", ascending: true)], - filters: [WeaviateFilterSpec(column: "title", op: "=", value: "Hello")], - logicMode: "AND", - propertyNames: ["uuid", "title"] - ) - #expect(WeaviateBrowseQuery.isTagged(encoded)) - let parsed = try #require(WeaviateBrowseQuery.parse(encoded)) - #expect(parsed.collection == "Article") - #expect(parsed.offset == 10) - #expect(parsed.limit == 25) - #expect(parsed.usesGraphQL) - #expect(parsed.filters.first?.value == "Hello") - } - - @Test("An unfiltered browse stays on REST objects") - func unfilteredUsesREST() throws { - let encoded = WeaviateBrowseQuery.encode( - collection: "Article", - offset: 0, - limit: 25, - sorts: [], - filters: [], - logicMode: "AND", - propertyNames: ["uuid", "title"] - ) - let parsed = try #require(WeaviateBrowseQuery.parse(encoded)) - #expect(!parsed.usesGraphQL) - } - - @Test("Write tags round-trip") - func writeRoundTrip() throws { - let original = WeaviateWriteRequest( - method: "PATCH", - path: "/v1/objects/abc", - query: ["class": "Article"], - body: "{\"title\":\"x\"}" - ) - let encoded = WeaviateWriteCodec.encode(original) - #expect(WeaviateWriteCodec.isTagged(encoded)) - #expect(WeaviateWriteCodec.decode(encoded) == original) - } -} - -@Suite("Weaviate GraphQL and console") -struct WeaviateGraphQLTests { - @Test("A Get query asks for _additional id and vector") - func getQueryIncludesAdditional() throws { - let query = try WeaviateGraphQL.getQuery( - collection: "Article", - properties: ["uuid", "title", "vector"], - limit: 10, - offset: 0, - sorts: [], - filters: [WeaviateFilterSpec(column: "title", op: "=", value: "Hello")], - logicMode: "AND", - schema: ["title": WeaviateProperty(name: "title", dataType: "text")] - ) - #expect(query.contains("Get")) - #expect(query.contains("Article")) - #expect(query.contains("title")) - #expect(query.contains("_additional { id vector }")) - #expect(query.contains("operator: Equal")) - #expect(query.contains("valueText: \"Hello\"")) - #expect(!query.contains(" uuid ")) - } - - @Test("GraphQL detection") - func detection() { - #expect(WeaviateGraphQL.looksLikeGraphQL("{ Get { Article { title } } }")) - #expect(WeaviateGraphQL.looksLikeGraphQL("query { Get { Article { title } } }")) - #expect(!WeaviateGraphQL.looksLikeGraphQL("GET /v1/schema")) - } - - @Test("Console parser reads a method and path") - func consoleParser() throws { - let request = try #require(WeaviateConsoleParser.parse("GET /v1/schema")) - #expect(request.method == "GET") - #expect(request.path == "/v1/schema") - #expect(request.body == nil) - - let withBody = try #require(WeaviateConsoleParser.parse("POST /v1/graphql\n{ \"query\": \"{ Get { Article { title } } }\" }")) - #expect(withBody.method == "POST") - #expect(withBody.body?.contains("Get") == true) - } - - @Test("A SQL delete is not a console request") - func sqlIsNotConsole() { - #expect(WeaviateConsoleParser.parse("DELETE FROM Article") == nil) - #expect(WeaviateConsoleParser.parse("UPDATE Article SET title = 'x'") == nil) - #expect(WeaviateConsoleParser.parse("GET schema") == nil) - } -} - -@Suite("Weaviate columns") -struct WeaviateSchemaTests { - @Test("uuid leads and vector trails, and both are the primary key surface") - func columnOrder() { - let collection = WeaviateCollection( - name: "Article", - properties: [WeaviateProperty(name: "title", dataType: "text")] - ) - let columns = WeaviateSchema.columns(for: collection) - #expect(columns.map(\.name) == ["uuid", "title", "vector"]) - #expect(columns.first?.isPrimaryKey == true) - #expect(WeaviateSchema.immutableColumns == ["uuid", "vector"]) - } - - @Test("Object and array properties display as JSON") - func displayTextEncodesCollections() { - #expect(WeaviateJSON.displayText(["title": "Hello"]) == "{\"title\":\"Hello\"}") - let vector = WeaviateJSON.displayText([0.1, 0.2]) - #expect(vector?.hasPrefix("[") == true) - #expect(vector?.hasSuffix("]") == true) - #expect(WeaviateJSON.displayText(true) == "true") - #expect(WeaviateJSON.displayText(NSNull()) == nil) - } -} diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateClientTests.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateClientTests.swift deleted file mode 100644 index cc288103d3..0000000000 --- a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateClientTests.swift +++ /dev/null @@ -1,305 +0,0 @@ -import Foundation -@testable import TableProWeaviateCore -import Testing - -@Suite("Weaviate client") -struct WeaviateClientTests { - @Test("Connect reads ready and meta, and stores the version") - func connectStoresVersion() async throws { - let transport = FakeWeaviateTransport() - transport.respond(method: "GET", path: "/v1/.well-known/ready", status: 200, body: ".") - transport.respond(method: "GET", path: "/v1/meta", status: 200, json: WeaviateFixtures.meta) - let client = testClient(transport: transport) - - try await client.connect() - - #expect(client.serverVersion == "1.27.0") - #expect(transport.requests.map { $0.url.path } == ["/v1/.well-known/ready", "/v1/meta"]) - } - - @Test("Connect asks for a fresh timeout before each bootstrap request") - func connectUsesRemainingTimeout() async throws { - let transport = FakeWeaviateTransport() - transport.respond(method: "GET", path: "/v1/.well-known/ready", status: 200, body: ".") - transport.respond(method: "GET", path: "/v1/meta", status: 200, json: WeaviateFixtures.meta) - let client = testClient(transport: transport) - let timeouts = WeaviateTimeoutSequence([2.5, 1.25]) - - try await client.connect(requestTimeout: { timeouts.next() }) - - #expect(transport.requests.map(\.timeoutInterval) == [2.5, 1.25]) - } - - @Test("An API key is sent as a Bearer header") - func apiKeyIsBearer() async throws { - let transport = FakeWeaviateTransport() - transport.respond(method: "GET", path: "/v1/meta", status: 200, json: WeaviateFixtures.meta) - let client = testClient( - transport: transport, - auth: WeaviateAuth(method: .apiKey, apiKey: "wv-secret") - ) - - try await client.ping() - - #expect(transport.requests.first?.headers["Authorization"] == "Bearer wv-secret") - } - - @Test("A ping asks an endpoint that checks the key") - func pingIsAuthenticated() async throws { - let transport = FakeWeaviateTransport() - transport.respond(method: "GET", path: "/v1/meta", status: 200, json: WeaviateFixtures.meta) - let client = testClient(transport: transport) - - try await client.ping() - - #expect(transport.requests.map { $0.url.path } == ["/v1/meta"]) - } - - @Test("Anonymous auth sends no Authorization header") - func anonymousHasNoAuthorization() async throws { - let transport = FakeWeaviateTransport() - transport.respond(method: "GET", path: "/v1/meta", status: 200, json: WeaviateFixtures.meta) - let client = testClient(transport: transport) - - try await client.ping() - - #expect(transport.requests.first?.headers["Authorization"] == nil) - } - - @Test("Schema lists collections and properties") - func schemaListsCollections() async throws { - let transport = FakeWeaviateTransport() - transport.respond(method: "GET", path: "/v1/schema", status: 200, json: WeaviateFixtures.schema) - let client = testClient(transport: transport) - - let collections = try await client.schema() - - #expect(collections.map(\.name) == ["Article"]) - #expect(collections.first?.properties.map(\.name) == ["title", "wordCount"]) - #expect(collections.first?.properties.map(\.dataType) == ["text", "int"]) - } - - @Test("Objects include uuid, properties and the vector as display text") - func objectsIncludeUUIDAndVector() async throws { - let transport = FakeWeaviateTransport() - transport.respond(method: "GET", path: "/v1/objects", status: 200, json: WeaviateFixtures.objects) - let client = testClient(transport: transport) - - let objects = try await client.objects(collection: "Article", limit: 25, offset: 0) - let object = try #require(objects.first) - let columns = ["uuid", "title", "wordCount", "vector"] - let row = WeaviateObjectCodec.row(for: object, columns: columns) - - #expect(object.uuid == WeaviateFixtures.articleUUID) - #expect(row[0] == WeaviateFixtures.articleUUID) - #expect(row[1] == "Hello") - #expect(row[2] == "12") - #expect(row[3]?.contains("0.1") == true) - #expect(row[3]?.hasPrefix("[") == true) - #expect(transport.requests.first?.url.query?.contains("class=Article") == true) - #expect(transport.requests.first?.url.query?.contains("include=vector") == true) - } - - @Test("HTTP 401 becomes an authentication error") - func unauthorizedIsAuthentication() async throws { - let transport = FakeWeaviateTransport() - transport.respond( - method: "GET", - path: "/v1/meta", - status: 401, - json: ["error": [["message": "invalid api key"]]] - ) - let client = testClient(transport: transport) - - await #expect(throws: WeaviateError.authentication("invalid api key")) { - try await client.ping() - } - } - - @Test("A server error body is surfaced") - func serverErrorMessage() async throws { - let transport = FakeWeaviateTransport() - transport.respond( - method: "GET", - path: "/v1/schema", - status: 500, - json: ["error": [["message": "store unavailable"]]] - ) - let client = testClient(transport: transport) - - await #expect(throws: WeaviateError.api(status: 500, message: "store unavailable")) { - _ = try await client.schema() - } - } - - @Test("GraphQL Get rows flatten uuid from _additional") - func graphqlGetFlattensUUID() async throws { - let transport = FakeWeaviateTransport() - transport.respond(method: "POST", path: "/v1/graphql", status: 200, json: WeaviateFixtures.graphqlGet) - let client = testClient(transport: transport) - - let response = try await client.graphql("{ Get { Article { title } } }") - let objects = WeaviateObjectCodec.objects(fromGraphQL: response.json as Any) - let object = try #require(objects.first) - - #expect(object.uuid == WeaviateFixtures.articleUUID) - #expect(object.properties["title"] == "Hello") - #expect(object.vectorText?.contains("0.1") == true) - } - - @Test("GraphQL errors in a 200 response still fail") - func graphqlErrorsFail() async throws { - let transport = FakeWeaviateTransport() - transport.respond( - method: "POST", - path: "/v1/graphql", - status: 200, - json: ["errors": [["message": "Cannot query field"]]] - ) - let client = testClient(transport: transport) - - await #expect(throws: WeaviateError.api(status: 200, message: "Cannot query field")) { - _ = try await client.graphql("{ Get { Missing { title } } }") - } - } -} - -private final class WeaviateTimeoutSequence: @unchecked Sendable { - private let lock = NSLock() - private var values: [TimeInterval] - - init(_ values: [TimeInterval]) { - self.values = values - } - - func next() -> TimeInterval { - lock.withLock { values.removeFirst() } - } -} - -@Suite("Weaviate uuid edits") -struct WeaviateUUIDEditTests { - @Test("An update is a PATCH keyed by uuid and does not write uuid or vector") - func updateIsPatchByUUID() async throws { - let batch = WeaviateStatementGenerator.generate( - collection: "Article", - columns: ["uuid", "title", "vector"], - typeNames: ["uuid", "text", "vector"], - changes: [ - WeaviateTrackedChange( - kind: .update, - uuid: WeaviateFixtures.articleUUID, - values: [:], - cellChanges: [ - WeaviateCellChange(column: "title", newText: "Edited"), - WeaviateCellChange(column: "vector", newText: "[9,9]"), - WeaviateCellChange(column: "uuid", newText: "nope") - ] - ) - ] - ) - let request = try #require(batch.requests.first) - #expect(request.method == "PATCH") - #expect(request.path == "/v1/objects/\(WeaviateFixtures.articleUUID)") - #expect(request.query["class"] == "Article") - let body = try #require(request.body) - #expect(body.contains("\"title\":\"Edited\"")) - #expect(!body.contains("vector")) - #expect(!body.contains("nope")) - - let transport = FakeWeaviateTransport() - transport.respond( - method: "PATCH", - path: "/v1/objects/\(WeaviateFixtures.articleUUID)", - status: 200, - json: ["id": WeaviateFixtures.articleUUID] - ) - let client = testClient(transport: transport) - let response = try await client.execute(write: request) - #expect(response.statusCode == 200) - #expect(transport.requests.first?.httpMethodMatchesPatch == true) - } - - @Test("A delete is DELETE /v1/objects/{uuid}") - func deleteUsesUUID() async throws { - let batch = WeaviateStatementGenerator.generate( - collection: "Article", - columns: ["uuid", "title"], - typeNames: ["uuid", "text"], - changes: [ - WeaviateTrackedChange( - kind: .delete, - uuid: WeaviateFixtures.articleUUID, - values: [:], - cellChanges: [] - ) - ] - ) - let request = try #require(batch.requests.first) - #expect(request.method == "DELETE") - #expect(request.path.hasSuffix(WeaviateFixtures.articleUUID)) - - let transport = FakeWeaviateTransport() - transport.respond( - method: "DELETE", - path: "/v1/objects/\(WeaviateFixtures.articleUUID)", - status: 204, - body: "" - ) - let client = testClient(transport: transport) - let response = try await client.execute(write: request) - #expect(response.statusCode == 204) - } - - @Test("An update without a uuid is skipped") - func updateWithoutUUIDIsSkipped() { - let batch = WeaviateStatementGenerator.generate( - collection: "Article", - columns: ["uuid", "title"], - typeNames: ["uuid", "text"], - changes: [ - WeaviateTrackedChange( - kind: .update, - uuid: nil, - values: [:], - cellChanges: [WeaviateCellChange(column: "title", newText: "Edited")] - ) - ] - ) - #expect(batch.requests.isEmpty) - #expect(batch.skipped == [WeaviateSkippedChange(kind: .update, reason: .missingUUID)]) - } - - @Test("Insert posts the collection and properties, and an explicit uuid") - func insertPostsObject() throws { - let batch = WeaviateStatementGenerator.generate( - collection: "Article", - columns: ["uuid", "title", "wordCount"], - typeNames: ["uuid", "text", "int"], - changes: [ - WeaviateTrackedChange( - kind: .insert, - uuid: WeaviateFixtures.articleUUID, - values: [ - "uuid": WeaviateFixtures.articleUUID, - "title": "Hello", - "wordCount": "12" - ], - cellChanges: [] - ) - ] - ) - let request = try #require(batch.requests.first) - #expect(request.method == "POST") - #expect(request.path == "/v1/objects") - let body = try #require(request.body) - #expect(body.contains("\"class\":\"Article\"")) - #expect(body.contains("\"id\":\"\(WeaviateFixtures.articleUUID)\"")) - #expect(body.contains("\"title\":\"Hello\"")) - #expect(body.contains("\"wordCount\":12")) - } -} - -private extension WeaviateHTTPRequest { - var httpMethodMatchesPatch: Bool { method == "PATCH" } -} diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateFilterAndPathTests.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateFilterAndPathTests.swift deleted file mode 100644 index 26362c7e19..0000000000 --- a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateFilterAndPathTests.swift +++ /dev/null @@ -1,614 +0,0 @@ -import Foundation -@testable import TableProWeaviateCore -import Testing - -private let articleTypes = [ - "title": "text", - "wordCount": "int", - "ratio": "number", - "live": "boolean", - "published": "date", - "tags": "text[]", - "scores": "int[]" -] - -private let articleSchema = articleTypes.mapValues { WeaviateProperty(name: "", dataType: $0) } - -private func operand(_ column: String, _ op: String, _ value: String, second: String? = nil) throws -> String { - try WeaviateFilterBuilder.operand( - for: WeaviateFilterSpec(column: column, op: op, value: value, secondValue: second), - types: articleTypes - ) -} - -@Suite("Weaviate filter value types") -struct WeaviateFilterValueTypeTests { - @Test("Each data type picks the value field Weaviate demands") - func valueFieldFollowsDataType() throws { - #expect(try operand("title", "=", "Hello").contains("valueText: \"Hello\"")) - #expect(try operand("wordCount", "=", "12").contains("valueInt: 12")) - #expect(try operand("ratio", ">", "1.5").contains("valueNumber: 1.5")) - #expect(try operand("live", "=", "true").contains("valueBoolean: true")) - #expect(try operand("published", ">", "2024-01-31T00:00:00Z") - .contains("valueDate: \"2024-01-31T00:00:00Z\"")) - } - - @Test("An array property filters on its element type") - func arrayUsesElementField() throws { - #expect(try operand("tags", "=", "alpha").contains("valueText: \"alpha\"")) - #expect(try operand("scores", "=", "3").contains("valueInt: 3")) - } - - @Test("The uuid column filters as text on the id path") - func uuidFiltersAsText() throws { - let clause = try operand(WeaviateSchema.uuidColumn, "=", "c8f5c3e0-1b2a-4d3e-9f10-111213141516") - #expect(clause.contains("path: [\"id\"]")) - #expect(clause.contains("valueText:")) - } - - @Test("A value that is not a number is refused instead of becoming zero") - func badNumberThrows() { - #expect(throws: WeaviateFilterError.notANumber(column: "wordCount", value: "abc")) { - _ = try operand("wordCount", "=", "abc") - } - #expect(throws: WeaviateFilterError.notANumber(column: "ratio", value: "abc")) { - _ = try operand("ratio", "=", "abc") - } - } - - @Test("A value that is not a boolean is refused instead of becoming false") - func badBooleanThrows() { - #expect(throws: WeaviateFilterError.notABoolean(column: "live", value: "yes")) { - _ = try operand("live", "=", "yes") - } - } - - @Test("A date needs a full RFC 3339 timestamp") - func badDateThrows() { - #expect(throws: WeaviateFilterError.notADate(column: "published", value: "2024-01-31")) { - _ = try operand("published", ">", "2024-01-31") - } - #expect(WeaviateDateLiteral.isRFC3339("2024-01-31T00:00:00+07:00")) - #expect(WeaviateDateLiteral.isRFC3339("2024-01-31T00:00:00.123Z")) - } -} - -@Suite("Weaviate filter operators") -struct WeaviateFilterOperatorTests { - @Test("Substring operators become Like patterns") - func likePatterns() throws { - #expect(try operand("title", "CONTAINS", "ell").contains("operator: Like valueText: \"*ell*\"")) - #expect(try operand("title", "STARTS WITH", "He").contains("valueText: \"He*\"")) - #expect(try operand("title", "ENDS WITH", "lo").contains("valueText: \"*lo\"")) - #expect(try operand("title", "NOT CONTAINS", "ell").hasPrefix("{ operator: Not operands: [")) - } - - @Test("IN and NOT IN become ContainsAny") - func listOperators() throws { - let inClause = try operand("wordCount", "IN", "10, 30") - #expect(inClause.contains("operator: ContainsAny valueInt: [10, 30]")) - let notIn = try operand("title", "NOT IN", "a,b") - #expect(notIn.contains("operator: ContainsNone valueText: [\"a\", \"b\"]")) - } - - @Test("IN with no values is refused") - func emptyListThrows() { - #expect(throws: WeaviateFilterError.emptyList(column: "title")) { - _ = try operand("title", "IN", " , ") - } - } - - @Test("BETWEEN becomes a bounded And and needs its upper bound") - func betweenBounds() throws { - let clause = try operand("wordCount", "BETWEEN", "5,10", second: "10") - #expect(clause.hasPrefix("{ operator: And operands: [")) - #expect(clause.contains("operator: GreaterThanEqual valueInt: 5")) - #expect(clause.contains("operator: LessThanEqual valueInt: 10")) - - #expect(throws: WeaviateFilterError.missingUpperBound(column: "wordCount")) { - _ = try operand("wordCount", "BETWEEN", "5") - } - } - - @Test("BETWEEN takes its lower bound off the value the app joins with the upper bound") - func betweenReadsLowerBoundOffJoinedValue() throws { - let numbers = try operand("ratio", "BETWEEN", "0.25,0.75", second: "0.75") - #expect(numbers.contains("operator: GreaterThanEqual valueNumber: 0.25")) - #expect(numbers.contains("operator: LessThanEqual valueNumber: 0.75")) - - let dates = try operand( - "published", "BETWEEN", "2024-01-01T00:00:00Z,2024-12-31T00:00:00Z", second: "2024-12-31T00:00:00Z" - ) - #expect(dates.contains("operator: GreaterThanEqual valueDate: \"2024-01-01T00:00:00Z\"")) - #expect(dates.contains("operator: LessThanEqual valueDate: \"2024-12-31T00:00:00Z\"")) - } - - @Test("BETWEEN refuses a lower bound whose last scalar joins the comma, naming that bound alone") - func betweenRefusesPrependScalarLowerBoundAlone() { - #expect(throws: WeaviateFilterError.notANumber(column: "wordCount", value: "5\u{0600}")) { - _ = try operand("wordCount", "BETWEEN", "5\u{0600},10", second: "10") - } - } - - @Test("IS NULL maps to IsNull in both directions") - func nullOperators() throws { - #expect(try operand("title", "IS NULL", "").contains("operator: IsNull valueBoolean: true")) - #expect(try operand("title", "IS NOT NULL", "").contains("operator: IsNull valueBoolean: false")) - } - - @Test("An operator Weaviate cannot express is reported, not dropped") - func unsupportedOperatorsThrow() { - #expect(throws: WeaviateFilterError.unsupportedOperator("REGEX")) { - _ = try operand("title", "REGEX", "^a") - } - } - - @Test("Emptiness counts with len(), which is what Weaviate offers") - func emptinessUsesLength() throws { - #expect(try operand("title", "IS EMPTY", "") - .contains("path: [\"len(title)\"] operator: Equal valueInt: 0")) - #expect(try operand("title", "IS NOT EMPTY", "") - .contains("path: [\"len(title)\"] operator: GreaterThan valueInt: 0")) - #expect(throws: WeaviateFilterError.textMatchNeedsText(column: "wordCount", op: "IS EMPTY")) { - _ = try operand("wordCount", "IS EMPTY", "") - } - } - - @Test("Comparing text, and matching a number as text, are both refused") - func mismatchedOperatorsThrow() { - #expect(throws: WeaviateFilterError.comparisonNeedsNumberOrDate(column: "title", op: ">")) { - _ = try operand("title", ">", "Row 1") - } - #expect(throws: WeaviateFilterError.textMatchNeedsText(column: "wordCount", op: "CONTAINS")) { - _ = try operand("wordCount", "CONTAINS", "1") - } - } - - @Test("The vector column cannot be filtered") - func vectorFilterThrows() { - #expect(throws: WeaviateFilterError.vectorNotFilterable(column: "vector")) { - _ = try operand(WeaviateSchema.vectorColumn, "=", "[1,2]") - } - } - - @Test("The raw filter row is refused instead of filtering a property named __RAW__") - func rawFilterRowIsRefused() { - #expect(throws: WeaviateFilterError.rawFilterUnsupported) { - _ = try operand("__RAW__", "=", "wordCount > 10") - } - #expect(throws: WeaviateFilterError.rawFilterUnsupported) { - _ = try WeaviateFilterBuilder.graphQLWhere( - filters: [ - WeaviateFilterSpec(column: "title", op: "=", value: "a"), - WeaviateFilterSpec(column: "__RAW__", op: "=", value: "{ path: [\"title\"] }") - ], - logicMode: "AND", - types: articleTypes - ) - } - } - - @Test("The raw filter refusal points at the GraphQL editor") - func rawFilterRefusalNamesTheEditor() { - #expect( - WeaviateFilterError.rawFilterUnsupported.errorDescription - == "Raw filters aren't available for Weaviate. Write the where filter in a GraphQL query in the editor." - ) - } - - @Test("A quote in a value cannot break out of the GraphQL string") - func valuesAreEscaped() throws { - let clause = try operand("title", "=", "a\"b\\c") - #expect(clause.contains("valueText: \"a\\\"b\\\\c\"")) - } - - @Test("Several filters join under one logic operator") - func logicMode() throws { - let built = try WeaviateFilterBuilder.graphQLWhere( - filters: [ - WeaviateFilterSpec(column: "title", op: "=", value: "a"), - WeaviateFilterSpec(column: "wordCount", op: ">", value: "3") - ], - logicMode: "OR", - types: articleTypes - ) - let clause = try #require(built) - #expect(clause.hasPrefix("{ operator: Or operands: [")) - } -} - -@Suite("Weaviate sorting") -struct WeaviateSortTests { - @Test("A uuid sort reaches GraphQL, which can sort by the object id") - func uuidSortUsesGraphQL() throws { - let encoded = WeaviateBrowseQuery.encode( - collection: "Article", - offset: 0, - limit: 25, - sorts: [WeaviateSortSpec(column: WeaviateSchema.uuidColumn, ascending: false)], - filters: [], - logicMode: "AND", - propertyNames: ["uuid", "title"] - ) - let parsed = try #require(WeaviateBrowseQuery.parse(encoded)) - #expect(parsed.usesGraphQL) - - let query = try WeaviateGraphQL.getQuery( - collection: "Article", - properties: parsed.propertyNames, - limit: parsed.limit, - offset: parsed.offset, - sorts: parsed.sortableSorts, - filters: [], - logicMode: "AND", - schema: articleSchema - ) - #expect(query.contains("sort: [{ path: [\"id\"] order: desc }]")) - } - - @Test("A vector sort is dropped, because Weaviate has no such property") - func vectorSortIsDropped() throws { - let encoded = WeaviateBrowseQuery.encode( - collection: "Article", - offset: 0, - limit: 25, - sorts: [WeaviateSortSpec(column: WeaviateSchema.vectorColumn, ascending: true)], - filters: [], - logicMode: "AND", - propertyNames: ["uuid", "vector"] - ) - let parsed = try #require(WeaviateBrowseQuery.parse(encoded)) - #expect(parsed.sortableSorts.isEmpty) - #expect(!parsed.usesGraphQL) - } - - @Test("BETWEEN survives the browse tag round-trip") - func secondValueRoundTrips() throws { - let encoded = WeaviateBrowseQuery.encode( - collection: "Article", - offset: 0, - limit: 25, - sorts: [], - filters: [WeaviateFilterSpec(column: "wordCount", op: "BETWEEN", value: "5,10", secondValue: "10")], - logicMode: "AND", - propertyNames: ["uuid"] - ) - let parsed = try #require(WeaviateBrowseQuery.parse(encoded)) - let filter = try #require(parsed.filters.first) - #expect(filter.secondValue == "10") - let clause = try WeaviateFilterBuilder.operand(for: filter, types: articleTypes) - #expect(clause.contains("operator: GreaterThanEqual valueInt: 5")) - #expect(clause.contains("operator: LessThanEqual valueInt: 10")) - } -} - -@Suite("Weaviate request paths") -struct WeaviatePathTests { - private let base = URL(string: "http://localhost:8080")! - - @Test("A console path keeps its query string instead of encoding the question mark") - func consoleQueryStringSurvives() throws { - let url = try #require( - WeaviatePathEncoding.resolve("/v1/objects?class=Article&limit=10", against: base) - ) - #expect(url.path == "/v1/objects") - #expect(!url.absoluteString.contains("%3F")) - #expect(url.query == "class=Article&limit=10") - } - - @Test("A path and passed query items merge") - func mergedQueryItems() throws { - let url = try #require( - WeaviatePathEncoding.resolve("/v1/objects?class=Article", query: ["limit": "5"], against: base) - ) - let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) - #expect(items.contains(URLQueryItem(name: "class", value: "Article"))) - #expect(items.contains(URLQueryItem(name: "limit", value: "5"))) - } - - @Test("An already-encoded segment is not encoded twice") - func encodedSegmentSurvives() throws { - let uuid = "c8f5c3e0-1b2a-4d3e-9f10-111213141516" - let url = try #require( - WeaviatePathEncoding.resolve("/v1/objects/\(WeaviatePathEncoding.segment(uuid))", against: base) - ) - #expect(url.path == "/v1/objects/\(uuid)") - } - - @Test("An absolute or relative path is refused") - func hostiledPathsRefused() { - #expect(WeaviatePathEncoding.resolve("http://evil.example/v1", against: base) == nil) - #expect(WeaviatePathEncoding.resolve("v1/schema", against: base) == nil) - } -} - -@Suite("Weaviate response decoding") -struct WeaviateResponseDecodingTests { - @Test("A vector component at the edge of Int does not trap") - func hugeVectorComponent() { - let object = WeaviateObject( - uuid: "u1", - className: "Article", - properties: [:], - vector: [9_223_372_036_854_775_808.0, 1.5] - ) - let text = object.vectorText - #expect(text?.hasPrefix("[9.223372036854776e+18") == true) - #expect(text?.hasSuffix("1.5]") == true) - } - - @Test("A Get row keeps the _additional fields a vector search returns") - func additionalFieldsBecomeColumns() { - let objects = WeaviateObjectCodec.objects(fromGraphQL: [ - "data": [ - "Get": [ - "Article": [ - [ - "title": "Hello", - "_additional": ["id": "u1", "distance": 0.42, "score": "0.9"] - ] - ] - ] - ] - ]) - let object = objects.first - #expect(object?.uuid == "u1") - #expect(object?.properties["title"] == "Hello") - #expect(object?.properties["distance"] == "0.42") - #expect(object?.properties["score"] == "0.9") - } - - @Test("A property keeps its name when _additional carries the same one") - func propertyWinsOverAdditional() { - let objects = WeaviateObjectCodec.objects(fromGraphQL: [ - "data": ["Get": ["Article": [["distance": "mine", "_additional": ["id": "u1", "distance": 0.42]]]]] - ]) - #expect(objects.first?.properties["distance"] == "mine") - #expect(objects.first?.properties["_additional.distance"] == "0.42") - } - - @Test("A response body is parsed once and still compares by its bytes") - func responseEquality() { - let body = Data(#"{"a":1}"#.utf8) - let first = WeaviateHTTPResponse(statusCode: 200, body: body) - let second = WeaviateHTTPResponse(statusCode: 200, body: body) - #expect(first == second) - #expect(WeaviateJSON.dictionary(first.json)?["a"] as? Int == 1) - #expect(WeaviateJSON.dictionary(first.json)?["a"] as? Int == 1) - #expect(first != WeaviateHTTPResponse(statusCode: 500, body: body)) - } -} - -@Suite("Weaviate write generation") -struct WeaviateWriteGenerationTests { - @Test("A duplicate column name does not trap the generator") - func duplicateColumnNames() throws { - let batch = WeaviateStatementGenerator.generate( - collection: "Article", - columns: ["uuid", "title", "title"], - typeNames: ["uuid", "text", "int"], - changes: [ - WeaviateTrackedChange( - kind: .update, - uuid: "c8f5c3e0-1b2a-4d3e-9f10-111213141516", - values: [:], - cellChanges: [WeaviateCellChange(column: "title", newText: "Edited")] - ) - ] - ) - let body = try #require(batch.requests.first?.body) - #expect(body.contains("\"title\":\"Edited\"")) - } - - @Test("A delete with no uuid is reported rather than dropped in silence") - func deleteWithoutUUIDIsReported() { - let batch = WeaviateStatementGenerator.generate( - collection: "Article", - columns: ["uuid", "title"], - typeNames: ["uuid", "text"], - changes: [WeaviateTrackedChange(kind: .delete, uuid: nil, values: [:], cellChanges: [])] - ) - #expect(batch.requests.isEmpty) - #expect(batch.skipped == [WeaviateSkippedChange(kind: .delete, reason: .missingUUID)]) - } - - @Test("An update touching only read-only columns is reported") - func updateWithNoEditableColumns() { - let batch = WeaviateStatementGenerator.generate( - collection: "Article", - columns: ["uuid", "vector"], - typeNames: ["uuid", "vector"], - changes: [ - WeaviateTrackedChange( - kind: .update, - uuid: "c8f5c3e0-1b2a-4d3e-9f10-111213141516", - values: [:], - cellChanges: [WeaviateCellChange(column: "vector", newText: "[1,2]")] - ) - ] - ) - #expect(batch.requests.isEmpty) - #expect(batch.skipped == [WeaviateSkippedChange(kind: .update, reason: .noEditableColumns)]) - } -} - - -@Suite("Weaviate selection sets") -struct WeaviateSelectionSetTests { - private func query(_ properties: [WeaviateProperty]) throws -> String { - try WeaviateGraphQL.getQuery( - collection: "Article", - properties: properties.map(\.name), - limit: 5, - offset: 0, - sorts: [], - filters: [], - logicMode: "AND", - schema: Dictionary(properties.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) - ) - } - - @Test("A structured property asks for its own fields instead of failing the query") - func structuredPropertiesGetSubSelections() throws { - let built = try query([ - WeaviateProperty(name: "title", dataType: "text"), - WeaviateProperty(name: "place", dataType: "geoCoordinates"), - WeaviateProperty(name: "phone", dataType: "phoneNumber") - ]) - #expect(built.contains("place { latitude longitude }")) - #expect(built.contains("phone { input internationalFormatted")) - #expect(built.contains(" title ")) - } - - @Test("An object property selects its declared nested properties") - func objectSelectsNested() throws { - let built = try query([ - WeaviateProperty( - name: "meta", - dataTypes: ["object"], - nestedProperties: [ - WeaviateProperty(name: "k", dataType: "text"), - WeaviateProperty(name: "inner", dataTypes: ["object"], nestedProperties: [ - WeaviateProperty(name: "deep", dataType: "int") - ]) - ] - ) - ]) - #expect(built.contains("meta { k inner { deep } }")) - } - - @Test("An object with no declared nested properties is left out") - func emptyObjectIsOmitted() throws { - let built = try query([ - WeaviateProperty(name: "title", dataType: "text"), - WeaviateProperty(name: "meta", dataTypes: ["object"], nestedProperties: []) - ]) - #expect(!built.contains("meta")) - #expect(built.contains("title")) - } - - @Test("A cross-reference asks for the referenced ids") - func crossReferenceUsesFragments() throws { - let built = try query([ - WeaviateProperty(name: "category", dataTypes: ["Category", "Topic"], nestedProperties: []) - ]) - #expect(built.contains("category { ... on Category { _additional { id } } ... on Topic { _additional { id } } }")) - } - - @Test("A nested property list survives schema parsing") - func schemaParsesNestedProperties() throws { - let collections = WeaviateSchema.collections(from: [ - "classes": [[ - "class": "Article", - "properties": [ - ["name": "meta", "dataType": ["object"], "nestedProperties": [["name": "k", "dataType": ["text"]]]], - ["name": "category", "dataType": ["Category"]] - ] - ]] - ]) - let properties = try #require(collections.first?.properties) - #expect(properties.first?.nestedProperties.map(\.name) == ["k"]) - #expect(WeaviatePropertyShape.of(properties[1]) == .crossReference(["Category"])) - } -} - -@Suite("Weaviate value round-trip") -struct WeaviateParsedValueTests { - @Test("Text keeps its own punctuation instead of being parsed as JSON") - func textStaysText() { - #expect(WeaviateJSON.parsedValue("{\"a\":1}", typeName: "text") as? String == "{\"a\":1}") - #expect(WeaviateJSON.parsedValue("[1,2]", typeName: "text") as? String == "[1,2]") - #expect(WeaviateJSON.parsedValue("{\"a\":1}", typeName: "string") as? String == "{\"a\":1}") - #expect(WeaviateJSON.parsedValue("2024-01-31T00:00:00Z", typeName: "date") as? String == "2024-01-31T00:00:00Z") - } - - @Test("A type the grid renders as JSON parses back") - func structuredParsesBack() { - #expect(WeaviateJSON.parsedValue("[\"a\",\"b\"]", typeName: "text[]") as? [String] == ["a", "b"]) - #expect(WeaviateJSON.parsedValue("[1,2]", typeName: "int[]") as? [Int] == [1, 2]) - let object = WeaviateJSON.parsedValue("{\"k\":\"v\"}", typeName: "object") as? [String: Any] - #expect(object?["k"] as? String == "v") - let geo = WeaviateJSON.parsedValue("{\"latitude\":1}", typeName: "geoCoordinates") as? [String: Any] - #expect(geo?["latitude"] as? Int == 1) - } - - @Test("A scalar parses to its own type, and an unparsable one stays text") - func scalarsParse() { - #expect(WeaviateJSON.parsedValue("12", typeName: "int") as? Int == 12) - #expect(WeaviateJSON.parsedValue("1.5", typeName: "number") as? Double == 1.5) - #expect(WeaviateJSON.parsedValue("true", typeName: "boolean") as? Bool == true) - #expect(WeaviateJSON.parsedValue("abc", typeName: "int") as? String == "abc") - #expect(WeaviateJSON.parsedValue("yes", typeName: "boolean") as? String == "yes") - } - - @Test("A property named with a leading underscore is written, and a synthetic column is not") - func underscoreNamedPropertyIsWritten() throws { - let batch = WeaviateStatementGenerator.generate( - collection: "Event", - columns: ["uuid", "_source", "distance"], - typeNames: ["uuid", "text", "number"], - changes: [ - WeaviateTrackedChange( - kind: .update, - uuid: "c8f5c3e0-1b2a-4d3e-9f10-111213141516", - values: [:], - cellChanges: [ - WeaviateCellChange(column: "_source", newText: "manual"), - WeaviateCellChange(column: "_additional.distance", newText: "0.4") - ] - ) - ] - ) - let body = try #require(batch.requests.first?.body) - #expect(body.contains("\"_source\":\"manual\"")) - #expect(!body.contains("_additional")) - } -} - -@Suite("Weaviate console requests") -struct WeaviateConsoleRequestTests { - @Test("A body typed on the request line is kept") - func inlineBodySurvives() throws { - let request = try #require( - WeaviateConsoleParser.parse("POST /v1/objects {\"class\": \"Article\"}") - ) - #expect(request.method == "POST") - #expect(request.path == "/v1/objects") - #expect(request.body == "{\"class\": \"Article\"}") - } - - @Test("A body on the lines below still wins") - func multilineBodyWins() throws { - let request = try #require(WeaviateConsoleParser.parse("POST /v1/graphql\n{ \"query\": \"x\" }")) - #expect(request.body == "{ \"query\": \"x\" }") - } - - @Test("Any path is resolved under /v1") - func everyPathIsPrefixed() throws { - #expect(try #require(WeaviateConsoleParser.parse("GET /nodes")).path == "/v1/nodes") - #expect(try #require(WeaviateConsoleParser.parse("POST /batch/objects")).path == "/v1/batch/objects") - #expect(try #require(WeaviateConsoleParser.parse("GET /v1/schema")).path == "/v1/schema") - #expect(try #require(WeaviateConsoleParser.parse("GET /")).path == "/") - } - - @Test("SQL is still not a console request") - func sqlIsRefused() { - #expect(WeaviateConsoleParser.parse("DELETE FROM Article") == nil) - #expect(WeaviateConsoleParser.parse("UPDATE Article SET title = 'x'") == nil) - } - - @Test("A browse that is not showing the vector does not ask for it") - func vectorIsOptional() throws { - let withVector = try WeaviateGraphQL.getQuery( - collection: "Article", properties: ["uuid", "title"], limit: 5, offset: 0, - sorts: [], filters: [], logicMode: "AND", schema: [:], includeVector: true - ) - let withoutVector = try WeaviateGraphQL.getQuery( - collection: "Article", properties: ["uuid", "title"], limit: 5, offset: 0, - sorts: [], filters: [], logicMode: "AND", schema: [:], includeVector: false - ) - #expect(withVector.contains("_additional { id vector }")) - #expect(withoutVector.contains("_additional { id }")) - #expect(!withoutVector.contains("vector")) - } -} diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateObjectPagesTests.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateObjectPagesTests.swift deleted file mode 100644 index 33a96410ef..0000000000 --- a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateObjectPagesTests.swift +++ /dev/null @@ -1,163 +0,0 @@ -import Foundation -@testable import TableProWeaviateCore -import Testing - -@Suite("Weaviate object pages") -struct WeaviateObjectPagesTests { - @Test("Reading a collection past the query maximum yields every object once, in order") - func readsPastQueryMaximum() async throws { - let transport = CursorPagingWeaviateTransport(objectCount: 12_000) - let client = testClient(transport: transport) - - var uuids: [String] = [] - for try await page in client.objectPages(collection: "Article", pageSize: 500) { - uuids += page.map(\.uuid) - } - - #expect(uuids.count == 12_000) - #expect(uuids == transport.uuids) - } - - @Test("The first page is a plain listing, so an object at the nil uuid is not skipped") - func firstPageKeepsTheNilUUID() async throws { - let transport = CursorPagingWeaviateTransport(objectCount: 1_200) - let client = testClient(transport: transport) - - var uuids: [String] = [] - for try await page in client.objectPages(collection: "Article", pageSize: 500) { - uuids += page.map(\.uuid) - } - - #expect(uuids.first == CursorPagingWeaviateTransport.nilUUID) - #expect(uuids == transport.uuids) - } - - @Test("Each later page starts after the last uuid of the page before it") - func pagesByCursor() async throws { - let transport = CursorPagingWeaviateTransport(objectCount: 1_200) - let client = testClient(transport: transport) - - var pages: [[WeaviateObject]] = [] - for try await page in client.objectPages(collection: "Article", pageSize: 500) { - pages.append(page) - } - - #expect(pages.map(\.count) == [500, 500, 200]) - #expect(transport.queries.map { $0["after"] } == [nil, pages[0].last?.uuid, pages[1].last?.uuid]) - #expect(transport.queries.allSatisfy { $0["offset"] == nil }) - #expect(transport.queries.allSatisfy { $0["class"] == "Article" && $0["include"] == "vector" }) - } - - @Test("A collection that fills its last page exactly ends on the empty page after it") - func endsOnEmptyPage() async throws { - let transport = CursorPagingWeaviateTransport(objectCount: 1_000) - let client = testClient(transport: transport) - - var counts: [Int] = [] - for try await page in client.objectPages(collection: "Article", pageSize: 500) { - counts.append(page.count) - } - - #expect(counts == [500, 500]) - #expect(transport.queries.count == 3) - } - - @Test("A server that ignores the cursor fails the read instead of repeating the first page") - func refusesACursorThatDoesNotMove() async throws { - let transport = CursorPagingWeaviateTransport(objectCount: 1_200, honorsCursor: false) - let client = testClient(transport: transport) - - let expected = WeaviateError.malformedResponse( - "Weaviate returned the same page of Article twice. Reading a whole collection needs Weaviate 1.18 or later." - ) - await #expect(throws: expected) { - for try await _ in client.objectPages(collection: "Article", pageSize: 500) {} - } - #expect(transport.queries.count == 2) - } - - @Test("A full page whose last object has no id cannot be paged past, so the read fails") - func refusesAPageWithoutACursor() async throws { - let transport = FakeWeaviateTransport() - transport.respond( - method: "GET", - path: "/v1/objects", - status: 200, - json: ["objects": [["class": "Article", "properties": ["title": "Hello"]]]] - ) - let client = testClient(transport: transport) - - let expected = WeaviateError.malformedResponse( - "Weaviate returned an object of Article without an id, so the rest of the collection cannot be read." - ) - await #expect(throws: expected) { - for try await _ in client.objectPages(collection: "Article", pageSize: 1) {} - } - #expect(transport.requests.count == 1) - } - - @Test("Leaving out the vector leaves out the include parameter") - func omitsVectorWhenAsked() async throws { - let transport = CursorPagingWeaviateTransport(objectCount: 10) - let client = testClient(transport: transport) - - for try await _ in client.objectPages(collection: "Article", pageSize: 500, includeVector: false) {} - - #expect(transport.queries.map { $0["include"] } == [nil]) - } -} - -final class CursorPagingWeaviateTransport: WeaviateTransport, @unchecked Sendable { - static let nilUUID = "00000000-0000-0000-0000-000000000000" - - let uuids: [String] - private let queryMaximumResults: Int - private let honorsCursor: Bool - private(set) var queries: [[String: String]] = [] - - init(objectCount: Int, queryMaximumResults: Int = 10_000, honorsCursor: Bool = true) { - uuids = (0.. WeaviateHTTPResponse { - let items = URLComponents(url: request.url, resolvingAgainstBaseURL: false)?.queryItems ?? [] - let query = Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { _, last in last }) - queries.append(query) - - let limit = Int(query["limit"] ?? "") ?? 25 - let offset = Int(query["offset"] ?? "") ?? 0 - if honorsCursor, let after = query["after"] { - guard offset == 0 else { - return refusal("offset cannot be set with after and limit parameters") - } - let start = uuids.firstIndex { $0 > after } ?? uuids.count - return page(start: start, limit: limit) - } - guard offset + limit <= queryMaximumResults else { - return refusal("query maximum results exceeded") - } - return page(start: offset, limit: limit) - } - - func cancelAll() {} - - private func page(start: Int, limit: Int) -> WeaviateHTTPResponse { - let lower = min(max(start, 0), uuids.count) - let upper = min(lower + max(limit, 0), uuids.count) - let objects: [[String: Any]] = uuids[lower.. WeaviateHTTPResponse { - let json: [String: Any] = ["error": [["message": "msg:offset or limit code:400 err:\(message)"]]] - let body = (try? JSONSerialization.data(withJSONObject: json)) ?? Data() - return WeaviateHTTPResponse(statusCode: 422, body: body) - } -} diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateOperationsTests.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateOperationsTests.swift deleted file mode 100644 index c85fd4495b..0000000000 --- a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateOperationsTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation -@testable import TableProWeaviateCore -import Testing - -@Suite("Weaviate object operations") -struct WeaviateOperationsTests { - @Test("Deleting a collection is the native REST request") - func deleteCollectionIsNative() { - #expect(WeaviateOperations.deleteCollection(named: "Article", objectType: "TABLE") == "DELETE /v1/schema/Article") - } - - /// The statement is shown in the confirmation and then run, so one the driver's own parser - /// rejects is the #2884 defect in another engine. - @Test("The generated statement parses as the request it names") - func statementRoundTrips() throws { - let statement = try #require(WeaviateOperations.deleteCollection(named: "Article", objectType: "TABLE")) - let request = try #require(WeaviateConsoleParser.parse(statement)) - #expect(request.method == "DELETE") - #expect(request.path == "/v1/schema/Article") - #expect(request.body == nil) - } - - @Test("A name that is not a class name is refused", arguments: [ - "", "1Article", "_Article", "Article/x", "Article Name", "Article,Other", "*", - ]) - func nonClassNamesRefused(name: String) { - #expect(WeaviateOperations.deleteCollection(named: name, objectType: "TABLE") == nil) - } - - @Test("Only a collection is droppable", arguments: ["VIEW", "MATERIALIZED VIEW", "SYSTEM TABLE"]) - func onlyCollectionsAreDroppable(objectType: String) { - #expect(WeaviateOperations.deleteCollection(named: "Article", objectType: objectType) == nil) - } -} diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateTestSupport.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateTestSupport.swift deleted file mode 100644 index d1ccaf1dcc..0000000000 --- a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateTestSupport.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Foundation -@testable import TableProWeaviateCore - -final class FakeWeaviateTransport: WeaviateTransport, @unchecked Sendable { - struct Route: Equatable { - let method: String - let path: String - } - - var responses: [String: WeaviateHTTPResponse] = [:] - var requests: [WeaviateHTTPRequest] = [] - var error: WeaviateError? - - func send(_ request: WeaviateHTTPRequest) async throws -> WeaviateHTTPResponse { - if let error { - throw error - } - requests.append(request) - let key = Self.key(method: request.method, url: request.url) - if let response = responses[key] ?? responses[request.url.path] { - return response - } - throw WeaviateError.malformedResponse("No fake response for \(key)") - } - - func cancelAll() {} - - func respond(method: String, path: String, status: Int, json: Any) { - let data = (try? JSONSerialization.data(withJSONObject: json)) ?? Data() - responses[Self.key(method: method, path: path)] = WeaviateHTTPResponse(statusCode: status, body: data) - } - - func respond(method: String, path: String, status: Int, body: String) { - responses[Self.key(method: method, path: path)] = WeaviateHTTPResponse( - statusCode: status, - body: Data(body.utf8) - ) - } - - static func key(method: String, path: String) -> String { - "\(method.uppercased()) \(path)" - } - - static func key(method: String, url: URL) -> String { - key(method: method, path: url.path) - } -} - -func testSettings(auth: WeaviateAuth = WeaviateAuth(method: .none)) -> WeaviateConnectionSettings { - WeaviateConnectionSettings( - host: "localhost", - port: 8_080, - usesTLS: false, - auth: auth, - skipTLSVerify: false - ) -} - -func testClient( - transport: any WeaviateTransport, - auth: WeaviateAuth = WeaviateAuth(method: .none) -) -> WeaviateClient { - WeaviateClient(settings: testSettings(auth: auth), transport: transport, timeout: { 30 }) -} - -enum WeaviateFixtures { - static let articleUUID = "c8f5c3e0-1b2a-4d3e-9f10-111213141516" - - static var schema: [String: Any] { - [ - "classes": [ - [ - "class": "Article", - "vectorizer": "none", - "properties": [ - ["name": "title", "dataType": ["text"]], - ["name": "wordCount", "dataType": ["int"]] - ] - ] - ] - ] - } - - static var objects: [String: Any] { - [ - "objects": [ - [ - "id": articleUUID, - "class": "Article", - "properties": ["title": "Hello", "wordCount": 12], - "vector": [0.1, 0.2, 0.3] - ] - ], - "totalResults": 1 - ] - } - - static var graphqlGet: [String: Any] { - [ - "data": [ - "Get": [ - "Article": [ - [ - "title": "Hello", - "wordCount": 12, - "_additional": [ - "id": articleUUID, - "vector": [0.1, 0.2] - ] - ] - ] - ] - ] - ] - } - - static var meta: [String: Any] { - ["version": "1.27.0"] - } -} diff --git a/Packages/TableProEditor/Tests/TableProTextEngineTests/TextLayoutLineStorageTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/TextLayoutLineStorageTests.swift index b6b7a07f58..30a0b20853 100644 --- a/Packages/TableProEditor/Tests/TableProTextEngineTests/TextLayoutLineStorageTests.swift +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/TextLayoutLineStorageTests.swift @@ -329,63 +329,6 @@ final class TextLayoutLineStorageTests: XCTestCase { } } - func test_insertPerformance() { - let tree = TextLineStorage() - var lines: [TextLineStorage.BuildItem] = [] - for idx in 0..<250_000 { - lines.append(TextLineStorage.BuildItem( - data: TextLine(), - length: idx + 1, - height: 0.0 - )) - } - tree.build(from: lines, estimatedLineHeight: 1.0) - // Measure time when inserting randomly into an already built tree. - // Start 0.667s - // 10/6/23 0.563s -15.59% - measure { - for _ in 0..<100_000 { - tree.insert( - line: TextLine(), atOffset: Int.random(in: 0..() - let lines: [TextLineStorage.BuildItem] = (0..<250_000).map { - TextLineStorage.BuildItem( - data: TextLine(), - length: $0 + 1, - height: 0.0 - ) - } - // Start 0.113s - measure { - tree.build(from: lines, estimatedLineHeight: 1.0) - } - } - - func test_iterationPerformance() { - let tree = TextLineStorage() - var lines: [TextLineStorage.BuildItem] = [] - for idx in 0..<100_000 { - lines.append(TextLineStorage.BuildItem( - data: TextLine(), - length: idx + 1, - height: 0.0 - )) - } - tree.build(from: lines, estimatedLineHeight: 1.0) - // Start 0.181s - measure { - for line in tree { - _ = line - } - } - } - func test_transplantWithExistingLeftNodes() throws { typealias Storage = TextLineStorage typealias Node = TextLineStorage.Node diff --git a/Plugins/.swiftlint.yml b/Plugins/.swiftlint.yml new file mode 100644 index 0000000000..c685190d4d --- /dev/null +++ b/Plugins/.swiftlint.yml @@ -0,0 +1,18 @@ +# Plugins/TableProPluginKit has its own config: it is part of TableProCore and is linted in full. +only_rules: + - public_error_text_in_log + - dispatch_handler_isolation + - fsevents_callback_isolation + - date_formatter_locale + - count_before_inflected_span + - empty_title_control_name + - column_classification_declared_type + - ping_never_reconnects + - ping_never_heals_through_execute + - index_ddl_single_owner + - vendored_sqlite_import + - pg_hand_built_literal + - pg_session_schema_lookup + - pg_session_escape_helper + - plugin_logger_import + - suite_without_trait diff --git a/Plugins/DamengDriverPluginTests/DamengConnectionRecoveryTests.swift b/Plugins/DamengDriverPluginTests/DamengConnectionRecoveryTests.swift deleted file mode 100644 index a0df63c80b..0000000000 --- a/Plugins/DamengDriverPluginTests/DamengConnectionRecoveryTests.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation -import TableProPluginKit -import XCTest - -/// Covers the two decisions that let a Dameng connection survive being stopped: which -/// statement a cancellation is allowed to reach, and which statements may be sent again on a -/// rebuilt connection. -final class DamengConnectionRecoveryTests: XCTestCase { - func testACancellationOnlyReachesTheStatementOnTheWire() { - var tickets = DamengStatementTickets() - let running = tickets.issue() - let queued = tickets.issue() - - XCTAssertTrue(tickets.beginExecuting(running)) - - // Stopping the queued statement must not interrupt the one already on the socket: - // tp_dm_cancel is connection-wide and interrupting costs the whole connection. - XCTAssertEqual(tickets.cancel(queued), .dropQueued) - XCTAssertEqual(tickets.cancel(running), .interruptConnection) - } - - func testACancelledStatementNeverReachesTheSocket() { - var tickets = DamengStatementTickets() - let queued = tickets.issue() - - XCTAssertEqual(tickets.cancel(queued), .dropQueued) - XCTAssertFalse(tickets.beginExecuting(queued)) - } - - func testCancellingTwiceOrLateIsIgnored() { - var tickets = DamengStatementTickets() - let ticket = tickets.issue() - - XCTAssertEqual(tickets.cancel(ticket), .dropQueued) - XCTAssertEqual(tickets.cancel(ticket), .ignore) - - let finished = tickets.issue() - XCTAssertTrue(tickets.beginExecuting(finished)) - tickets.finishExecuting(finished) - XCTAssertEqual(tickets.cancel(finished), .ignore) - - XCTAssertEqual(tickets.cancel(9_999), .ignore) - } - - func testATicketIsReusableAfterTheConnectionIsRebuilt() { - var tickets = DamengStatementTickets() - let abandoned = tickets.issue() - XCTAssertTrue(tickets.beginExecuting(abandoned)) - - tickets.reset() - - let fresh = tickets.issue() - XCTAssertNotEqual(fresh, abandoned) - XCTAssertTrue(tickets.beginExecuting(fresh)) - XCTAssertEqual(tickets.cancel(fresh), .interruptConnection) - } - - /// Rebuilding the connection clears the cancellations with everything else, so a statement - /// whose caller had already cancelled it must not become runnable again. A cancelled Save - /// applying after the fact is the failure this prevents. - func testARebuildDoesNotResurrectACancelledStatement() { - var tickets = DamengStatementTickets() - let running = tickets.issue() - let queued = tickets.issue() - XCTAssertTrue(tickets.beginExecuting(running)) - XCTAssertEqual(tickets.cancel(queued), .dropQueued) - - tickets.reset() - - XCTAssertFalse(tickets.beginExecuting(queued)) - } - - func testOnlyReadsAreSentAgainOnARebuiltConnection() { - let driver = DamengPluginDriver(config: DriverConnectionConfig( - host: "127.0.0.1", port: 5_236, username: "SYSDBA", password: "test-only", database: "APP" - )) - for query in ["SELECT 1 FROM DUAL", " show tables", "DESC APP.CUSTOMER", "EXPLAIN SELECT 1"] { - XCTAssertEqual(driver.replayPolicy(for: query), .replayable, query) - } - - // A write may already have committed before the abandoned reply was dropped, so it is - // reported rather than sent twice. WITH and VALUES stay out of the allowlist because - // either can head a statement that writes. - for query in [ - "INSERT INTO \"APP\".\"T\" VALUES(1)", - "UPDATE \"APP\".\"T\" SET \"C\" = 1", - "DELETE FROM \"APP\".\"T\"", - "MERGE INTO \"APP\".\"T\" USING DUAL ON (1 = 1)", - "CREATE TABLE \"APP\".\"T\"(\"C\" INT)", - "BEGIN EXECUTE IMMEDIATE 'SET SCHEMA \"APP\"'; END;", - "WITH c AS (SELECT 1) SELECT * FROM c", - "VALUES(1)" - ] { - XCTAssertEqual(driver.replayPolicy(for: query), .reportFailure, query) - } - } - - func testAReadIsStillRecognisedBehindAComment() { - let driver = DamengPluginDriver(config: DriverConnectionConfig( - host: "127.0.0.1", port: 5_236, username: "SYSDBA", password: "test-only", database: "APP" - )) - XCTAssertEqual( - driver.replayPolicy(for: "-- reload\n/* nested /* block */ */ SELECT 1 FROM DUAL"), - .replayable - ) - } - - /// A script now really executes, so classifying it on its leading keyword alone would - /// replay the write that follows the read. - func testAScriptIsOnlyReplayableWhenEveryStatementIsARead() { - let driver = DamengPluginDriver(config: DriverConnectionConfig( - host: "127.0.0.1", port: 5_236, username: "SYSDBA", password: "test-only", database: "APP" - )) - XCTAssertEqual( - driver.replayPolicy(for: "SELECT 1 FROM DUAL; DELETE FROM \"APP\".\"T\""), - .reportFailure - ) - XCTAssertEqual( - driver.replayPolicy(for: "SELECT 1 FROM DUAL; SELECT 2 FROM DUAL"), - .replayable - ) - } - - /// The flag decides whether the driver reconnects, so a failure that only rejected the - /// statement must never claim the connection is gone. - func testAPlainFailureDoesNotClaimTheConnectionIsGone() { - XCTAssertFalse(DamengError(message: "invalid table name").closedConnection) - XCTAssertTrue(DamengError(message: "cancelled", closedConnection: true).closedConnection) - } -} diff --git a/Plugins/DamengDriverPluginTests/DamengPluginDriverTests.swift b/Plugins/DamengDriverPluginTests/DamengPluginDriverTests.swift deleted file mode 100644 index 68eb639710..0000000000 --- a/Plugins/DamengDriverPluginTests/DamengPluginDriverTests.swift +++ /dev/null @@ -1,809 +0,0 @@ -import Foundation -import TableProPluginKit -import XCTest - -final class DamengPluginDriverTests: XCTestCase { - func testTypingSuggestionsIncludeDM8StatementsAndDialectSymbols() { - let completions = Set(DamengPlugin.statementCompletions.map(\.label)) - - XCTAssertTrue(completions.isSuperset(of: ["SET SCHEMA", "CREATE SCHEMA", "EXPLAIN", "CONNECT BY"])) - XCTAssertTrue(DamengPlugin.sqlDialect?.keywords.contains("ROWNUM") == true) - XCTAssertTrue(DamengPlugin.sqlDialect?.functions.contains("NVL") == true) - XCTAssertTrue(DamengPlugin.sqlDialect?.dataTypes.contains("VARCHAR2") == true) - - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - XCTAssertEqual(driver.buildExplainQuery("SELECT 1"), "EXPLAIN SELECT 1") - } - - func testDDLGenerationQuotesNamesAndPreservesConstraints() throws { - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - let definition = PluginCreateTableDefinition( - tableName: "order", - columns: [ - PluginColumnDefinition( - name: "id", - dataType: "int", - isNullable: false, - isPrimaryKey: true, - autoIncrement: true - ), - PluginColumnDefinition( - name: "display name", - dataType: "varchar(100)", - defaultValue: "'guest''s record'", - comment: "customer's label" - ) - ], - indexes: [PluginIndexDefinition(name: "idx display", columns: ["display name"])], - foreignKeys: [] - ) - - let sql = try XCTUnwrap(driver.generateCreateTableSQL(definition: definition)) - - XCTAssertTrue(sql.hasPrefix("CREATE TABLE \"APP\".\"order\"")) - XCTAssertTrue(sql.contains("\"id\" INT IDENTITY(1,1) NOT NULL PRIMARY KEY")) - XCTAssertTrue(sql.contains("CREATE INDEX \"idx display\" ON \"APP\".\"order\" (\"display name\")")) - XCTAssertFalse(sql.contains("EXECUTE IMMEDIATE")) - XCTAssertTrue(sql.contains("DEFAULT 'guest''s record'")) - XCTAssertTrue(sql.contains("IS 'customer''s label'")) - } - - func testDDLGenerationDoesNotNestStatementsInAStringLiteral() throws { - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - let definition = PluginCreateTableDefinition( - tableName: "note", - columns: [ - PluginColumnDefinition( - name: "body", - dataType: "varchar(100)", - comment: "a quote carrying a combining mark: '\u{0301} end" - ) - ], - indexes: [], - foreignKeys: [] - ) - - let sql = try XCTUnwrap(driver.generateCreateTableSQL(definition: definition)) - - // Re-encoding statements as a PL/SQL literal doubled every quote a second time and - // relied on grapheme-cluster matching, which leaves a decorated quote undoubled. - XCTAssertFalse(sql.contains("BEGIN\n")) - XCTAssertFalse(sql.contains("EXECUTE IMMEDIATE")) - XCTAssertTrue(sql.contains("''\u{0301}")) - } - - func testRowChangesUsePrimaryKeyAndBoundValues() throws { - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - let change = PluginRowChange( - rowIndex: 2, - type: .update, - cellChanges: [(1, "name", .text("old"), .text("new"))], - originalRow: [.text("42"), .text("old")] - ) - - let statements = try XCTUnwrap(driver.generateStatements( - table: "users", - schema: "APP", - columns: ["id", "name"], - primaryKeyColumns: ["id"], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - )) - - XCTAssertEqual(statements.count, 1) - XCTAssertEqual( - statements[0].statement, - "UPDATE \"APP\".\"users\" SET \"name\" = ? WHERE \"id\" = ? AND ROWNUM = 1" - ) - XCTAssertEqual(statements[0].parameters, [.text("new"), .text("42")]) - } - - func testDeletesBindTheWholePrimaryKey() throws { - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: [.text("42"), .text("old")] - ) - - let statements = try XCTUnwrap(driver.generateStatements( - table: "users", - schema: "APP", - columns: ["id", "name"], - primaryKeyColumns: ["id"], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - )) - - XCTAssertEqual(statements.count, 1) - XCTAssertEqual( - statements[0].statement, - "DELETE FROM \"APP\".\"users\" WHERE \"id\" = ? AND ROWNUM = 1" - ) - XCTAssertEqual(statements[0].parameters, [.text("42")]) - } - - func testCompositePrimaryKeyConstrainsEveryKeyColumn() throws { - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(2, "total", .text("1"), .text("2"))], - originalRow: [.text("7"), .text("EU"), .text("1")] - ) - - let statements = try XCTUnwrap(driver.generateStatements( - table: "orders", - schema: "APP", - columns: ["id", "region", "total"], - primaryKeyColumns: ["id", "region"], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - )) - - XCTAssertEqual(statements.count, 1) - XCTAssertTrue(statements[0].statement.contains("\"id\" = ?")) - XCTAssertTrue(statements[0].statement.contains("\"region\" = ?")) - XCTAssertEqual(statements[0].parameters, [.text("2"), .text("7"), .text("EU")]) - } - - func testBinaryValuesAreBoundRatherThanInlined() throws { - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - let payload = Data([0x00, 0xFF, 0x10]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(1, "blob", .null, .bytes(payload))], - originalRow: [.text("1"), .null] - ) - - let statements = try XCTUnwrap(driver.generateStatements( - table: "files", - schema: "APP", - columns: ["id", "blob"], - primaryKeyColumns: ["id"], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - )) - - XCTAssertEqual(statements.count, 1) - XCTAssertTrue(statements[0].statement.contains("\"blob\" = ?")) - XCTAssertFalse(statements[0].statement.contains("HEXTORAW")) - XCTAssertEqual(statements[0].parameters.first, .bytes(payload)) - } - - func testWithoutAPrimaryKeyEveryColumnConstrainsTheRow() throws { - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(1, "name", .text("old"), .text("new"))], - originalRow: [.text("42"), .text("old")] - ) - - let statements = try XCTUnwrap(driver.generateStatements( - table: "users", - schema: "APP", - columns: ["id", "name"], - primaryKeyColumns: [], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - )) - - XCTAssertEqual( - statements[0].statement, - "UPDATE \"APP\".\"users\" SET \"name\" = ? WHERE \"id\" = ? AND \"name\" = ? AND ROWNUM = 1" - ) - XCTAssertEqual(statements[0].parameters, [.text("new"), .text("42"), .text("old")]) - } - - func testAnUnresolvableKeyColumnProducesNoStatement() throws { - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(1, "name", .text("old"), .text("new"))], - originalRow: [.text("42"), .text("old")] - ) - - // "tenant_id" is a declared key the grid is not showing. Skipping it would leave - // "id" alone in the predicate, and ROWNUM = 1 would pick an arbitrary matching row. - let statements = driver.generateStatements( - table: "users", - schema: "APP", - columns: ["id", "name"], - primaryKeyColumns: ["id", "tenant_id"], - changes: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - XCTAssertNil(statements) - } - - func testDropObjectStatementKeepsDM8ObjectKeywords() { - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - - XCTAssertEqual( - driver.dropObjectStatement( - name: "SALES_MV", objectType: "MATERIALIZED VIEW", schema: "APP", cascade: false - ), - "DROP MATERIALIZED VIEW \"APP\".\"SALES_MV\"" - ) - XCTAssertEqual( - driver.dropObjectStatement(name: "ACTIVE_USERS", objectType: "view", schema: "APP", cascade: true), - "DROP VIEW \"APP\".\"ACTIVE_USERS\"" - ) - XCTAssertEqual( - driver.dropObjectStatement(name: "EXT_LOG", objectType: "FOREIGN TABLE", schema: "APP", cascade: false), - "DROP TABLE \"APP\".\"EXT_LOG\"" - ) - XCTAssertEqual( - driver.dropObjectStatement(name: "ORDERS", objectType: "TABLE", schema: "APP", cascade: true), - "DROP TABLE \"APP\".\"ORDERS\" CASCADE" - ) - } - - func testObjectCommentStatementSetsAndClearsATableComment() { - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - - XCTAssertEqual( - driver.objectCommentStatement(name: "ORDERS", objectType: "TABLE", schema: "SALES", comment: "customer's orders"), - "COMMENT ON TABLE \"SALES\".\"ORDERS\" IS 'customer''s orders'" - ) - XCTAssertEqual( - driver.objectCommentStatement(name: "ORDERS", objectType: "TABLE", schema: nil, comment: "x"), - "COMMENT ON TABLE \"APP\".\"ORDERS\" IS 'x'" - ) - XCTAssertEqual( - driver.objectCommentStatement(name: "ORDERS", objectType: "TABLE", schema: "APP", comment: nil), - "COMMENT ON TABLE \"APP\".\"ORDERS\" IS ''" - ) - XCTAssertEqual( - driver.objectCommentStatement(name: "ORDERS", objectType: "TABLE", schema: "APP", comment: ""), - "COMMENT ON TABLE \"APP\".\"ORDERS\" IS ''" - ) - } - - func testObjectCommentStatementCoversTablesOnly() { - let driver = DamengPluginDriver(config: testConfig(database: "APP")) - - for kind in ["VIEW", "MATERIALIZED VIEW", "SEQUENCE", "SYSTEM TABLE", "FOREIGN TABLE"] { - XCTAssertNil(driver.objectCommentStatement(name: "t", objectType: kind, schema: "s", comment: nil), kind) - } - XCTAssertNotNil(driver.objectCommentStatement(name: "t", objectType: "TABLE", schema: "s", comment: nil)) - } - - func testRowCapClampsToTheEmergencyMaximum() { - XCTAssertEqual(DamengConnection.fetchLimit(nil), PluginRowLimits.emergencyMax) - XCTAssertEqual(DamengConnection.fetchLimit(0), PluginRowLimits.emergencyMax) - XCTAssertEqual(DamengConnection.fetchLimit(-5), PluginRowLimits.emergencyMax) - XCTAssertEqual(DamengConnection.fetchLimit(10), 10) - XCTAssertEqual(DamengConnection.fetchLimit(9_000_000), PluginRowLimits.emergencyMax) - } - - func testCastTruncationIsDetectedAtTheDM8VarcharCeiling() { - let full = String(repeating: "a", count: 8_188) - - XCTAssertFalse(DamengSchemaValue.isCastTruncated(full, storedLength: 8_188)) - XCTAssertTrue(DamengSchemaValue.isCastTruncated(full, storedLength: 8_189)) - - XCTAssertFalse(DamengSchemaValue.isCastTruncated(String(repeating: "a", count: 8_187), storedLength: nil)) - XCTAssertTrue(DamengSchemaValue.isCastTruncated(full, storedLength: nil)) - XCTAssertTrue(DamengSchemaValue.isCastTruncated(String(repeating: "\u{e9}", count: 4_094), storedLength: nil)) - } - - func testEffectiveSchemaPreservesQuotedIdentifierCase() { - let configured = DamengPluginDriver(config: testConfig(database: "CamelCaseSchema")) - XCTAssertEqual(configured.effectiveSchema(nil), "CamelCaseSchema") - XCTAssertEqual(configured.effectiveSchema("lowercase_schema"), "lowercase_schema") - - let fallback = DamengPluginDriver(config: DriverConnectionConfig( - host: "127.0.0.1", - port: 5_236, - username: "sysdba", - password: "test-only", - database: "" - )) - XCTAssertEqual(fallback.effectiveSchema(nil), "SYSDBA") - } - - func testLiveDM8Workflow() async throws { - let environment = try liveEnvironment() - let driver = DamengPluginDriver(config: environment.config(database: "SYSDBA")) - let schema = "TP_\(UUID().uuidString.prefix(12).replacingOccurrences(of: "-", with: ""))".uppercased() - try await checked("connect") { - try await driver.connect() - } - defer { driver.disconnect() } - - _ = try await checked("create schema") { - try await driver.execute(query: "CREATE SCHEMA \(driver.quoteIdentifier(schema)) AUTHORIZATION SYSDBA") - } - do { - try await runLiveWorkflow(driver: driver, schema: schema) - try await driver.switchSchema(to: "SYSDBA") - try await driver.dropSchema(name: schema) - } catch { - try? await driver.switchSchema(to: "SYSDBA") - _ = try? await driver.execute(query: "DROP SCHEMA \(driver.quoteIdentifier(schema)) CASCADE") - throw error - } - } - - /// The regression test for #2262. Stopping a DM8 statement closes its connection, because - /// DM8 has no out-of-band cancel, and every later statement used to fail forever with - /// "The Dameng connection is closed": switching schema reported a failure and tables took a - /// long time to open or never opened at all. - /// - /// The stopped statement generates its own rows from `DUAL` rather than reading a table, so - /// it takes the same few seconds on any server and needs no fixture. It also ends on its - /// own: DM8 keeps running an abandoned statement, so one chosen to run for hours would pin - /// the server long after the test finished. - func testLiveDM8RecoversFromAStoppedStatement() async throws { - let environment = try liveEnvironment() - let driver = DamengPluginDriver(config: environment.config(database: "SYSDBA")) - try await checked("connect") { try await driver.connect() } - defer { driver.disconnect() } - - let stopped = Task { - try await driver.execute( - query: """ - SELECT COUNT(*) FROM - (SELECT LEVEL AS N FROM DUAL CONNECT BY LEVEL <= 6000) a, - (SELECT LEVEL AS N FROM DUAL CONNECT BY LEVEL <= 6000) b - WHERE TO_CHAR(a.N) || TO_CHAR(b.N) LIKE '%99999%' - """ - ) - } - try await Task.sleep(for: .milliseconds(300)) - try driver.cancelQuery() - if case .success = await stopped.result { - XCTFail("Expected the stopped statement to fail") - } - - let recovered = try await checked("statement after the stop") { - try await driver.execute(query: "SELECT 1 FROM DUAL") - } - XCTAssertEqual(recovered.rows.first?.first, .text("1")) - XCTAssertEqual(driver.currentSchema, "SYSDBA") - - try await checked("switch schema after the stop") { try await driver.switchSchema(to: "SYSDBA") } - try await checked("ping after the stop") { try await driver.ping() } - } - - /// A rebuilt connection never saw the transaction's BEGIN, so replaying into it would let - /// the later commit report success having written nothing. The loss is reported instead. - /// - /// A bare `SET SCHEMA` is the deterministic way to lose the connection: DM8 answers it with - /// a truncated frame, which desyncs the stream and closes the connection every time. A stop - /// would not do, because one that lands after the server already answered leaves the - /// connection healthy and there would be nothing to report. - func testLiveDM8ReportsATransactionLostWithItsConnection() async throws { - let environment = try liveEnvironment() - let driver = DamengPluginDriver(config: environment.config(database: "SYSDBA")) - try await checked("connect") { try await driver.connect() } - defer { driver.disconnect() } - - try await checked("begin") { try await driver.beginTransaction() } - do { - _ = try await driver.execute(query: "SET SCHEMA \"SYSDBA\"") - XCTFail("Expected the statement to lose its connection") - } catch { - XCTAssertTrue(String(describing: error).contains("transaction"), "\(error)") - } - XCTAssertFalse(driver.hasOpenTransaction) - - // Reporting it once ends the transaction, so the session recovers from there. - let recovered = try await checked("statement after the lost transaction") { - try await driver.execute(query: "SELECT 1 FROM DUAL") - } - XCTAssertEqual(recovered.rows.first?.first, .text("1")) - XCTAssertEqual(driver.currentSchema, "SYSDBA") - } - - /// Several statements finding the connection dead share one rebuild. Each starting its own - /// would retire the connection the previous one had just adopted. - func testLiveDM8SharesOneRebuildBetweenConcurrentStatements() async throws { - let environment = try liveEnvironment() - let driver = DamengPluginDriver(config: environment.config(database: "SYSDBA")) - try await checked("connect") { try await driver.connect() } - defer { driver.disconnect() } - - try driver.cancelQuery() - - try await withThrowingTaskGroup(of: PluginCellValue?.self) { group in - for _ in 0..<8 { - group.addTask { - try await driver.execute(query: "SELECT USER FROM DUAL").rows.first?.first - } - } - for try await value in group { - XCTAssertEqual(value, .text("SYSDBA")) - } - } - XCTAssertEqual(driver.currentSchema, "SYSDBA") - } - - /// DM8 answers a query with one inline batch and holds the rest on a cursor. The driver - /// used to stop at that batch, so a 20000 row table came back as 662 rows while reporting - /// itself complete, and the sidebar listed 1159 of 2000 tables. - func testLiveDM8ReturnsEveryRowOfALargeResult() async throws { - let environment = try liveEnvironment() - let driver = DamengPluginDriver(config: environment.config(database: "SYSDBA")) - try await checked("connect") { try await driver.connect() } - defer { driver.disconnect() } - - let table = "TP_ROWS_\(UUID().uuidString.prefix(8))".uppercased() - let quoted = driver.quoteIdentifier(table) - _ = try await driver.execute(query: "CREATE TABLE \(quoted)(\"ID\" INT, \"PAD\" VARCHAR(40))") - defer { Task { _ = try? await driver.execute(query: "DROP TABLE \(quoted)") } } - _ = try await driver.execute(query: """ - INSERT INTO \(quoted)("ID", "PAD") - SELECT LEVEL, 'padpadpadpadpadpadpadpadpad' FROM DUAL CONNECT BY LEVEL <= 5000 - """) - _ = try await driver.execute(query: "COMMIT") - - let all = try await checked("read every row") { - try await driver.execute(query: "SELECT \"ID\", \"PAD\" FROM \(quoted) ORDER BY \"ID\"") - } - XCTAssertEqual(all.rows.count, 5_000) - XCTAssertEqual(all.columns.count, 2) - XCTAssertFalse(all.isTruncated) - XCTAssertEqual(all.rows.first?.first, .text("1")) - XCTAssertEqual(all.rows.last?.first, .text("5000")) - - // A row cap still stops early, and says so. - let capped = try await checked("read with a cap") { - try await driver.executeUserQuery( - query: "SELECT \"ID\" FROM \(quoted) ORDER BY \"ID\"", rowCap: 100, parameters: nil - ) - } - XCTAssertEqual(capped.rows.count, 100) - XCTAssertTrue(capped.isTruncated) - } - - /// A LOB column descriptor is longer than the others, and mis-measuring it shifted the - /// column list and reparsed the remaining descriptors as rows. - func testLiveDM8ReadsATableWithALobColumn() async throws { - let environment = try liveEnvironment() - let driver = DamengPluginDriver(config: environment.config(database: "SYSDBA")) - try await checked("connect") { try await driver.connect() } - defer { driver.disconnect() } - - let table = "TP_LOB_\(UUID().uuidString.prefix(8))".uppercased() - let quoted = driver.quoteIdentifier(table) - _ = try await driver.execute(query: """ - CREATE TABLE \(quoted)("ID" INT, "NOTE" CLOB, "NAME" VARCHAR(50), "BLOB_COL" BLOB) - """) - defer { Task { _ = try? await driver.execute(query: "DROP TABLE \(quoted)") } } - _ = try await driver.execute(query: "INSERT INTO \(quoted)(\"ID\", \"NAME\") VALUES(1, 'one')") - _ = try await driver.execute(query: "INSERT INTO \(quoted)(\"ID\", \"NAME\") VALUES(2, 'two')") - _ = try await driver.execute(query: "COMMIT") - - let everyColumn = try await checked("select star") { - try await driver.execute(query: "SELECT * FROM \(quoted) ORDER BY \"ID\"") - } - XCTAssertEqual(everyColumn.columns, ["ID", "NOTE", "NAME", "BLOB_COL"]) - XCTAssertEqual(everyColumn.rows.count, 2) - - let lobFirst = try await checked("lob as the first column") { - try await driver.execute(query: "SELECT \"NOTE\", \"ID\" FROM \(quoted) ORDER BY \"ID\"") - } - XCTAssertEqual(lobFirst.columns.count, 2) - XCTAssertEqual(lobFirst.rows.count, 2) - XCTAssertEqual(lobFirst.rows.last?[1], .text("2")) - } - - func testLiveDM8RejectsBadCredentialsAndInvalidPort() async throws { - let environment = try liveEnvironment() - let invalidPortDriver = DamengPluginDriver(config: DriverConnectionConfig( - host: environment.host, - port: 70_000, - username: environment.username, - password: environment.password, - database: "" - )) - do { - try await invalidPortDriver.connect() - XCTFail("Expected an invalid port to fail") - } catch { - XCTAssertTrue(String(describing: error).contains("65535")) - } - - let badPasswordDriver = DamengPluginDriver(config: DriverConnectionConfig( - host: environment.host, - port: environment.port, - username: environment.username, - password: "not-the-password", - database: "" - )) - do { - try await badPasswordDriver.connect() - XCTFail("Expected invalid credentials to fail") - } catch { - XCTAssertFalse(String(describing: error).isEmpty) - } - } - - private func runLiveWorkflow(driver: DamengPluginDriver, schema: String) async throws { - try await checked("switch schema") { - try await driver.switchSchema(to: schema) - } - XCTAssertEqual(driver.currentSchema, schema) - XCTAssertTrue(driver.serverVersion?.contains("DM Database Server") == true) - try await checked("ping") { - try await driver.ping() - } - - let parentDefinition = PluginCreateTableDefinition( - tableName: "PARENT", - columns: [ - PluginColumnDefinition( - name: "ID", - dataType: "INT", - isNullable: false, - isPrimaryKey: true, - autoIncrement: true - ), - PluginColumnDefinition( - name: "NAME", - dataType: "VARCHAR(100)", - isNullable: false, - comment: "the parent name" - ), - PluginColumnDefinition(name: "PAYLOAD", dataType: "VARBINARY(8188)") - ], - indexes: [PluginIndexDefinition(name: "IDX_PARENT_NAME", columns: ["NAME"])], - foreignKeys: [] - ) - _ = try await checked("create parent") { - try await driver.execute(query: try XCTUnwrap(driver.generateCreateTableSQL(definition: parentDefinition))) - } - _ = try await checked("comment parent") { - try await driver.execute(query: "COMMENT ON TABLE \"PARENT\" IS 'TablePro DM8 integration fixture'") - } - _ = try await checked("create child") { - try await driver.execute(query: """ - CREATE TABLE "CHILD" ( - "ID" INT PRIMARY KEY, - "PARENT_ID" INT, - CONSTRAINT "FK_CHILD_PARENT" FOREIGN KEY ("PARENT_ID") REFERENCES "PARENT" ("ID") ON DELETE CASCADE - ) - """) - } - _ = try await checked("create view") { - try await driver.execute( - query: "CREATE OR REPLACE VIEW \"PARENT_VIEW\" AS SELECT \"ID\", \"NAME\" FROM \"PARENT\"" - ) - } - - let hostileText = "Robert'); DROP TABLE \"PARENT\"; -- 达梦" - let payload = Data([0x00, 0x01, 0x7F, 0xFF]) - _ = try await checked("insert unicode and binary") { - try await driver.executeParameterized( - query: "INSERT INTO \"PARENT\" (\"NAME\", \"PAYLOAD\") VALUES (?, ?)", - parameters: [.text(hostileText), .bytes(payload)] - ) - } - for index in 1...4 { - _ = try await checked("insert row \(index)") { - try await driver.executeParameterized( - query: "INSERT INTO \"PARENT\" (\"NAME\") VALUES (?)", - parameters: [.text("row-\(index)")] - ) - } - } - - // ALL_COL_COMMENTS keys its OWNER on the schema's owning user, not on the schema, so a - // schema owned by a differently named user used to return no column comments at all. - let parentColumns = try await checked("read columns") { - try await driver.fetchColumns(table: "PARENT", schema: schema) - } - let nameColumn = parentColumns.first { $0.name == "NAME" } - XCTAssertEqual(nameColumn?.comment, "the parent name") - XCTAssertEqual(parentColumns.first { $0.name == "ID" }?.isPrimaryKey, true) - - let columnsByTable = try await checked("read all columns") { - try await driver.fetchAllColumns(schema: schema) - } - let bulkName = columnsByTable["PARENT"]?.first { $0.name == "NAME" } - XCTAssertEqual(bulkName?.comment, "the parent name") - - let valueResult = try await checked("read unicode and binary metadata") { - try await driver.execute( - query: "SELECT \"NAME\", RAWTOHEX(\"PAYLOAD\") FROM \"PARENT\" ORDER BY \"ID\"" - ) - } - XCTAssertEqual(valueResult.rows.first?[0], .text(hostileText)) - XCTAssertEqual(valueResult.rows.first?[1], .text("00017FFF")) - - // The last fractional digit is deliberately non-zero: DM8 renders a value without its - // insignificant trailing zeros, so a literal ending in one could not tell that apart - // from the 38th significant digit being lost, which is what this checks. - let preciseDecimal = "1234567890123456789012345678.1234567891" - let decimalResult = try await checked("read high-precision decimal") { - try await driver.execute( - query: "SELECT CAST('\(preciseDecimal)' AS DECIMAL(38, 10)) FROM DUAL" - ) - } - XCTAssertEqual(decimalResult.rows.first?.first, .text(preciseDecimal)) - - let capped = try await driver.executeUserQuery( - query: "SELECT \"ID\" FROM \"PARENT\" ORDER BY \"ID\"", - rowCap: 2, - parameters: nil - ) - XCTAssertEqual(capped.rows.count, 2) - XCTAssertTrue(capped.isTruncated) - - let tables = try await driver.fetchTables(schema: schema) - XCTAssertTrue(tables.contains { $0.name == "PARENT" && $0.type == "TABLE" }) - XCTAssertTrue(tables.contains { $0.name == "PARENT_VIEW" && $0.type == "VIEW" }) - let columns = try await checked("fetch columns") { - try await driver.fetchColumns(table: "PARENT", schema: schema) - } - XCTAssertTrue(columns.contains { $0.name == "ID" && $0.isPrimaryKey }) - XCTAssertTrue(columns.contains { $0.name == "NAME" && $0.dataType == "VARCHAR(100)" }) - let allColumns = try await checked("fetch completion columns") { - try await driver.fetchAllColumns(schema: schema) - } - XCTAssertEqual(allColumns["PARENT"]?.map(\.name), ["ID", "NAME", "PAYLOAD"]) - XCTAssertEqual(allColumns["PARENT_VIEW"]?.map(\.name), ["ID", "NAME"]) - let indexes = try await checked("fetch indexes") { - try await driver.fetchIndexes(table: "PARENT", schema: schema) - } - XCTAssertTrue(indexes.contains { $0.name == "IDX_PARENT_NAME" && $0.columns == ["NAME"] }) - let foreignKeys = try await checked("fetch foreign keys") { - try await driver.fetchForeignKeys(table: "CHILD", schema: schema) - } - XCTAssertTrue(foreignKeys.contains { - $0.name == "FK_CHILD_PARENT" && $0.referencedTable == "PARENT" && $0.onDelete == "CASCADE" - }) - let emptyForeignKeys = try await checked("fetch empty foreign keys") { - try await driver.fetchForeignKeys(table: "PARENT", schema: schema) - } - XCTAssertTrue(emptyForeignKeys.isEmpty) - try await checked("ping after empty metadata") { - try await driver.ping() - } - let viewDefinition = try await checked("fetch view definition") { - try await driver.fetchViewDefinition(view: "PARENT_VIEW", schema: schema) - } - XCTAssertTrue(viewDefinition.contains("PARENT")) - let tableDDL = try await checked("fetch table DDL") { - try await driver.fetchTableDDL(table: "PARENT", schema: schema) - } - XCTAssertTrue(tableDDL.contains("PRIMARY KEY")) - let metadata = try await checked("fetch table metadata") { - try await driver.fetchTableMetadata(table: "PARENT", schema: schema) - } - XCTAssertEqual(metadata.engine, "DM8") - XCTAssertEqual(metadata.comment, "TablePro DM8 integration fixture") - - let explain = try await checked("explain") { - try await driver.execute(query: try XCTUnwrap(driver.buildExplainQuery("SELECT * FROM \"PARENT\""))) - } - XCTAssertEqual(explain.columns, ["PLAN"]) - XCTAssertTrue(explain.rows.first?.first?.asText?.contains("#") == true) - try await checked("ping after explain") { - try await driver.ping() - } - - try await checked("begin transaction") { - try await driver.beginTransaction() - } - _ = try await checked("insert rollback row") { - try await driver.execute(query: "INSERT INTO \"PARENT\" (\"NAME\") VALUES ('rollback-row')") - } - try await checked("rollback transaction") { - try await driver.rollbackTransaction() - } - let rollbackCount = try await checked("verify rollback") { - try await driver.execute(query: "SELECT COUNT(*) FROM \"PARENT\" WHERE \"NAME\" = 'rollback-row'") - } - XCTAssertEqual(rollbackCount.rows.first?.first, .text("0")) - - do { - _ = try await driver.execute(query: "SELECT * FROM \"MISSING_TABLE\"") - XCTFail("Expected an invalid query to fail") - } catch { - try await driver.ping() - } - - do { - try await driver.switchSchema(to: "\"; DROP SCHEMA \(schema) CASCADE; --") - XCTFail("Expected an invalid schema to fail") - } catch { - let schemas = try await driver.fetchSchemas() - XCTAssertTrue(schemas.contains(schema)) - } - - do { - try await driver.dropSchema(name: "SYSDBA") - XCTFail("Expected a system schema drop to be rejected") - } catch { - XCTAssertTrue(String(describing: error).contains("system")) - } - - try await withThrowingTaskGroup(of: PluginCellValue?.self) { group in - for _ in 0..<8 { - group.addTask { - let result = try await driver.execute(query: "SELECT USER FROM DUAL") - return result.rows.first?.first - } - } - for try await value in group { - XCTAssertEqual(value, .text("SYSDBA")) - } - } - } - - private func liveEnvironment() throws -> LiveEnvironment { - let environment = ProcessInfo.processInfo.environment - guard environment["TABLEPRO_DM8_INTEGRATION"] == "1" else { - throw XCTSkip("Set TABLEPRO_DM8_INTEGRATION=1 to run against DM8 in OrbStack") - } - guard let host = environment["DM_HOST"], - let portText = environment["DM_PORT"], - let port = Int(portText), - let username = environment["DM_USER"], - let password = environment["DM_PASSWORD"] else { - XCTFail("DM_HOST, DM_PORT, DM_USER, and DM_PASSWORD are required") - throw XCTSkip("DM8 integration environment is incomplete") - } - return LiveEnvironment(host: host, port: port, username: username, password: password) - } - - @discardableResult - private func checked( - _ step: String, - operation: () async throws -> T - ) async throws -> T { - do { - return try await operation() - } catch { - XCTFail("\(step): \(error)") - throw error - } - } - - private func testConfig(database: String) -> DriverConnectionConfig { - DriverConnectionConfig( - host: "127.0.0.1", - port: 5_236, - username: "SYSDBA", - password: "test-only", - database: database - ) - } -} - -private struct LiveEnvironment { - let host: String - let port: Int - let username: String - let password: String - - func config(database: String) -> DriverConnectionConfig { - DriverConnectionConfig( - host: host, - port: port, - username: username, - password: password, - database: database - ) - } -} diff --git a/Plugins/DamengDriverPluginTests/DamengScriptSplitterTests.swift b/Plugins/DamengDriverPluginTests/DamengScriptSplitterTests.swift deleted file mode 100644 index 83ed2da166..0000000000 --- a/Plugins/DamengDriverPluginTests/DamengScriptSplitterTests.swift +++ /dev/null @@ -1,135 +0,0 @@ -import Foundation -import XCTest - -/// DM8 rejects a string holding more than one statement, so the driver has to find the -/// boundaries itself. Getting a boundary wrong sends the server half a statement. -final class DamengScriptSplitterTests: XCTestCase { - func testASingleStatementIsReturnedWhole() { - XCTAssertEqual( - DamengScriptSplitter.statements(in: "SELECT 1 FROM DUAL"), - ["SELECT 1 FROM DUAL"] - ) - XCTAssertEqual( - DamengScriptSplitter.statements(in: " SELECT 1 FROM DUAL;\n"), - ["SELECT 1 FROM DUAL"] - ) - XCTAssertEqual(DamengScriptSplitter.statements(in: " \n "), []) - } - - func testGeneratedTableDDLSplitsIntoItsStatements() { - let script = """ - CREATE TABLE "APP"."order" ( - "id" INT IDENTITY(1,1) NOT NULL PRIMARY KEY, - "note" VARCHAR(100) - ); - CREATE INDEX "idx note" ON "APP"."order" ("note"); - COMMENT ON COLUMN "APP"."order"."note" IS 'a note'; - """ - let statements = DamengScriptSplitter.statements(in: script) - - XCTAssertEqual(statements.count, 3) - XCTAssertTrue(statements[0].hasPrefix("CREATE TABLE")) - XCTAssertTrue(statements[1].hasPrefix("CREATE INDEX")) - XCTAssertTrue(statements[2].hasPrefix("COMMENT ON COLUMN")) - XCTAssertFalse(statements.contains { $0.hasSuffix(";") }) - } - - /// Pairing BEGIN/CASE/IF/LOOP against END, END IF, END LOOP and END CASE is more structure - /// than a driver should guess at, and guessing wrong cuts a block in half. A script holding - /// any of them is passed through whole instead, which is what happened before the splitter. - func testAnyBlockKeywordAbandonsTheSplit() { - let block = "BEGIN EXECUTE IMMEDIATE 'SET SCHEMA \"APP\"'; END;" - XCTAssertEqual(DamengScriptSplitter.statements(in: block), [block]) - - for script in [ - "BEGIN IF 1 = 1 THEN NULL; END IF; END;", - "DECLARE n INT; BEGIN n := 1; END;", - "BEGIN FOR r IN (SELECT 1 FROM DUAL) LOOP NULL; END LOOP; END;", - "SELECT CASE WHEN 1 = 1 THEN 'y' END FROM DUAL; SELECT 2 FROM DUAL" - ] { - XCTAssertEqual(DamengScriptSplitter.statements(in: script), [script], script) - } - } - - /// A block keyword inside a literal or an identifier is not a block. - func testABlockKeywordInsideALiteralStillSplits() { - let script = "INSERT INTO t VALUES('BEGIN');\nSELECT 1 FROM DUAL" - XCTAssertEqual( - DamengScriptSplitter.statements(in: script), - ["INSERT INTO t VALUES('BEGIN')", "SELECT 1 FROM DUAL"] - ) - } - - /// `q'{...}'` holds an apostrophe without ending the literal, so a naive scan would treat - /// the semicolon after it as a boundary and cut the statement in half. - func testAlternativeQuotingHidesItsApostrophesAndSemicolons() { - let script = "INSERT INTO t VALUES(q'{it's ok; really}');\nSELECT 1 FROM DUAL" - XCTAssertEqual( - DamengScriptSplitter.statements(in: script), - ["INSERT INTO t VALUES(q'{it's ok; really}')", "SELECT 1 FROM DUAL"] - ) - } - - func testABackslashEscapedQuoteDoesNotEndTheLiteral() { - let script = "SELECT 'a\\'; b' FROM DUAL;\nSELECT 2 FROM DUAL" - XCTAssertEqual( - DamengScriptSplitter.statements(in: script, escaping: .backslashEscape), - ["SELECT 'a\\'; b' FROM DUAL", "SELECT 2 FROM DUAL"] - ) - } - - /// A trailing comment is not a statement. Sending one to DM8 fails the script after its - /// real work has already run and cannot be undone. - func testACommentOnlySegmentIsNotAStatement() { - XCTAssertEqual( - DamengScriptSplitter.statements(in: "SELECT 1 FROM DUAL;\n-- done"), - ["SELECT 1 FROM DUAL"] - ) - XCTAssertEqual( - DamengScriptSplitter.statements(in: "SELECT 1 FROM DUAL;\n/* done */\n"), - ["SELECT 1 FROM DUAL"] - ) - } - - /// A nested block comment closes twice. Counting one close would leave the stray `*/` looking - /// like a statement, and sending that to DM8 fails the script after its real work has run. - func testANestedCommentOnlySegmentIsNotAStatement() { - XCTAssertEqual( - DamengScriptSplitter.statements(in: "SELECT 1 FROM DUAL;\n/* a /* b */ */"), - ["SELECT 1 FROM DUAL"] - ) - } - - func testASemicolonInsideALiteralOrIdentifierIsNotABoundary() { - XCTAssertEqual( - DamengScriptSplitter.statements(in: "INSERT INTO t VALUES('a;b')"), - ["INSERT INTO t VALUES('a;b')"] - ) - XCTAssertEqual( - DamengScriptSplitter.statements(in: "INSERT INTO t VALUES('it''s; fine')"), - ["INSERT INTO t VALUES('it''s; fine')"] - ) - XCTAssertEqual( - DamengScriptSplitter.statements(in: "SELECT \"a;b\" FROM t"), - ["SELECT \"a;b\" FROM t"] - ) - } - - func testASemicolonInsideACommentIsNotABoundary() { - XCTAssertEqual( - DamengScriptSplitter.statements(in: "SELECT 1 -- one; two\nFROM DUAL"), - ["SELECT 1 -- one; two\nFROM DUAL"] - ) - XCTAssertEqual( - DamengScriptSplitter.statements(in: "SELECT /* one; /* two; */ */ 1 FROM DUAL"), - ["SELECT /* one; /* two; */ */ 1 FROM DUAL"] - ) - } - - func testEmptyStatementsBetweenTerminatorsAreDropped() { - XCTAssertEqual( - DamengScriptSplitter.statements(in: "SELECT 1 FROM DUAL;;\n;SELECT 2 FROM DUAL;"), - ["SELECT 1 FROM DUAL", "SELECT 2 FROM DUAL"] - ) - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaBridgeContractTests.swift b/Plugins/HanaDriverPluginTests/HanaBridgeContractTests.swift deleted file mode 100644 index 6f66153732..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaBridgeContractTests.swift +++ /dev/null @@ -1,249 +0,0 @@ -import Foundation -import TableProPluginKit -import XCTest - -final class HanaBridgeContractTests: XCTestCase { - func testQueryEnvelopeDecodesTextNullAndBytesCells() throws { - let json = """ - {"columns":["ID","NAME","PAYLOAD"],"columnTypeNames":["INTEGER","NVARCHAR","VARBINARY"], - "columnClassifications":[null,null,null], - "rows":[["1","a",{"bytes":"AAEC/w=="}],["2",null,null]], - "rowsAffected":0,"hasResultSet":true,"executionTime":0.0123,"isTruncated":false,"truncatedLobCount":0} - """ - let envelope = try decode(json) - - XCTAssertEqual(envelope.columns, ["ID", "NAME", "PAYLOAD"]) - XCTAssertEqual(envelope.rows[0], [.text("1"), .text("a"), .bytes(Data([0, 1, 2, 255]))]) - XCTAssertEqual(envelope.rows[1], [.text("2"), .null, .null]) - - let result = HanaResultMapping.pluginResult(from: envelope) - XCTAssertEqual(result.columnTypeNames, ["INTEGER", "NVARCHAR", "VARBINARY"]) - XCTAssertEqual(result.rows[0], [.text("1"), .text("a"), .bytes(Data([0, 1, 2, 255]))]) - XCTAssertEqual(result.rows[1], [.text("2"), .null, .null]) - XCTAssertEqual(result.executionTime, 0.0123, accuracy: 1e-6) - XCTAssertNil(result.columnMeta) - XCTAssertNil(result.statusMessage) - } - - func testDataManipulationEnvelopeCarriesRowsAffectedAndNoRows() throws { - let json = """ - {"columns":[],"columnTypeNames":[],"columnClassifications":[],"rows":[], - "rowsAffected":42,"hasResultSet":false,"executionTime":0.5,"isTruncated":false,"truncatedLobCount":0} - """ - let result = HanaResultMapping.pluginResult(from: try decode(json)) - - XCTAssertEqual(result.rowsAffected, 42) - XCTAssertTrue(result.columns.isEmpty) - XCTAssertTrue(result.rows.isEmpty) - XCTAssertFalse(result.isTruncated) - } - - func testEmptyResultSetKeepsItsColumns() throws { - let json = """ - {"columns":["ID"],"columnTypeNames":["BIGINT"],"columnClassifications":[null],"rows":[], - "rowsAffected":0,"hasResultSet":true,"executionTime":0,"isTruncated":false,"truncatedLobCount":0} - """ - let result = HanaResultMapping.pluginResult(from: try decode(json)) - - XCTAssertEqual(result.columns, ["ID"]) - XCTAssertEqual(result.columnTypeNames, ["BIGINT"]) - XCTAssertTrue(result.rows.isEmpty) - } - - func testSessionLostIsReadAndDefaultsToFalse() throws { - let fields = """ - "columns":[],"columnTypeNames":[],"columnClassifications":[],"rows":[], - "rowsAffected":1,"hasResultSet":false,"executionTime":0,"isTruncated":false,"truncatedLobCount":0 - """ - - XCTAssertTrue(try decode("{\(fields),\"sessionLost\":true}").sessionLost) - XCTAssertFalse(try decode("{\(fields),\"sessionLost\":false}").sessionLost) - XCTAssertFalse(try decode("{\(fields)}").sessionLost) - } - - func testNumericCellsAreRejectedBecauseTheContractSendsText() { - let json = """ - {"columns":["ID"],"columnTypeNames":["INTEGER"],"columnClassifications":[null],"rows":[[1]], - "rowsAffected":0,"hasResultSet":true,"executionTime":0,"isTruncated":false,"truncatedLobCount":0} - """ - XCTAssertThrowsError(try decode(json)) - } - - func testInvalidBase64BytesAreRejected() { - let json = """ - {"columns":["B"],"columnTypeNames":["BLOB"],"columnClassifications":[null],"rows":[[{"bytes":"%%%"}]], - "rowsAffected":0,"hasResultSet":true,"executionTime":0,"isTruncated":false,"truncatedLobCount":0} - """ - XCTAssertThrowsError(try decode(json)) - } - - func testClassificationHintsBecomeColumnMeta() throws { - let json = """ - {"columns":["CREATED","AMOUNT","NAME"],"columnTypeNames":["SECONDDATE","SMALLDECIMAL","NVARCHAR"], - "columnClassifications":["TIMESTAMP","DECIMAL",null],"rows":[], - "rowsAffected":0,"hasResultSet":true,"executionTime":0,"isTruncated":false,"truncatedLobCount":0} - """ - let result = HanaResultMapping.pluginResult(from: try decode(json)) - let meta = try XCTUnwrap(result.columnMeta) - - XCTAssertEqual(meta.map(\.name), ["CREATED", "AMOUNT", "NAME"]) - XCTAssertEqual(meta.map(\.dataType), ["SECONDDATE", "SMALLDECIMAL", "NVARCHAR"]) - XCTAssertEqual(meta.map(\.classificationTypeName), ["TIMESTAMP", "DECIMAL", nil]) - XCTAssertEqual(result.columnTypeNames, ["SECONDDATE", "SMALLDECIMAL", "NVARCHAR"]) - } - - func testMismatchedClassificationCountIsIgnored() { - let envelope = HanaResultEnvelope( - columns: ["A", "B"], - columnTypeNames: ["SECONDDATE", "INTEGER"], - columnClassifications: ["TIMESTAMP"], - rows: [], - rowsAffected: 0, - hasResultSet: true, - executionTime: 0, - isTruncated: false, - truncatedLobCount: 0 - ) - XCTAssertNil(HanaResultMapping.columnMeta(for: envelope)) - } - - func testTruncatedLobsAndRowCapAreReported() throws { - let json = """ - {"columns":["DOC"],"columnTypeNames":["NCLOB"],"columnClassifications":[null],"rows":[["x"]], - "rowsAffected":0,"hasResultSet":true,"executionTime":0,"isTruncated":true,"truncatedLobCount":3} - """ - let result = HanaResultMapping.pluginResult(from: try decode(json)) - - XCTAssertTrue(result.isTruncated) - let message = try XCTUnwrap(result.statusMessage) - XCTAssertTrue(message.contains("3")) - XCTAssertNil(HanaResultMapping.statusMessage(truncatedLobCount: 0)) - } - - func testExecuteRequestEncodesAbsentParametersAsNull() throws { - let request = HanaExecuteRequest(sql: "SELECT 1 FROM DUMMY", parameters: nil, rowCap: 1_000, timeoutSeconds: 30) - let object = try jsonObject(request) - - XCTAssertEqual(object["sql"] as? String, "SELECT 1 FROM DUMMY") - XCTAssertTrue(object["parameters"] is NSNull) - XCTAssertEqual(object["rowCap"] as? Int, 1_000) - XCTAssertEqual(object["timeoutSeconds"] as? Int, 30) - } - - func testExecuteRequestEncodesEveryParameterKind() throws { - let request = HanaExecuteRequest( - sql: "UPDATE T SET A = ?, B = ?, C = ? WHERE ID = ?", - parameters: [.text("it's"), .null, .bytes(Data([0xDE, 0xAD])), .text("7")], - rowCap: 0, - timeoutSeconds: 0 - ) - let object = try jsonObject(request) - let parameters = try XCTUnwrap(object["parameters"] as? [Any]) - - XCTAssertEqual(parameters[0] as? String, "it's") - XCTAssertTrue(parameters[1] is NSNull) - XCTAssertEqual((parameters[2] as? [String: String])?["bytes"], "3q0=") - XCTAssertEqual(parameters[3] as? String, "7") - } - - func testBridgeCellsRoundTripPluginValues() { - let values: [PluginCellValue] = [.null, .text("x"), .bytes(Data([1, 2]))] - XCTAssertEqual(values.map { HanaBridgeCell($0).pluginValue }, values) - } - - func testConnectConfigurationUsesTheContractSpelling() throws { - let configuration = HanaConnectConfiguration( - host: "abc.hana.prod-eu10.hanacloud.ondemand.com", - port: 443, - username: "DBADMIN", - password: "secret", - schema: "APP", - tlsMode: .verifyIdentity, - tlsServerName: "", - caCertificatePath: "", - clientCertificatePath: "", - clientKeyPath: "", - connectTimeoutSeconds: 30 - ) - let object = try jsonObject(configuration) - - XCTAssertEqual(object["tlsMode"] as? String, "verifyIdentity") - XCTAssertEqual(object["port"] as? Int, 443) - XCTAssertEqual(object["connectTimeoutSeconds"] as? Double, 30) - XCTAssertEqual( - Set(object.keys), - [ - "host", "port", "username", "password", "schema", "tlsMode", "tlsServerName", - "caCertificatePath", "clientCertificatePath", "clientKeyPath", "connectTimeoutSeconds" - ] - ) - XCTAssertEqual(HanaConnectConfiguration.TLSMode(.verifyCa).rawValue, "verifyCa") - XCTAssertEqual(HanaConnectConfiguration.TLSMode(.preferred).rawValue, "preferred") - XCTAssertEqual(HanaConnectConfiguration.TLSMode(.required).rawValue, "required") - XCTAssertEqual(HanaConnectConfiguration.TLSMode(.disabled).rawValue, "disabled") - } - - func testExplainRequestCarriesTheInnerStatement() throws { - let object = try jsonObject(HanaExplainRequest(sql: "SELECT * FROM T", timeoutSeconds: 5)) - - XCTAssertEqual(object["sql"] as? String, "SELECT * FROM T") - XCTAssertEqual(object["timeoutSeconds"] as? Int, 5) - } - - func testConnectResultDecodes() throws { - let json = #"{"serverVersion":"2.00.070.00.1234567890","currentSchema":"APP","connectionId":200123}"# - let result = try JSONDecoder().decode(HanaConnectResult.self, from: Data(json.utf8)) - - XCTAssertEqual( - result, - HanaConnectResult(serverVersion: "2.00.070.00.1234567890", currentSchema: "APP", connectionId: 200_123) - ) - } - - func testFailureDecodesEveryField() { - let json = """ - {"kind":"parameter","code":0,"position":0,"message":"2024-13-01","parameter":2,"expected":"date"} - """ - let failure = HanaBridgeFailure.decoded(from: Data(json.utf8)) - - XCTAssertEqual( - failure, - HanaBridgeFailure(kind: .parameter, message: "2024-13-01", parameter: 2, expected: "date") - ) - } - - func testFailureKindsMatchTheContract() { - let kinds = [ - "server", "cancelled", "timeout", "connectionLost", "closed", "parameter", "tls", - "configuration", "connect", "internal" - ] - let decoded = kinds.map { HanaBridgeFailure.decoded(from: Data(#"{"kind":"\#($0)"}"#.utf8)).kind } - - XCTAssertEqual( - decoded, - [ - .server, .cancelled, .timeout, .connectionLost, .closed, .parameter, .tls, - .configuration, .connect, .internalFailure - ] - ) - } - - func testUnknownOrUnreadableFailureBecomesInternal() { - let unknown = HanaBridgeFailure.decoded(from: Data(#"{"kind":"surprise","message":"m"}"#.utf8)) - XCTAssertEqual(unknown.kind, .internalFailure) - XCTAssertEqual(unknown.message, "m") - - let unreadable = HanaBridgeFailure.decoded(from: Data("not json".utf8)) - XCTAssertEqual(unreadable.kind, .internalFailure) - XCTAssertEqual(unreadable.message, "not json") - } - - private func decode(_ json: String) throws -> HanaResultEnvelope { - try JSONDecoder().decode(HanaResultEnvelope.self, from: Data(json.utf8)) - } - - private func jsonObject(_ value: some Encodable) throws -> [String: Any] { - let data = try JSONEncoder().encode(value) - return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaCatalogTests.swift b/Plugins/HanaDriverPluginTests/HanaCatalogTests.swift deleted file mode 100644 index a4baef8dad..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaCatalogTests.swift +++ /dev/null @@ -1,386 +0,0 @@ -import Foundation -import TableProPluginKit -import XCTest - -final class HanaCatalogTests: XCTestCase { - func testQuotingDoublesEveryQuoteEvenBeforeACombiningMark() { - XCTAssertEqual(HanaSQL.quoteIdentifier("a\"b"), "\"a\"\"b\"") - XCTAssertEqual(HanaSQL.quoteLiteral("a'b"), "N'a''b'") - XCTAssertEqual(HanaSQL.quoteIdentifier("x\"\u{0301}y"), "\"x\"\"\u{0301}y\"") - XCTAssertEqual(HanaSQL.quoteLiteral("x'\u{0301} OR 1=1 --"), "N'x''\u{0301} OR 1=1 --'") - XCTAssertEqual(HanaSQL.escapeLiteralBody("a\0'b"), "a''b") - XCTAssertEqual(HanaSQL.qualifiedName(schema: "S\"1", name: "T"), "\"S\"\"1\".\"T\"") - } - - func testCommentStatementSetsAndClearsTableAndViewComments() { - let target = HanaSQL.qualifiedName(schema: "APP", name: "ORDERS") - XCTAssertEqual( - HanaSQL.commentStatement(objectType: "TABLE", qualifiedName: target, comment: "it's"), - "COMMENT ON TABLE \"APP\".\"ORDERS\" IS N'it''s'" - ) - XCTAssertEqual( - HanaSQL.commentStatement(objectType: "VIEW", qualifiedName: target, comment: "Orders"), - "COMMENT ON VIEW \"APP\".\"ORDERS\" IS N'Orders'" - ) - XCTAssertEqual( - HanaSQL.commentStatement(objectType: "TABLE", qualifiedName: target, comment: nil), - "COMMENT ON TABLE \"APP\".\"ORDERS\" IS NULL" - ) - XCTAssertEqual( - HanaSQL.commentStatement(objectType: "VIEW", qualifiedName: target, comment: ""), - "COMMENT ON VIEW \"APP\".\"ORDERS\" IS NULL" - ) - for kind in ["MATERIALIZED VIEW", "SEQUENCE", "SYSTEM TABLE", "FOREIGN TABLE"] { - XCTAssertNil(HanaSQL.commentStatement(objectType: kind, qualifiedName: target, comment: nil), kind) - } - } - - func testCatalogQueriesEscapeEveryLiteral() { - let schema = "O'Brien\u{0301}" - let table = "T'1" - let queries = [ - HanaCatalogQueries.tables(schema: schema), - HanaCatalogQueries.columns(schema: schema, table: table), - HanaCatalogQueries.indexes(schema: schema, table: table), - HanaCatalogQueries.foreignKeys(schema: schema, table: table), - HanaCatalogQueries.tableMetadata(schema: schema, table: table), - HanaCatalogQueries.viewComment(schema: schema, view: table), - HanaCatalogQueries.approximateRowCount(schema: schema, table: table), - HanaCatalogQueries.tableStore(schema: schema, table: table), - HanaCatalogQueries.viewDefinition(schema: schema, view: table) - ] - for query in queries { - XCTAssertTrue(query.contains("N'O''Brien\u{0301}'"), query) - XCTAssertFalse(query.contains("N'O'Brien"), query) - } - XCTAssertTrue(HanaCatalogQueries.tableCount(schema: schema).contains("N'O''Brien\u{0301}'")) - XCTAssertEqual(HanaCatalogQueries.setSchema("My \"Schema\""), "SET SCHEMA \"My \"\"Schema\"\"\"") - } - - func testTablesQueryListsTablesAndViewsWithoutTableTypes() { - let query = HanaCatalogQueries.tables(schema: "APP") - XCTAssertTrue(query.contains("FROM SYS.TABLES")) - XCTAssertTrue(query.contains("IS_USER_DEFINED_TYPE = 'FALSE'")) - XCTAssertTrue(query.contains("FROM SYS.VIEWS")) - XCTAssertTrue(query.contains("COMMENTS")) - } - - func testColumnsQueryCoversTablesAndViewsAndFiltersOnlyWhenATableIsNamed() { - let single = HanaCatalogQueries.columns(schema: "APP", table: "ORDERS") - XCTAssertTrue(single.contains("FROM SYS.TABLE_COLUMNS")) - XCTAssertTrue(single.contains("FROM SYS.VIEW_COLUMNS")) - XCTAssertTrue(single.contains("AND TABLE_NAME = N'ORDERS'")) - XCTAssertTrue(single.contains("AND VIEW_NAME = N'ORDERS'")) - XCTAssertTrue(single.contains("K.IS_PRIMARY_KEY = 'TRUE'")) - - let bulk = HanaCatalogQueries.columns(schema: "APP", table: nil) - XCTAssertFalse(bulk.contains("AND TABLE_NAME =")) - XCTAssertFalse(bulk.contains("AND VIEW_NAME =")) - XCTAssertTrue(bulk.contains("ORDER BY C.TABLE_NAME, C.POSITION")) - } - - func testTypeRenderingAddsLengthPrecisionAndScaleWhereHanaDeclaresThem() { - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "NVARCHAR", length: 100, scale: nil), "NVARCHAR(100)") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "VARCHAR", length: 20, scale: nil), "VARCHAR(20)") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "CHAR", length: 1, scale: nil), "CHAR(1)") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "NCHAR", length: 3, scale: nil), "NCHAR(3)") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "ALPHANUM", length: 10, scale: nil), "ALPHANUM(10)") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "SHORTTEXT", length: 50, scale: nil), "SHORTTEXT(50)") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "VARBINARY", length: 16, scale: nil), "VARBINARY(16)") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "BINARY", length: 8, scale: nil), "BINARY(8)") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "DECIMAL", length: 10, scale: 2), "DECIMAL(10,2)") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "DECIMAL", length: 34, scale: nil), "DECIMAL") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "INTEGER", length: 10, scale: 0), "INTEGER") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "NCLOB", length: 2_147_483_647, scale: nil), "NCLOB") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "TIMESTAMP", length: 27, scale: 7), "TIMESTAMP") - XCTAssertEqual(HanaCatalogMapping.renderedType(name: "NVARCHAR", length: nil, scale: nil), "NVARCHAR") - } - - func testClassificationHintsCoverTypesTheAppDoesNotKnow() { - XCTAssertEqual(HanaCatalogMapping.classificationTypeName(forTypeName: "SECONDDATE"), "TIMESTAMP") - XCTAssertEqual(HanaCatalogMapping.classificationTypeName(forTypeName: "SMALLDECIMAL"), "DECIMAL") - XCTAssertEqual(HanaCatalogMapping.classificationTypeName(forTypeName: "ST_POINT"), "TEXT") - XCTAssertEqual(HanaCatalogMapping.classificationTypeName(forTypeName: "ST_GEOMETRY"), "TEXT") - XCTAssertNil(HanaCatalogMapping.classificationTypeName(forTypeName: "NVARCHAR")) - XCTAssertNil(HanaCatalogMapping.classificationTypeName(forTypeName: "TIMESTAMP")) - } - - func testColumnRowsMapToPluginColumns() throws { - let rows: [[PluginCellValue]] = [ - columnRow("ORDERS", "ID", "INTEGER", length: "10", scale: "0", nullable: "FALSE", - generation: "BY DEFAULT AS IDENTITY", keyPosition: "1"), - columnRow("ORDERS", "NAME", "NVARCHAR", length: "100", nullable: "TRUE", defaultValue: "'guest'", - comment: "Display name"), - columnRow("ORDERS", "TOTAL", "DECIMAL", length: "12", scale: "2", nullable: "TRUE", - generation: "ALWAYS AS", expression: "\"PRICE\" * \"QTY\""), - columnRow("ORDERS", "CREATED", "SECONDDATE", length: "19", nullable: "TRUE") - ] - let columns = HanaCatalogMapping.columns(from: rows).map(\.pluginColumn) - - XCTAssertEqual(columns.map(\.name), ["ID", "NAME", "TOTAL", "CREATED"]) - XCTAssertEqual(columns.map(\.dataType), ["INTEGER", "NVARCHAR(100)", "DECIMAL(12,2)", "SECONDDATE"]) - - XCTAssertTrue(columns[0].isPrimaryKey) - XCTAssertFalse(columns[0].isNullable) - XCTAssertEqual(columns[0].identityKind, .byDefault) - XCTAssertFalse(columns[0].isGenerated) - - XCTAssertFalse(columns[1].isPrimaryKey) - XCTAssertTrue(columns[1].isNullable) - XCTAssertEqual(columns[1].defaultValue, "'guest'") - XCTAssertEqual(columns[1].comment, "Display name") - XCTAssertNil(columns[1].identityKind) - - XCTAssertTrue(columns[2].isGenerated) - XCTAssertEqual(columns[2].generationKind, .stored) - XCTAssertEqual(columns[2].generationExpression, "\"PRICE\" * \"QTY\"") - - XCTAssertEqual(columns[3].classificationTypeName, "TIMESTAMP") - XCTAssertNil(columns[0].classificationTypeName) - } - - func testGenerationTypesMapToIdentityAndComputedKinds() { - XCTAssertEqual(HanaColumnGeneration(generationType: "ALWAYS AS IDENTITY"), .identity(.always)) - XCTAssertEqual(HanaColumnGeneration(generationType: "BY DEFAULT AS IDENTITY"), .identity(.byDefault)) - XCTAssertEqual(HanaColumnGeneration(generationType: "always as"), .computed(.stored)) - XCTAssertEqual(HanaColumnGeneration(generationType: "ALWAYS CALCULATED AS"), .computed(.virtual)) - XCTAssertEqual(HanaColumnGeneration(generationType: "ALWAYS AS ROW START"), .systemTime) - XCTAssertEqual(HanaColumnGeneration(generationType: "ALWAYS AS ROW END"), .systemTime) - XCTAssertNil(HanaColumnGeneration(generationType: nil)) - XCTAssertNil(HanaColumnGeneration(generationType: "SOMETHING ELSE")) - - let systemTime = HanaCatalogColumn(table: "T", name: "VALID_FROM", typeName: "TIMESTAMP", generation: .systemTime) - XCTAssertTrue(systemTime.pluginColumn.isGenerated) - XCTAssertNil(systemTime.pluginColumn.identityKind) - } - - func testBulkColumnsGroupByTable() { - let rows: [[PluginCellValue]] = [ - columnRow("A", "ID", "INTEGER"), - columnRow("A", "NAME", "NVARCHAR", length: "10"), - columnRow("B_VIEW", "ID", "INTEGER") - ] - let grouped = HanaCatalogMapping.columnsByTable(from: rows) - - XCTAssertEqual(grouped["A"]?.map(\.name), ["ID", "NAME"]) - XCTAssertEqual(grouped["B_VIEW"]?.map(\.name), ["ID"]) - } - - func testTablesMapTypeSchemaAndComment() { - let rows: [[PluginCellValue]] = [ - [.text("ORDERS"), .text("TABLE"), .text("Customer orders")], - [.text("V_ORDERS"), .text("VIEW"), .null] - ] - let tables = HanaCatalogMapping.tables(from: rows, schema: "APP") - - XCTAssertEqual(tables.map(\.name), ["ORDERS", "V_ORDERS"]) - XCTAssertEqual(tables.map(\.type), ["TABLE", "VIEW"]) - XCTAssertEqual(tables.map(\.schema), ["APP", "APP"]) - XCTAssertEqual(tables.map(\.comment), ["Customer orders", nil]) - } - - func testIndexRowsGroupInKeyOrderWithConstraintKinds() { - let rows: [[PluginCellValue]] = [ - [.text("IDX_NAME"), .text("CPBTREE"), .null, .text("LAST")], - [.text("IDX_NAME"), .text("CPBTREE"), .null, .text("FIRST")], - [.text("SYS_TREE_CS_#1_#0_#PK"), .text("INVERTED VALUE"), .text("PRIMARY_KEY"), .text("ID")], - [.text("UQ_EMAIL"), .text("INVERTED VALUE"), .text("NOT_NULL_UNIQUE"), .text("EMAIL")], - [.text("UQ_CODE"), .text("BTREE"), .text("UNIQUE"), .text("CODE")], - [.text("FT_BODY"), .text("FULLTEXT"), .text("FULLTEXT"), .text("BODY")] - ] - let indexes = HanaCatalogMapping.indexes(from: rows) - - XCTAssertEqual(indexes.map(\.name), ["IDX_NAME", "SYS_TREE_CS_#1_#0_#PK", "UQ_EMAIL", "UQ_CODE", "FT_BODY"]) - XCTAssertEqual(indexes[0].columns, ["LAST", "FIRST"]) - XCTAssertEqual(indexes[0].type, "CPBTREE") - XCTAssertFalse(indexes[0].isUnique) - XCTAssertFalse(indexes[0].isPrimary) - XCTAssertTrue(indexes[1].isPrimary) - XCTAssertTrue(indexes[1].isUnique) - XCTAssertTrue(indexes[2].isUnique) - XCTAssertFalse(indexes[2].isPrimary) - XCTAssertTrue(indexes[3].isUnique) - XCTAssertFalse(indexes[4].isUnique) - XCTAssertEqual(indexes[4].type, "FULLTEXT") - } - - func testSpacedConstraintSpellingsAreRecognised() { - let rows: [[PluginCellValue]] = [[.text("PK"), .text("BTREE"), .text("PRIMARY KEY"), .text("ID")]] - let index = HanaCatalogMapping.indexes(from: rows)[0] - XCTAssertTrue(index.isPrimary) - XCTAssertTrue(index.isUnique) - } - - func testForeignKeyRowsKeepConstraintColumnOrderAndRules() { - let rows: [[PluginCellValue]] = [ - [.text("FK_LINE_ORDER"), .text("ORDER_ID"), .text("SALES"), .text("ORDERS"), .text("ID"), - .text("CASCADE"), .text("SET NULL")], - [.text("FK_LINE_ORDER"), .text("ORDER_REGION"), .text("SALES"), .text("ORDERS"), .text("REGION"), - .text("CASCADE"), .text("SET NULL")], - [.text("FK_LINE_ITEM"), .text("ITEM_ID"), .null, .text("ITEMS"), .text("ID"), .null, .null] - ] - let keys = HanaCatalogMapping.foreignKeys(from: rows) - - XCTAssertEqual(keys.map(\.name), ["FK_LINE_ORDER", "FK_LINE_ORDER", "FK_LINE_ITEM"]) - XCTAssertEqual(keys.map(\.column), ["ORDER_ID", "ORDER_REGION", "ITEM_ID"]) - XCTAssertEqual(keys.map(\.referencedColumn), ["ID", "REGION", "ID"]) - XCTAssertEqual(keys[0].referencedTable, "ORDERS") - XCTAssertEqual(keys[0].referencedSchema, "SALES") - XCTAssertEqual(keys[0].onUpdate, "CASCADE") - XCTAssertEqual(keys[0].onDelete, "SET NULL") - XCTAssertNil(keys[2].referencedSchema) - XCTAssertEqual(keys[2].onDelete, "RESTRICT") - XCTAssertEqual(keys[2].onUpdate, "RESTRICT") - } - - func testTableMetadataReadsCountSizeCommentAndStore() { - let metadata = HanaCatalogMapping.tableMetadata( - table: "ORDERS", - row: [.text("COLUMN"), .text("Orders"), .text("1250"), .text("65536")] - ) - - XCTAssertEqual(metadata.tableName, "ORDERS") - XCTAssertEqual(metadata.rowCount, 1_250) - XCTAssertEqual(metadata.dataSize, 65_536) - XCTAssertEqual(metadata.totalSize, 65_536) - XCTAssertEqual(metadata.comment, "Orders") - XCTAssertEqual(metadata.engine, "COLUMN") - } - - func testTableDDLCarriesStoreTypesDefaultsIdentityAndPrimaryKeyInKeyOrder() { - let columns = [ - HanaCatalogColumn(table: "T", name: "REGION", typeName: "NVARCHAR", length: 2, isNullable: false, - primaryKeyPosition: 2), - HanaCatalogColumn(table: "T", name: "ID", typeName: "BIGINT", isNullable: false, - generation: .identity(.always), primaryKeyPosition: 1), - HanaCatalogColumn(table: "T", name: "NOTE", typeName: "NVARCHAR", length: 50, defaultValue: "'n/a'"), - HanaCatalogColumn(table: "T", name: "AMOUNT", typeName: "DECIMAL", length: 10, scale: 2), - HanaCatalogColumn(table: "T", name: "TAX", typeName: "DECIMAL", length: 10, scale: 2, - generation: .computed(.stored), generationExpression: "\"AMOUNT\" * 0.2"), - HanaCatalogColumn(table: "T", name: "LABEL", typeName: "NVARCHAR", length: 60, - generation: .computed(.virtual), generationExpression: "UPPER(\"NOTE\")"), - HanaCatalogColumn(table: "T", name: "SEQ", typeName: "INTEGER", isNullable: false, - generation: .identity(.byDefault)) - ] - let ddl = HanaCatalogMapping.tableDDL(schema: "APP", table: "T", isColumnTable: true, columns: columns) - - XCTAssertEqual(ddl, """ - CREATE COLUMN TABLE "APP"."T" ( - "REGION" NVARCHAR(2) NOT NULL, - "ID" BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, - "NOTE" NVARCHAR(50) DEFAULT 'n/a', - "AMOUNT" DECIMAL(10,2), - "TAX" DECIMAL(10,2) GENERATED ALWAYS AS ("AMOUNT" * 0.2), - "LABEL" NVARCHAR(60) AS (UPPER("NOTE")), - "SEQ" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL, - PRIMARY KEY ("ID", "REGION") - ); - """) - } - - func testRowTableDDLQuotesNamesAndOmitsAnAbsentPrimaryKey() { - let columns = [HanaCatalogColumn(table: "odd\"name", name: "a\"b", typeName: "INTEGER")] - let ddl = HanaCatalogMapping.tableDDL(schema: "s", table: "odd\"name", isColumnTable: false, columns: columns) - - XCTAssertEqual(ddl, "CREATE ROW TABLE \"s\".\"odd\"\"name\" (\n \"a\"\"b\" INTEGER\n);") - } - - func testViewDDLPrependsTheCreateHeader() { - XCTAssertEqual( - HanaCatalogMapping.viewDDL(schema: "APP", view: "V\"1", definition: "SELECT 1 FROM DUMMY"), - "CREATE VIEW \"APP\".\"V\"\"1\" AS\nSELECT 1 FROM DUMMY" - ) - } - - func testDefaultsAreQuotedForCharacterAndDateColumnsOnly() { - XCTAssertEqual(HanaCatalogMapping.renderedDefault("guest", typeName: "NVARCHAR"), "N'guest'") - XCTAssertEqual(HanaCatalogMapping.renderedDefault("it's", typeName: "VARCHAR"), "N'it''s'") - XCTAssertEqual(HanaCatalogMapping.renderedDefault("", typeName: "NVARCHAR"), "N''") - XCTAssertEqual(HanaCatalogMapping.renderedDefault("2020-01-01", typeName: "DATE"), "N'2020-01-01'") - XCTAssertEqual(HanaCatalogMapping.renderedDefault("'kept'", typeName: "NVARCHAR"), "'kept'") - XCTAssertEqual(HanaCatalogMapping.renderedDefault("N'kept'", typeName: "NCHAR"), "N'kept'") - XCTAssertEqual( - HanaCatalogMapping.renderedDefault("CURRENT_TIMESTAMP", typeName: "TIMESTAMP"), - "CURRENT_TIMESTAMP" - ) - XCTAssertEqual(HanaCatalogMapping.renderedDefault("current_date", typeName: "DATE"), "current_date") - XCTAssertEqual(HanaCatalogMapping.renderedDefault("0", typeName: "INTEGER"), "0") - XCTAssertEqual(HanaCatalogMapping.renderedDefault("TRUE", typeName: "BOOLEAN"), "TRUE") - } - - func testTableDDLQuotesABareCharacterDefault() { - let columns = [ - HanaCatalogColumn(table: "T", name: "NAME", typeName: "NVARCHAR", length: 20, defaultValue: "guest"), - HanaCatalogColumn(table: "T", name: "QTY", typeName: "INTEGER", defaultValue: "1") - ] - let ddl = HanaCatalogMapping.tableDDL(schema: "APP", table: "T", isColumnTable: true, columns: columns) - - XCTAssertTrue(ddl.contains("\"NAME\" NVARCHAR(20) DEFAULT N'guest'"), ddl) - XCTAssertTrue(ddl.contains("\"QTY\" INTEGER DEFAULT 1"), ddl) - } - - func testIndexStatementsSkipThePrimaryKeyAndKeepUniquenessTypeAndOrder() { - let rows: [[PluginCellValue]] = [ - [.text("PK_T"), .text("CPBTREE"), .text("PRIMARY KEY"), .text("ID"), .text("TRUE")], - [.text("UQ_EMAIL"), .text("CPBTREE"), .text("UNIQUE"), .text("EMAIL"), .text("TRUE")], - [.text("IX_NAME"), .text("BTREE"), .null, .text("LAST"), .text("TRUE")], - [.text("IX_NAME"), .text("BTREE"), .null, .text("FIRST"), .text("FALSE")], - [.text("IX_VALUE"), .text("inverted value"), .null, .text("CODE"), .text("TRUE")], - [.text("IX_GEO"), .text("GEOCODE"), .null, .text("ADDRESS"), .text("TRUE")] - ] - let statements = HanaCatalogMapping.indexStatements(schema: "APP", table: "T", rows: rows) - - XCTAssertEqual(statements, [ - #"CREATE UNIQUE CPBTREE INDEX "APP"."UQ_EMAIL" ON "APP"."T" ("EMAIL");"#, - #"CREATE BTREE INDEX "APP"."IX_NAME" ON "APP"."T" ("LAST", "FIRST" DESC);"#, - #"CREATE INVERTED VALUE INDEX "APP"."IX_VALUE" ON "APP"."T" ("CODE");"# - ]) - } - - func testCommentStatementsCoverTheRelationAndItsColumns() { - let columns = [ - HanaCatalogColumn(table: "V", name: "ID", typeName: "INTEGER"), - HanaCatalogColumn(table: "V", name: "NOTE", typeName: "NVARCHAR", length: 10, comment: "free 'text'") - ] - let statements = HanaCatalogMapping.commentStatements( - schema: "APP", - table: "V", - relation: [.text("VIEW"), .text("Active orders")], - columns: columns - ) - - XCTAssertEqual(statements, [ - #"COMMENT ON VIEW "APP"."V" IS N'Active orders';"#, - #"COMMENT ON COLUMN "APP"."V"."NOTE" IS N'free ''text''';"# - ]) - XCTAssertEqual( - HanaCatalogMapping.commentStatements( - schema: "APP", - table: "T", - relation: [.text("TABLE"), .null], - columns: [] - ), - [] - ) - } - - private func columnRow( - _ table: String, - _ name: String, - _ type: String, - length: String? = nil, - scale: String? = nil, - nullable: String = "TRUE", - defaultValue: String? = nil, - comment: String? = nil, - generation: String? = nil, - expression: String? = nil, - keyPosition: String? = nil - ) -> [PluginCellValue] { - [ - .text(table), .text(name), .text(type), .fromOptional(length), .fromOptional(scale), .text(nullable), - .fromOptional(defaultValue), .fromOptional(comment), .fromOptional(generation), .fromOptional(expression), - .fromOptional(keyPosition) - ] - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaConnectionTests.swift b/Plugins/HanaDriverPluginTests/HanaConnectionTests.swift deleted file mode 100644 index 50784bd1b3..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaConnectionTests.swift +++ /dev/null @@ -1,371 +0,0 @@ -import Foundation -import XCTest - -final class HanaConnectionTests: XCTestCase { - private static let configuration = HanaConnectConfiguration( - host: "hana.example", - port: 443, - username: "DBADMIN", - password: "test-only", - schema: "APP", - tlsMode: .verifyIdentity, - tlsServerName: "", - caCertificatePath: "", - clientCertificatePath: "", - clientKeyPath: "", - connectTimeoutSeconds: 30 - ) - - func testConnectOpensTheSessionFromTheConnectionQueueAndNotTheCaller() async throws { - let bridge = HanaFakeBridge() - let queue = HanaManualQueue() - let connection = HanaConnection(bridge: bridge, queue: queue) - let configuration = Self.configuration - let connect = Task { try await connection.connect(configuration) } - await queue.pending(reaching: 1) - - XCTAssertEqual(bridge.opens, 0) - queue.runNext() - await queue.pending(reaching: 1) - - XCTAssertEqual(bridge.opens, 1) - XCTAssertTrue(bridge.connects.isEmpty) - queue.runNext() - let result = try await connect.value - XCTAssertEqual(result.connectionId, 200_123) - XCTAssertEqual(bridge.connects.count, 1) - XCTAssertEqual(queue.pendingCount, 0) - } - - func testCancellingTheConnectWhileItsOpenWaitsInterruptsTheOpen() async throws { - let bridge = HanaFakeBridge() - let hold = bridge.holdOpen() - let connection = HanaConnection(bridge: bridge) - let configuration = Self.configuration - let connect = Task { try await connection.connect(configuration) } - let interruption = await hold.arrival() - XCTAssertFalse(interruption.isInterrupted) - - connect.cancel() - - await assertFailure(of: connect, kind: .cancelled) - XCTAssertTrue(interruption.isInterrupted) - XCTAssertEqual(bridge.opens, 1) - XCTAssertTrue(bridge.connects.isEmpty) - XCTAssertTrue(bridge.closes.isEmpty) - } - - func testAConnectCancelledBeforeItsOpenRunsHandsTheBridgeAnInterruptedOpen() async throws { - let bridge = HanaFakeBridge() - let queue = HanaManualQueue() - let connection = HanaConnection(bridge: bridge, queue: queue) - let configuration = Self.configuration - let connect = Task { try await connection.connect(configuration) } - await queue.pending(reaching: 1) - - connect.cancel() - queue.runNext() - - await assertFailure(of: connect, kind: .cancelled) - XCTAssertTrue(bridge.connects.isEmpty) - XCTAssertEqual(queue.pendingCount, 0) - } - - func testAConnectAfterAnInterruptedOpenOpensNormally() async throws { - let bridge = HanaFakeBridge() - let hold = bridge.holdOpen() - let connection = HanaConnection(bridge: bridge) - let configuration = Self.configuration - let cancelled = Task { try await connection.connect(configuration) } - _ = await hold.arrival() - cancelled.cancel() - await assertFailure(of: cancelled, kind: .cancelled) - let secondHold = bridge.holdOpen() - let second = Task { try await connection.connect(configuration) } - let secondOpen = await secondHold.arrival() - secondHold.release() - - let result = try await second.value - - XCTAssertFalse(secondOpen.isInterrupted) - XCTAssertEqual(result.connectionId, 200_123) - XCTAssertEqual(bridge.connects.count, 1) - } - - func testCancellingASlotThatNeverHeldAnOperationSendsNothing() async throws { - let bridge = HanaFakeBridge() - let connection = HanaConnection(bridge: bridge) - - connection.cancel(HanaOperationSlot()) - _ = try await connection.connect(Self.configuration) - _ = try await connection.execute("SELECT 1 FROM DUMMY") - connection.cancel(HanaOperationSlot()) - - XCTAssertTrue(bridge.cancels.isEmpty) - } - - func testASlotCancelledBeforeItsQueryIsEnqueuedKeepsTheQueryOffTheBridge() async throws { - let bridge = HanaFakeBridge() - let connection = HanaConnection(bridge: bridge) - _ = try await connection.connect(Self.configuration) - let slot = HanaOperationSlot() - - connection.cancel(slot) - - do { - _ = try await connection.execute("DELETE FROM T", cancellation: slot) - XCTFail("a query whose slot was already cancelled should not run") - } catch { - XCTAssertTrue(error is CancellationError, "got \(error)") - } - XCTAssertTrue(bridge.statements.isEmpty) - XCTAssertTrue(bridge.cancels.isEmpty) - } - - func testCancellingAQueryQueuedBehindAPingStopsTheQueryAndLeavesThePingAlone() async throws { - let bridge = HanaFakeBridge() - let queue = HanaRecordingQueue() - let connection = HanaConnection(bridge: bridge, queue: queue) - _ = try await connection.connect(Self.configuration) - let pingHold = bridge.holdPing() - let ping = Task { try await connection.ping() } - let pingTicket = await pingHold.arrival() - let slot = HanaOperationSlot() - let query = Task { try await connection.execute("SELECT * FROM BIG", cancellation: slot) } - await queue.submissions(reaching: 4) - - connection.cancel(slot) - - let queryTicket = HanaOperationTicket(session: pingTicket.session, operation: pingTicket.operation + 1) - XCTAssertEqual(bridge.cancels, [queryTicket]) - pingHold.release() - try await ping.value - do { - _ = try await query.value - XCTFail("the query queued behind the ping should not run") - } catch { - XCTAssertTrue(error is CancellationError, "got \(error)") - } - XCTAssertEqual(bridge.pings, [pingTicket]) - XCTAssertTrue(bridge.statements.isEmpty) - } - - func testAStopThatLandsAfterItsOperationFinishedNeverCancelsTheNextOne() async throws { - let bridge = HanaFakeBridge() - let connection = HanaConnection(bridge: bridge) - _ = try await connection.connect(Self.configuration) - let first = bridge.hold(sql: "UPDATE A SET X = 1") - let second = bridge.hold(sql: "UPDATE B SET X = 1") - let delivery = bridge.holdCancels() - let firstSlot = HanaOperationSlot() - let firstRun = Task { try await connection.execute("UPDATE A SET X = 1", cancellation: firstSlot) } - let firstTicket = await first.arrival() - - let stopReturned = HanaLatch() - DispatchQueue.global().async { - connection.cancel(firstSlot) - stopReturned.open() - } - let stoppedTicket = await delivery.arrival() - first.release() - _ = try await firstRun.value - let secondRun = Task { try await connection.execute("UPDATE B SET X = 1") } - let secondTicket = await second.arrival() - delivery.release() - await stopReturned.wait() - second.release() - - _ = try await secondRun.value - XCTAssertEqual(stoppedTicket, firstTicket) - XCTAssertNotEqual(secondTicket, firstTicket) - XCTAssertEqual(bridge.cancels, [firstTicket]) - } - - func testAStopWhileAnOperationRunsNamesExactlyThatOperation() async throws { - let bridge = HanaFakeBridge() - let connection = HanaConnection(bridge: bridge) - _ = try await connection.connect(Self.configuration) - let hold = bridge.hold(sql: "SELECT * FROM BIG") - let slot = HanaOperationSlot() - let run = Task { try await connection.execute("SELECT * FROM BIG", cancellation: slot) } - let ticket = await hold.arrival() - - connection.cancel(slot) - - XCTAssertEqual(bridge.cancels, [ticket]) - XCTAssertNotEqual(ticket.operation, 0) - hold.release() - await assertFailure(of: run, kind: .cancelled) - } - - func testCancellingASlotAgainAfterItsOperationFinishedSendsNothingNew() async throws { - let bridge = HanaFakeBridge() - let connection = HanaConnection(bridge: bridge) - _ = try await connection.connect(Self.configuration) - let hold = bridge.hold(sql: "SELECT * FROM BIG") - let slot = HanaOperationSlot() - let run = Task { try await connection.execute("SELECT * FROM BIG", cancellation: slot) } - let ticket = await hold.arrival() - connection.cancel(slot) - XCTAssertEqual(bridge.cancels, [ticket]) - hold.release() - await assertFailure(of: run, kind: .cancelled) - - connection.cancel(slot) - - XCTAssertEqual(bridge.cancels, [ticket]) - _ = try await connection.execute("SELECT 1 FROM DUMMY") - XCTAssertEqual(bridge.statements.map(\.sql), ["SELECT * FROM BIG", "SELECT 1 FROM DUMMY"]) - } - - func testCancellingTheTaskStopsItsOwnOperationBeforeCancelReturns() async throws { - let bridge = HanaFakeBridge() - let connection = HanaConnection(bridge: bridge) - _ = try await connection.connect(Self.configuration) - let hold = bridge.hold(sql: "SELECT * FROM BIG") - let run = Task { try await connection.execute("SELECT * FROM BIG") } - let ticket = await hold.arrival() - - run.cancel() - - XCTAssertEqual(bridge.cancels, [ticket]) - hold.release() - await assertFailure(of: run, kind: .cancelled) - XCTAssertEqual(bridge.cancels, [ticket]) - } - - func testATaskCancelledWhileQueuedNeverReachesTheBridge() async throws { - let bridge = HanaFakeBridge() - let queue = HanaRecordingQueue() - let connection = HanaConnection(bridge: bridge, queue: queue) - _ = try await connection.connect(Self.configuration) - let hold = bridge.hold(sql: "SELECT * FROM BIG") - let running = Task { try await connection.execute("SELECT * FROM BIG") } - _ = await hold.arrival() - let queued = Task { try await connection.execute("DELETE FROM T") } - await queue.submissions(reaching: 4) - - queued.cancel() - hold.release() - - _ = try await running.value - do { - _ = try await queued.value - XCTFail("the queued statement should not run") - } catch { - XCTAssertTrue(error is CancellationError, "got \(error)") - } - XCTAssertEqual(bridge.statements.map(\.sql), ["SELECT * FROM BIG"]) - } - - func testDisconnectClosesTheSessionBeforeReturningAndDropsQueuedWork() async throws { - let bridge = HanaFakeBridge() - let queue = HanaRecordingQueue() - let connection = HanaConnection(bridge: bridge, queue: queue) - _ = try await connection.connect(Self.configuration) - let hold = bridge.hold(sql: "UPDATE A SET X = 1") - let running = Task { try await connection.execute("UPDATE A SET X = 1") } - let runningTicket = await hold.arrival() - let queued = Task { try await connection.execute("DELETE FROM T") } - await queue.submissions(reaching: 4) - - connection.disconnect() - - XCTAssertEqual(bridge.closes, [runningTicket.session]) - hold.release() - await assertFailure(of: running, kind: .closed) - await assertFailure(of: queued, kind: .closed) - XCTAssertEqual(bridge.statements.map(\.sql), ["UPDATE A SET X = 1"]) - XCTAssertFalse(connection.hasLostConnection) - } - - func testReconnectClosesTheOldSessionAndDropsWorkQueuedForIt() async throws { - let bridge = HanaFakeBridge() - let queue = HanaRecordingQueue() - let connection = HanaConnection(bridge: bridge, queue: queue) - _ = try await connection.connect(Self.configuration) - let hold = bridge.hold(sql: "UPDATE A SET X = 1") - let running = Task { try await connection.execute("UPDATE A SET X = 1") } - let oldSession = await hold.arrival().session - let queued = Task { try await connection.execute("DELETE FROM T") } - await queue.submissions(reaching: 4) - - let configuration = Self.configuration - let reconnect = Task { try await connection.connect(configuration) } - await queue.submissions(reaching: 5) - - XCTAssertEqual(bridge.closes, [oldSession]) - hold.release() - await assertFailure(of: queued, kind: .closed) - _ = try await reconnect.value - _ = try? await running.value - XCTAssertEqual(bridge.statements.map(\.sql), ["UPDATE A SET X = 1"]) - XCTAssertEqual(bridge.connects.map(\.session), [oldSession, oldSession + 1]) - _ = try await connection.execute("SELECT 1 FROM DUMMY") - XCTAssertEqual(bridge.statements.last?.ticket.session, oldSession + 1) - } - - func testASessionLostEnvelopeKeepsItsRowsAndMarksTheConnectionLost() async throws { - let bridge = HanaFakeBridge() - bridge.respond(to: "COMMIT", with: HanaBridgeJSON.envelope(columns: ["A"], rows: [["1"]], sessionLost: true)) - let connection = HanaConnection(bridge: bridge) - _ = try await connection.connect(Self.configuration) - - _ = try await connection.execute("SELECT 1 FROM DUMMY") - XCTAssertFalse(connection.hasLostConnection) - let envelope = try await connection.execute("COMMIT") - - XCTAssertEqual(envelope.rows, [[.text("1")]]) - XCTAssertTrue(envelope.sessionLost) - XCTAssertTrue(connection.hasLostConnection) - } - - func testAPlanFromASessionLostWhileDiscardingItStillArrives() async throws { - let bridge = HanaFakeBridge() - let plan = HanaBridgeJSON.envelope(columns: ["QUERY PLAN"], rows: [["COLUMN SEARCH"]], sessionLost: true) - bridge.respond(to: "SELECT * FROM T", with: plan) - let connection = HanaConnection(bridge: bridge) - _ = try await connection.connect(Self.configuration) - - let envelope = try await connection.explain(sql: "SELECT * FROM T", cancellation: HanaOperationSlot()) - - XCTAssertEqual(envelope.rows, [[.text("COLUMN SEARCH")]]) - XCTAssertTrue(connection.hasLostConnection) - } - - func testReconnectingClearsALostSession() async throws { - let bridge = HanaFakeBridge() - bridge.respond(to: "COMMIT", with: HanaBridgeJSON.envelope(sessionLost: true)) - let connection = HanaConnection(bridge: bridge) - _ = try await connection.connect(Self.configuration) - _ = try await connection.execute("COMMIT") - XCTAssertTrue(connection.hasLostConnection) - - _ = try await connection.connect(Self.configuration) - - XCTAssertFalse(connection.hasLostConnection) - } - - private func assertFailure( - of task: Task, - kind: HanaBridgeFailure.Kind, - file: StaticString = #filePath, - line: UInt = #line - ) async { - do { - _ = try await task.value - XCTFail("the operation should fail with \(kind)", file: file, line: line) - } catch { - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, kind, "got \(error)", file: file, line: line) - } - } -} - -private extension HanaConnection { - func execute( - _ sql: String, - cancellation: HanaOperationSlot = HanaOperationSlot() - ) async throws -> HanaResultEnvelope { - try await execute(sql: sql, parameters: nil, rowCap: 0, cancellation: cancellation) - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaExplainStatementTests.swift b/Plugins/HanaDriverPluginTests/HanaExplainStatementTests.swift deleted file mode 100644 index e68d9091dc..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaExplainStatementTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import XCTest - -final class HanaExplainStatementTests: XCTestCase { - func testTheAppBuiltPrefixIsRecognised() { - XCTAssertEqual( - HanaExplainStatement.explainedStatement(in: "EXPLAIN PLAN FOR SELECT * FROM \"APP\".\"T\""), - "SELECT * FROM \"APP\".\"T\"" - ) - } - - func testCaseWhitespaceAndCommentsAroundTheKeywordsAreAccepted() { - XCTAssertEqual(HanaExplainStatement.explainedStatement(in: "explain plan for select 1 from dummy"), "select 1 from dummy") - XCTAssertEqual( - HanaExplainStatement.explainedStatement(in: " \n\t-- why\nExplain\n Plan\tFor\n SELECT 1 FROM DUMMY"), - "SELECT 1 FROM DUMMY" - ) - XCTAssertEqual( - HanaExplainStatement.explainedStatement(in: "/* plan */ EXPLAIN/**/PLAN /* x */ FOR SELECT 1 FROM DUMMY"), - "SELECT 1 FROM DUMMY" - ) - XCTAssertEqual( - HanaExplainStatement.explainedStatement(in: "EXPLAIN PLAN FOR(SELECT 1 FROM DUMMY)"), - "(SELECT 1 FROM DUMMY)" - ) - } - - func testTrailingTerminatorsAreDropped() { - XCTAssertEqual( - HanaExplainStatement.explainedStatement(in: "EXPLAIN PLAN FOR UPDATE T SET A = 1; \n"), - "UPDATE T SET A = 1" - ) - } - - func testOtherStatementsAreLeftAlone() { - let statements = [ - "SELECT 'EXPLAIN PLAN FOR SELECT 1' FROM DUMMY", - "EXPLAIN PLAN SET STATEMENT_NAME = 'x' FOR SELECT 1 FROM DUMMY", - "EXPLAINPLAN FOR SELECT 1 FROM DUMMY", - "EXPLAIN PLANS FOR SELECT 1 FROM DUMMY", - "EXPLAIN PLAN FORX SELECT 1", - "EXPLAIN PLAN FOR", - "EXPLAIN PLAN FOR ;", - "EXPLAIN PLAN", - "-- EXPLAIN PLAN FOR SELECT 1", - "/* EXPLAIN PLAN FOR SELECT 1", - "", - " " - ] - for statement in statements { - XCTAssertNil(HanaExplainStatement.explainedStatement(in: statement), statement) - } - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaFailureMappingTests.swift b/Plugins/HanaDriverPluginTests/HanaFailureMappingTests.swift deleted file mode 100644 index 96a6f0fc4d..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaFailureMappingTests.swift +++ /dev/null @@ -1,169 +0,0 @@ -import Foundation -import TableProPluginKit -import XCTest - -final class HanaFailureMappingTests: XCTestCase { - func testRequestedCancellationBecomesCancellationError() { - let error = map(HanaBridgeFailure(kind: .cancelled), cancellationRequested: true) - XCTAssertTrue(error is CancellationError) - } - - func testUnrequestedCancellationStaysAnError() throws { - let error = try hanaError(map(HanaBridgeFailure(kind: .cancelled), cancellationRequested: false)) - XCTAssertEqual(error.kind, .cancelled) - XCTAssertFalse(error.message.isEmpty) - } - - func testServerErrorKeepsTheHanaCodeAndText() throws { - let failure = HanaBridgeFailure(kind: .server, code: 259, position: 14, message: "invalid table name: FOO") - let error = try hanaError(map(failure)) - - XCTAssertEqual(error.kind, .server) - XCTAssertEqual(error.pluginErrorCode, 259) - XCTAssertEqual(error.pluginErrorMessage, "invalid table name: FOO") - XCTAssertEqual(error.errorDescription, "[259] invalid table name: FOO") - } - - func testServerErrorWithoutTextOrCodeStillReadsWell() throws { - let error = try hanaError(map(HanaBridgeFailure(kind: .server))) - XCTAssertNil(error.pluginErrorCode) - XCTAssertFalse(error.message.isEmpty) - } - - func testServerCancelIsNotTreatedAsTheUsersStop() throws { - let error = try hanaError(map(HanaBridgeFailure(kind: .server, code: 139, message: "current operation cancelled"))) - XCTAssertEqual(error.kind, .server) - XCTAssertEqual(error.pluginErrorCode, 139) - } - - func testTimeoutConnectionLostAndClosedHaveTheirOwnText() throws { - let timeout = try hanaError(map(HanaBridgeFailure(kind: .timeout))) - let lost = try hanaError(map(HanaBridgeFailure(kind: .connectionLost, message: "EOF"))) - let closed = try hanaError(map(HanaBridgeFailure(kind: .closed))) - - XCTAssertEqual(timeout.kind, .timeout) - XCTAssertEqual(lost.kind, .connectionLost) - XCTAssertEqual(lost.detail, "EOF") - XCTAssertTrue(lost.message.lowercased().contains("lost connection")) - XCTAssertEqual(closed.kind, .closed) - XCTAssertTrue(closed.message.lowercased().contains("connection is closed")) - XCTAssertEqual(Set([timeout.message, lost.message, closed.message]).count, 3) - } - - func testTimeoutIsReportedEvenWhenTheUserAlsoCancelled() throws { - let error = try hanaError(map(HanaBridgeFailure(kind: .timeout), cancellationRequested: true)) - XCTAssertEqual(error.kind, .timeout) - } - - func testParameterErrorsNameThePositionValueAndFormat() throws { - let cases: [(expected: String, fragment: String)] = [ - ("date", "YYYY-MM-DD"), - ("time", "HH:MM:SS"), - ("seconddate", "YYYY-MM-DD HH:MM:SS"), - ("timestamp", "YYYY-MM-DD HH:MM:SS.FFFFFFF"), - ("boolean", "TRUE or FALSE"), - ("integer", "whole number"), - ("decimal", "decimal number"), - ("double", "number"), - ("scale", "decimal places"), - ("hex", "well-known binary"), - ("unexpected", "could not be converted") - ] - for testCase in cases { - let failure = HanaBridgeFailure(kind: .parameter, message: "bad value", parameter: 3, expected: testCase.expected) - let error = try hanaError(map(failure)) - - XCTAssertEqual(error.kind, .parameter, testCase.expected) - XCTAssertTrue(error.message.contains("3"), testCase.expected) - XCTAssertTrue(error.message.contains("bad value"), testCase.expected) - XCTAssertTrue(error.message.contains(testCase.fragment), testCase.expected) - } - } - - func testOutputParameterNamesItsPosition() throws { - let error = try hanaError(map(HanaBridgeFailure(kind: .parameter, parameter: 2, expected: "output"))) - XCTAssertEqual(error.kind, .parameter) - XCTAssertTrue(error.message.contains("2")) - XCTAssertTrue(error.message.contains("OUT parameter")) - } - - func testTLSCodesBecomeHandshakeErrors() { - let untrusted = map(HanaBridgeFailure(kind: .tls, code: 1, message: "x509: unknown authority")) - let mismatch = map(HanaBridgeFailure(kind: .tls, code: 2, message: "x509: certificate is valid for a")) - let plaintext = map(HanaBridgeFailure(kind: .tls, code: 3, message: "tls: first record does not look like TLS")) - let key = map(HanaBridgeFailure(kind: .tls, code: 4, message: "tls: failed to parse private key")) - let unknown = map(HanaBridgeFailure(kind: .tls, code: 99, message: "?")) - - guard case .untrustedCertificate(let untrustedMessage) = untrusted as? SSLHandshakeError else { - return XCTFail("expected untrustedCertificate, got \(untrusted)") - } - XCTAssertEqual(untrustedMessage, "x509: unknown authority") - guard case .hostnameMismatch = mismatch as? SSLHandshakeError else { - return XCTFail("expected hostnameMismatch, got \(mismatch)") - } - guard case .serverRequiresPlaintext = plaintext as? SSLHandshakeError else { - return XCTFail("expected serverRequiresPlaintext, got \(plaintext)") - } - guard case .clientKeyInvalid = key as? SSLHandshakeError else { - return XCTFail("expected clientKeyInvalid, got \(key)") - } - guard case .unknown = unknown as? SSLHandshakeError else { - return XCTFail("expected unknown, got \(unknown)") - } - } - - func testConfigurationConnectAndInternalCarryTheBridgeDetail() throws { - let configuration = try hanaError(map(HanaBridgeFailure(kind: .configuration, message: "missing host"))) - let connect = try hanaError(map(HanaBridgeFailure(kind: .connect, message: "dial tcp: connection refused"))) - let internalFailure = try hanaError(map(HanaBridgeFailure(kind: .internalFailure, message: "panic: boom"))) - - XCTAssertEqual(configuration.kind, .configuration) - XCTAssertEqual(configuration.detail, "missing host") - XCTAssertEqual(connect.kind, .connect) - XCTAssertEqual(connect.detail, "dial tcp: connection refused") - XCTAssertEqual(internalFailure.kind, .internalFailure) - XCTAssertEqual(internalFailure.detail, "panic: boom") - XCTAssertNotEqual(connect.message, "dial tcp: connection refused") - } - - func testConnectTimeoutIsNotReportedAsAQueryTimeout() throws { - let queryTimeout = try hanaError(map(HanaBridgeFailure(kind: .timeout))) - let connectTimeout = try hanaError( - HanaFailureMapping.connectError(for: HanaBridgeFailure(kind: .timeout), cancellationRequested: false) - ) - - XCTAssertEqual(connectTimeout.kind, .connect) - XCTAssertTrue(connectTimeout.message.contains("30")) - XCTAssertNotEqual(connectTimeout.message, queryTimeout.message) - - let remaining = try hanaError(HanaFailureMapping.connectError( - for: HanaBridgeFailure(kind: .timeout), - cancellationRequested: false, - timeoutSeconds: 1.25 - )) - XCTAssertTrue(remaining.message.contains("2")) - } - - func testConnectMappingLeavesOtherKindsAlone() throws { - let failure = HanaBridgeFailure(kind: .connect, message: "dial tcp: connection refused") - let connect = try hanaError(HanaFailureMapping.connectError(for: failure, cancellationRequested: false)) - XCTAssertEqual(connect, try hanaError(map(failure))) - XCTAssertTrue( - HanaFailureMapping.connectError(for: HanaBridgeFailure(kind: .cancelled), cancellationRequested: true) - is CancellationError - ) - } - - func testEmptyBridgeDetailIsDropped() throws { - let error = try hanaError(map(HanaBridgeFailure(kind: .connect, message: " "))) - XCTAssertNil(error.detail) - } - - private func map(_ failure: HanaBridgeFailure, cancellationRequested: Bool = false) -> any Error { - HanaFailureMapping.error(for: failure, cancellationRequested: cancellationRequested) - } - - private func hanaError(_ error: any Error) throws -> HanaError { - try XCTUnwrap(error as? HanaError, "expected HanaError, got \(error)") - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaFakeBridge.swift b/Plugins/HanaDriverPluginTests/HanaFakeBridge.swift deleted file mode 100644 index 953dc1d380..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaFakeBridge.swift +++ /dev/null @@ -1,318 +0,0 @@ -import Foundation - -final class HanaFakeBridge: HanaNativeBridge, @unchecked Sendable { - struct Statement: Equatable { - let ticket: HanaOperationTicket - let sql: String - } - - static let connectResult = Data(#"{"serverVersion":"4.00.000.00.1","currentSchema":"APP","connectionId":200123}"#.utf8) - - private let lock = NSLock() - private var lastSession: UInt64 = 40 - private var openSessions: Set = [] - private var running: HanaOperationTicket? - private var stopped: Set = [] - private var openCount = 0 - private var connectLog: [HanaOperationTicket] = [] - private var statementLog: [Statement] = [] - private var pingLog: [HanaOperationTicket] = [] - private var cancelLog: [HanaOperationTicket] = [] - private var closeLog: [UInt64] = [] - private var statementHolds: [String: HanaHold] = [:] - private var openHold: HanaHold? - private var pingHold: HanaHold? - private var cancelHold: HanaHold? - private var responses: [String: Data] = [:] - - var opens: Int { lock.withLock { openCount } } - var connects: [HanaOperationTicket] { lock.withLock { connectLog } } - var statements: [Statement] { lock.withLock { statementLog } } - var pings: [HanaOperationTicket] { lock.withLock { pingLog } } - var cancels: [HanaOperationTicket] { lock.withLock { cancelLog } } - var closes: [UInt64] { lock.withLock { closeLog } } - - func hold(sql: String) -> HanaHold { - let hold = HanaHold() - lock.withLock { statementHolds[sql] = hold } - return hold - } - - func holdOpen() -> HanaHold { - let hold = HanaHold() - lock.withLock { openHold = hold } - return hold - } - - func holdPing() -> HanaHold { - let hold = HanaHold() - lock.withLock { pingHold = hold } - return hold - } - - func holdCancels() -> HanaHold { - let hold = HanaHold() - lock.withLock { cancelHold = hold } - return hold - } - - func respond(to sql: String, with envelope: Data) { - lock.withLock { responses[sql] = envelope } - } - - func open(configuration: Data, interruption: HanaOpenInterruption) throws -> UInt64 { - let hold = lock.withLock { () -> HanaHold? in - openCount += 1 - return openHold - } - if let hold, interruption.whenInterrupted({ hold.release() }) { - hold.arrive(interruption) - } - guard !interruption.isInterrupted else { throw HanaBridgeFailure(kind: .cancelled) } - return lock.withLock { - lastSession += 1 - openSessions.insert(lastSession) - return lastSession - } - } - - func connect(_ ticket: HanaOperationTicket) throws -> Data { - lock.withLock { connectLog.append(ticket) } - return try run(ticket, hold: nil) { Self.connectResult } - } - - func execute(_ ticket: HanaOperationTicket, request: Data) throws -> Data { - try statement(ticket, request: request) - } - - func explain(_ ticket: HanaOperationTicket, request: Data) throws -> Data { - try statement(ticket, request: request) - } - - func ping(_ ticket: HanaOperationTicket) throws { - let hold = lock.withLock { () -> HanaHold? in - pingLog.append(ticket) - return pingHold - } - _ = try run(ticket, hold: hold) { Data() } - } - - func cancel(_ ticket: HanaOperationTicket) { - let hold = lock.withLock { () -> HanaHold? in - cancelLog.append(ticket) - return cancelHold - } - hold?.arrive(ticket) - lock.withLock { - guard let running, running == ticket || (ticket.operation == 0 && ticket.session == running.session) else { - return - } - stopped.insert(running) - } - } - - func close(session: UInt64) { - lock.withLock { - closeLog.append(session) - openSessions.remove(session) - } - } - - private func statement(_ ticket: HanaOperationTicket, request: Data) throws -> Data { - let object = try JSONSerialization.jsonObject(with: request) as? [String: Any] - let sql = object?["sql"] as? String ?? "" - let (hold, response) = lock.withLock { () -> (HanaHold?, Data?) in - statementLog.append(Statement(ticket: ticket, sql: sql)) - return (statementHolds[sql], responses[sql]) - } - return try run(ticket, hold: hold) { response ?? HanaBridgeJSON.envelope() } - } - - private func run( - _ ticket: HanaOperationTicket, - hold: HanaHold?, - result: () -> Data - ) throws -> Data { - try lock.withLock { - guard openSessions.contains(ticket.session) else { throw HanaBridgeFailure.closed } - running = ticket - } - hold?.arrive(ticket) - let outcome = lock.withLock { () -> HanaBridgeFailure.Kind? in - if running == ticket { - running = nil - } - guard openSessions.contains(ticket.session) else { return .closed } - return stopped.contains(ticket) ? .cancelled : nil - } - if let outcome { - throw HanaBridgeFailure(kind: outcome) - } - return result() - } -} - -final class HanaHold: @unchecked Sendable { - private let lock = NSLock() - private let gate = DispatchSemaphore(value: 0) - private var arrivals: [Arrival] = [] - private var waiters: [CheckedContinuation] = [] - - func arrive(_ value: Arrival) { - let waiting = lock.withLock { () -> [CheckedContinuation] in - arrivals.append(value) - let waiting = waiters - waiters.removeAll() - return waiting - } - waiting.forEach { $0.resume(returning: value) } - _ = gate.wait(timeout: .now() + 10) - } - - func arrive(_ value: Arrival) async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { - self.arrive(value) - continuation.resume() - } - } - } - - func arrival() async -> Arrival { - await withCheckedContinuation { continuation in - let first = lock.withLock { () -> Arrival? in - guard let first = arrivals.first else { - waiters.append(continuation) - return nil - } - return first - } - guard let first else { return } - continuation.resume(returning: first) - } - } - - func release() { - gate.signal() - } -} - -final class HanaRecordingQueue: HanaOperationQueue, @unchecked Sendable { - private let queue = DispatchQueue(label: "com.TablePro.tests.hana.connection") - private let lock = NSLock() - private var submitted = 0 - private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] - - func submit(_ work: @escaping @Sendable () -> Void) { - queue.async(execute: work) - let ready = lock.withLock { () -> [CheckedContinuation] in - submitted += 1 - let ready = waiters.filter { $0.count <= submitted }.map(\.continuation) - waiters.removeAll { $0.count <= submitted } - return ready - } - ready.forEach { $0.resume() } - } - - func submissions(reaching count: Int) async { - await withCheckedContinuation { continuation in - let reached = lock.withLock { () -> Bool in - guard submitted >= count else { - waiters.append((count, continuation)) - return false - } - return true - } - guard reached else { return } - continuation.resume() - } - } -} - -final class HanaManualQueue: HanaOperationQueue, @unchecked Sendable { - private let lock = NSLock() - private var waiting: [@Sendable () -> Void] = [] - private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] - - var pendingCount: Int { lock.withLock { waiting.count } } - - func submit(_ work: @escaping @Sendable () -> Void) { - let ready = lock.withLock { () -> [CheckedContinuation] in - waiting.append(work) - let ready = waiters.filter { $0.count <= waiting.count }.map(\.continuation) - waiters.removeAll { $0.count <= waiting.count } - return ready - } - ready.forEach { $0.resume() } - } - - func pending(reaching count: Int) async { - await withCheckedContinuation { continuation in - let reached = lock.withLock { () -> Bool in - guard waiting.count >= count else { - waiters.append((count, continuation)) - return false - } - return true - } - guard reached else { return } - continuation.resume() - } - } - - func runNext() { - let next = lock.withLock { () -> (@Sendable () -> Void)? in - guard !waiting.isEmpty else { return nil } - return waiting.removeFirst() - } - next?() - } -} - -final class HanaLatch: @unchecked Sendable { - private let lock = NSLock() - private var isOpen = false - private var waiters: [CheckedContinuation] = [] - - func open() { - let waiting = lock.withLock { () -> [CheckedContinuation] in - isOpen = true - let waiting = waiters - waiters.removeAll() - return waiting - } - waiting.forEach { $0.resume() } - } - - func wait() async { - await withCheckedContinuation { continuation in - let opened = lock.withLock { () -> Bool in - guard isOpen else { - waiters.append(continuation) - return false - } - return true - } - guard opened else { return } - continuation.resume() - } - } -} - -enum HanaBridgeJSON { - static func envelope(columns: [String] = [], rows: [[String]] = [], sessionLost: Bool = false) -> Data { - let object: [String: Any] = [ - "columns": columns, - "columnTypeNames": Array(repeating: "NVARCHAR", count: columns.count), - "columnClassifications": Array(repeating: NSNull(), count: columns.count), - "rows": rows, - "rowsAffected": 0, - "hasResultSet": !columns.isEmpty, - "executionTime": 0.01, - "isTruncated": false, - "truncatedLobCount": 0, - "sessionLost": sessionLost - ] - return (try? JSONSerialization.data(withJSONObject: object)) ?? Data() - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaFakeSession.swift b/Plugins/HanaDriverPluginTests/HanaFakeSession.swift deleted file mode 100644 index 4bc2b7c16e..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaFakeSession.swift +++ /dev/null @@ -1,158 +0,0 @@ -import Foundation -import TableProPluginKit - -final class HanaFakeSession: HanaSession, @unchecked Sendable { - struct ExecutedStatement: Equatable { - let sql: String - let parameters: [HanaBridgeCell]? - let rowCap: Int - } - - private let lock = NSLock() - private var responses: [Result] = [] - private var connectOutcome: Result = .success( - HanaConnectResult(serverVersion: "4.00.000.00.1234567890", currentSchema: "DBADMIN", connectionId: 200_123) - ) - private var statementHook: (@Sendable () -> Void)? - private var statementHolds: [String: HanaHold] = [:] - private var cancelHold: HanaHold? - private var executedStatements: [ExecutedStatement] = [] - private var explainedStatements: [String] = [] - private var statementSlots: [HanaOperationSlot] = [] - private var cancelledSlots: [HanaOperationSlot] = [] - private var connectConfigurations: [HanaConnectConfiguration] = [] - private var disconnectCalls = 0 - private var appliedTimeout: Int? - private var lostConnection = false - - var hasLostConnection: Bool { lock.withLock { lostConnection } } - var executed: [ExecutedStatement] { lock.withLock { executedStatements } } - var explained: [String] { lock.withLock { explainedStatements } } - var slots: [HanaOperationSlot] { lock.withLock { statementSlots } } - var cancelled: [HanaOperationSlot] { lock.withLock { cancelledSlots } } - var connects: [HanaConnectConfiguration] { lock.withLock { connectConfigurations } } - var disconnectCount: Int { lock.withLock { disconnectCalls } } - var timeout: Int? { lock.withLock { appliedTimeout } } - - func respond(_ envelope: HanaResultEnvelope) { - lock.withLock { responses.append(.success(envelope)) } - } - - func fail(_ error: any Error) { - lock.withLock { responses.append(.failure(error)) } - } - - func connectWith(_ outcome: Result) { - lock.withLock { connectOutcome = outcome } - } - - func runDuringStatement(_ hook: @escaping @Sendable () -> Void) { - lock.withLock { statementHook = hook } - } - - func hold(sql: String) -> HanaHold { - let hold = HanaHold() - lock.withLock { statementHolds[sql] = hold } - return hold - } - - func holdCancels() -> HanaHold { - let hold = HanaHold() - lock.withLock { cancelHold = hold } - return hold - } - - func markConnectionLost() { - lock.withLock { lostConnection = true } - } - - func connect(_ configuration: HanaConnectConfiguration) async throws -> HanaConnectResult { - let outcome = lock.withLock { () -> Result in - connectConfigurations.append(configuration) - return connectOutcome - } - return try outcome.get() - } - - func disconnect() { - lock.withLock { disconnectCalls += 1 } - } - - func ping() async throws {} - - func execute( - sql: String, - parameters: [HanaBridgeCell]?, - rowCap: Int, - cancellation: HanaOperationSlot - ) async throws -> HanaResultEnvelope { - let hold = lock.withLock { () -> HanaHold? in - executedStatements.append(ExecutedStatement(sql: sql, parameters: parameters, rowCap: rowCap)) - statementSlots.append(cancellation) - return statementHolds[sql] - } - await hold?.arrive(cancellation) - return try nextResponse(for: cancellation) - } - - func explain(sql: String, cancellation: HanaOperationSlot) async throws -> HanaResultEnvelope { - let hold = lock.withLock { () -> HanaHold? in - explainedStatements.append(sql) - statementSlots.append(cancellation) - return statementHolds[sql] - } - await hold?.arrive(cancellation) - return try nextResponse(for: cancellation) - } - - func cancel(_ cancellation: HanaOperationSlot) { - let hold = lock.withLock { () -> HanaHold? in - cancelledSlots.append(cancellation) - return cancelHold - } - hold?.arrive(cancellation) - _ = cancellation.cancel() - } - - func applyQueryTimeout(seconds: Int) { - lock.withLock { appliedTimeout = seconds } - } - - private func nextResponse(for cancellation: HanaOperationSlot) throws -> HanaResultEnvelope { - let (hook, response) = lock.withLock { () -> ((@Sendable () -> Void)?, Result) in - let response = responses.isEmpty ? .success(HanaEnvelopes.empty) : responses.removeFirst() - return (statementHook, response) - } - hook?() - guard !cancellation.isCancelled else { throw HanaBridgeFailure(kind: .cancelled) } - return try response.get() - } -} - -enum HanaEnvelopes { - static let empty = HanaResultEnvelope( - columns: [], - columnTypeNames: [], - columnClassifications: [], - rows: [], - rowsAffected: 0, - hasResultSet: false, - executionTime: 0, - isTruncated: false, - truncatedLobCount: 0 - ) - - static func rows(_ columns: [String], _ rows: [[HanaBridgeCell]]) -> HanaResultEnvelope { - HanaResultEnvelope( - columns: columns, - columnTypeNames: Array(repeating: "NVARCHAR", count: columns.count), - columnClassifications: Array(repeating: nil, count: columns.count), - rows: rows, - rowsAffected: 0, - hasResultSet: true, - executionTime: 0.01, - isTruncated: false, - truncatedLobCount: 0 - ) - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaHelperBridgeTests.swift b/Plugins/HanaDriverPluginTests/HanaHelperBridgeTests.swift deleted file mode 100644 index 4f5d9dcbef..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaHelperBridgeTests.swift +++ /dev/null @@ -1,499 +0,0 @@ -import Darwin -import Foundation -import TableProPluginKit -import XCTest - -final class HanaHelperBridgeTests: XCTestCase { - private static let missingHelper = """ - tablepro-hana-helper is missing from the test bundle's Contents/MacOS. \ - Run scripts/build-hana.sh, then build HanaDriverTests again. - """ - private static let callDeadline: TimeInterval = 30 - private static let foreignTeam = "ABCDE12345" - private static let developmentHost = HanaHelperTrust(signingTeam: nil, admitsUnsignedHost: true) - - private var bundledHelper: URL? - private var bridges: [HanaHelperBridge] = [] - private var servers: [HanaSilentServer] = [] - private var folder: HanaStandInFolder? - - override func setUpWithError() throws { - let helper = Bundle(for: HanaPluginDriver.self).url(forAuxiliaryExecutable: HanaHelperTrust.executableName) - bundledHelper = try XCTUnwrap(helper, Self.missingHelper) - folder = try HanaStandInFolder(named: "HanaHelperBridgeTests") - } - - override func tearDown() { - bridges.forEach { $0.shutdown() } - bridges.removeAll() - servers.forEach { $0.stop() } - servers.removeAll() - folder?.remove() - super.tearDown() - } - - func testOpeningTheFirstSessionStartsAHelperThatPassesTheHandshake() throws { - let bridge = makeBridge() - - let session = try open(on: bridge, port: HanaLoopbackSocket.closedPort()) - - XCTAssertGreaterThan(session, 0) - let processIdentifier = try XCTUnwrap(bridge.helperProcessIdentifier(serving: session)) - XCTAssertTrue(HanaProcessProbe.isRunning(processIdentifier)) - } - - func testConfiguredConnectTimeoutReachesTheHandshakeAndOpenPhases() throws { - let now: ContinuousClock.Instant = .now - let oneSecond = HanaHelperConnectDeadline( - configuration: try configuration(port: 30_015, connectTimeoutSeconds: 1), - now: now - ) - let sixtySeconds = HanaHelperConnectDeadline( - configuration: try configuration(port: 30_015, connectTimeoutSeconds: 60), - now: now - ) - - XCTAssertEqual(oneSecond.handshakeTimeout(at: now), 1, accuracy: 1e-6) - XCTAssertEqual(oneSecond.openTimeout(at: now), 1, accuracy: 1e-6) - XCTAssertEqual(sixtySeconds.handshakeTimeout(at: now), 60, accuracy: 1e-6) - XCTAssertEqual(sixtySeconds.openTimeout(at: now), 60, accuracy: 1e-6) - } - - func testHandshakeAndOpenConsumeOneConnectDeadlineWithoutResettingIt() throws { - let started: ContinuousClock.Instant = .now - let deadline = HanaHelperConnectDeadline( - configuration: try configuration(port: 30_015, connectTimeoutSeconds: 60), - now: started - ) - - let handshakeTimeout = deadline.handshakeTimeout(at: started.advanced(by: .seconds(7))) - let openPhase = deadline.openPhase( - configuration: try configuration(port: 30_015, connectTimeoutSeconds: 60), - at: started.advanced(by: .seconds(19)) - ) - let openConfiguration = try XCTUnwrap( - JSONSerialization.jsonObject(with: openPhase.configuration) as? [String: Any] - ) - - XCTAssertEqual(handshakeTimeout, 53, accuracy: 1e-6) - XCTAssertEqual(openPhase.timeout, 41, accuracy: 1e-6) - XCTAssertEqual( - try XCTUnwrap(openConfiguration["connectTimeoutSeconds"] as? Double), - 41, - accuracy: 1e-6 - ) - XCTAssertLessThan(openPhase.timeout, handshakeTimeout) - } - - func testTheBundledHelperAnnouncesTheBridgesForcedSeverGrace() throws { - let launched = try HanaHelperProcess.launch(executable: XCTUnwrap(bundledHelper), handshakeDeadline: Self.callDeadline) - defer { launched.process.shutdown() } - - XCTAssertEqual(launched.greeting, HanaHelperGreeting(forcedSeverGrace: 30)) - XCTAssertEqual(HanaHelperBridge.cancelDeadline(forcedSeverGrace: launched.greeting.forcedSeverGrace), 40) - } - - func testEverySessionHasItsOwnHelperAndClosingOneLeavesTheOtherServing() throws { - let bridge = makeBridge() - let port = try HanaLoopbackSocket.closedPort() - let first = try open(on: bridge, port: port) - let second = try open(on: bridge, port: port) - let firstHelper = try XCTUnwrap(bridge.helperProcessIdentifier(serving: first)) - let secondHelper = try XCTUnwrap(bridge.helperProcessIdentifier(serving: second)) - - bridge.close(session: first) - - XCTAssertNotEqual(first, second) - XCTAssertNotEqual(firstHelper, secondHelper) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: firstHelper, within: Self.callDeadline)) - XCTAssertTrue(HanaProcessProbe.isRunning(secondHelper)) - XCTAssertThrowsError(try bridge.connect(HanaOperationTicket(session: second, operation: 1))) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .connect, "got \(error)") - } - } - - func testAnOpenTheHelperRefusesShutsThatHelperDown() throws { - let recordedIdentifier = try standInFile("pid") - let standIn = try standIn(""" - echo $$ > '\(recordedIdentifier.path)' - \(HanaStandInFrame.greeting()) - /usr/bin/head -c 13 > /dev/null - \(HanaStandInFrame.frame(id: 1, code: 1, body: #"{"kind":"configuration","message":"host"}"#)) - \(HanaStandInFrame.absorbInput()) - """) - let bridge = makeBridge(trust: Self.developmentHost, locateExecutable: { standIn }) - - XCTAssertThrowsError(try open(on: bridge, port: 30_015)) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .configuration, "got \(error)") - } - - let processIdentifier = try XCTUnwrap(HanaFileProbe.processIdentifier(recordedAt: recordedIdentifier, within: 1)) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: processIdentifier, within: Self.callDeadline)) - } - - func testOpeningWithAnInvalidConfigurationIsAConfigurationFailure() throws { - let bridge = makeBridge() - - XCTAssertThrowsError(try bridge.open(configuration: configuration(host: "", port: 30_015), interruption: HanaOpenInterruption())) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .configuration, "got \(error)") - XCTAssertEqual(failure?.message, "host") - } - } - - func testConnectingToAClosedPortIsAConnectFailure() throws { - let bridge = makeBridge() - let session = try open(on: bridge, port: HanaLoopbackSocket.closedPort()) - - XCTAssertThrowsError(try bridge.connect(HanaOperationTicket(session: session, operation: 1))) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .connect, "got \(error)") - } - } - - func testCancellingAConnectTheServerNeverAnswersReportsCancelled() throws { - let bridge = makeBridge() - let (ticket, call) = try blockedConnect(on: bridge) - - bridge.cancel(ticket) - - XCTAssertEqual(try failure(of: call).kind, .cancelled) - let processIdentifier = try XCTUnwrap(bridge.helperProcessIdentifier(serving: ticket.session)) - XCTAssertTrue(HanaProcessProbe.isRunning(processIdentifier)) - } - - func testKillingTheHelperFailsThePendingCallAsConnectionLostAndLaterCallsFailCleanly() throws { - let bridge = makeBridge() - let (ticket, call) = try blockedConnect(on: bridge) - let processIdentifier = try XCTUnwrap(bridge.helperProcessIdentifier(serving: ticket.session)) - - kill(processIdentifier, SIGKILL) - for _ in 0..<200 { - bridge.cancel(ticket) - } - - let lost = try failure(of: call) - XCTAssertEqual(lost.kind, .connectionLost) - XCTAssertTrue(lost.message.contains("signal \(SIGKILL)"), lost.message) - XCTAssertThrowsError(try bridge.ping(HanaOperationTicket(session: ticket.session, operation: 2))) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .connectionLost, "got \(error)") - } - XCTAssertThrowsError(try bridge.connect(HanaOperationTicket(session: ticket.session, operation: 3))) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .connectionLost, "got \(error)") - } - bridge.close(session: ticket.session) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: processIdentifier, within: Self.callDeadline)) - } - - func testOpeningAfterTheHelperDiedStartsANewHelper() throws { - let bridge = makeBridge() - let (ticket, call) = try blockedConnect(on: bridge) - let firstHelper = try XCTUnwrap(bridge.helperProcessIdentifier(serving: ticket.session)) - kill(firstHelper, SIGKILL) - XCTAssertEqual(try failure(of: call).kind, .connectionLost) - - let session = try open(on: bridge, port: HanaLoopbackSocket.closedPort()) - - let secondHelper = try XCTUnwrap(bridge.helperProcessIdentifier(serving: session)) - XCTAssertNotEqual(secondHelper, firstHelper) - XCTAssertThrowsError(try bridge.connect(HanaOperationTicket(session: session, operation: 1))) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .connect, "got \(error)") - } - } - - func testACancelledCallTheHelperNeverAnswersEndsTheHelperAtTheDeadline() throws { - let bridge = makeBridge(cancelDeadline: 1) - let (ticket, call) = try blockedConnect(on: bridge) - let processIdentifier = try XCTUnwrap(bridge.helperProcessIdentifier(serving: ticket.session)) - kill(processIdentifier, SIGSTOP) - - bridge.cancel(ticket) - - let lost = try failure(of: call) - XCTAssertEqual(lost.kind, .connectionLost) - XCTAssertTrue(lost.message.contains("cancelled operation"), lost.message) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: processIdentifier, within: Self.callDeadline)) - } - - func testTheCancelDeadlineIsTheForcedSeverGraceTheHelperAnnouncedPlusTenSeconds() throws { - let standIn = try standIn(""" - \(HanaStandInFrame.greeting(#"{"protocol":1,"forcedSeverGraceSeconds":1}"#)) - /usr/bin/head -c 13 > /dev/null - \(HanaStandInFrame.frame(id: 1, code: 0, body: #"{"session":7}"#)) - \(HanaStandInFrame.absorbInput()) - """) - let bridge = makeBridge(trust: Self.developmentHost, locateExecutable: { standIn }) - let session = try open(on: bridge, port: 30_015) - let ticket = HanaOperationTicket(session: session, operation: 1) - let call = HanaBlockingCall { try bridge.connect(ticket) } - let cancelled = Date() - - bridge.cancel(ticket) - - let lost = try failure(of: call) - let elapsed = Date().timeIntervalSince(cancelled) - XCTAssertEqual(lost.kind, .connectionLost) - XCTAssertTrue(lost.message.contains("still running 11 seconds later"), lost.message) - XCTAssertGreaterThanOrEqual(elapsed, 10) - XCTAssertLessThan(elapsed, 25) - } - - func testClosingASessionShutsItsHelperDownAndTheNextOpenStartsANewOne() throws { - let bridge = makeBridge() - let port = try HanaLoopbackSocket.closedPort() - let session = try open(on: bridge, port: port) - let firstHelper = try XCTUnwrap(bridge.helperProcessIdentifier(serving: session)) - - bridge.close(session: session) - - XCTAssertNil(bridge.helperProcessIdentifier(serving: session)) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: firstHelper, within: Self.callDeadline)) - XCTAssertThrowsError(try bridge.connect(HanaOperationTicket(session: session, operation: 1))) { error in - XCTAssertEqual(error as? HanaBridgeFailure, .closed) - } - let next = try open(on: bridge, port: port) - let secondHelper = try XCTUnwrap(bridge.helperProcessIdentifier(serving: next)) - XCTAssertNotEqual(secondHelper, firstHelper) - XCTAssertTrue(HanaProcessProbe.isRunning(secondHelper)) - } - - func testShutdownMakesTheHelperExit() throws { - let bridge = makeBridge() - let session = try open(on: bridge, port: HanaLoopbackSocket.closedPort()) - let processIdentifier = try XCTUnwrap(bridge.helperProcessIdentifier(serving: session)) - - bridge.shutdown() - - XCTAssertNil(bridge.helperProcessIdentifier(serving: session)) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: processIdentifier, within: Self.callDeadline)) - XCTAssertThrowsError(try bridge.connect(HanaOperationTicket(session: session, operation: 1))) { error in - XCTAssertEqual(error as? HanaBridgeFailure, .closed) - } - } - - func testTheBundledHelperIsStoppedWhenItFailsTheRunningCodeCheck() throws { - let simulated = HanaBridgeFailure(kind: .internalFailure, message: "simulated signature failure") - let checks = HanaRunningCodeChecks(failingWith: simulated) - let helper = try XCTUnwrap(bundledHelper) - let bridge = makeBridge( - trust: HanaHelperTrust(signingTeam: Self.foreignTeam, admitsUnsignedHost: false, checkRunningCode: checks.record), - locateExecutable: { helper } - ) - - XCTAssertThrowsError(try open(on: bridge, port: HanaLoopbackSocket.closedPort())) { error in - XCTAssertEqual(error as? HanaBridgeFailure, simulated) - } - - let checked = try XCTUnwrap(checks.requests.first) - XCTAssertEqual(checked.requirement, HanaHelperTrust.requirement(forTeam: Self.foreignTeam)) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: checked.processIdentifier, within: Self.callDeadline)) - } - - func testAHelperSignedByAnotherTeamIsRefusedByTheRunningCodeCheck() throws { - let checks = HanaRunningCodeChecks() - let helper = try XCTUnwrap(bundledHelper) - let trust = HanaHelperTrust(signingTeam: Self.foreignTeam, admitsUnsignedHost: false) { processIdentifier, requirement in - try checks.record(processIdentifier, requirement) - try HanaHelperTrust.checkRunningCode(processIdentifier: processIdentifier, requirement: requirement) - } - let bridge = makeBridge(trust: trust, locateExecutable: { helper }) - - XCTAssertThrowsError(try open(on: bridge, port: HanaLoopbackSocket.closedPort())) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .internalFailure, "got \(error)") - XCTAssertEqual(failure?.message.contains("does not satisfy"), true, "\(error)") - } - - let checked = try XCTUnwrap(checks.requests.first) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: checked.processIdentifier, within: Self.callDeadline)) - } - - func testAStandInThatFailsTheRunningCodeCheckIsSentNoBytes() throws { - let received = try standInFile("received") - let recordedIdentifier = try standInFile("pid") - let standIn = try standIn(""" - echo $$ > '\(recordedIdentifier.path)' - \(HanaStandInFrame.greeting()) - \(HanaStandInFrame.absorbInput(into: received)) - """) - let checks = HanaRunningCodeChecks(failingWith: HanaBridgeFailure(kind: .internalFailure, message: "refused")) - let bridge = makeBridge( - trust: HanaHelperTrust(signingTeam: Self.foreignTeam, admitsUnsignedHost: false, checkRunningCode: checks.record), - locateExecutable: { standIn } - ) - - XCTAssertThrowsError(try open(on: bridge, port: 30_015)) - - let processIdentifier = try XCTUnwrap(HanaFileProbe.processIdentifier(recordedAt: recordedIdentifier, within: Self.callDeadline)) - XCTAssertEqual(checks.requests.map(\.processIdentifier), [processIdentifier]) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: processIdentifier, within: Self.callDeadline)) - XCTAssertEqual(HanaFileProbe.byteCount(at: received), 0) - } - - func testAnOpenTheHelperNeverAnswersFailsAtTheDeadlineAndFreesTheConnectionQueue() async throws { - let received = try standInFile("received") - let recordedIdentifier = try standInFile("pid") - let standIn = try standIn(""" - echo $$ > '\(recordedIdentifier.path)' - \(HanaStandInFrame.greeting()) - \(HanaStandInFrame.absorbInput(into: received)) - """) - let bridge = makeBridge(trust: Self.developmentHost, openDeadline: 1, locateExecutable: { standIn }) - let connection = HanaConnection(bridge: bridge) - - let started = Date() - await assertOpenMissesItsDeadline(on: connection) - - XCTAssertLessThan(Date().timeIntervalSince(started), 10) - XCTAssertGreaterThan(HanaFileProbe.byteCount(at: received), HanaHelperFrameHeader.byteCount) - let first = try XCTUnwrap(HanaFileProbe.processIdentifier(recordedAt: recordedIdentifier, within: 1)) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: first, within: Self.callDeadline)) - try FileManager.default.removeItem(at: recordedIdentifier) - - await assertOpenMissesItsDeadline(on: connection) - - let second = try XCTUnwrap(HanaFileProbe.processIdentifier(recordedAt: recordedIdentifier, within: 1)) - XCTAssertNotEqual(second, first) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: second, within: Self.callDeadline)) - } - - func testCancellingAConnectWhileTheHelperHoldsItsOpenThrowsCancellationAndStopsTheHelper() async throws { - let received = try standInFile("received") - let recordedIdentifier = try standInFile("pid") - let standIn = try standIn(""" - echo $$ > '\(recordedIdentifier.path)' - \(HanaStandInFrame.greeting()) - \(HanaStandInFrame.absorbInput(into: received)) - """) - let bridge = makeBridge(trust: Self.developmentHost, openDeadline: Self.callDeadline, locateExecutable: { standIn }) - let driver = HanaPluginDriver( - config: DriverConnectionConfig( - host: "127.0.0.1", - port: 30_015, - username: "SYSTEM", - password: "test-only", - database: "", - ssl: SSLConfiguration(mode: .disabled) - ), - session: HanaConnection(bridge: bridge) - ) - let connect = Task { try await driver.connect() } - XCTAssertTrue( - HanaFileProbe.waitForBytes(at: received, reaching: HanaHelperFrameHeader.byteCount + 1, within: Self.callDeadline), - "the stand-in never received the open request" - ) - let processIdentifier = try XCTUnwrap(HanaFileProbe.processIdentifier(recordedAt: recordedIdentifier, within: 1)) - let cancelled = Date() - - connect.cancel() - - do { - try await connect.value - XCTFail("a cancelled connect should not succeed") - } catch { - XCTAssertTrue(error is CancellationError, "got \(error)") - } - XCTAssertLessThan(Date().timeIntervalSince(cancelled), 5) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: processIdentifier, within: Self.callDeadline)) - } - - private static func connectConfiguration( - host: String = "127.0.0.1", - port: Int = 30_015, - connectTimeoutSeconds: TimeInterval = 120 - ) -> HanaConnectConfiguration { - HanaConnectConfiguration( - host: host, - port: port, - username: "SYSTEM", - password: "test-only", - schema: "", - tlsMode: .disabled, - tlsServerName: "", - caCertificatePath: "", - clientCertificatePath: "", - clientKeyPath: "", - connectTimeoutSeconds: connectTimeoutSeconds - ) - } - - private func makeBridge( - trust: HanaHelperTrust = .host, - cancelDeadline: TimeInterval? = nil, - openDeadline: TimeInterval? = nil, - locateExecutable: (@Sendable () throws -> URL)? = nil - ) -> HanaHelperBridge { - let bridge = HanaHelperBridge( - trust: trust, - cancelDeadline: cancelDeadline, - openDeadline: openDeadline, - locateExecutable: locateExecutable - ) - bridges.append(bridge) - return bridge - } - - private func assertOpenMissesItsDeadline( - on connection: HanaConnection, - file: StaticString = #filePath, - line: UInt = #line - ) async { - do { - _ = try await connection.connect(Self.connectConfiguration()) - XCTFail("an open the helper never answers should fail", file: file, line: line) - } catch { - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .internalFailure, "got \(error)", file: file, line: line) - XCTAssertEqual( - failure?.message.contains("did not answer the connection request within 1 seconds"), - true, - "\(error)", - file: file, - line: line - ) - } - } - - private func standIn(_ body: String) throws -> URL { - try XCTUnwrap(folder).script(body) - } - - private func standInFile(_ name: String) throws -> URL { - try XCTUnwrap(folder).file(named: name) - } - - private func open(on bridge: HanaHelperBridge, port: Int) throws -> UInt64 { - try bridge.open(configuration: configuration(port: port), interruption: HanaOpenInterruption()) - } - - private func blockedConnect(on bridge: HanaHelperBridge) throws -> (HanaOperationTicket, HanaBlockingCall) { - let server = try HanaSilentServer() - servers.append(server) - let session = try open(on: bridge, port: server.port) - let ticket = HanaOperationTicket(session: session, operation: 1) - let call = HanaBlockingCall { try bridge.connect(ticket) } - XCTAssertTrue(server.awaitConnection(within: Self.callDeadline), "the helper never dialled the test server") - XCTAssertFalse(call.hasFinished, "the connect should still be waiting for the server") - return (ticket, call) - } - - private func failure(of call: HanaBlockingCall) throws -> HanaBridgeFailure { - let outcome = try XCTUnwrap(call.outcome(within: Self.callDeadline), "the call never finished") - guard case .failure(let error) = outcome else { - XCTFail("the call should fail") - return HanaBridgeFailure(kind: .internalFailure) - } - return try XCTUnwrap(error as? HanaBridgeFailure, "got \(error)") - } - - private func configuration( - host: String = "127.0.0.1", - port: Int, - connectTimeoutSeconds: TimeInterval = 120 - ) throws -> Data { - try JSONEncoder().encode(Self.connectConfiguration( - host: host, - port: port, - connectTimeoutSeconds: connectTimeoutSeconds - )) - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaHelperFrameTests.swift b/Plugins/HanaDriverPluginTests/HanaHelperFrameTests.swift deleted file mode 100644 index 11ee3b880f..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaHelperFrameTests.swift +++ /dev/null @@ -1,268 +0,0 @@ -import Foundation -import XCTest - -final class HanaHelperFrameTests: XCTestCase { - func testHeaderIsLengthThenIdThenCodeInBigEndian() { - let header = HanaHelperFrameHeader(bodyLength: 7, id: 0x0102_0304_0506_0708, code: 3) - - XCTAssertEqual([UInt8](header.encoded), [0, 0, 0, 7, 1, 2, 3, 4, 5, 6, 7, 8, 3]) - } - - func testHeaderRoundTripsAtTheLimitsOfEveryField() throws { - let headers = [ - HanaHelperFrameHeader(bodyLength: 0, id: 0, code: 0), - HanaHelperFrameHeader(bodyLength: UInt32.max, id: UInt64.max, code: UInt8.max), - HanaHelperFrameHeader(bodyLength: 1 << 30, id: 42, code: 1) - ] - - for header in headers { - XCTAssertEqual(try HanaHelperFrameHeader(decoding: header.encoded), header) - } - } - - func testRequestEncodesItsBodyAfterTheHeader() throws { - let body = Data(#"{"host":"db.example.com"}"#.utf8) - let request = try HanaHelperRequest(id: 9, opcode: .open, body: body) - let encoded = request.encoded - - let header = try HanaHelperFrameHeader(decoding: encoded.prefix(HanaHelperFrameHeader.byteCount)) - XCTAssertEqual(header, HanaHelperFrameHeader(bodyLength: UInt32(body.count), id: 9, code: 1)) - XCTAssertEqual(encoded.dropFirst(HanaHelperFrameHeader.byteCount), body) - XCTAssertEqual(encoded.count, HanaHelperFrameHeader.byteCount + body.count) - } - - func testEmptyRequestIsTheHeaderAlone() throws { - let request = try HanaHelperRequest(id: 3, opcode: .cancel, body: Data()) - - XCTAssertEqual(request.encoded.count, HanaHelperFrameHeader.byteCount) - XCTAssertEqual(request.header.code, HanaHelperOpcode.cancel.rawValue) - } - - func testOpcodesMatchTheHelperProtocol() { - let opcodes: [HanaHelperOpcode] = [.open, .connect, .execute, .explain, .ping, .cancel, .close] - - XCTAssertEqual(opcodes.map(\.rawValue), [1, 2, 3, 4, 5, 6, 7]) - } - - func testRequestOverOneGibibyteIsRefusedBeforeSending() { - let oversized = Data(count: HanaHelperRequest.maximumBodyLength + 1) - - XCTAssertThrowsError(try HanaHelperRequest(id: 1, opcode: .execute, body: oversized)) { error in - XCTAssertEqual( - error as? HanaHelperFrameError, - .requestTooLarge(byteCount: HanaHelperRequest.maximumBodyLength + 1) - ) - } - } - - func testRequestOfExactlyOneGibibyteIsAccepted() throws { - let largest = Data(count: HanaHelperRequest.maximumBodyLength) - - let request = try HanaHelperRequest(id: 1, opcode: .execute, body: largest) - - XCTAssertEqual(Int(request.header.bodyLength), HanaHelperRequest.maximumBodyLength) - } - - func testHeaderShorterOrLongerThanThirteenBytesIsMalformed() { - for count in [0, 1, 12, 14] { - XCTAssertThrowsError(try HanaHelperFrameHeader(decoding: Data(count: count))) { error in - XCTAssertEqual(error as? HanaHelperFrameError, .truncatedHeader(receivedByteCount: count)) - } - } - } - - func testSuccessReplyCarriesItsBody() throws { - let body = Data(#"{"session":7}"#.utf8) - - XCTAssertEqual(try HanaHelperReply(status: 0, body: body), .success(body)) - } - - func testErrorReplyDecodesTheBridgeFailure() throws { - let body = Data(#"{"kind":"connect","code":0,"position":0,"message":"refused","parameter":0,"expected":""}"#.utf8) - - let reply = try HanaHelperReply(status: 1, body: body) - - XCTAssertEqual(reply, .failure(HanaBridgeFailure(kind: .connect, message: "refused"))) - } - - func testErrorReplyThatIsNotJSONBecomesAnInternalFailure() throws { - let reply = try HanaHelperReply(status: 1, body: Data("not json".utf8)) - - XCTAssertEqual(reply, .failure(HanaBridgeFailure(kind: .internalFailure, message: "not json"))) - } - - func testUnknownReplyStatusIsMalformed() { - XCTAssertThrowsError(try HanaHelperReply(status: 2, body: Data())) { error in - XCTAssertEqual(error as? HanaHelperFrameError, .unknownStatus(2)) - } - } - - func testHandshakeAcceptsProtocolOneAndReadsTheForcedSeverGrace() throws { - let body = Data(#"{"protocol":1,"forcedSeverGraceSeconds":30}"#.utf8) - let header = HanaHelperFrameHeader(bodyLength: UInt32(body.count), id: 0, code: 0) - - XCTAssertEqual(try HanaHelperHandshake.greeting(from: header, body: body), HanaHelperGreeting(forcedSeverGrace: 30)) - } - - func testHandshakeNamesAnIncompatibleProtocol() { - let body = Data(#"{"protocol":2,"forcedSeverGraceSeconds":30}"#.utf8) - let header = HanaHelperFrameHeader(bodyLength: UInt32(body.count), id: 0, code: 0) - - XCTAssertThrowsError(try HanaHelperHandshake.greeting(from: header, body: body)) { error in - XCTAssertEqual(error as? HanaHelperFrameError, .incompatibleProtocol(2)) - XCTAssertEqual((error as? HanaHelperFrameError)?.failure.message, "incompatible helper protocol 2") - XCTAssertEqual((error as? HanaHelperFrameError)?.failure.kind, .internalFailure) - } - } - - func testHandshakeWithoutAUsableForcedSeverGraceIsAnIncompatibleHelper() { - let bodies = [ - #"{"protocol":1}"#, - #"{"protocol":1,"forcedSeverGraceSeconds":null}"#, - #"{"protocol":1,"forcedSeverGraceSeconds":0}"#, - #"{"protocol":1,"forcedSeverGraceSeconds":-30}"#, - #"{"protocol":1,"forcedSeverGraceSeconds":3601}"# - ] - - for body in bodies { - let header = HanaHelperFrameHeader(bodyLength: UInt32(body.utf8.count), id: 0, code: 0) - XCTAssertThrowsError(try HanaHelperHandshake.greeting(from: header, body: Data(body.utf8))) { error in - XCTAssertEqual(error as? HanaHelperFrameError, .unusableForcedSeverGrace, body) - XCTAssertEqual((error as? HanaHelperFrameError)?.failure.kind, .internalFailure, body) - XCTAssertEqual((error as? HanaHelperFrameError)?.message.hasPrefix("incompatible helper"), true, body) - } - } - } - - func testHandshakeRefusesAnythingButAGreetingOnFrameZero() { - let greeting = #"{"protocol":1,"forcedSeverGraceSeconds":30}"# - let cases: [(HanaHelperFrameHeader, String)] = [ - (HanaHelperFrameHeader(bodyLength: 43, id: 1, code: 0), greeting), - (HanaHelperFrameHeader(bodyLength: 43, id: 0, code: 1), greeting), - (HanaHelperFrameHeader(bodyLength: 8, id: 0, code: 0), "garbage!"), - (HanaHelperFrameHeader(bodyLength: 2, id: 0, code: 0), "{}"), - (HanaHelperFrameHeader(bodyLength: 45, id: 0, code: 0), #"{"protocol":1,"forcedSeverGraceSeconds":"30"}"#) - ] - - for (header, body) in cases { - XCTAssertThrowsError(try HanaHelperHandshake.greeting(from: header, body: Data(body.utf8))) { error in - XCTAssertEqual(error as? HanaHelperFrameError, .unexpectedHandshake, "\(header) \(body)") - } - } - } - - func testFramesFromTheHelperAreCappedBelowTwoGibibytesAndTheHandshakeAtFourKibibytes() { - XCTAssertEqual(HanaHelperReply.maximumBodyLength, 2_147_483_647) - XCTAssertEqual(HanaHelperHandshake.maximumBodyLength, 4_096) - XCTAssertEqual( - HanaHelperFrameError.frameTooLarge(byteCount: 4_294_967_295, limit: 2_147_483_647).failure, - HanaBridgeFailure( - kind: .internalFailure, - message: "the helper announced a 4294967295-byte frame, over the 2147483647-byte limit" - ) - ) - } - - func testTheCancelDeadlineIsTheHelpersForcedSeverGracePlusTenSeconds() { - XCTAssertEqual(HanaHelperBridge.cancelDeadline(forcedSeverGrace: 30), 40) - XCTAssertEqual(HanaHelperBridge.cancelDeadline(forcedSeverGrace: 1), 11) - } - - func testOperationBodyNamesTheSessionAndOperation() throws { - let body = HanaHelperMessage.operation(HanaOperationTicket(session: 4, operation: UInt64.max)) - - let decoded = try JSONDecoder().decode(OperationBody.self, from: body) - XCTAssertEqual(decoded, OperationBody(session: 4, operation: UInt64.max)) - } - - func testStatementBodyEmbedsTheRequestUnchanged() throws { - let request = try JSONEncoder().encode( - HanaExecuteRequest(sql: "SELECT \"A\" FROM T WHERE X = ?", parameters: [.text("é\n")], rowCap: 10, timeoutSeconds: 5) - ) - - let body = HanaHelperMessage.statement(HanaOperationTicket(session: 2, operation: 3), request: request) - - let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - XCTAssertEqual(object["session"] as? Int, 2) - XCTAssertEqual(object["operation"] as? Int, 3) - let embedded = try JSONSerialization.data(withJSONObject: XCTUnwrap(object["request"]), options: [.sortedKeys]) - let original = try JSONSerialization.data( - withJSONObject: JSONSerialization.jsonObject(with: request), - options: [.sortedKeys] - ) - XCTAssertEqual(embedded, original) - } - - func testSessionBodyNamesOnlyTheSession() throws { - let object = try XCTUnwrap(JSONSerialization.jsonObject(with: HanaHelperMessage.session(11)) as? [String: Any]) - - XCTAssertEqual(object.count, 1) - XCTAssertEqual(object["session"] as? Int, 11) - } - - func testOpenedSessionIsReadFromTheReply() throws { - XCTAssertEqual(try HanaHelperMessage.openedSession(from: Data(#"{"session":18446744073709551615}"#.utf8)), UInt64.max) - } - - func testOpenReplyWithoutAUsableSessionIsAnInternalFailure() { - for reply in [#"{"session":0}"#, "{}", "[]", ""] { - XCTAssertThrowsError(try HanaHelperMessage.openedSession(from: Data(reply.utf8))) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .internalFailure, reply) - } - } - } - - func testCancelWatchCoversOnlyItsOwnOperation() { - let watch = HanaHelperCancelWatch(ticket: HanaOperationTicket(session: 1, operation: 5), issuedThrough: 10) - - XCTAssertTrue(watch.covers(callID: 4, ticket: HanaOperationTicket(session: 1, operation: 5))) - XCTAssertTrue(watch.covers(callID: 12, ticket: HanaOperationTicket(session: 1, operation: 5))) - XCTAssertFalse(watch.covers(callID: 4, ticket: HanaOperationTicket(session: 1, operation: 6))) - XCTAssertFalse(watch.covers(callID: 4, ticket: HanaOperationTicket(session: 2, operation: 5))) - XCTAssertFalse(watch.covers(callID: 4, ticket: nil)) - } - - func testCancelWatchForWhateverRunsCoversOnlyCallsIssuedBeforeTheCancel() { - let watch = HanaHelperCancelWatch(ticket: HanaOperationTicket(session: 1, operation: 0), issuedThrough: 10) - - XCTAssertTrue(watch.covers(callID: 9, ticket: HanaOperationTicket(session: 1, operation: 7))) - XCTAssertFalse(watch.covers(callID: 11, ticket: HanaOperationTicket(session: 1, operation: 8))) - XCTAssertFalse(watch.covers(callID: 9, ticket: HanaOperationTicket(session: 2, operation: 7))) - } - - func testOutputTailKeepsOnlyTheLastBytes() { - var tail = HanaHelperOutputTail(limit: 8) - - tail.append(Data("panic: ".utf8)) - tail.append(Data("invalid type code".utf8)) - - XCTAssertEqual(tail.bytes, Data("ype code".utf8)) - XCTAssertEqual(tail.text, "ype code") - } - - func testOutputTailDefaultsToFourKibibytes() { - var tail = HanaHelperOutputTail() - - tail.append(Data(repeating: UInt8(ascii: "a"), count: 5_000)) - tail.append(Data("\ngoroutine 1 [running]:\n".utf8)) - - XCTAssertEqual(tail.bytes.count, 4_096) - XCTAssertTrue(tail.text.hasSuffix("goroutine 1 [running]:")) - } - - func testExitReportNamesTheStatusTheCauseAndTheTail() { - let exited = HanaHelperExitReport(termination: .exited(status: 2), stopCause: nil, errorTail: "panic: boom") - let killed = HanaHelperExitReport(termination: .signalled(9), stopCause: "TablePro stopped it.", errorTail: "") - let unknown = HanaHelperExitReport(termination: .unknown, stopCause: nil, errorTail: "") - - XCTAssertEqual(exited.message, "The SAP HANA helper exited with status 2.\npanic: boom") - XCTAssertEqual(exited.summary, "The SAP HANA helper exited with status 2.") - XCTAssertEqual(killed.message, "TablePro stopped it. The SAP HANA helper was stopped by signal 9.") - XCTAssertEqual(unknown.message, "The SAP HANA helper closed its output.") - } -} - -private struct OperationBody: Decodable, Equatable { - let session: UInt64 - let operation: UInt64 -} diff --git a/Plugins/HanaDriverPluginTests/HanaHelperPipeTests.swift b/Plugins/HanaDriverPluginTests/HanaHelperPipeTests.swift deleted file mode 100644 index 0ad7666f72..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaHelperPipeTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Darwin -import Foundation -import XCTest - -final class HanaHelperPipeTests: XCTestCase { - func testWritingToAPipeNobodyReadsIsAnErrorAndNotASignal() throws { - let pipe = Pipe() - try HanaHelperPipe.suppressBrokenPipeSignal(on: pipe.fileHandleForWriting.fileDescriptor) - try pipe.fileHandleForReading.close() - - XCTAssertThrowsError(try HanaHelperPipe.write(Data("frame".utf8), to: pipe.fileHandleForWriting.fileDescriptor)) { error in - XCTAssertEqual((error as? POSIXError)?.code, .EPIPE) - } - } - - func testReadingExactlyWaitsForEveryByte() throws { - let pipe = Pipe() - let reader = pipe.fileHandleForReading.fileDescriptor - let writer = pipe.fileHandleForWriting - DispatchQueue.global().async { - for chunk in ["ab", "cd", "e"] { - try? HanaHelperPipe.write(Data(chunk.utf8), to: writer.fileDescriptor) - Thread.sleep(forTimeInterval: 0.02) - } - } - - XCTAssertEqual(try HanaHelperPipe.read(exactly: 5, from: reader), .complete(Data("abcde".utf8))) - try writer.close() - } - - func testAFrameLargerThanOneChunkCrossesThePipeIntact() throws { - let pipe = Pipe() - let reader = pipe.fileHandleForReading.fileDescriptor - let writer = pipe.fileHandleForWriting - let count = HanaHelperPipe.maximumChunkByteCount * 2 + 12_345 - let block = Data((0..<65_521).map { UInt8(truncatingIfNeeded: $0 &* 31) }) - var pattern = Data() - while pattern.count < count { - pattern.append(block) - } - let payload = Data(pattern.prefix(count)) - DispatchQueue.global().async { - try? HanaHelperPipe.write(payload, to: writer.fileDescriptor) - try? writer.close() - } - - let received = try HanaHelperPipe.read(exactly: count, from: reader) - - XCTAssertEqual(received, .complete(payload)) - XCTAssertEqual(try HanaHelperPipe.read(exactly: 1, from: reader), .endOfStream(receivedByteCount: 0)) - } - - func testReadingReportsHowFarTheStreamGotBeforeItEnded() throws { - let pipe = Pipe() - try HanaHelperPipe.write(Data("abc".utf8), to: pipe.fileHandleForWriting.fileDescriptor) - try pipe.fileHandleForWriting.close() - let reader = pipe.fileHandleForReading.fileDescriptor - - XCTAssertEqual(try HanaHelperPipe.read(exactly: 13, from: reader), .endOfStream(receivedByteCount: 3)) - XCTAssertEqual(try HanaHelperPipe.read(exactly: 13, from: reader), .endOfStream(receivedByteCount: 0)) - } - - func testReadingNothingNeedsNoBytes() throws { - let pipe = Pipe() - - XCTAssertEqual(try HanaHelperPipe.read(exactly: 0, from: pipe.fileHandleForReading.fileDescriptor), .complete(Data())) - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaHelperProcessTests.swift b/Plugins/HanaDriverPluginTests/HanaHelperProcessTests.swift deleted file mode 100644 index c102e40f2c..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaHelperProcessTests.swift +++ /dev/null @@ -1,265 +0,0 @@ -import Foundation -import XCTest - -final class HanaHelperProcessTests: XCTestCase { - private static let gibibyte = 1 << 30 - - private var folder: HanaStandInFolder? - - override func setUpWithError() throws { - folder = try HanaStandInFolder(named: "HanaHelperProcessTests") - } - - override func tearDown() { - folder?.remove() - super.tearDown() - } - - func testTheGreetingCarriesTheHelpersForcedSeverGrace() throws { - let helper = try script(""" - \(HanaStandInFrame.greeting(#"{"protocol":1,"forcedSeverGraceSeconds":7}"#)) - \(HanaStandInFrame.absorbInput()) - """) - - let launched = try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30) - defer { launched.process.shutdown() } - - XCTAssertEqual(launched.greeting, HanaHelperGreeting(forcedSeverGrace: 7)) - } - - func testAHelperSpeakingAnotherProtocolIsRefusedByNumber() throws { - let helper = try script(""" - \(HanaStandInFrame.greeting(#"{"protocol":2,"forcedSeverGraceSeconds":30}"#)) - \(HanaStandInFrame.absorbInput()) - """) - - XCTAssertThrowsError(try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30)) { error in - XCTAssertEqual(error as? HanaBridgeFailure, HanaBridgeFailure( - kind: .internalFailure, - message: "incompatible helper protocol 2" - )) - } - } - - func testAGreetingThatNamesNoForcedSeverGraceIsRefused() throws { - let helper = try script(""" - \(HanaStandInFrame.greeting(#"{"protocol":1}"#)) - \(HanaStandInFrame.absorbInput()) - """) - - XCTAssertThrowsError(try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30)) { error in - XCTAssertEqual(error as? HanaBridgeFailure, HanaHelperFrameError.unusableForcedSeverGrace.failure) - XCTAssertEqual((error as? HanaBridgeFailure)?.message.hasPrefix("incompatible helper"), true, "\(error)") - } - } - - func testAHandshakeOverFourKibibytesIsRefusedBeforeItsBodyArrives() throws { - let helper = try script(""" - \(HanaStandInFrame.header(bodyLength: 4_097, id: 0, code: 0)) - \(HanaStandInFrame.absorbInput()) - """) - let started = Date() - - XCTAssertThrowsError(try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30)) { error in - XCTAssertEqual(error as? HanaBridgeFailure, HanaBridgeFailure( - kind: .internalFailure, - message: "the helper announced a 4097-byte frame, over the 4096-byte limit" - )) - } - XCTAssertLessThan(Date().timeIntervalSince(started), 10) - } - - func testAHelperThatNeverGreetsIsStoppedAtTheHandshakeDeadline() throws { - let helper = try script("exec /bin/sleep 30") - - XCTAssertThrowsError(try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 1)) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .internalFailure, "got \(error)") - XCTAssertEqual(failure?.message.contains("did not answer within 1 seconds"), true, "\(error)") - } - } - - func testInterruptingAHandshakeStopsTheHelperWithoutWaitingForTheDeadline() throws { - let recordedIdentifier = try XCTUnwrap(folder).file(named: "pid") - let helper = try script(""" - echo $$ > '\(recordedIdentifier.path)' - \(HanaStandInFrame.absorbInput()) - """) - let interruption = HanaOpenInterruption() - let launch = HanaBlockingCall { - _ = try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30, interruption: interruption) - return Data() - } - let helperIdentifier = try XCTUnwrap(HanaFileProbe.processIdentifier(recordedAt: recordedIdentifier, within: 10)) - let interrupted = Date() - - interruption.interrupt() - - let outcome = try XCTUnwrap(launch.outcome(within: 10), "the launch never finished") - XCTAssertLessThan(Date().timeIntervalSince(interrupted), 5) - guard case .failure(let error) = outcome else { - XCTFail("an interrupted handshake should fail") - return - } - XCTAssertEqual(error as? HanaBridgeFailure, HanaHelperProcess.interruptedFailure) - XCTAssertTrue(HanaProcessProbe.waitForExit(of: helperIdentifier, within: 10)) - } - - func testAHelperThatExitsBeforeGreetingReportsItsStatusAndErrorOutput() throws { - let helper = try script(""" - echo 'panic: invalid type code' >&2 - exit 2 - """) - - XCTAssertThrowsError(try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30)) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .internalFailure, "got \(error)") - XCTAssertEqual(failure?.message.contains("exited with status 2"), true, "\(error)") - XCTAssertEqual(failure?.message.contains("panic: invalid type code"), true, "\(error)") - } - } - - func testAReplyToAFrameNobodyIssuedLosesTheConnectionAndNamesTheViolation() throws { - let helper = try script(""" - \(HanaStandInFrame.greeting()) - /usr/bin/head -c 15 > /dev/null - \(HanaStandInFrame.frame(id: 999, code: 0, body: "{}")) - \(HanaStandInFrame.absorbInput()) - """) - let process = try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30).process - defer { process.shutdown() } - - XCTAssertThrowsError(try process.call(.ping, body: Data("{}".utf8), ticket: nil)) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .connectionLost, "got \(error)") - XCTAssertEqual(failure?.message.contains("the helper answered frame 999, which no call is waiting for"), true, "\(error)") - } - XCTAssertFalse(process.isAlive) - XCTAssertThrowsError(try process.call(.ping, body: Data("{}".utf8), ticket: nil)) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .connectionLost, "got \(error)") - XCTAssertEqual(failure?.message.contains("frame 999"), true, "\(error)") - } - } - - func testAReplyAnnouncingMoreThanTwoGibibytesIsRefusedWithoutAllocatingIt() throws { - let helper = try script(""" - \(HanaStandInFrame.greeting()) - /usr/bin/head -c 15 > /dev/null - \(HanaStandInFrame.header(bodyLength: UInt32.max, id: 1, code: 0)) - \(HanaStandInFrame.absorbInput()) - """) - let process = try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30).process - defer { process.shutdown() } - let peakBefore = HanaMemoryProbe.peakResidentByteCount - - XCTAssertThrowsError(try process.call(.ping, body: Data("{}".utf8), ticket: nil)) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .connectionLost, "got \(error)") - XCTAssertEqual( - failure?.message.contains("the helper announced a 4294967295-byte frame, over the 2147483647-byte limit"), - true, - "\(error)" - ) - } - - XCTAssertLessThan(HanaMemoryProbe.peakResidentByteCount - peakBefore, Self.gibibyte) - XCTAssertFalse(process.isAlive) - } - - func testAReplyCutShortLosesTheConnection() throws { - let helper = try script(""" - \(HanaStandInFrame.greeting()) - /usr/bin/head -c 15 > /dev/null - \(HanaStandInFrame.header(bodyLength: 100_000, id: 1, code: 0)) - /usr/bin/printf '{"partial"' - exit 0 - """) - let process = try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30).process - defer { process.shutdown() } - - XCTAssertThrowsError(try process.call(.ping, body: Data("{}".utf8), ticket: nil)) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .connectionLost, "got \(error)") - XCTAssertEqual(failure?.message.contains("after 10 of 100000 bytes"), true, "\(error)") - } - } - - func testCallsAfterTheHelperEndedFailWithoutWriting() throws { - let helper = try script(""" - \(HanaStandInFrame.greeting()) - exit 0 - """) - let process = try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30).process - defer { process.shutdown() } - - XCTAssertThrowsError(try process.call(.ping, body: Data("{}".utf8), ticket: nil)) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .connectionLost, "got \(error)") - } - XCTAssertNil(process.post(.close, body: Data(#"{"session":1}"#.utf8))) - XCTAssertThrowsError(try process.call(.ping, body: Data("{}".utf8), ticket: nil)) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.message.contains("exited with status 0"), true, "\(error)") - } - } - - func testAStoppedHelperRefusesNewCallsAsLost() throws { - let helper = try script(""" - \(HanaStandInFrame.greeting()) - \(HanaStandInFrame.absorbInput()) - """) - let process = try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30).process - defer { process.shutdown() } - - process.stop(cause: "TablePro stopped it for the test.") - - XCTAssertFalse(process.isAlive) - XCTAssertThrowsError(try process.call(.ping, body: Data("{}".utf8), ticket: nil)) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .connectionLost, "got \(error)") - XCTAssertEqual(failure?.message.contains("TablePro stopped it for the test."), true, "\(error)") - } - XCTAssertNil(process.post(.close, body: Data(#"{"session":1}"#.utf8))) - } - - func testACallInFlightWhenTableProShutsTheHelperDownFailsAsClosed() async throws { - let received = try XCTUnwrap(folder).file(named: "received") - let helper = try script(""" - \(HanaStandInFrame.greeting()) - \(HanaStandInFrame.absorbInput(into: received)) - """) - let process = try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30).process - let ping = Task.detached { try process.call(.ping, body: Data("{}".utf8), ticket: nil) } - XCTAssertTrue( - HanaFileProbe.waitForBytes(at: received, reaching: HanaHelperFrameHeader.byteCount, within: 10), - "the stand-in never received the ping" - ) - - process.shutdown() - - do { - _ = try await ping.value - XCTFail("a ping the helper never answered should fail") - } catch { - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .closed, "got \(error)") - } - } - - func testACallAfterTableProShutTheHelperDownFailsAsClosed() throws { - let helper = try script(""" - \(HanaStandInFrame.greeting()) - \(HanaStandInFrame.absorbInput()) - """) - let process = try HanaHelperProcess.launch(executable: helper, handshakeDeadline: 30).process - - process.shutdown() - - XCTAssertFalse(process.isAlive) - XCTAssertThrowsError(try process.call(.ping, body: Data("{}".utf8), ticket: nil)) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .closed, "got \(error)") - } - } - - private func script(_ body: String) throws -> URL { - try XCTUnwrap(folder).script(body) - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaHelperTestSupport.swift b/Plugins/HanaDriverPluginTests/HanaHelperTestSupport.swift deleted file mode 100644 index 0d4b489ef8..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaHelperTestSupport.swift +++ /dev/null @@ -1,252 +0,0 @@ -import Darwin -import Foundation - -enum HanaTestSocketError: Error { - case failed(String, Int32) -} - -final class HanaSilentServer: @unchecked Sendable { - let port: Int - - private let listener: Int32 - private let lock = NSLock() - private var accepted: [Int32] = [] - private var isStopped = false - - init() throws { - let listener = try HanaLoopbackSocket.bound() - guard Darwin.listen(listener, 8) == 0 else { - let failure = errno - Darwin.close(listener) - throw HanaTestSocketError.failed("listen", failure) - } - self.listener = listener - port = try HanaLoopbackSocket.port(of: listener) - } - - deinit { - stop() - } - - func awaitConnection(within seconds: TimeInterval) -> Bool { - var request = pollfd(fd: listener, events: Int16(POLLIN), revents: 0) - guard poll(&request, 1, Int32(seconds * 1_000)) > 0 else { return false } - return lock.withLock { () -> Bool in - guard !isStopped else { return false } - let connection = accept(listener, nil, nil) - guard connection >= 0 else { return false } - accepted.append(connection) - return true - } - } - - func stop() { - let open = lock.withLock { () -> [Int32] in - guard !isStopped else { return [] } - isStopped = true - let open = accepted + [listener] - accepted.removeAll() - return open - } - open.forEach { Darwin.close($0) } - } -} - -enum HanaLoopbackSocket { - static func closedPort() throws -> Int { - let socket = try bound() - defer { Darwin.close(socket) } - return try port(of: socket) - } - - static func bound() throws -> Int32 { - let descriptor = socket(AF_INET, SOCK_STREAM, 0) - guard descriptor >= 0 else { throw HanaTestSocketError.failed("socket", errno) } - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.size) - address.sin_family = sa_family_t(AF_INET) - address.sin_port = 0 - address.sin_addr.s_addr = inet_addr("127.0.0.1") - let status = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - bind(descriptor, $0, socklen_t(MemoryLayout.size)) - } - } - guard status == 0 else { - let failure = errno - Darwin.close(descriptor) - throw HanaTestSocketError.failed("bind", failure) - } - return descriptor - } - - static func port(of descriptor: Int32) throws -> Int { - var address = sockaddr_in() - var length = socklen_t(MemoryLayout.size) - let status = withUnsafeMutablePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - getsockname(descriptor, $0, &length) - } - } - guard status == 0 else { throw HanaTestSocketError.failed("getsockname", errno) } - return Int(UInt16(bigEndian: address.sin_port)) - } -} - -final class HanaBlockingCall: @unchecked Sendable { - private let lock = NSLock() - private let finished = DispatchSemaphore(value: 0) - private var outcome: Result? - - init(_ work: @escaping @Sendable () throws -> Data) { - DispatchQueue.global(qos: .userInitiated).async { - let outcome = Result { try work() } - self.lock.withLock { self.outcome = outcome } - self.finished.signal() - } - } - - var hasFinished: Bool { - lock.withLock { outcome != nil } - } - - func outcome(within seconds: TimeInterval) -> Result? { - guard finished.wait(timeout: .now() + seconds) == .success else { return nil } - finished.signal() - return lock.withLock { outcome } - } -} - -enum HanaProcessProbe { - static func isRunning(_ processIdentifier: pid_t) -> Bool { - kill(processIdentifier, 0) == 0 || errno != ESRCH - } - - static func waitForExit(of processIdentifier: pid_t, within seconds: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(seconds) - while Date() < deadline { - guard isRunning(processIdentifier) else { return true } - Thread.sleep(forTimeInterval: 0.05) - } - return !isRunning(processIdentifier) - } -} - -final class HanaStandInFolder: @unchecked Sendable { - let url: URL - - init(named prefix: String) throws { - url = FileManager.default.temporaryDirectory - .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - } - - func remove() { - try? FileManager.default.removeItem(at: url) - } - - func file(named name: String) -> URL { - url.appendingPathComponent(name) - } - - func script(_ body: String) throws -> URL { - let script = file(named: "helper-\(UUID().uuidString)") - try Data("#!/bin/sh\n\(body)\n".utf8).write(to: script) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) - return script - } -} - -enum HanaStandInFrame { - static let greetingBody = #"{"protocol":1,"forcedSeverGraceSeconds":30}"# - - static func greeting(_ body: String = greetingBody) -> String { - frame(id: HanaHelperHandshake.frameID, code: 0, body: body) - } - - static func frame(id: UInt64, code: UInt8, body: String) -> String { - let header = HanaHelperFrameHeader(bodyLength: UInt32(body.utf8.count), id: id, code: code) - return "/usr/bin/printf '\(escaped(header.encoded))\(body)'" - } - - static func header(bodyLength: UInt32, id: UInt64, code: UInt8) -> String { - let header = HanaHelperFrameHeader(bodyLength: bodyLength, id: id, code: code) - return "/usr/bin/printf '\(escaped(header.encoded))'" - } - - static func absorbInput(into file: URL? = nil) -> String { - "exec /bin/cat 3>&1 > '\(file?.path ?? "/dev/null")'" - } - - private static func escaped(_ bytes: Data) -> String { - bytes.map { String(format: "\\%03o", UInt32($0)) }.joined() - } -} - -enum HanaFileProbe { - static func byteCount(at url: URL) -> Int { - let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) - return (attributes?[.size] as? NSNumber)?.intValue ?? 0 - } - - static func waitForBytes(at url: URL, reaching count: Int, within seconds: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(seconds) - while Date() < deadline { - guard byteCount(at: url) < count else { return true } - Thread.sleep(forTimeInterval: 0.02) - } - return byteCount(at: url) >= count - } - - static func processIdentifier(recordedAt url: URL, within seconds: TimeInterval) -> pid_t? { - let deadline = Date().addingTimeInterval(seconds) - while Date() < deadline { - if let recorded = recordedProcessIdentifier(at: url) { - return recorded - } - Thread.sleep(forTimeInterval: 0.02) - } - return recordedProcessIdentifier(at: url) - } - - private static func recordedProcessIdentifier(at url: URL) -> pid_t? { - guard let text = try? String(contentsOf: url, encoding: .utf8), text.hasSuffix("\n") else { return nil } - return pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) - } -} - -enum HanaMemoryProbe { - static var peakResidentByteCount: Int { - var usage = rusage() - guard getrusage(RUSAGE_SELF, &usage) == 0 else { return 0 } - return usage.ru_maxrss - } -} - -final class HanaRunningCodeChecks: @unchecked Sendable { - struct Request: Equatable { - let processIdentifier: pid_t - let requirement: String - } - - private let lock = NSLock() - private var recorded: [Request] = [] - private let failure: HanaBridgeFailure? - - init(failingWith failure: HanaBridgeFailure? = nil) { - self.failure = failure - } - - var requests: [Request] { - lock.withLock { recorded } - } - - var record: HanaHelperTrust.RunningCodeCheck { - { [self] processIdentifier, requirement in - lock.withLock { recorded.append(Request(processIdentifier: processIdentifier, requirement: requirement)) } - if let failure { - throw failure - } - } - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaHelperTrustTests.swift b/Plugins/HanaDriverPluginTests/HanaHelperTrustTests.swift deleted file mode 100644 index ca74641715..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaHelperTrustTests.swift +++ /dev/null @@ -1,217 +0,0 @@ -import Foundation -import XCTest - -final class HanaHelperTrustTests: XCTestCase { - private static let team = "D7HJ5TFYCU" - private static let developmentHost = HanaHelperTrust(signingTeam: nil, admitsUnsignedHost: true) - - private var root: URL? - - override func setUpWithError() throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("HanaHelperTrustTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - self.root = root - } - - override func tearDownWithError() throws { - guard let root else { return } - try FileManager.default.removeItem(at: root) - } - - func testADevelopmentBuildWithATeamlessHostTrustsARegularExecutableInsideContentsMacOS() throws { - let bundle = try makeBundle(named: "Regular") - try writeHelper(at: helperURL(in: bundle), permissions: 0o755) - - let executable = try Self.developmentHost.verifiedExecutable(in: XCTUnwrap(Bundle(url: bundle))) - - XCTAssertEqual(executable.lastPathComponent, HanaHelperTrust.executableName) - XCTAssertEqual(executable.deletingLastPathComponent().lastPathComponent, "MacOS") - } - - func testADevelopmentBuildWithATeamlessHostSkipsTheRunningCodeCheck() throws { - let checks = HanaRunningCodeChecks() - let trust = HanaHelperTrust(signingTeam: nil, admitsUnsignedHost: true, checkRunningCode: checks.record) - - XCTAssertNoThrow(try trust.verifyRunningHelper(4_242)) - - XCTAssertTrue(checks.requests.isEmpty) - } - - func testAReleaseBuildWithATeamlessHostTrustsNoHelper() throws { - let bundle = try makeBundle(named: "Release") - try writeHelper(at: helperURL(in: bundle), permissions: 0o755) - let checks = HanaRunningCodeChecks() - let trust = HanaHelperTrust(signingTeam: nil, admitsUnsignedHost: false, checkRunningCode: checks.record) - - assertRefused(bundle, by: trust, mentioning: "not signed with a Team ID") - XCTAssertThrowsError(try trust.verifyRunningHelper(4_242)) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .internalFailure, "got \(error)") - } - XCTAssertTrue(checks.requests.isEmpty) - } - - func testATeamSignedHostRefusesAnUnsignedHelperBeforeItRuns() throws { - let bundle = try makeBundle(named: "Unsigned") - try writeHelper(at: helperURL(in: bundle), permissions: 0o755) - let trust = HanaHelperTrust(signingTeam: Self.team, admitsUnsignedHost: true) - - assertRefused(bundle, by: trust, mentioning: "does not satisfy") - } - - func testATeamSignedHostChecksTheRunningHelperAgainstItsOwnTeam() throws { - let checks = HanaRunningCodeChecks() - let trust = HanaHelperTrust(signingTeam: Self.team, admitsUnsignedHost: false, checkRunningCode: checks.record) - - let expected = try XCTUnwrap(HanaHelperTrust.requirement(forTeam: Self.team)) - - try trust.verifyRunningHelper(4_242) - - XCTAssertEqual(checks.requests, [HanaRunningCodeChecks.Request(processIdentifier: 4_242, requirement: expected)]) - } - - func testARunningCodeCheckThatFailsIsAnInternalFailure() { - let trust = HanaHelperTrust(signingTeam: Self.team, admitsUnsignedHost: false) { _, _ in - throw CocoaError(.fileReadNoPermission) - } - - XCTAssertThrowsError(try trust.verifyRunningHelper(4_242)) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .internalFailure, "got \(error)") - XCTAssertEqual(failure?.message.contains("failed its signature check"), true, "\(error)") - } - } - - func testAHostWithAnInvalidTeamTrustsNoHelper() throws { - let bundle = try makeBundle(named: "BadTeam") - try writeHelper(at: helperURL(in: bundle), permissions: 0o755) - let trust = HanaHelperTrust(signingTeam: "d7hj5tfycu", admitsUnsignedHost: true) - - assertRefused(bundle, by: trust, mentioning: "Team ID is not valid") - XCTAssertThrowsError(try trust.verifyRunningHelper(4_242)) - } - - func testTheRunningCodeCheckAcceptsAnAppleBinaryAndRefusesAnotherTeam() throws { - let sleeper = Process() - sleeper.executableURL = URL(fileURLWithPath: "/bin/sleep") - sleeper.arguments = ["30"] - try sleeper.run() - defer { sleeper.terminate() } - - XCTAssertNoThrow(try HanaHelperTrust.checkRunningCode(processIdentifier: sleeper.processIdentifier, requirement: "anchor apple")) - let teamRequirement = try XCTUnwrap(HanaHelperTrust.requirement(forTeam: Self.team)) - XCTAssertThrowsError( - try HanaHelperTrust.checkRunningCode(processIdentifier: sleeper.processIdentifier, requirement: teamRequirement) - ) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .internalFailure, "got \(error)") - XCTAssertEqual(failure?.message.contains("does not satisfy"), true, "\(error)") - } - } - - func testAMissingHelperIsRefused() throws { - let bundle = try makeBundle(named: "Missing") - - assertRefused(bundle, by: Self.developmentHost, mentioning: "missing") - } - - func testASymlinkedHelperIsRefusedEvenWhenItPointsAtAnExecutable() throws { - let bundle = try makeBundle(named: "Symlink") - let outside = try XCTUnwrap(root).appendingPathComponent("outside-helper") - try writeHelper(at: outside, permissions: 0o755) - try FileManager.default.createSymbolicLink(at: helperURL(in: bundle), withDestinationURL: outside) - - assertRefused(bundle, by: Self.developmentHost, mentioning: "regular file") - } - - func testADirectoryNamedLikeTheHelperIsRefused() throws { - let bundle = try makeBundle(named: "Directory") - try FileManager.default.createDirectory(at: helperURL(in: bundle), withIntermediateDirectories: true) - - assertRefused(bundle, by: Self.developmentHost, mentioning: "regular file") - } - - func testAHelperThatIsNotExecutableIsRefused() throws { - let bundle = try makeBundle(named: "NotExecutable") - try writeHelper(at: helperURL(in: bundle), permissions: 0o644) - - assertRefused(bundle, by: Self.developmentHost, mentioning: "not executable") - } - - func testAHelperReachedThroughASymlinkedMacOSFolderIsRefused() throws { - let bundle = try makeBundle(named: "LinkedFolder", withMacOSFolder: false) - let outsideFolder = try XCTUnwrap(root).appendingPathComponent("outside-macos", isDirectory: true) - try FileManager.default.createDirectory(at: outsideFolder, withIntermediateDirectories: true) - try writeHelper(at: outsideFolder.appendingPathComponent(HanaHelperTrust.executableName), permissions: 0o755) - try FileManager.default.createSymbolicLink( - at: bundle.appendingPathComponent("Contents/MacOS"), - withDestinationURL: outsideFolder - ) - - assertRefused(bundle, by: Self.developmentHost, mentioning: "Contents/MacOS") - } - - func testTheLocationCheckRefusesAHelperFromAnotherBundle() throws { - let first = try makeBundle(named: "First") - let second = try makeBundle(named: "Second") - try writeHelper(at: helperURL(in: second), permissions: 0o755) - - XCTAssertThrowsError(try HanaHelperTrust.verifyLocation(of: helperURL(in: second), inBundleAt: first)) { error in - XCTAssertEqual((error as? HanaBridgeFailure)?.kind, .internalFailure) - } - XCTAssertNoThrow(try HanaHelperTrust.verifyLocation(of: helperURL(in: second), inBundleAt: second)) - } - - func testTheTeamRequirementNamesTheLeafCertificateUnit() { - XCTAssertEqual( - HanaHelperTrust.requirement(forTeam: Self.team), - "anchor apple generic and certificate leaf[subject.OU] = \"D7HJ5TFYCU\"" - ) - } - - func testATeamIdentifierThatIsNotTenUppercaseLettersOrDigitsBuildsNoRequirement() { - for team in ["", "D7HJ5TFYC", "D7HJ5TFYCUX", "d7hj5tfycu", "D7HJ5\"TFYC", "ABC\" or \"1", "D7HJ5TFYÇU"] { - XCTAssertNil(HanaHelperTrust.requirement(forTeam: team), team) - } - } - - private func makeBundle(named name: String, withMacOSFolder: Bool = true) throws -> URL { - let bundle = try XCTUnwrap(root).appendingPathComponent("\(name).tableplugin", isDirectory: true) - let contents = bundle.appendingPathComponent("Contents", isDirectory: true) - let folder = withMacOSFolder ? contents.appendingPathComponent("MacOS", isDirectory: true) : contents - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - return bundle - } - - private func helperURL(in bundle: URL) -> URL { - bundle.appendingPathComponent("Contents/MacOS/\(HanaHelperTrust.executableName)") - } - - private func writeHelper(at url: URL, permissions: Int) throws { - try Data("#!/bin/sh\nexit 0\n".utf8).write(to: url) - try FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path) - } - - private func assertRefused( - _ bundle: URL, - by trust: HanaHelperTrust, - mentioning fragment: String, - file: StaticString = #filePath, - line: UInt = #line - ) { - guard let loaded = Bundle(url: bundle) else { - XCTFail("\(bundle.path) did not open as a bundle", file: file, line: line) - return - } - XCTAssertThrowsError(try trust.verifiedExecutable(in: loaded), file: file, line: line) { error in - let failure = error as? HanaBridgeFailure - XCTAssertEqual(failure?.kind, .internalFailure, "got \(error)", file: file, line: line) - XCTAssertEqual( - failure?.message.contains(fragment), true, - "\(failure?.message ?? "") does not mention \(fragment)", - file: file, - line: line - ) - } - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaOperationSlotTests.swift b/Plugins/HanaDriverPluginTests/HanaOperationSlotTests.swift deleted file mode 100644 index 8b6a15f2f9..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaOperationSlotTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import XCTest - -final class HanaOperationSlotTests: XCTestCase { - func testACancelAfterAssignmentReturnsTheTicketToInterrupt() { - let slot = HanaOperationSlot() - let ticket = HanaOperationTicket(session: 7, operation: 3) - - XCTAssertTrue(slot.assign(ticket)) - XCTAssertEqual(slot.cancel(), ticket) - } - - func testACancelBeforeAssignmentKeepsTheOperationFromBeingIssued() { - let slot = HanaOperationSlot() - - XCTAssertNil(slot.cancel()) - XCTAssertFalse(slot.assign(HanaOperationTicket(session: 7, operation: 4))) - } - - func testOnlyTheFirstCancelReturnsTheTicket() { - let slot = HanaOperationSlot() - let ticket = HanaOperationTicket(session: 7, operation: 5) - - XCTAssertTrue(slot.assign(ticket)) - XCTAssertEqual(slot.cancel(), ticket) - XCTAssertNil(slot.cancel()) - XCTAssertTrue(slot.isCancelled) - } - - func testInterruptingAnOpenRunsItsResponseOnce() { - let interruption = HanaOpenInterruption() - let responses = HanaResponseCounter() - - XCTAssertTrue(interruption.whenInterrupted { responses.record() }) - interruption.interrupt() - interruption.interrupt() - - XCTAssertEqual(responses.count, 1) - XCTAssertTrue(interruption.isInterrupted) - } - - func testAnOpenAlreadyInterruptedRefusesANewResponse() { - let interruption = HanaOpenInterruption() - let responses = HanaResponseCounter() - interruption.interrupt() - - XCTAssertFalse(interruption.whenInterrupted { responses.record() }) - - XCTAssertEqual(responses.count, 0) - } - - func testAResponseNoLongerWatchedIsNotRun() { - let interruption = HanaOpenInterruption() - let responses = HanaResponseCounter() - XCTAssertTrue(interruption.whenInterrupted { responses.record() }) - - interruption.stopWatching() - interruption.interrupt() - - XCTAssertEqual(responses.count, 0) - XCTAssertTrue(interruption.isInterrupted) - } -} - -private final class HanaResponseCounter: @unchecked Sendable { - private let lock = NSLock() - private var recorded = 0 - - var count: Int { lock.withLock { recorded } } - - func record() { - lock.withLock { recorded += 1 } - } -} diff --git a/Plugins/HanaDriverPluginTests/HanaPluginDriverTests.swift b/Plugins/HanaDriverPluginTests/HanaPluginDriverTests.swift deleted file mode 100644 index 1b9d2bb3d7..0000000000 --- a/Plugins/HanaDriverPluginTests/HanaPluginDriverTests.swift +++ /dev/null @@ -1,654 +0,0 @@ -import Foundation -import TableProPluginKit -import XCTest - -final class HanaPluginDriverTests: XCTestCase { - func testPluginDeclaresTheHanaTransportAndScope() { - XCTAssertEqual(HanaPlugin.databaseTypeId, "SAP HANA") - XCTAssertEqual(HanaPlugin.defaultPort, 443) - XCTAssertTrue(HanaPlugin.isDownloadable) - XCTAssertTrue(HanaPlugin.supportsSSL) - XCTAssertFalse(HanaPlugin.supportsSSH) - XCTAssertTrue(HanaPlugin.supportsForeignKeys) - XCTAssertFalse(HanaPlugin.supportsSchemaEditing) - XCTAssertFalse(HanaPlugin.supportsAddColumn) - XCTAssertFalse(HanaPlugin.supportsDropIndex) - XCTAssertTrue(HanaPlugin.supportsSchemaSwitching) - XCTAssertFalse(HanaPlugin.supportsDatabaseSwitching) - XCTAssertEqual(HanaPlugin.urlSchemes, ["hdb"]) - XCTAssertTrue(HanaPlugin.additionalConnectionFields.contains { $0.id == HanaMetadata.tlsServerNameField }) - } - - func testExplainVariantUsesTheIndentedPlanFormat() throws { - let variant = try XCTUnwrap(HanaPlugin.explainVariants.first) - XCTAssertEqual(HanaPlugin.explainVariants.count, 1) - XCTAssertEqual(variant.id, "plan") - XCTAssertEqual(variant.sqlPrefix, "EXPLAIN PLAN FOR") - XCTAssertEqual(variant.format, .indentedText) - XCTAssertEqual( - HanaExplainStatement.explainedStatement(in: "\(variant.sqlPrefix) SELECT 1 FROM DUMMY"), - "SELECT 1 FROM DUMMY" - ) - } - - func testDialectMatchesWhatHanaAccepts() throws { - let dialect = try XCTUnwrap(HanaPlugin.sqlDialect) - XCTAssertEqual(dialect.identifierQuote, "\"") - XCTAssertEqual(dialect.regexSyntax, .unsupported) - XCTAssertEqual(dialect.booleanLiteralStyle, .truefalse) - XCTAssertEqual(dialect.likeEscapeStyle, .explicit) - XCTAssertEqual(dialect.paginationStyle, .limit) - XCTAssertEqual(dialect.autoLimitStyle, .limit) - XCTAssertEqual(dialect.caseSensitivityStyle, .caseFoldFunction) - XCTAssertEqual(dialect.caseFoldFunction, "LOWER") - XCTAssertFalse(dialect.requiresBackslashEscaping) - XCTAssertNil(dialect.textCastTypeName) - XCTAssertTrue(dialect.functionNamesAreCaseInsensitive) - XCTAssertEqual(dialect.lexicalFeatures, [.dollarAndHashInIdentifiers]) - XCTAssertEqual(HanaPlugin.caseSensitivityStyle, .caseFoldFunction) - } - - func testExplainCompletionIsAPlainPlanStatement() throws { - let completion = try XCTUnwrap(HanaPlugin.statementCompletions.first { $0.label == "EXPLAIN PLAN" }) - XCTAssertTrue(completion.insertText.hasPrefix("EXPLAIN PLAN FOR SELECT")) - XCTAssertNotNil(HanaExplainStatement.explainedStatement(in: completion.insertText)) - } - - func testDriverQuotingSurvivesCombiningMarks() { - let driver = HanaPluginDriver(config: config(), session: HanaFakeSession()) - XCTAssertEqual(driver.quoteIdentifier("a\"\u{0301}"), "\"a\"\"\u{0301}\"") - XCTAssertEqual(driver.escapeStringLiteral("a'\u{0301}\0"), "a''\u{0301}") - } - - func testObjectCommentStatementResolvesTheSchemaWithoutThrowing() { - let configured = HanaPluginDriver(config: config(database: "APP"), session: HanaFakeSession()) - XCTAssertEqual( - configured.objectCommentStatement(name: "ORDERS", objectType: "TABLE", schema: nil, comment: "x"), - "COMMENT ON TABLE \"APP\".\"ORDERS\" IS N'x'" - ) - XCTAssertEqual( - configured.objectCommentStatement(name: "V", objectType: "VIEW", schema: "SALES", comment: nil), - "COMMENT ON VIEW \"SALES\".\"V\" IS NULL" - ) - XCTAssertNil(configured.objectCommentStatement(name: "t", objectType: "SEQUENCE", schema: "s", comment: nil)) - - let unscoped = HanaPluginDriver(config: config(database: ""), session: HanaFakeSession()) - XCTAssertEqual( - unscoped.objectCommentStatement(name: "T", objectType: "TABLE", schema: nil, comment: nil), - "COMMENT ON TABLE \"T\" IS NULL" - ) - } - - func testCreateViewTemplateIsACreateViewSkeleton() throws { - let driver = HanaPluginDriver(config: config(), session: HanaFakeSession()) - let template = try XCTUnwrap(driver.createViewTemplate()) - XCTAssertTrue(template.hasPrefix("CREATE VIEW view_name AS\nSELECT")) - } - - func testConnectSendsTheConfiguredSettingsAndKeepsTheConfiguredSchema() async throws { - let session = HanaFakeSession() - let driver = HanaPluginDriver( - config: config( - database: " APP ", - ssl: SSLConfiguration(mode: .verifyIdentity, caCertificatePath: " /tmp/ca.pem "), - additionalFields: [HanaMetadata.tlsServerNameField: "real.example.com"] - ), - session: session - ) - - try await driver.connect() - - let sent = try XCTUnwrap(session.connects.first) - XCTAssertEqual(sent.host, "hana.example") - XCTAssertEqual(sent.schema, "APP") - XCTAssertEqual(sent.tlsMode, .verifyIdentity) - XCTAssertEqual(sent.tlsServerName, "real.example.com") - XCTAssertEqual(sent.caCertificatePath, "/tmp/ca.pem") - XCTAssertEqual(sent.connectTimeoutSeconds, HanaConnectConfiguration.defaultConnectTimeoutSeconds) - XCTAssertEqual(driver.currentSchema, "APP") - XCTAssertEqual(driver.serverVersion, "4.00.000.00.1234567890") - } - - func testConnectSendsTheRemainingMillisecondBudget() async throws { - let session = HanaFakeSession() - let driver = HanaPluginDriver( - config: config(additionalFields: [ - "connectTimeoutMilliseconds": "1250", - "connectTimeoutSeconds": "30" - ]), - session: session - ) - - try await driver.connect() - - XCTAssertEqual(try XCTUnwrap(session.connects.first).connectTimeoutSeconds, 1.25) - } - - func testConnectFallsBackToConfiguredSecondsForAnOlderHost() throws { - let configuration = try HanaConnectionSettings.configuration(from: config( - additionalFields: ["connectTimeoutSeconds": "2.5"] - )) - - XCTAssertEqual(configuration.connectTimeoutSeconds, 2.5) - } - - func testConnectAdoptsTheServerSchemaWhenNoneIsConfigured() async throws { - let driver = HanaPluginDriver(config: config(database: ""), session: HanaFakeSession()) - - try await driver.connect() - - XCTAssertEqual(driver.currentSchema, "DBADMIN") - } - - func testDisconnectKeepsTheConfiguredSchema() async throws { - let session = HanaFakeSession() - let driver = HanaPluginDriver(config: config(database: "APP"), session: session) - try await driver.connect() - try await driver.switchSchema(to: "OTHER") - - driver.disconnect() - - XCTAssertEqual(session.disconnectCount, 1) - XCTAssertEqual(driver.currentSchema, "APP") - XCTAssertNil(driver.serverVersion) - } - - func testDisabledTLSSendsNoCertificatePaths() throws { - let configuration = try HanaConnectionSettings.configuration(from: config( - ssl: SSLConfiguration( - mode: .disabled, - caCertificatePath: "/ca.pem", - clientCertificatePath: "/cert.pem", - clientKeyPath: "/key.pem" - ), - additionalFields: [HanaMetadata.tlsServerNameField: "other"] - )) - - XCTAssertEqual(configuration.tlsMode, .disabled) - XCTAssertEqual(configuration.caCertificatePath, "") - XCTAssertEqual(configuration.clientCertificatePath, "") - XCTAssertEqual(configuration.clientKeyPath, "") - XCTAssertEqual(configuration.tlsServerName, "") - } - - func testSettingsAreValidatedBeforeTheBridgeIsCalled() { - let invalid = [ - config(host: " "), - config(port: 0), - config(port: 70_000), - config(username: ""), - config(ssl: SSLConfiguration(mode: .required, clientCertificatePath: "/cert.pem")), - config(ssl: SSLConfiguration(mode: .required, clientKeyPath: "/key.pem")) - ] - for candidate in invalid { - XCTAssertThrowsError(try HanaConnectionSettings.configuration(from: candidate)) { error in - XCTAssertEqual((error as? HanaError)?.kind, .configuration) - } - } - XCTAssertNoThrow(try HanaConnectionSettings.configuration(from: config( - ssl: SSLConfiguration(mode: .required, clientCertificatePath: "/cert.pem", clientKeyPath: "/key.pem") - ))) - } - - func testConnectMapsBridgeFailures() async { - let session = HanaFakeSession() - session.connectWith(.failure(HanaBridgeFailure(kind: .tls, code: 1, message: "x509"))) - let driver = HanaPluginDriver(config: config(), session: session) - - do { - try await driver.connect() - XCTFail("connect should fail") - } catch { - guard case .untrustedCertificate = error as? SSLHandshakeError else { - return XCTFail("expected an untrusted certificate error, got \(error)") - } - } - } - - func testUserQueriesBindParametersThroughTheBridge() async throws { - let session = HanaFakeSession() - let driver = HanaPluginDriver(config: config(), session: session) - - _ = try await driver.executeUserQuery( - query: "UPDATE T SET A = ? WHERE ID = ?", - rowCap: 200, - parameters: [.text("x"), .bytes(Data([1]))] - ) - _ = try await driver.executeParameterized(query: "DELETE FROM T WHERE ID = ?", parameters: [.null]) - _ = try await driver.executeUserQuery(query: "SELECT 1 FROM DUMMY", rowCap: nil, parameters: []) - - XCTAssertEqual(session.executed, [ - HanaFakeSession.ExecutedStatement( - sql: "UPDATE T SET A = ? WHERE ID = ?", - parameters: [.text("x"), .bytes(Data([1]))], - rowCap: 200 - ), - HanaFakeSession.ExecutedStatement( - sql: "DELETE FROM T WHERE ID = ?", - parameters: [.null], - rowCap: PluginRowLimits.emergencyMax - ), - HanaFakeSession.ExecutedStatement( - sql: "SELECT 1 FROM DUMMY", - parameters: nil, - rowCap: PluginRowLimits.emergencyMax - ) - ]) - } - - func testRowCapIsBoundedByTheEmergencyLimit() { - XCTAssertEqual(HanaPluginDriver.fetchLimit(nil), PluginRowLimits.emergencyMax) - XCTAssertEqual(HanaPluginDriver.fetchLimit(0), PluginRowLimits.emergencyMax) - XCTAssertEqual(HanaPluginDriver.fetchLimit(500), 500) - XCTAssertEqual(HanaPluginDriver.fetchLimit(PluginRowLimits.emergencyMax + 1), PluginRowLimits.emergencyMax) - } - - func testExplainRunsTheBridgePlanOperationWithTheInnerStatement() async throws { - let session = HanaFakeSession() - session.respond(HanaEnvelopes.rows(["QUERY PLAN"], [[.text("COLUMN SEARCH")], [.text(" COLUMN TABLE [table APP.T]")]])) - let driver = HanaPluginDriver(config: config(), session: session) - - let result = try await driver.executeUserQuery( - query: "EXPLAIN PLAN FOR SELECT * FROM \"APP\".\"T\"", - rowCap: nil, - parameters: nil - ) - - XCTAssertEqual(session.explained, ["SELECT * FROM \"APP\".\"T\""]) - XCTAssertTrue(session.executed.isEmpty) - XCTAssertEqual(result.columns, ["QUERY PLAN"]) - XCTAssertEqual(result.rows.count, 2) - } - - /// `SYS.TABLES` has no row for a view, so Show DDL reported every view as a missing table. - func testTableDDLAnswersAViewWithItsDefinition() async throws { - let session = HanaFakeSession() - session.respond(HanaEnvelopes.empty) - session.respond(HanaEnvelopes.rows(["DEFINITION"], [[.text("SELECT ID FROM ORDERS WHERE OPEN = 1")]])) - let driver = HanaPluginDriver(config: config(), session: session) - try await driver.connect() - - let ddl = try await driver.fetchTableDDL(table: "OPEN_ORDERS", schema: "APP") - - XCTAssertEqual(ddl, "CREATE VIEW \"APP\".\"OPEN_ORDERS\" AS\nSELECT ID FROM ORDERS WHERE OPEN = 1") - XCTAssertEqual(session.executed.count, 2) - } - - func testExplainWithParametersIsExecutedAsTyped() async throws { - let session = HanaFakeSession() - let driver = HanaPluginDriver(config: config(), session: session) - - _ = try await driver.executeUserQuery(query: "EXPLAIN PLAN FOR SELECT ?", rowCap: nil, parameters: [.text("1")]) - - XCTAssertTrue(session.explained.isEmpty) - XCTAssertEqual(session.executed.map(\.sql), ["EXPLAIN PLAN FOR SELECT ?"]) - } - - func testStopDuringAStatementReportsACancellation() async throws { - let session = HanaFakeSession() - let driver = HanaPluginDriver(config: config(), session: session) - session.runDuringStatement { [weak driver] in - try? driver?.cancelQuery() - } - session.fail(HanaBridgeFailure(kind: .cancelled)) - - do { - _ = try await driver.executeUserQuery(query: "SELECT * FROM BIG", rowCap: nil, parameters: nil) - XCTFail("the statement should report a cancellation") - } catch { - XCTAssertTrue(error is CancellationError, "got \(error)") - } - XCTAssertEqual(session.cancelled.map { ObjectIdentifier($0) }, session.slots.map { ObjectIdentifier($0) }) - XCTAssertEqual(session.cancelled.count, 1) - } - - func testCancellationNobodyAskedForIsAnError() async { - let session = HanaFakeSession() - session.fail(HanaBridgeFailure(kind: .cancelled)) - let driver = HanaPluginDriver(config: config(), session: session) - - do { - _ = try await driver.executeUserQuery(query: "SELECT 1 FROM DUMMY", rowCap: nil, parameters: nil) - XCTFail("the statement should fail") - } catch { - XCTAssertEqual((error as? HanaError)?.kind, .cancelled, "got \(error)") - } - } - - func testAStopForAnEarlierStatementDoesNotCancelTheNextOne() async { - let session = HanaFakeSession() - let driver = HanaPluginDriver(config: config(), session: session) - _ = try? await driver.executeUserQuery(query: "SELECT 1 FROM DUMMY", rowCap: nil, parameters: nil) - try? driver.cancelQuery() - session.fail(HanaBridgeFailure(kind: .cancelled)) - - do { - _ = try await driver.executeUserQuery(query: "SELECT 2 FROM DUMMY", rowCap: nil, parameters: nil) - XCTFail("the statement should fail") - } catch { - XCTAssertEqual((error as? HanaError)?.kind, .cancelled, "got \(error)") - } - XCTAssertTrue(session.cancelled.isEmpty) - } - - func testStopCancelsTheSlotOfTheQueryThatIsRunning() async throws { - let session = HanaFakeSession() - let driver = HanaPluginDriver(config: config(), session: session) - let hold = session.hold(sql: "SELECT * FROM BIG") - let run = Task { try await driver.executeUserQuery(query: "SELECT * FROM BIG", rowCap: nil, parameters: nil) } - let slot = await hold.arrival() - - try driver.cancelQuery() - - XCTAssertEqual(session.cancelled.map { ObjectIdentifier($0) }, [ObjectIdentifier(slot)]) - XCTAssertTrue(slot.isCancelled) - hold.release() - do { - _ = try await run.value - XCTFail("the statement should report a cancellation") - } catch { - XCTAssertTrue(error is CancellationError, "got \(error)") - } - } - - func testStopWithNoQueryRunningCancelsNoSlot() async throws { - let session = HanaFakeSession() - let driver = HanaPluginDriver(config: config(), session: session) - - try driver.cancelQuery() - _ = try await driver.executeUserQuery(query: "SELECT 1 FROM DUMMY", rowCap: nil, parameters: nil) - try driver.cancelQuery() - - XCTAssertTrue(session.cancelled.isEmpty) - let slot = try XCTUnwrap(session.slots.first) - XCTAssertEqual(session.slots.count, 1) - XCTAssertFalse(slot.isCancelled) - } - - func testAStopDeliveredAfterItsQueryFinishedLeavesTheNextQuerysSlotAlone() async throws { - let session = HanaFakeSession() - let driver = HanaPluginDriver(config: config(), session: session) - let first = session.hold(sql: "UPDATE A SET X = 1") - let second = session.hold(sql: "UPDATE B SET X = 1") - let delivery = session.holdCancels() - let firstRun = Task { - try await driver.executeUserQuery(query: "UPDATE A SET X = 1", rowCap: nil, parameters: nil) - } - let firstSlot = await first.arrival() - - let stopReturned = HanaLatch() - DispatchQueue.global().async { - try? driver.cancelQuery() - stopReturned.open() - } - let stoppedSlot = await delivery.arrival() - first.release() - _ = try await firstRun.value - let secondRun = Task { - try await driver.executeUserQuery(query: "UPDATE B SET X = 1", rowCap: nil, parameters: nil) - } - let secondSlot = await second.arrival() - delivery.release() - await stopReturned.wait() - second.release() - - _ = try await secondRun.value - XCTAssertIdentical(stoppedSlot, firstSlot) - XCTAssertNotIdentical(secondSlot, firstSlot) - XCTAssertFalse(secondSlot.isCancelled) - XCTAssertEqual(session.cancelled.map { ObjectIdentifier($0) }, [ObjectIdentifier(firstSlot)]) - } - - func testStopWhileAQueryWaitsBehindAPingStopsTheQueryAndNotThePing() async throws { - let bridge = HanaFakeBridge() - let queue = HanaRecordingQueue() - let driver = HanaPluginDriver(config: config(), session: HanaConnection(bridge: bridge, queue: queue)) - try await driver.connect() - let pingHold = bridge.holdPing() - let ping = Task { try await driver.ping() } - let pingTicket = await pingHold.arrival() - let query = Task { try await driver.executeUserQuery(query: "SELECT * FROM BIG", rowCap: nil, parameters: nil) } - await queue.submissions(reaching: 4) - - try driver.cancelQuery() - - let queryTicket = HanaOperationTicket(session: pingTicket.session, operation: pingTicket.operation + 1) - XCTAssertEqual(bridge.cancels, [queryTicket]) - pingHold.release() - try await ping.value - do { - _ = try await query.value - XCTFail("the query queued behind the ping should report a cancellation") - } catch { - XCTAssertTrue(error is CancellationError, "got \(error)") - } - XCTAssertEqual(bridge.pings, [pingTicket]) - XCTAssertTrue(bridge.statements.isEmpty) - } - - func testStopWithNoQueryInFlightLeavesARunningPingAlone() async throws { - let bridge = HanaFakeBridge() - let driver = HanaPluginDriver(config: config(), session: HanaConnection(bridge: bridge)) - try await driver.connect() - let hold = bridge.holdPing() - let ping = Task { try await driver.ping() } - _ = await hold.arrival() - - try driver.cancelQuery() - - XCTAssertTrue(bridge.cancels.isEmpty) - hold.release() - try await ping.value - } - - func testStopDuringAQueryReachesTheBridgeBeforeCancelQueryReturns() async throws { - let bridge = HanaFakeBridge() - let driver = HanaPluginDriver(config: config(), session: HanaConnection(bridge: bridge)) - try await driver.connect() - let hold = bridge.hold(sql: "SELECT * FROM BIG") - let run = Task { try await driver.executeUserQuery(query: "SELECT * FROM BIG", rowCap: nil, parameters: nil) } - let ticket = await hold.arrival() - - try driver.cancelQuery() - - XCTAssertEqual(bridge.cancels, [ticket]) - hold.release() - do { - _ = try await run.value - XCTFail("the statement should report a cancellation") - } catch { - XCTAssertTrue(error is CancellationError, "got \(error)") - } - } - - func testAStatementThatSucceededOnASessionLostReturnsItsResultAndReportsTheLoss() async throws { - let bridge = HanaFakeBridge() - let lost = HanaBridgeJSON.envelope(columns: ["ID"], rows: [["7"]], sessionLost: true) - bridge.respond(to: "SELECT ID FROM T", with: lost) - let driver = HanaPluginDriver(config: config(), session: HanaConnection(bridge: bridge)) - try await driver.connect() - - let result = try await driver.executeUserQuery(query: "SELECT ID FROM T", rowCap: nil, parameters: nil) - - XCTAssertEqual(result.columns, ["ID"]) - XCTAssertEqual(result.rows, [[.text("7")]]) - XCTAssertTrue(driver.hasLostConnection) - } - - func testServerErrorsAreMappedWithTheirCode() async { - let session = HanaFakeSession() - session.fail(HanaBridgeFailure(kind: .server, code: 259, message: "invalid table name")) - let driver = HanaPluginDriver(config: config(), session: session) - - do { - _ = try await driver.execute(query: "SELECT * FROM MISSING") - XCTFail("the statement should fail") - } catch { - XCTAssertEqual((error as? HanaError)?.pluginErrorCode, 259, "got \(error)") - } - } - - func testTimeoutAndLostConnectionReachTheSession() async throws { - let session = HanaFakeSession() - let driver = HanaPluginDriver(config: config(), session: session) - - XCTAssertFalse(driver.hasLostConnection) - session.markConnectionLost() - XCTAssertTrue(driver.hasLostConnection) - - try await driver.applyQueryTimeout(45) - XCTAssertEqual(session.timeout, 45) - } - - func testSwitchSchemaRunsSetSchemaWithTheExactQuotedName() async throws { - let session = HanaFakeSession() - let driver = HanaPluginDriver(config: config(), session: session) - - try await driver.switchSchema(to: "Mixed \"Case\"") - - XCTAssertEqual(session.executed.map(\.sql), ["SET SCHEMA \"Mixed \"\"Case\"\"\""]) - XCTAssertEqual(driver.currentSchema, "Mixed \"Case\"") - do { - try await driver.switchSchema(to: " ") - XCTFail("an empty schema name should be refused") - } catch { - XCTAssertEqual((error as? HanaError)?.kind, .configuration) - } - } - - func testFetchTablesAndDatabasesReadTheCatalogForTheCurrentSchema() async throws { - let session = HanaFakeSession() - session.respond(HanaEnvelopes.rows( - ["TABLE_NAME", "OBJECT_TYPE", "COMMENTS"], - [[.text("ORDERS"), .text("TABLE"), .null], [.text("V1"), .text("VIEW"), .text("A view")]] - )) - session.respond(HanaEnvelopes.rows(["SCHEMA_NAME"], [[.text("APP")], [.text("SYS")]])) - session.respond(HanaEnvelopes.rows(["COUNT(*)"], [[.text("12")]])) - let driver = HanaPluginDriver(config: config(database: "APP"), session: session) - - let tables = try await driver.fetchTables(schema: nil) - let databases = try await driver.fetchDatabases() - let metadata = try await driver.fetchDatabaseMetadata("SYS") - - XCTAssertEqual(tables.map(\.name), ["ORDERS", "V1"]) - XCTAssertEqual(tables.map(\.type), ["TABLE", "VIEW"]) - XCTAssertEqual(databases, ["APP", "SYS"]) - XCTAssertEqual(metadata.tableCount, 12) - XCTAssertTrue(metadata.isSystemDatabase) - XCTAssertEqual(session.executed.map(\.sql), [ - HanaCatalogQueries.tables(schema: "APP"), - HanaCatalogQueries.schemas, - HanaCatalogQueries.tableCount(schema: "SYS") - ]) - } - - func testBulkColumnFetchIsOneQuery() async throws { - let session = HanaFakeSession() - session.respond(HanaEnvelopes.rows( - Array(repeating: "C", count: 11), - [ - [.text("A"), .text("ID"), .text("INTEGER"), .null, .null, .text("FALSE"), .null, .null, .null, .null, - .text("1")], - [.text("B"), .text("ID"), .text("INTEGER"), .null, .null, .text("TRUE"), .null, .null, .null, .null, - .null] - ] - )) - let driver = HanaPluginDriver(config: config(database: "APP"), session: session) - - let columns = try await driver.fetchAllColumns(schema: "APP") - - XCTAssertTrue(driver.providesBulkColumnFetch) - XCTAssertEqual(session.executed.map(\.sql), [HanaCatalogQueries.columns(schema: "APP", table: nil)]) - XCTAssertEqual(columns["A"]?.first?.isPrimaryKey, true) - XCTAssertEqual(columns["B"]?.first?.isPrimaryKey, false) - } - - func testTableDDLComesFromTheCatalog() async throws { - let session = HanaFakeSession() - session.respond(HanaEnvelopes.rows(["IS_COLUMN_TABLE"], [[.text("TRUE")]])) - session.respond(HanaEnvelopes.rows( - Array(repeating: "C", count: 11), - [[.text("T"), .text("ID"), .text("INTEGER"), .null, .null, .text("FALSE"), .null, .null, .null, .null, - .text("1")]] - )) - let driver = HanaPluginDriver(config: config(database: "APP"), session: session) - - let ddl = try await driver.fetchTableDDL(table: "T", schema: nil) - - XCTAssertEqual(ddl, "CREATE COLUMN TABLE \"APP\".\"T\" (\n \"ID\" INTEGER NOT NULL,\n PRIMARY KEY (\"ID\")\n);") - } - - func testTableDDLForAMissingTableIsAnError() async { - let driver = HanaPluginDriver(config: config(database: "APP"), session: HanaFakeSession()) - - do { - _ = try await driver.fetchTableDDL(table: "MISSING", schema: nil) - XCTFail("a missing table should fail") - } catch { - XCTAssertEqual((error as? HanaError)?.kind, .missingObject, "got \(error)") - } - } - - func testViewDefinitionIsACreateViewStatement() async throws { - let session = HanaFakeSession() - session.respond(HanaEnvelopes.rows(["DEFINITION"], [[.text("SELECT * FROM \"APP\".\"T\"")]])) - let driver = HanaPluginDriver(config: config(database: "APP"), session: session) - - let definition = try await driver.fetchViewDefinition(view: "V", schema: nil) - - XCTAssertEqual(definition, "CREATE VIEW \"APP\".\"V\" AS\nSELECT * FROM \"APP\".\"T\"") - } - - func testTableMetadataFallsBackToTheViewComment() async throws { - let session = HanaFakeSession() - session.respond(HanaEnvelopes.rows(["TABLE_TYPE", "COMMENTS", "RECORD_COUNT", "TABLE_SIZE"], [])) - session.respond(HanaEnvelopes.rows(["COMMENTS"], [[.text("Orders view")]])) - let driver = HanaPluginDriver(config: config(database: "APP"), session: session) - - let metadata = try await driver.fetchTableMetadata(table: "V", schema: nil) - - XCTAssertEqual(metadata.comment, "Orders view") - XCTAssertNil(metadata.rowCount) - } - - func testApproximateRowCountReadsTheMonitoringView() async throws { - let session = HanaFakeSession() - session.respond(HanaEnvelopes.rows(["RECORD_COUNT"], [[.text("98765")]])) - let driver = HanaPluginDriver(config: config(database: "APP"), session: session) - - let count = try await driver.fetchApproximateRowCount(table: "T", schema: "APP") - - XCTAssertEqual(count, 98_765) - XCTAssertEqual(session.executed.map(\.sql), [HanaCatalogQueries.approximateRowCount(schema: "APP", table: "T")]) - } - - func testCatalogReadsNeedASchema() async { - let driver = HanaPluginDriver(config: config(database: ""), session: HanaFakeSession()) - - do { - _ = try await driver.fetchTables(schema: nil) - XCTFail("a catalog read without a schema should fail") - } catch { - XCTAssertEqual((error as? HanaError)?.kind, .configuration, "got \(error)") - } - } - - private func config( - host: String = "hana.example", - port: Int = 443, - username: String = "DBADMIN", - database: String = "APP", - ssl: SSLConfiguration = SSLConfiguration(mode: .verifyIdentity), - additionalFields: [String: String] = [:] - ) -> DriverConnectionConfig { - DriverConnectionConfig( - host: host, - port: port, - username: username, - password: "test-only", - database: database, - ssl: ssl, - additionalFields: additionalFields - ) - } -} diff --git a/Plugins/MongoDBDriverPlugin/MongoBsonBuilder.swift b/Plugins/MongoDBDriverPlugin/MongoBsonBuilder.swift index da35c144be..a8b0f08319 100644 --- a/Plugins/MongoDBDriverPlugin/MongoBsonBuilder.swift +++ b/Plugins/MongoDBDriverPlugin/MongoBsonBuilder.swift @@ -8,9 +8,6 @@ import CLibMongoc import Foundation /// Builds the BSON document `MongoBsonAssembly` plans for one JSON text. -/// -/// Kept apart from `MongoDBConnection` so `scripts/check-mongodb-filter-shapes.sh` compiles this -/// same code against the libbson the plugin links, rather than a copy of what it is meant to do. enum MongoBsonBuilder { /// A new document the caller destroys, or nil after `onParseFailure` has been given libbson's /// reason for the text it refused. diff --git a/Plugins/TableProPluginKit/.swiftlint.yml b/Plugins/TableProPluginKit/.swiftlint.yml new file mode 100644 index 0000000000..a9daddd575 --- /dev/null +++ b/Plugins/TableProPluginKit/.swiftlint.yml @@ -0,0 +1,3 @@ +# Linted in full like the rest of TableProCore, which links this directory. Without this file the +# Plugins/ nested config would narrow it. +disabled_rules: [] diff --git a/Plugins/TableProPluginKit/PluginHostStorage.swift b/Plugins/TableProPluginKit/PluginHostStorage.swift index 388f6b0dfb..c6a4799cde 100644 --- a/Plugins/TableProPluginKit/PluginHostStorage.swift +++ b/Plugins/TableProPluginKit/PluginHostStorage.swift @@ -5,26 +5,16 @@ import Foundation -/// Where the host app and everything loaded into it keep preferences. A UI test names a throwaway -/// directory in `TABLEPRO_UI_TEST_SANDBOX`, and every writer resolves its defaults domain from here -/// so none of them can reach the real one. -/// -/// This lives in PluginKit rather than in the app because `PluginSettingsStorage` is here and cannot -/// see the app module, and one derivation of the suite name is the only way the two agree. Adding a -/// type is additive, so it needs no PluginKit version bump. +/// Lives in PluginKit so `PluginSettingsStorage` and the app derive the same sandbox suite. public enum PluginHostStorage { public static let sandboxVariable = "TABLEPRO_UI_TEST_SANDBOX" - /// One fixed domain for every sandboxed run, not one per run. A defaults domain is a file in - /// the user's preferences directory and `cfprefsd` writes it out after the process that owns it - /// has already exited, so neither the app nor the test can reliably delete its own: one session - /// left 194 of them behind that way. A single name means at most one file exists, and the test - /// harness empties the domain before each test, which is what actually isolates them. The files - /// inside the sandbox directory stay per-run. + /// The prefix of every sandbox suite; `scripts/clean-test-residue.sh` finds them by it. public static let sandboxSuiteName = "com.TablePro.uitest" + /// One suite per sandbox path, so concurrent runs from other plans or checkouts never share one. public static func suiteName(forSandboxAt path: String) -> String { - sandboxSuiteName + "\(sandboxSuiteName).\(stableDigest(of: URL(fileURLWithPath: path).path))" } public static func resolveDefaults() -> UserDefaults { @@ -35,4 +25,13 @@ public enum PluginHostStorage { else { return .standard } return sandboxed } + + /// FNV-1a, because `Hasher` is seeded per process and the app, its plugins and the next run must agree. + private static func stableDigest(of text: String) -> String { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in text.utf8 { + hash = (hash ^ UInt64(byte)) &* 0x0000_0100_0000_01b3 + } + return String(format: "%016llx", hash) + } } diff --git a/TablePro/Core/MCP/MCPAuditLogger.swift b/TablePro/Core/MCP/MCPAuditLogger.swift index 4842b2d684..58845f078f 100644 --- a/TablePro/Core/MCP/MCPAuditLogger.swift +++ b/TablePro/Core/MCP/MCPAuditLogger.swift @@ -257,8 +257,8 @@ enum MCPAuditLogger { ) { let digest = SHA256.hash(data: payload).compactMap { String(format: "%02x", $0) }.joined() /// Named for what it digests. In this surface a fingerprint means a pairing token's, which - /// no log line may carry, and `MCPAuditLogStorageTests` reads that vocabulary rather than - /// the value. + /// no log line may carry, and the `mcp_log_token_material` SwiftLint rule reads that + /// vocabulary rather than the value. let payloadDigest = String(digest.prefix(16)) serverTool.info( """ diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 26030fc0a4..b282fd9e31 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -181,8 +181,11 @@ final class PluginManager: ObservableObject { let continuation: CheckedContinuation } + // Until `loadPlugins()` runs, no load is in flight and a wait could only time out. + private var hasStartedInitialLoad = false + func waitForInitialLoad() async { - if hasFinishedInitialLoad { return } + guard hasStartedInitialLoad, !hasFinishedInitialLoad else { return } let waiterId = UUID() let timeoutTask = Task { [weak self] in try? await Task.sleep(for: .seconds(10)) @@ -311,6 +314,7 @@ final class PluginManager: ObservableObject { // MARK: - Loading func loadPlugins() { + hasStartedInitialLoad = true cleanStaleStagingArtifacts() discoverAllPlugins() diff --git a/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift b/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift index c9c85718a0..a476ddcbf7 100644 --- a/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift +++ b/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift @@ -7,10 +7,10 @@ import AppKit import os /// The only place in the app that reads or writes `NSApplication.activationPolicy`, and the only -/// place that calls `NSApplication.activate`. `AppActivationPolicySourceTests` enforces that, because -/// the two are the same decision: bringing the app forward for a person is what makes it an app the -/// person can see, and a background process that activates without a Dock icon or a menu bar leaves -/// them a window with no Cmd+W, no Cmd+Q and no Edit menu. +/// place that calls `NSApplication.activate`. The `app_activation_owner` SwiftLint rule enforces +/// that, because the two are the same decision: bringing the app forward for a person is what makes +/// it an app the person can see, and a background process that activates without a Dock icon or a +/// menu bar leaves them a window with no Cmd+W, no Cmd+Q and no Edit menu. /// /// Three measured behaviours shape this (macOS 26, probe app observed through `lsappinfo`): /// the launch role has to be applied before `NSApplicationMain` or LaunchServices registers a diff --git a/TablePro/Core/Services/Infrastructure/SampleDatabaseService.swift b/TablePro/Core/Services/Infrastructure/SampleDatabaseService.swift index 52af4f5d41..2dd852dd6d 100644 --- a/TablePro/Core/Services/Infrastructure/SampleDatabaseService.swift +++ b/TablePro/Core/Services/Infrastructure/SampleDatabaseService.swift @@ -85,7 +85,6 @@ internal final class SampleDatabaseService { } if fileManager.fileExists(atPath: installed.path) { - seedUITestFixturesIfRequested(at: installed) return } @@ -97,15 +96,6 @@ internal final class SampleDatabaseService { } catch { throw SampleDatabaseError.copyFailed(message: error.localizedDescription) } - seedUITestFixturesIfRequested(at: installed) - } - - /// Chinook carries nothing a JSON editor will open, so a UI test that needs one asks for a - /// fixture table at launch. Seeded on every install, including the one that finds the file - /// already there, so a case that edited the row does not hand it to the next case. - private func seedUITestFixturesIfRequested(at installed: URL) { - guard UITestJsonFixture.isRequested else { return } - UITestJsonFixture.seed(into: installed) } internal func resetToBundled() throws { @@ -133,7 +123,6 @@ internal final class SampleDatabaseService { } catch { throw SampleDatabaseError.copyFailed(message: error.localizedDescription) } - seedUITestFixturesIfRequested(at: installed) } private func removeInstalledDatabaseFiles() throws { diff --git a/TablePro/Core/Services/Infrastructure/UITestJsonFixture.swift b/TablePro/Core/Services/Infrastructure/UITestJsonFixture.swift deleted file mode 100644 index 08ad0ea2ef..0000000000 --- a/TablePro/Core/Services/Infrastructure/UITestJsonFixture.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// UITestJsonFixture.swift -// TablePro -// - -import Foundation -import os -import SQLite3 - -/// A table with a JSON document in it, added to the installed sample database so a UI test can -/// reach the row inspector's JSON field editor. -/// -/// Chinook has no JSON column and no text value that parses as a JSON object, so -/// `FieldEditorResolver` never returns `.json` against it and `JsonEditorView` never appears. That -/// left the editor with no deterministic fixture at all, which is why the update loop of #3051 -/// shipped with no UI coverage over it. -/// -/// Written straight through SQLite rather than through a driver plugin: this runs while the sample -/// file is being installed, before any connection to it exists, and the app already links SQLite -/// for its own stores. Gated on the storage sandbox and on an explicit launch variable, so a -/// shipped build cannot reach it even by accident. -internal enum UITestJsonFixture { - internal static let launchVariable = "TABLEPRO_UI_TEST_SEED_JSON_TABLE" - internal static let tableName = "json_fixture" - - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "UITestJsonFixture") - - /// A document with enough structure to lay out over several lines, and a value a test can - /// assert on after typing into it. - internal static let document = """ - {"id": 1, "name": "Acme", "active": true, "tags": ["alpha", "beta"], "limits": {"seats": 25}} - """ - - internal static var isRequested: Bool { - guard AppStorageEnvironment.shared.isIsolated else { return false } - let raw = ProcessInfo.processInfo.environment[launchVariable]? - .trimmingCharacters(in: .whitespacesAndNewlines) - return !(raw ?? "").isEmpty - } - - /// Adds the fixture table to a SQLite file, replacing any earlier copy so a re-installed sample - /// never inherits a row a previous test edited. - internal static func seed(into fileURL: URL) { - var handle: OpaquePointer? - guard sqlite3_open_v2(fileURL.path, &handle, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK, let handle else { - logger.error("Could not open the sample database to seed the JSON fixture") - sqlite3_close(handle) - return - } - defer { sqlite3_close(handle) } - - let statements = [ - "DROP TABLE IF EXISTS \(tableName)", - "CREATE TABLE \(tableName) (id INTEGER PRIMARY KEY, label TEXT NOT NULL, payload TEXT NOT NULL)", - "INSERT INTO \(tableName) (id, label, payload) VALUES (1, 'First', '\(escaped(document))')", - "INSERT INTO \(tableName) (id, label, payload) VALUES (2, 'Second', '\(escaped(document))')" - ] - for statement in statements { - var message: UnsafeMutablePointer? - guard sqlite3_exec(handle, statement, nil, nil, &message) == SQLITE_OK else { - logger.error("Seeding the JSON fixture failed: \(String(cString: message ?? strdup("")), privacy: .public)") - sqlite3_free(message) - return - } - sqlite3_free(message) - } - } - - private static func escaped(_ value: String) -> String { - value.replacingOccurrences(of: "'", with: "''") - } -} diff --git a/TablePro/Core/Storage/AppStorageEnvironment.swift b/TablePro/Core/Storage/AppStorageEnvironment.swift index cc0ec1e315..2404606c7b 100644 --- a/TablePro/Core/Storage/AppStorageEnvironment.swift +++ b/TablePro/Core/Storage/AppStorageEnvironment.swift @@ -7,34 +7,17 @@ import Foundation import os import TableProPluginKit -/// The three roots every persistent store resolves through: the Application Support directory, the -/// defaults domain, and the keychain. Production resolves them to the user's own locations. A UI -/// test names a throwaway directory in `TABLEPRO_UI_TEST_SANDBOX` and gets all three inside it, so -/// driving the app from a test can never read or write the data of the person running it. -/// -/// The app cannot detect on its own that it is running under XCUITest. Measured on macOS 27, the -/// app under test receives no `XCTestConfigurationFilePath`, no `XCTestSessionIdentifier` and no -/// XCTest `DYLD_INSERT_LIBRARIES`; the only test-related variable present is the one the test -/// itself sets. Every other marker (`XCODE_SCHEME_NAME`, `NSUnbufferedIO`) is also present on an -/// ordinary Run from Xcode, so none of them can gate this. The guard is therefore two layers: at -/// runtime `TABLEPRO_UI_TESTING` without a sandbox beside it is fatal, which covers every test that -/// opts in; and at build time `UITestCase` is the only way a UI test may construct an application, -/// which covers a new test that opts into neither. +/// Every store, the defaults suite and the keychain resolve inside `TABLEPRO_UI_TEST_SANDBOX` when it is set. +/// The app cannot detect XCUITest (no XCTest variable reaches it), so `TABLEPRO_UI_TESTING` without a sandbox is fatal. internal final class AppStorageEnvironment: @unchecked Sendable { internal static let shared = AppStorageEnvironment.resolve() - /// The Application Support root. Every store already appends its own `TablePro/...` subpath to - /// this, so redirecting the root alone moves all of them and leaves each site's own layout - /// untouched. + /// Every store appends its own `TablePro/...` subpath, so moving the root moves all of them. internal let applicationSupportRoot: URL internal let defaults: UserDefaults internal let keychain: any KeychainStoring internal let isIsolated: Bool internal let isUnitTestHost: Bool - - /// Named so a test can remove the domain once the app it belongs to is gone. The app cannot do - /// it itself: `XCUIApplication.terminate()` kills the process, so `applicationWillTerminate` - /// never runs under a UI test. internal let defaultsSuiteName: String? internal var supportDirectory: URL { @@ -46,6 +29,7 @@ internal final class AppStorageEnvironment: @unchecked Sendable { internal static let uiTestingVariable = "TABLEPRO_UI_TESTING" internal static let sandboxVariable = PluginHostStorage.sandboxVariable internal static let unitTestHostVariable = "XCTestConfigurationFilePath" + internal static let sandboxMarkerName = ".defaults-suite" private init( applicationSupportRoot: URL, @@ -63,20 +47,15 @@ internal final class AppStorageEnvironment: @unchecked Sendable { self.defaultsSuiteName = defaultsSuiteName } - - /// Resolved from `main.swift` before anything else runs. A storage singleton reached from a - /// static initializer would otherwise have computed a production path already, and the sandbox - /// would apply to whatever happened to be touched after it. + /// Called first from `main.swift`, before a static initializer can resolve a production path. internal static func bootstrap() { _ = shared } - /// The choice the environment variables imply, separated from acting on it so it can be tested - /// without a process that has already resolved its storage. internal enum Decision: Equatable { case production case isolated(path: String) - /// A UI test opted in but named no sandbox. Launching would drive the real store. + /// A UI test opted in but named no sandbox; launching would drive the real store. case refuseToLaunch } @@ -122,12 +101,14 @@ internal final class AppStorageEnvironment: @unchecked Sendable { ) } - /// A failure to prepare the sandbox is fatal rather than a fallback to production. Falling back - /// is what turns a broken test setup into a polluted store, which is the whole failure this - /// type exists to prevent. + /// Fatal rather than a fallback, which would turn a broken test setup into a polluted real store. private static func isolated(at root: URL, isUnitTestHost: Bool) -> AppStorageEnvironment { let supportDirectory = root.appendingPathComponent("TablePro", isDirectory: true) do { + // The Unit plan reuses one sandbox path per checkout, so every host launch starts empty. + if isUnitTestHost, FileManager.default.fileExists(atPath: supportDirectory.path) { + try FileManager.default.removeItem(at: supportDirectory) + } try FileManager.default.createDirectory(at: supportDirectory, withIntermediateDirectories: true) } catch { logger.fault( @@ -144,6 +125,7 @@ internal final class AppStorageEnvironment: @unchecked Sendable { logger.fault("Refusing to launch: could not open the defaults suite \(suiteName, privacy: .public)") exit(EXIT_FAILURE) } + emptyDefaultsIfNewSandbox(defaults, suiteName: suiteName, supportDirectory: supportDirectory) logger.notice("Storage sandboxed at \(supportDirectory.path, privacy: .private(mask: .hash))") return AppStorageEnvironment( @@ -156,8 +138,18 @@ internal final class AppStorageEnvironment: @unchecked Sendable { ) } - /// The one place in the app allowed to ask the file system for this. Every other store goes - /// through `applicationSupportRoot`, and a SwiftLint rule keeps it that way. + /// cfprefsd keeps a suite after its sandbox directory is deleted, so a directory without the + /// marker is a new sandbox and gets an empty suite; a relaunch into the same directory keeps it. + internal static func emptyDefaultsIfNewSandbox(_ defaults: UserDefaults, suiteName: String, supportDirectory: URL) { + let marker = supportDirectory.appendingPathComponent(sandboxMarkerName, isDirectory: false) + guard !FileManager.default.fileExists(atPath: marker.path) else { return } + defaults.removePersistentDomain(forName: suiteName) + if !FileManager.default.createFile(atPath: marker.path, contents: Data()) { + logger.error("Could not mark the sandbox, so the next launch empties its defaults again") + } + } + + /// The only direct lookup; a SwiftLint rule sends every other store through `applicationSupportRoot`. private static func productionRoot() -> URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory diff --git a/TablePro/Models/ThirdParty/ThirdPartyComponent.swift b/TablePro/Models/ThirdParty/ThirdPartyComponent.swift index dc6d0fdcfb..6ab551651b 100644 --- a/TablePro/Models/ThirdParty/ThirdPartyComponent.swift +++ b/TablePro/Models/ThirdParty/ThirdPartyComponent.swift @@ -12,7 +12,7 @@ struct ThirdPartyComponent: Codable, Identifiable, Hashable { /// Where the shipped version is pinned, so a bump cannot pass unnoticed. /// /// `shellVariable` and `swiftPackage` are checked against the real pin by - /// `ThirdPartyLicenseInventoryTests`. `manual` opts out, and is only correct for a + /// `scripts/ci/check-license-inventory.py`. `manual` opts out, and is only correct for a /// component whose version is recorded nowhere a machine can read. enum VersionSource: Hashable { case shellVariable(String) diff --git a/TablePro/Models/UI/SplitViewAutosaveName.swift b/TablePro/Models/UI/SplitViewAutosaveName.swift index c1cb4bf0ad..740bebb874 100644 --- a/TablePro/Models/UI/SplitViewAutosaveName.swift +++ b/TablePro/Models/UI/SplitViewAutosaveName.swift @@ -5,52 +5,13 @@ import Foundation -/// The names AppKit files persisted window and pane geometry under. -/// -/// AppKit writes an autosave record into the app process's own standard defaults domain. The UI -/// test sandbox does not reach that: it redirects `applicationSupportRoot` and hands out its own -/// `UserDefaults(suiteName:)`, and these records land in neither. So a sidebar width, a divider -/// position, a collapsed pane or a window frame set by one UI test case is inherited by every case -/// that runs after it in the same shard, on the real machine's defaults. -/// -/// That is not theoretical. CI shards the UI suite by list position (`runnable[index::count]` in -/// `scripts/ci/list_tests.py`), so adding two unrelated cases re-deals every later case onto a -/// different runner. A query history case that had been passing moved shards, woke up with another -/// case's geometry, and its drawer laid out taller than the 1024x768 runner's window: the detail -/// pane ran from y=545 to y=712 against a window bottom edge at y=705, so Copy, Run in New Tab and -/// Load in Editor all straddled the edge and the click on Load in Editor did nothing. The failure -/// looked like a broken feature and was a leaked preference. -/// -/// Namespacing the record per sandbox gives every case the default layout it expects. Production -/// keeps the bare name: versioning those would throw away every width, divider and frame a real -/// user has saved. internal enum SplitViewAutosaveName { - /// Never version this. `NSSplitView` clamps a restored frame against the current minimums, so a - /// pane simply widens to fit; bumping the key instead discards every width and collapse state - /// the user has and leaves the old keys orphaned. + /// Never version this: `NSSplitView` clamps a restored frame to the current minimums, and a new key discards every saved width. internal static let base = "com.TablePro.mainSplit" - /// The drawer that carries one connection's query history. - /// - /// Declared here rather than spelled at the call site, so the name and anything that has to - /// recognise it share one spelling instead of two lists nothing forces to agree. internal static let historyDrawerPrefix = "HistoryDrawer-" - /// The editor/results divider of one connection's query pane. - /// - /// Per connection, not per tab, because a per-tab record is not reachable through - /// `autosaveName` at all. `VerticalCollapsibleSplitView` assigns the name in - /// `makeNSViewController` only, and the query tab branch of `MainEditorContentView` is the one - /// tab-content branch with no `.id(tab.id)`, so switching between two query tabs reuses the same - /// `NSSplitViewController` with the name it was born with: both tabs autosaved into the first - /// tab's record and the second never had one. Measured before this changed: 164 persisted query - /// tabs against 1 tab id with a record, beside 795 records naming tabs that were gone. - /// - /// Reassigning the name on update cannot fix it. `MainSplitViewController` records the same - /// lesson one layer up: assigning a name to a split view that has already laid out does not - /// re-apply the saved frames. Giving the branch its own identity per tab would, and would also - /// rebuild the editor on every tab switch, discarding the find panel, the undo stack and the - /// grid's scroll and selection. So the honest name is the one the pane actually has. + /// Per connection, not per tab: the query tab branch has no `.id(tab.id)`, so its split view keeps the name it was born with. internal static let querySplitPrefix = "QuerySplit-" internal static func historyDrawer(connectionId: UUID) -> String { @@ -61,32 +22,14 @@ internal enum SplitViewAutosaveName { "\(querySplitPrefix)\(connectionId.uuidString)" } - /// - Parameter sandboxIdentifier: something unique to one test case. The sandbox root's last - /// path component is a fresh UUID per case, which is exactly that. - internal static func resolved(isIsolated: Bool, sandboxIdentifier: String?) -> String { - resolved(base, isIsolated: isIsolated, sandboxIdentifier: sandboxIdentifier) - } - - /// The general form. Every autosave name in the app goes through this, because they all land in - /// the same unisolated defaults and leak the same way. - internal static func resolved( - _ name: String, - isIsolated: Bool, - sandboxIdentifier: String? - ) -> String { - guard isIsolated, let sandboxIdentifier, !sandboxIdentifier.isEmpty else { return name } - return "\(name).\(sandboxIdentifier)" - } - - /// What a call site uses. Reads the running environment so a caller cannot forget the rule. + /// Nil in the unit test host and the storage sandbox, because AppKit files the record in the real standard domain. @MainActor internal static func current(_ name: String) -> String? { let environment = AppStorageEnvironment.shared - guard !environment.isUnitTestHost else { return nil } - return resolved( - name, - isIsolated: environment.isIsolated, - sandboxIdentifier: environment.applicationSupportRoot.lastPathComponent - ) + return autosaveName(name, isUnitTestHost: environment.isUnitTestHost, isIsolated: environment.isIsolated) + } + + internal static func autosaveName(_ name: String, isUnitTestHost: Bool, isIsolated: Bool) -> String? { + isUnitTestHost || isIsolated ? nil : name } } diff --git a/TablePro/Resources/ThirdPartyLicenses/licenses.yml b/TablePro/Resources/ThirdPartyLicenses/licenses.yml index 7bf38e082d..4bfaf8ac32 100644 --- a/TablePro/Resources/ThirdPartyLicenses/licenses.yml +++ b/TablePro/Resources/ThirdPartyLicenses/licenses.yml @@ -1,7 +1,7 @@ # Open source libraries TablePro redistributes. # -# Hand-maintained. ThirdPartyLicenseInventoryTests checks each version against the pin -# in scripts/build-*.sh or Package.resolved and fails when they disagree, because a +# Hand-maintained. scripts/ci/check-license-inventory.py checks each version against the +# pin in scripts/build-*.sh or Package.resolved and fails when they disagree, because a # version bump can change a license and that needs a person to re-read it. # # spdx: UNVERIFIED means the project publishes no license we could read. Those entries @@ -10,7 +10,7 @@ # platforms lists where a component ships: macos, ios or both. Absent means [macos]. # The iOS app reads TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json, the # projection of every ios entry. Run scripts/generate-ios-acknowledgements.py after editing an -# ios entry; ThirdPartyLicenseInventoryTests fails while that file is stale. +# ios entry; scripts/ci/check-license-inventory.py fails while that file is stale. - id: bigint name: BigInt version: 5.7.0 @@ -167,7 +167,7 @@ library vendors golang.org/x/crypto, x/net, x/sys and x/text (src/vendor/modules.txt at go1.27.1), all under this same BSD-3-Clause notice from The Go Authors. PATENTS beside LICENSE is an extra patent grant from Google and adds no notice duty. source is manual because the pin lives in go.mod, - not in a build-*.sh variable, so ThirdPartyLicenseInventoryTests does not check this version. + not in a build-*.sh variable, so scripts/ci/check-license-inventory.py does not check this version. Reaches users only through the registry-only SAP HANA plugin: the app and every bundled plugin link no Go code.' - id: go-hdb diff --git a/TablePro/Theme/ThemeDefinition.swift b/TablePro/Theme/ThemeDefinition.swift index 091ff476f5..227eaea001 100644 --- a/TablePro/Theme/ThemeDefinition.swift +++ b/TablePro/Theme/ThemeDefinition.swift @@ -59,9 +59,9 @@ internal struct ThemeDefinition: Identifiable, Equatable, Sendable { } } -/// The single registry every slot goes through. A slot exists here because a call site reads it, -/// which `ThemeSlotCoverageTests` enforces. The document decoder, the encoder and the theme editor -/// all enumerate this list rather than keeping their own copies. +/// The single registry every slot goes through. A slot exists here because a call site reads it. +/// The document decoder, the encoder and the theme editor all enumerate this list rather than +/// keeping their own copies. internal enum ThemeSlot: String, CaseIterable, Sendable { case windowBackdrop = "window.background" diff --git a/TablePro/Views/Welcome/WelcomeLibraryPane.swift b/TablePro/Views/Welcome/WelcomeLibraryPane.swift index ce76005a01..c1cf09d6ec 100644 --- a/TablePro/Views/Welcome/WelcomeLibraryPane.swift +++ b/TablePro/Views/Welcome/WelcomeLibraryPane.swift @@ -148,7 +148,7 @@ internal struct WelcomeViewOptionsMenu: View { } /// A toolbar-hosted `Menu` is named by this modifier and not by its label: built against /// the macOS 26 SDK, the label alone publishes "chevron.pulldown". An in-view `Menu` is the - /// reverse, which is the rule `MenuDisclosureIndicatorTests` holds everywhere else. + /// reverse. .accessibilityLabel(String(localized: "View Options")) .help(String(localized: "Sort and filter connections")) .accessibilityIdentifier("welcome-toolbar-view-options") diff --git a/TableProMobile/.swiftlint.yml b/TableProMobile/.swiftlint.yml new file mode 100644 index 0000000000..d591f7d639 --- /dev/null +++ b/TableProMobile/.swiftlint.yml @@ -0,0 +1,6 @@ +only_rules: + - dispatch_handler_isolation + - fsevents_callback_isolation + - date_formatter_locale + - count_before_inflected_span + - sync_record_id_construction diff --git a/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift deleted file mode 100644 index 189f3a9766..0000000000 --- a/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift +++ /dev/null @@ -1,277 +0,0 @@ -import XCTest -import TableProDatabase -import TableProModels -@testable import TableProMobile - -/// Integration tests for the iOS MSSQL driver against a real SQL Server instance. -/// -/// All tests skip unless `MSSQL_TEST_HOST` is set in the environment. To run locally: -/// ``` -/// MSSQL_TEST_HOST=localhost MSSQL_TEST_USER=sa MSSQL_TEST_PASSWORD='YourStrong!Pass' \ -/// xcodebuild test -scheme TableProMobile -only-testing:TableProMobileTests/MSSQLDriverTests -/// ``` -final class MSSQLDriverTests: XCTestCase { - private var driver: MSSQLDriver? - - private static func loadTestConfig() -> [String: String]? { - let env = ProcessInfo.processInfo.environment - if env["MSSQL_TEST_HOST"] != nil { - return env - } - let fallbackPath = env["MSSQL_TEST_CONFIG_PATH"] ?? "/tmp/mssql-test.json" - guard let data = FileManager.default.contents(atPath: fallbackPath), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] - else { return nil } - return json - } - - override func setUp() async throws { - guard let config = Self.loadTestConfig() else { - throw XCTSkip("MSSQL_TEST_HOST not set and /tmp/mssql-test.json not found, skipping integration tests") - } - let mode: SSLConfiguration.SSLMode - switch config["MSSQL_TEST_SSL_MODE"] ?? "disable" { - case "require": mode = .require - case "verifyCa": mode = .verifyCa - case "verifyFull": mode = .verifyFull - default: mode = .disable - } - let connection = DatabaseConnection( - name: "test", - type: .mssql, - host: config["MSSQL_TEST_HOST"] ?? "localhost", - port: Int(config["MSSQL_TEST_PORT"] ?? "1433") ?? 1433, - username: config["MSSQL_TEST_USER"] ?? "sa", - database: config["MSSQL_TEST_DATABASE"] ?? "master", - additionalFields: ["mssqlSchema": "dbo"], - sslEnabled: mode != .disable, - sslConfiguration: SSLConfiguration(mode: mode) - ) - let password = config["MSSQL_TEST_PASSWORD"] ?? "" - driver = MSSQLDriver(connection: connection, password: password) - try await driver?.connect() - } - - override func tearDown() async throws { - try await driver?.disconnect() - driver = nil - } - - func testConnectAndPing() async throws { - let driver = try XCTUnwrap(driver) - let pong = try await driver.ping() - XCTAssertTrue(pong) - XCTAssertNotNil(driver.serverVersion) - } - - func testSimpleQuery() async throws { - let driver = try XCTUnwrap(driver) - let result = try await driver.execute(query: "SELECT 1 AS one, 'hello' AS greeting") - XCTAssertEqual(result.columns.map { $0.name }, ["one", "greeting"]) - XCTAssertEqual(result.rows.first?[0], "1") - XCTAssertEqual(result.rows.first?[1], "hello") - } - - func testFetchDatabasesIncludesMaster() async throws { - let driver = try XCTUnwrap(driver) - let names = try await driver.fetchDatabases() - XCTAssertTrue(names.contains("master")) - } - - func testFetchSchemasExcludesSystemSchemas() async throws { - let driver = try XCTUnwrap(driver) - let names = try await driver.fetchSchemas() - XCTAssertFalse(names.contains("sys")) - XCTAssertFalse(names.contains("information_schema")) - } - - func testFetchTablesReturnsResults() async throws { - let driver = try XCTUnwrap(driver) - _ = try await driver.execute(query: """ - IF OBJECT_ID('dbo.tablepro_test_table', 'U') IS NULL - CREATE TABLE dbo.tablepro_test_table (id INT PRIMARY KEY IDENTITY(1,1), name NVARCHAR(100)) - """) - let tables = try await driver.fetchTables(schema: "dbo") - XCTAssertTrue(tables.contains { $0.name == "tablepro_test_table" && $0.type == .table }) - } - - func testFetchColumnsReturnsTypeMetadata() async throws { - let driver = try XCTUnwrap(driver) - _ = try await driver.execute(query: """ - IF OBJECT_ID('dbo.tablepro_test_table', 'U') IS NULL - CREATE TABLE dbo.tablepro_test_table (id INT PRIMARY KEY IDENTITY(1,1), name NVARCHAR(100)) - """) - let columns = try await driver.fetchColumns(table: "tablepro_test_table", schema: "dbo") - XCTAssertEqual(columns.count, 2) - let id = columns.first { $0.name == "id" } - XCTAssertEqual(id?.isPrimaryKey, true) - XCTAssertEqual(id?.typeName, "int") - let name = columns.first { $0.name == "name" } - XCTAssertEqual(name?.typeName, "nvarchar(100)") - } - - func testExplicitTransactionRollback() async throws { - let driver = try XCTUnwrap(driver) - _ = try await driver.execute(query: """ - IF OBJECT_ID('dbo.tablepro_tx_test', 'U') IS NULL - CREATE TABLE dbo.tablepro_tx_test (v INT) - """) - _ = try await driver.execute(query: "DELETE FROM dbo.tablepro_tx_test") - - try await driver.beginTransaction() - _ = try await driver.execute(query: "INSERT INTO dbo.tablepro_tx_test VALUES (42)") - try await driver.rollbackTransaction() - - let result = try await driver.execute(query: "SELECT COUNT(*) FROM dbo.tablepro_tx_test") - XCTAssertEqual(result.rows.first?.first, "0") - } - - func testSwitchDatabaseChangesActiveDatabase() async throws { - let driver = try XCTUnwrap(driver) - try await driver.switchDatabase(to: "tempdb") - let result = try await driver.execute(query: "SELECT DB_NAME()") - XCTAssertEqual(result.rows.first?.first, "tempdb") - } - - func testSortedPaginationEmitsOrderByAndFetch() async throws { - let driver = try XCTUnwrap(driver) - _ = try await driver.execute(query: """ - IF OBJECT_ID('dbo.tablepro_pagination_test', 'U') IS NOT NULL - DROP TABLE dbo.tablepro_pagination_test; - CREATE TABLE dbo.tablepro_pagination_test (id INT PRIMARY KEY, label NVARCHAR(20)); - INSERT INTO dbo.tablepro_pagination_test VALUES - (1, N'a'), (2, N'b'), (3, N'c'), (4, N'd'), (5, N'e'); - """) - let result = try await driver.execute(query: """ - SELECT id, label FROM dbo.tablepro_pagination_test - ORDER BY id ASC - OFFSET 2 ROWS FETCH NEXT 2 ROWS ONLY - """) - XCTAssertEqual(result.rows.count, 2) - XCTAssertEqual(result.rows.first?.first, "3") - XCTAssertEqual(result.rows.last?.first, "4") - } - - func testCancellationStopsQuery() async throws { - let driver = try XCTUnwrap(driver) - let task = Task { [driver] in - try await driver.execute(query: "WAITFOR DELAY '00:00:05'; SELECT 1") - } - try await Task.sleep(nanoseconds: 100_000_000) - task.cancel() - do { - _ = try await task.value - XCTFail("Cancelled task should have thrown") - } catch is CancellationError { - // expected - } catch { - // FreeTDS may surface as DatabaseError on race; both are acceptable signals that the - // query was interrupted rather than completing the 5-second WAITFOR. - XCTAssertNotNil(error) - } - } - - func testWrongPasswordFailsClearly() async throws { - try XCTSkipIf(Self.loadTestConfig() == nil, "no test config") - let connection = DatabaseConnection( - name: "wrong-pw", - type: .mssql, - host: "localhost", - port: 1433, - username: "sa", - database: "master", - additionalFields: ["mssqlSchema": "dbo"] - ) - let badDriver = MSSQLDriver(connection: connection, password: "definitely-wrong-pass-123") - do { - try await badDriver.connect() - XCTFail("Bad password should fail to connect") - } catch { - // any error is acceptable; verify it's not nil and surfaces a message - let desc = (error as? LocalizedError)?.errorDescription ?? "\(error)" - XCTAssertFalse(desc.isEmpty) - } - } - - func testUnreachableHostFails() async throws { - try XCTSkipIf(Self.loadTestConfig() == nil, "no test config") - let connection = DatabaseConnection( - name: "unreachable", - type: .mssql, - host: "192.0.2.1", - port: 1433, - username: "sa", - database: "master", - additionalFields: ["mssqlSchema": "dbo", "mssqlLoginTimeout": "5"] - ) - let badDriver = MSSQLDriver(connection: connection, password: "x") - do { - try await badDriver.connect() - XCTFail("Connecting to RFC5737 TEST-NET should fail") - } catch { - let desc = (error as? LocalizedError)?.errorDescription ?? "\(error)" - XCTAssertFalse(desc.isEmpty) - } - } - - // Note: FreeTDS db-lib does not expose per-connection cert validation. `verifyFull` maps to - // `require` (TLS on, but no CA chain check). Testing strict TLS rejection requires either - // building FreeTDS with custom OpenSSL callbacks or trusting the certificate via machine-wide - // freetds.conf, neither of which is per-connection. Out of scope for this driver. - - func testSortedWithFilterCombinesOrderAndWhere() async throws { - let driver = try XCTUnwrap(driver) - _ = try await driver.execute(query: """ - IF OBJECT_ID('dbo.tp_filter_sort', 'U') IS NOT NULL DROP TABLE dbo.tp_filter_sort; - CREATE TABLE dbo.tp_filter_sort (id INT PRIMARY KEY, kind NVARCHAR(10), score INT); - INSERT INTO dbo.tp_filter_sort VALUES - (1, N'a', 10), (2, N'b', 5), (3, N'a', 20), (4, N'a', 15), (5, N'b', 1); - """) - let result = try await driver.execute(query: """ - SELECT id, kind, score FROM [dbo].[tp_filter_sort] - WHERE kind = N'a' - ORDER BY score DESC - OFFSET 0 ROWS FETCH NEXT 2 ROWS ONLY - """) - XCTAssertEqual(result.rows.count, 2) - XCTAssertEqual(result.rows.first?[0], "3") - XCTAssertEqual(result.rows.last?[0], "4") - } - - func testSelectTopOneCellLookup() async throws { - let driver = try XCTUnwrap(driver) - _ = try await driver.execute(query: """ - IF OBJECT_ID('dbo.tp_lookup', 'U') IS NOT NULL DROP TABLE dbo.tp_lookup; - CREATE TABLE dbo.tp_lookup (id INT PRIMARY KEY, payload NVARCHAR(MAX)); - INSERT INTO dbo.tp_lookup VALUES (7, N'hello'); - """) - let result = try await driver.execute(query: "SELECT TOP 1 [payload] FROM [dbo].[tp_lookup] WHERE [id] = 7") - XCTAssertEqual(result.rows.first?.first, "hello") - } - - func testMultiColumnForeignKey() async throws { - let driver = try XCTUnwrap(driver) - _ = try await driver.execute(query: """ - IF OBJECT_ID('dbo.tp_fk_child', 'U') IS NOT NULL DROP TABLE dbo.tp_fk_child; - IF OBJECT_ID('dbo.tp_fk_parent', 'U') IS NOT NULL DROP TABLE dbo.tp_fk_parent; - CREATE TABLE dbo.tp_fk_parent ( - tenant_id INT NOT NULL, - external_id INT NOT NULL, - CONSTRAINT pk_tp_fk_parent PRIMARY KEY (tenant_id, external_id) - ); - CREATE TABLE dbo.tp_fk_child ( - id INT PRIMARY KEY, - tenant_id INT NOT NULL, - external_id INT NOT NULL, - CONSTRAINT fk_tp_fk_child_parent FOREIGN KEY (tenant_id, external_id) - REFERENCES dbo.tp_fk_parent (tenant_id, external_id) - ); - """) - let fks = try await driver.fetchForeignKeys(table: "tp_fk_child", schema: "dbo") - let matched = fks.filter { $0.name == "fk_tp_fk_child_parent" } - XCTAssertEqual(matched.count, 2, "composite FK should produce two rows") - let cols = Set(matched.map { $0.column }) - XCTAssertEqual(cols, Set(["tenant_id", "external_id"])) - XCTAssertTrue(matched.allSatisfy { $0.referencedTable == "tp_fk_parent" }) - } -} diff --git a/TableProMobile/TableProMobileTests/Drivers/OracleDriverTests.swift b/TableProMobile/TableProMobileTests/Drivers/OracleDriverTests.swift deleted file mode 100644 index 4aca1803ac..0000000000 --- a/TableProMobile/TableProMobileTests/Drivers/OracleDriverTests.swift +++ /dev/null @@ -1,285 +0,0 @@ -import TableProDatabase -@testable import TableProMobile -import TableProModels -import TableProOracleCore -import XCTest - -/// Integration tests for the iOS Oracle driver against a real Oracle instance. -/// -/// All tests skip unless `ORACLE_TEST_HOST` is set in the environment. To run locally: -/// ``` -/// ORACLE_TEST_HOST=localhost ORACLE_TEST_USER=system ORACLE_TEST_PASSWORD='oracle' \ -/// ORACLE_TEST_SERVICE=FREEPDB1 \ -/// xcodebuild test -scheme TableProMobile -only-testing:TableProMobileTests/OracleDriverTests -/// ``` -final class OracleDriverTests: XCTestCase { - private var driver: OracleDriver? - private var schema = "" - - private static func loadTestConfig() -> [String: String]? { - let env = ProcessInfo.processInfo.environment - if env["ORACLE_TEST_HOST"] != nil { - return env - } - let fallbackPath = env["ORACLE_TEST_CONFIG_PATH"] ?? "/tmp/oracle-test.json" - guard let data = FileManager.default.contents(atPath: fallbackPath), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] - else { return nil } - return json - } - - private static func makeConnection(from config: [String: String]) -> DatabaseConnection { - let usesSID = config["ORACLE_TEST_SID"] != nil - var additionalFields = [ - OracleConnectionOptions.AdditionalFieldKey.connectionType: - usesSID ? OracleConnectionOptions.IdentifierMode.sid.rawValue - : OracleConnectionOptions.IdentifierMode.service.rawValue - ] - additionalFields[OracleConnectionOptions.AdditionalFieldKey.serviceName] = - config["ORACLE_TEST_SERVICE"] ?? "FREEPDB1" - additionalFields[OracleConnectionOptions.AdditionalFieldKey.sid] = config["ORACLE_TEST_SID"] ?? "" - - let mode: SSLConfiguration.SSLMode - switch config["ORACLE_TEST_SSL_MODE"] ?? "disable" { - case "require": mode = .require - case "verifyFull": mode = .verifyFull - default: mode = .disable - } - - return DatabaseConnection( - name: "test", - type: .oracle, - host: config["ORACLE_TEST_HOST"] ?? "localhost", - port: Int(config["ORACLE_TEST_PORT"] ?? "1521") ?? 1_521, - username: config["ORACLE_TEST_USER"] ?? "system", - database: "", - additionalFields: additionalFields, - sslEnabled: mode != .disable, - sslConfiguration: SSLConfiguration(mode: mode) - ) - } - - override func setUp() async throws { - guard let config = Self.loadTestConfig() else { - throw XCTSkip("ORACLE_TEST_HOST not set and /tmp/oracle-test.json not found, skipping integration tests") - } - let connection = Self.makeConnection(from: config) - schema = (config["ORACLE_TEST_USER"] ?? "system").uppercased() - let driver = OracleDriver(connection: connection, password: config["ORACLE_TEST_PASSWORD"] ?? "") - try await driver.connect() - self.driver = driver - } - - override func tearDown() async throws { - try await driver?.disconnect() - driver = nil - } - - func testConnectAndPing() async throws { - let driver = try XCTUnwrap(driver) - let pong = try await driver.ping() - XCTAssertTrue(pong) - XCTAssertNotNil(driver.serverVersion) - XCTAssertNotNil(driver.currentSchema) - } - - func testSimpleQuery() async throws { - let driver = try XCTUnwrap(driver) - let result = try await driver.execute(query: "SELECT 1 AS one, 'hello' AS greeting FROM DUAL") - XCTAssertEqual(result.columns.map(\.name), ["ONE", "GREETING"]) - XCTAssertEqual(result.rows.first?[0], "1") - XCTAssertEqual(result.rows.first?[1], "hello") - } - - func testPingRecoversAfterTheConnectionIsDropped() async throws { - let driver = try XCTUnwrap(driver) - try await driver.cancelCurrentQuery() - let pong = try await driver.ping() - XCTAssertTrue(pong, "ping must redial rather than report a dead connection as healthy") - } - - func testFetchSchemasIncludesTheConnectedUser() async throws { - let driver = try XCTUnwrap(driver) - let names = try await driver.fetchSchemas() - XCTAssertTrue(names.contains(schema)) - } - - func testFetchDatabasesMatchesSchemas() async throws { - let driver = try XCTUnwrap(driver) - let databases = try await driver.fetchDatabases() - let schemas = try await driver.fetchSchemas() - XCTAssertEqual(databases, schemas) - } - - func testFetchTablesAndColumns() async throws { - let driver = try XCTUnwrap(driver) - try await recreateTable( - "TP_MOBILE_TEST", - body: "id NUMBER(10) PRIMARY KEY, name VARCHAR2(100)" - ) - - let tables = try await driver.fetchTables(schema: schema) - XCTAssertTrue(tables.contains { $0.name == "TP_MOBILE_TEST" && $0.type == .table }) - - let columns = try await driver.fetchColumns(table: "TP_MOBILE_TEST", schema: schema) - XCTAssertEqual(columns.count, 2) - let id = columns.first { $0.name == "ID" } - XCTAssertEqual(id?.isPrimaryKey, true) - XCTAssertEqual(id?.typeName, "number(10)") - XCTAssertEqual(columns.first { $0.name == "NAME" }?.typeName, "varchar2(100)") - } - - func testFetchIndexesReportsThePrimaryKey() async throws { - let driver = try XCTUnwrap(driver) - try await recreateTable("TP_MOBILE_IDX", body: "id NUMBER(10) PRIMARY KEY, label VARCHAR2(20)") - let indexes = try await driver.fetchIndexes(table: "TP_MOBILE_IDX", schema: schema) - XCTAssertTrue(indexes.contains { $0.isPrimary && $0.columns == ["ID"] }) - } - - func testFetchForeignKeysResolvesTheReference() async throws { - let driver = try XCTUnwrap(driver) - try await recreateTable("TP_MOBILE_PARENT", body: "id NUMBER(10) PRIMARY KEY") - try await recreateTable( - "TP_MOBILE_CHILD", - body: """ - id NUMBER(10) PRIMARY KEY, \ - parent_id NUMBER(10), \ - CONSTRAINT fk_tp_mobile_child FOREIGN KEY (parent_id) REFERENCES TP_MOBILE_PARENT (id) - """ - ) - let keys = try await driver.fetchForeignKeys(table: "TP_MOBILE_CHILD", schema: schema) - let match = try XCTUnwrap(keys.first { $0.name.uppercased() == "FK_TP_MOBILE_CHILD" }) - XCTAssertEqual(match.column, "PARENT_ID") - XCTAssertEqual(match.referencedTable, "TP_MOBILE_PARENT") - XCTAssertEqual(match.referencedColumn, "ID") - } - - func testOffsetFetchPaginationOrdersDeterministically() async throws { - let driver = try XCTUnwrap(driver) - try await recreateTable("TP_MOBILE_PAGE", body: "id NUMBER(10) PRIMARY KEY, label VARCHAR2(20)") - for (id, label) in [(1, "a"), (2, "b"), (3, "c"), (4, "d"), (5, "e")] { - _ = try await driver.execute(query: "INSERT INTO TP_MOBILE_PAGE VALUES (\(id), '\(label)')") - } - try await driver.commitTransaction() - - let result = try await driver.execute(query: """ - SELECT id, label FROM TP_MOBILE_PAGE - ORDER BY id ASC - OFFSET 2 ROWS FETCH NEXT 2 ROWS ONLY - """) - XCTAssertEqual(result.rows.count, 2) - XCTAssertEqual(result.rows.first?.first, "3") - XCTAssertEqual(result.rows.last?.first, "4") - } - - func testRollbackDiscardsInsertedRows() async throws { - let driver = try XCTUnwrap(driver) - try await recreateTable("TP_MOBILE_TX", body: "v NUMBER(10)") - try await driver.beginTransaction() - _ = try await driver.execute(query: "INSERT INTO TP_MOBILE_TX VALUES (42)") - try await driver.rollbackTransaction() - - let result = try await driver.execute(query: "SELECT COUNT(*) FROM TP_MOBILE_TX") - XCTAssertEqual(result.rows.first?.first, "0") - } - - func testAQueryEditorWriteIsVisibleToAnotherSession() async throws { - let driver = try XCTUnwrap(driver) - try await recreateTable("TP_MOBILE_AUTOCOMMIT", body: "v NUMBER(10)") - for try await _ in driver.executeStreaming(query: "INSERT INTO TP_MOBILE_AUTOCOMMIT VALUES (1)", options: .default) {} - - let seen = try await countSeenByAnotherSession("TP_MOBILE_AUTOCOMMIT") - XCTAssertEqual(seen, "1") - } - - func testARowEditIsVisibleToAnotherSession() async throws { - let driver = try XCTUnwrap(driver) - try await recreateTable("TP_MOBILE_ROW_EDIT", body: "v NUMBER(10)") - try await driver.executeWrite(["INSERT INTO TP_MOBILE_ROW_EDIT VALUES (1)"]) - - let seen = try await countSeenByAnotherSession("TP_MOBILE_ROW_EDIT") - XCTAssertEqual(seen, "1") - let state = await driver.sessionTransactionState() - XCTAssertEqual(state, .idle) - } - - func testAnOpenTransactionHoldsItsWritesUntilRollback() async throws { - let driver = try XCTUnwrap(driver) - try await recreateTable("TP_MOBILE_HELD", body: "v NUMBER(10)") - try await driver.beginTransaction() - _ = try await driver.execute(query: "INSERT INTO TP_MOBILE_HELD VALUES (1)") - let stateInside = await driver.sessionTransactionState() - XCTAssertEqual(stateInside, .explicitTransaction) - let seenInside = try await countSeenByAnotherSession("TP_MOBILE_HELD") - XCTAssertEqual(seenInside, "0") - - try await driver.rollbackTransaction() - let stateAfter = await driver.sessionTransactionState() - XCTAssertEqual(stateAfter, .idle) - let result = try await driver.execute(query: "SELECT COUNT(*) FROM TP_MOBILE_HELD") - XCTAssertEqual(result.rows.first?.first, "0") - } - - func testStreamingYieldsColumnsThenRows() async throws { - let driver = try XCTUnwrap(driver) - try await recreateTable("TP_MOBILE_STREAM", body: "id NUMBER(10) PRIMARY KEY") - for id in 1...3 { - _ = try await driver.execute(query: "INSERT INTO TP_MOBILE_STREAM VALUES (\(id))") - } - try await driver.commitTransaction() - - var sawColumns = false - var rowCount = 0 - for try await element in driver.executeStreaming( - query: "SELECT id FROM TP_MOBILE_STREAM ORDER BY id", - options: .default - ) { - switch element { - case .columns(let columns): - sawColumns = true - XCTAssertEqual(columns.map(\.name), ["ID"]) - case .row: - rowCount += 1 - default: - break - } - } - XCTAssertTrue(sawColumns) - XCTAssertEqual(rowCount, 3) - } - - func testWrongIdentifierModeReportsTheListenerCode() async throws { - let config = try XCTUnwrap(Self.loadTestConfig()) - var connection = Self.makeConnection(from: config) - connection.additionalFields[OracleConnectionOptions.AdditionalFieldKey.connectionType] = - OracleConnectionOptions.IdentifierMode.sid.rawValue - connection.additionalFields[OracleConnectionOptions.AdditionalFieldKey.sid] = "DEFINITELY_NOT_A_SID" - - let badDriver = OracleDriver(connection: connection, password: config["ORACLE_TEST_PASSWORD"] ?? "") - do { - try await badDriver.connect() - XCTFail("An unknown SID should be refused by the listener") - } catch { - let description = (error as? LocalizedError)?.errorDescription ?? "\(error)" - XCTAssertTrue( - description.contains("ORA-12505") || description.contains("ORA-12514"), - "expected a listener refusal code, got \(description)" - ) - } - } - - private func countSeenByAnotherSession(_ table: String) async throws -> String? { - let config = try XCTUnwrap(Self.loadTestConfig()) - let other = OracleDriver(connection: Self.makeConnection(from: config), password: config["ORACLE_TEST_PASSWORD"] ?? "") - try await other.connect() - let result = try await other.execute(query: "SELECT COUNT(*) FROM \(schema).\(table)") - try await other.disconnect() - return result.rows.first?.first ?? nil - } - - private func recreateTable(_ name: String, body: String) async throws { - let driver = try XCTUnwrap(driver) - _ = try? await driver.execute(query: "DROP TABLE \(name) CASCADE CONSTRAINTS") - _ = try await driver.execute(query: "CREATE TABLE \(name) (\(body))") - } -} diff --git a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLDriverCatalogTests.swift b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLDriverCatalogTests.swift deleted file mode 100644 index 339a4233c1..0000000000 --- a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLDriverCatalogTests.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Foundation -@testable import TableProMobile -import TableProModels -import XCTest - -final class PostgreSQLDriverCatalogTests: XCTestCase { - private static let schema = "ios_pg_catalog_test" - private static let foreignDataWrapper = "ios_pg_catalog_test_fdw" - - private var driver: PostgreSQLDriver? - - private static func loadTestConfig() -> [String: String]? { - let env = ProcessInfo.processInfo.environment - if env["POSTGRES_TEST_HOST"] != nil { - return env - } - let fallbackPath = env["POSTGRES_TEST_CONFIG_PATH"] ?? "/tmp/postgres-test.json" - guard let data = FileManager.default.contents(atPath: fallbackPath), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] - else { return nil } - return json - } - - override func setUp() async throws { - guard let config = Self.loadTestConfig() else { - throw XCTSkip("POSTGRES_TEST_HOST not set and /tmp/postgres-test.json not found, skipping integration tests") - } - let driver = PostgreSQLDriver( - host: config["POSTGRES_TEST_HOST"] ?? "127.0.0.1", - port: Int(config["POSTGRES_TEST_PORT"] ?? "5432") ?? 5_432, - user: config["POSTGRES_TEST_USER"] ?? "postgres", - password: config["POSTGRES_TEST_PASSWORD"] ?? "", - database: config["POSTGRES_TEST_DATABASE"] ?? "postgres" - ) - try await driver.connect() - self.driver = driver - for statement in Self.fixture { - _ = try await driver.execute(query: statement) - } - } - - override func tearDown() async throws { - if let driver { - _ = try? await driver.execute(query: "DROP SCHEMA IF EXISTS \(Self.schema) CASCADE") - _ = try? await driver.execute(query: "DROP FOREIGN DATA WRAPPER IF EXISTS \(Self.foreignDataWrapper) CASCADE") - try await driver.disconnect() - } - driver = nil - } - - private static let fixture = [ - "DROP SCHEMA IF EXISTS \(schema) CASCADE", - "CREATE SCHEMA \(schema)", - "CREATE TABLE \(schema).t (id int PRIMARY KEY, a int, b text, email text, tenant_id int, n int GENERATED BY DEFAULT AS IDENTITY)", - "CREATE INDEX t_mixed ON \(schema).t (tenant_id, lower(email))", - "CREATE INDEX t_include ON \(schema).t (a) INCLUDE (b)", - "CREATE INDEX t_order ON \(schema).t (b, a)", - "CREATE UNIQUE INDEX t_partial ON \(schema).t (a) WHERE a > 0", - "CREATE INDEX t_hash ON \(schema).t USING hash (email)", - "CREATE VIEW \(schema).v AS SELECT id FROM \(schema).t", - "CREATE MATERIALIZED VIEW \(schema).mv AS SELECT id AS mv_id, lower(email) AS mv_expr, b::varchar(20) AS label FROM \(schema).t", - "CREATE UNIQUE INDEX mv_id_key ON \(schema).mv (mv_id)", - "CREATE INDEX mv_expr_idx ON \(schema).mv (lower(mv_expr))" - ] - - private static let foreignTableFixture = [ - "DROP FOREIGN DATA WRAPPER IF EXISTS \(foreignDataWrapper) CASCADE", - "CREATE FOREIGN DATA WRAPPER \(foreignDataWrapper)", - "CREATE SERVER ios_pg_catalog_test_server FOREIGN DATA WRAPPER \(foreignDataWrapper)", - "CREATE FOREIGN TABLE \(schema).ft (a int) SERVER ios_pg_catalog_test_server" - ] - - func testMaterializedViewIsListedAsOne() async throws { - let driver = try XCTUnwrap(driver) - - let tables = try await driver.fetchTables(schema: Self.schema) - - XCTAssertEqual(tables.map(\.name), ["mv", "t", "v"]) - XCTAssertEqual(tables.map(\.type), [.materializedView, .table, .view]) - } - - func testForeignTableIsListedAsOne() async throws { - let driver = try XCTUnwrap(driver) - do { - for statement in Self.foreignTableFixture { - _ = try await driver.execute(query: statement) - } - } catch { - throw XCTSkip("Creating a foreign data wrapper needs a superuser: \(error.localizedDescription)") - } - - let tables = try await driver.fetchTables(schema: Self.schema) - - XCTAssertEqual(tables.map(\.name), ["ft", "mv", "t", "v"]) - XCTAssertEqual(tables.first?.type, .foreignTable) - XCTAssertEqual(tables.first?.type.allowsDrop, false) - } - - func testIndexesKeepKeyOrderExpressionsIncludeColumnsAndPredicates() async throws { - let driver = try XCTUnwrap(driver) - - let indexes = try await driver.fetchIndexes(table: "t", schema: Self.schema) - let byName = Dictionary(uniqueKeysWithValues: indexes.map { ($0.name, $0) }) - - XCTAssertEqual(indexes.first?.name, "t_pkey") - XCTAssertEqual(byName["t_mixed"]?.columns, ["tenant_id", "lower(email)"]) - XCTAssertEqual(byName["t_include"]?.columns, ["a"]) - XCTAssertEqual(byName["t_include"]?.includedColumns, ["b"]) - XCTAssertEqual(byName["t_order"]?.columns, ["b", "a"]) - XCTAssertEqual(byName["t_partial"]?.whereClause, "(a > 0)") - XCTAssertEqual(byName["t_hash"]?.type, "HASH") - } - - func testMaterializedViewHasColumnsAndIndexes() async throws { - let driver = try XCTUnwrap(driver) - - let columns = try await driver.fetchColumns(table: "mv", schema: Self.schema) - let indexes = try await driver.fetchIndexes(table: "mv", schema: Self.schema) - - XCTAssertEqual(columns.map(\.name), ["mv_id", "mv_expr", "label"]) - XCTAssertEqual(columns.map(\.typeName), ["integer", "text", "character varying"]) - XCTAssertEqual(columns.last?.characterMaxLength, 20) - XCTAssertEqual(Set(indexes.map(\.name)), ["mv_id_key", "mv_expr_idx"]) - XCTAssertEqual(indexes.first { $0.name == "mv_expr_idx" }?.columns, ["lower(mv_expr)"]) - XCTAssertEqual( - driver.columnReadSupport, - PostgreSQLColumnReadSupport(identityColumns: true, materializedViewColumns: true) - ) - } - - func testTableColumnsKeepTheirPrimaryKeyAndIdentity() async throws { - let driver = try XCTUnwrap(driver) - - let columns = try await driver.fetchColumns(table: "t", schema: Self.schema) - - XCTAssertEqual(columns.map(\.name), ["id", "a", "b", "email", "tenant_id", "n"]) - XCTAssertEqual(columns.first?.isPrimaryKey, true) - XCTAssertEqual(columns.first?.isNullable, false) - XCTAssertEqual(columns.last?.isAutoIncrement, true) - } -} diff --git a/TableProMobile/TableProMobileTests/OffMainActorHandlerGuardTests.swift b/TableProMobile/TableProMobileTests/OffMainActorHandlerGuardTests.swift deleted file mode 100644 index 5254521845..0000000000 --- a/TableProMobile/TableProMobileTests/OffMainActorHandlerGuardTests.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation -import Testing - -@Suite("Off-main-actor callback isolation") -struct OffMainActorHandlerGuardTests { - @Test("Every Dispatch source handler declares its own isolation") - func dispatchSourceHandlersDeclareTheirIsolation() throws { - let offenders = try Self.offenders { lines in - lines.indices.filter { index in - let line = lines[index] - guard Self.dispatchHandlerSetters.contains(where: line.contains), line.contains("{") else { - return false - } - guard !line.contains("@Sendable") else { return false } - return !Self.nextNonEmptyLine(in: lines, after: index).hasPrefix("@Sendable") - } - } - #expect( - offenders.isEmpty, - """ - Dispatch calls a source handler on the source's own queue, and DispatchSourceHandler is \ - not @Sendable, so a closure literal written inside a @MainActor type inherits an \ - isolation that queue cannot honour and the process traps before the body runs. Write \ - `{ @Sendable in ... }` and hop with `Task { @MainActor in ... }`: \(offenders.sorted()) - """ - ) - } - - private static let dispatchHandlerSetters = [ - "setEventHandler", - "setCancelHandler", - "setRegistrationHandler", - ] - - private static func nextNonEmptyLine(in lines: [String], after index: Int) -> String { - lines[(index + 1)...] - .lazy - .map { $0.trimmingCharacters(in: .whitespaces) } - .first { !$0.isEmpty } ?? "" - } - - private static func offenders(_ rule: ([String]) -> [Int]) throws -> [String] { - let sourceRoot = try repoRoot().appendingPathComponent("TableProMobile/TableProMobile") - guard let enumerator = FileManager.default.enumerator( - at: sourceRoot, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - - var matches: [String] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) - matches += rule(lines).map { "\(url.lastPathComponent):\($0 + 1)" } - } - return matches - } - - private static func repoRoot(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("CLAUDE.md").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw HandlerGuardError.repoRootNotFound - } - - private enum HandlerGuardError: Error { - case repoRootNotFound - } -} diff --git a/TableProTests/.swiftlint.yml b/TableProTests/.swiftlint.yml new file mode 100644 index 0000000000..9c5f40de5f --- /dev/null +++ b/TableProTests/.swiftlint.yml @@ -0,0 +1,2 @@ +only_rules: + - suite_without_trait diff --git a/TableProTests/Accessibility/AccessibleControlNameTests.swift b/TableProTests/Accessibility/AccessibleControlNameTests.swift index 86c69cdf61..18831838bf 100644 --- a/TableProTests/Accessibility/AccessibleControlNameTests.swift +++ b/TableProTests/Accessibility/AccessibleControlNameTests.swift @@ -8,104 +8,8 @@ import Foundation @testable import TablePro import Testing -/// A control with no accessible name is silent to VoiceOver, and an icon-only button is the shape -/// that gets there by accident: it looks finished on screen because the symbol reads as a label to -/// a sighted user. -/// -/// `.help()` is not a substitute, and treating it as one is what this suite exists to catch. On -/// macOS it sets the tooltip and the accessibility *hint*; VoiceOver announces the label first and -/// reads hints only after a delay, if the user has not turned them off. So a button carrying a -/// perfectly good localized string in `.help()` and nothing in `.accessibilityLabel()` announces -/// its SF Symbol name, or nothing at all. -struct AccessibleControlNameTests { - /// Every one of these had a name in `.help()` and none in `.accessibilityLabel()`. The pairs - /// are (file, the label string that must appear in it), so the test fails if a label is dropped - /// rather than merely if some label exists. - private static let labelledControls: [(path: String, label: String)] = [ - ("TablePro/Views/Results/CellImageViewer.swift", "Open in Window"), - ("TablePro/Views/Results/JSONViewerView.swift", "Open in Window"), - ("TablePro/Views/Results/PhpViewerView.swift", "Open in Window"), - ("TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift", "Open in Window"), - ("TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift", "Open in Window"), - ("TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift", "Open in Window"), - ("TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift", "Fit to Geometry"), - ("TablePro/Views/Editor/QueryParameterPanelView.swift", "Close parameter panel"), - ("TablePro/Views/DataFiles/DataFileFindBar.swift", "Find Options"), - ("TablePro/Views/DataFiles/DataFileContentView.swift", "Sheet"), - ("TablePro/Views/Structure/CreateTableView.swift", "Add Row"), - ("TablePro/Views/Structure/CreateTableView.swift", "Delete Selected"), - ("TablePro/Views/ServerDashboard/DashboardToolbarView.swift", "Refresh Now"), - ("TablePro/Views/Settings/LinkedFoldersSection.swift", "folder.name"), - ("TablePro/Views/UsersRoles/PrivilegeChecklistView.swift", "Bulk actions"), - ("Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/FindControls.swift", "Previous Match"), - ("Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/FindControls.swift", "Next Match"), - ("Packages/TableProEditor/Sources/TableProEditorKit/Find/PanelView/ReplaceControls.swift", "Replace All"), - ("Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/PanelTextField.swift", "Clear") - ] - - @Test("Every icon-only control that had only a tooltip now carries an accessibility label") - func iconOnlyControlsCarryLabels() throws { - let root = try repositoryRoot() - for control in Self.labelledControls { - let source = try String(contentsOf: root.appendingPathComponent(control.path), encoding: .utf8) - #expect( - Self.names(control.label, in: source), - "\(control.path) lost the accessibility label for \(control.label)" - ) - } - } - - /// A `Menu` is named by its `Label`: `.accessibilityLabel` on a menu leaves it nameless, which - /// `MenuDisclosureIndicatorTests` guards, so the label's own `Text` counts as the name. - private static func names(_ label: String, in source: String) -> Bool { - let namedByModifier = source.contains(".accessibilityLabel") && source.contains(label) - let namedByLabel = source.contains("Label { Text(\"\(label)\") }") - return namedByModifier || namedByLabel - } - - /// The find bar's clear button is the one that had neither a label nor a tooltip, so nothing - /// named it at all. - @Test("The panel text field's clear button is named") - func clearButtonIsNamed() throws { - let root = try repositoryRoot() - let path = "Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/PanelTextField.swift" - let source = try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) - - #expect(source.contains(#".accessibilityLabel("Clear")"#)) - } - - /// Committing a foreign-key row used to happen only in `onTapGesture`, which no assistive - /// technology can reach, so the picker's whole purpose was mouse-only. - @Test("A foreign-key row can be committed without a mouse") - func foreignKeyRowHasAPressAction() throws { - let root = try repositoryRoot() - let path = "TablePro/Views/Results/ForeignKeyPickerView.swift" - let source = try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) - - #expect(source.contains(".accessibilityAction { commit(entry) }")) - #expect(source.contains(".accessibilityAddTraits(.isButton)")) - } - - private func repositoryRoot(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw AccessibilityTestError.repositoryRootNotFound - } - - private enum AccessibilityTestError: Error { - case repositoryRootNotFound - } -} - -/// `NSTableView.sortDescriptors` paints the header's arrow but reaches no accessibility client, -/// measured, so a sorted column is announced only when its header cell is told directly. The plan -/// outline gives every column a `sortDescriptorPrototype`, so all of them are click-sortable, and -/// none of them said which way it was sorted. +/// `NSTableView.sortDescriptors` paints the header arrow but reaches no accessibility client +/// (measured), so a sorted column is announced only when its header cell is told directly. @MainActor struct QueryPlanSortDirectionTests { @Test("The sorted column publishes its direction and the others publish none") diff --git a/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift b/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift index 124b51f7d7..65344bc8ee 100644 --- a/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift +++ b/TableProTests/CloudSQL/CloudSQLProxyModelTests.swift @@ -91,14 +91,6 @@ struct CloudSQLProxyModelTests { #expect(!isValid("")) } - @Test("CloudSQLProxyPidRecord round-trips for the stale-PID sweep") - func pidRecordRoundTrip() throws { - let records = [CloudSQLProxyPidRecord(pid: 4_242, binaryPath: "/opt/homebrew/bin/cloud-sql-proxy")] - let data = try JSONEncoder().encode(records) - let decoded = try JSONDecoder().decode([CloudSQLProxyPidRecord].self, from: data) - #expect(decoded == records) - } - @Test("supportsCloudSQLProxy is limited to Cloud SQL engines") func capabilityEngines() { #expect(DatabaseType.mysql.supportsCloudSQLProxy) diff --git a/TableProTests/Cloudflare/CloudflareModelTests.swift b/TableProTests/Cloudflare/CloudflareModelTests.swift index d8d6f330f9..5eba546416 100644 --- a/TableProTests/Cloudflare/CloudflareModelTests.swift +++ b/TableProTests/Cloudflare/CloudflareModelTests.swift @@ -71,14 +71,6 @@ struct CloudflareModelTests { #expect(decoded.resolvedCloudflareConfig == nil) } - @Test("CloudflaredPidRecord round-trips for the stale-PID sweep") - func pidRecordRoundTrip() throws { - let records = [CloudflaredPidRecord(pid: 4_242, binaryPath: "/opt/homebrew/bin/cloudflared")] - let data = try JSONEncoder().encode(records) - let decoded = try JSONDecoder().decode([CloudflaredPidRecord].self, from: data) - #expect(decoded == records) - } - @Test("The cloudflared not-found error is plain text that names the install command") func binaryNotFoundIsPlainText() { let description = CloudflareTunnelError.binaryNotFound.errorDescription ?? "" diff --git a/TableProTests/Core/AI/ChatGPTCodexRegistrationTests.swift b/TableProTests/Core/AI/ChatGPTCodexRegistrationTests.swift index 186c936882..d28d0f67da 100644 --- a/TableProTests/Core/AI/ChatGPTCodexRegistrationTests.swift +++ b/TableProTests/Core/AI/ChatGPTCodexRegistrationTests.swift @@ -13,12 +13,6 @@ struct ChatGPTCodexRegistrationTests { #expect(AIProviderType.chatgptCodex.authStyle == .oauth) } - @Test("ChatGPT Codex is a known provider type") - func isKnownType() { - #expect(AIProviderType.allCases.contains(.chatgptCodex)) - #expect(AIProviderType(rawValue: "chatgptCodex") == .chatgptCodex) - } - @Test("Registry builds a ChatGPTCodexProvider for the descriptor") func descriptorMakesCodexProvider() { AIProviderRegistration.registerAll() diff --git a/TableProTests/Core/AI/ChatToolRegistryTests.swift b/TableProTests/Core/AI/ChatToolRegistryTests.swift index 85f7fad26f..bf6e94d96f 100644 --- a/TableProTests/Core/AI/ChatToolRegistryTests.swift +++ b/TableProTests/Core/AI/ChatToolRegistryTests.swift @@ -3,7 +3,6 @@ // TableProTests // -import Foundation import TableProPluginKit @testable import TablePro import Testing @@ -91,12 +90,4 @@ struct ChatToolRegistryTests { registry.unregister(name: "alpha") #expect(registry.tool(named: "alpha") == nil) } - - @Test("ChatToolResult is Codable for round-trip with ToolResultBlock") - func chatToolResultRoundTripsThroughCodable() throws { - let result = ChatToolResult(content: "hello", isError: true) - let data = try JSONEncoder().encode(result) - let decoded = try JSONDecoder().decode(ChatToolResult.self, from: data) - #expect(decoded == result) - } } diff --git a/TableProTests/Core/Autocomplete/KeywordVocabularyParityTests.swift b/TableProTests/Core/Autocomplete/KeywordVocabularyParityTests.swift index 34f526c7af..74471213b3 100644 --- a/TableProTests/Core/Autocomplete/KeywordVocabularyParityTests.swift +++ b/TableProTests/Core/Autocomplete/KeywordVocabularyParityTests.swift @@ -2,11 +2,8 @@ // KeywordVocabularyParityTests.swift // TableProTests // -// Two lists decide what counts as a SQL keyword, and nothing made them agree. The completion -// provider offers keywords from clause-specific arrays written inline in `getCandidates`, while -// Auto-uppercase consults `SQLKeywords.keywordSet` alone, so a word the popup had offered a -// keystroke earlier was left in lower case: `alter table users add column note text;` came out as -// `ALTER TABLE users add COLUMN note TEXT;`. +// The completion provider and Auto-uppercase keep separate keyword lists. A word the popup +// offers but `SQLKeywords.keywordSet` lacks stays lower case while the words around it change. // import Foundation @@ -17,12 +14,57 @@ struct KeywordVocabularyParityTests { /// The uppercaser only ever looks at one word at a time, so a multi-word entry such as /// `ORDER BY` can never match and is not expected in the set. @Test("Every single-word keyword the provider offers is one the uppercaser knows") - func offeredKeywordsAreUppercasable() throws { - let offered = try Self.providerOfferedKeywords() - #expect(offered.count > 50, "The provider source parsed to a real vocabulary") + func offeredKeywordsAreUppercasable() async { + let samples: [(text: String, clause: SQLClauseType)] = [ + ("", .unknown), + ("SELECT ", .select), + ("SELECT COUNT(", .select), + ("SELECT * FROM users ", .from), + ("INSERT INTO ", .into), + ("SELECT * FROM users u ON ", .on), + ("SELECT * FROM users WHERE ", .where_), + ("SELECT * FROM users GROUP BY ", .groupBy), + ("SELECT * FROM users ORDER BY ", .orderBy), + ("UPDATE users SET ", .set), + ("INSERT INTO users VALUES (", .values), + ("SELECT CASE WHEN ", .caseExpression), + ("SELECT * FROM users WHERE id IN (", .inList), + ("SELECT * FROM users LIMIT ", .limit), + ("ALTER TABLE users ", .alterTable), + ("CREATE TABLE ", .createTable), + ("CREATE TABLE test (id ", .createTable), + ("ALTER TABLE users ADD COLUMN email ", .columnDef), + ("SELECT * FROM users RETURNING ", .returning), + ("SELECT 1 UNION ", .union), + ("SELECT ROW_NUMBER() OVER (", .window), + ("DROP TABLE ", .dropObject), + ("CREATE INDEX ", .createIndex), + ("CREATE INDEX idx ON users (", .createIndex), + ("CREATE VIEW ", .createView), + ] + let provider = SQLCompletionProvider(schemaProvider: nil) + // A table in scope ranks keywords ahead of functions, so none fall outside the candidate pool. + let scopes: [[TableReference]?] = [nil, [TableReference(tableName: "users", alias: nil)]] - let unknown = offered - .filter { $0.allSatisfy(\.isLetter) || $0.contains("_") } + var offered: Set = [] + for sample in samples { + for scope in scopes { + let session = await provider.completionSession( + text: sample.text, + cursorPosition: (sample.text as NSString).length, + forcedTableReferences: scope + ) + #expect(session.context.clauseType == sample.clause, "\"\(sample.text)\"") + offered.formUnion(session.candidates.filter { $0.kind == .keyword }.map(\.label)) + } + } + + let singleWords = offered.filter { label in + label.allSatisfy { $0.isLetter } || label.contains("_") + } + #expect(singleWords.count > 50, "The provider offered only \(singleWords.sorted())") + + let unknown = singleWords .filter { !SQLKeywords.keywordSet.contains($0.lowercased()) } .sorted() @@ -57,8 +99,7 @@ struct KeywordVocabularyParityTests { #expect(SQLKeywords.keywordSet.contains(keyword)) } - /// The reported statement, walked word by word the way the uppercaser sees it. Every keyword in - /// it is now recognised; `users` and `note` are identifiers and stay untouched. + /// `users` and `note` are identifiers and stay untouched. @Test("The reported statement uppercases every keyword and nothing else") func reportedStatementUppercasesConsistently() { let statement = "alter table users add column note text" @@ -73,45 +114,4 @@ struct KeywordVocabularyParityTests { let endOfUsers = text.range(of: "users").location + 5 #expect(KeywordUppercaseHelper.keywordBeforePosition(text, at: endOfUsers) == nil) } - - private static func providerOfferedKeywords(file: StaticString = #filePath) throws -> Set { - let source = try repositoryRoot(file: file) - .appendingPathComponent("TablePro/Core/Autocomplete/SQLCompletionProvider.swift") - let text = try String(contentsOf: source, encoding: .utf8) - - var found: Set = [] - for marker in ["filterKeywords([", "boostedKeywords([", "statementStartKeywords = ["] { - var searchStart = text.startIndex - while let open = text.range(of: marker, range: searchStart.. [String] { - fragment - .split(separator: "\n") - .map { $0.components(separatedBy: "//").first ?? "" } - .flatMap { $0.components(separatedBy: ",") } - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: ["\""]) } - .filter { !$0.isEmpty } - } - - private static func repositoryRoot(file: StaticString) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - let candidate = directory.appendingPathComponent("TablePro/Core/Autocomplete/SQLKeywords.swift") - if FileManager.default.fileExists(atPath: candidate.path) { return directory } - directory = directory.deletingLastPathComponent() - } - throw ParityError.sourceNotFound - } - - private enum ParityError: Error { - case sourceNotFound - } } diff --git a/TableProTests/Core/Autocomplete/MongoCompletionCaseTests.swift b/TableProTests/Core/Autocomplete/MongoCompletionCaseTests.swift deleted file mode 100644 index 9b38c6180f..0000000000 --- a/TableProTests/Core/Autocomplete/MongoCompletionCaseTests.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// MongoCompletionCaseTests.swift -// TableProTests -// -// MongoDB's vocabulary is JavaScript, so every name in it is case-significant. -// Completion used to route it through a factory that uppercased, which offered -// `$MATCH` and `DB` and produced pipelines the server rejects. -// - -import Foundation -@testable import TablePro -import Testing - -@MainActor -struct MongoCompletionCaseTests { - private func service() -> MongoCompletionService { - MongoCompletionService(schemaProvider: nil, databaseType: .mongodb) - } - - private func completions(_ text: String) async -> [SQLCompletionItem] { - let ns = text as NSString - let session = await service().completions(in: ns, at: ns.length, isManualTrigger: true) - return session?.items ?? [] - } - - @Test("A pipeline stage is offered and inserted in its own case") - func pipelineStageKeepsItsCase() async { - let items = await completions("db.orders.aggregate([{ $ma") - let match = items.first { $0.label.lowercased() == "$match" } - #expect(match?.label == "$match") - #expect(match?.insertText == "$match") - #expect(!items.contains { $0.insertText == "$MATCH" }) - } - - @Test("An uppercase prefix does not uppercase a stage") - func uppercasePrefixDoesNotRecaseStage() async { - let items = await completions("db.orders.aggregate([{ $MA") - #expect(!items.contains { $0.insertText == "$MATCH" }) - } - - @Test("A camel-cased stage keeps its inner capitals") - func camelCaseStageIsIntact() async { - let items = await completions("db.orders.aggregate([{ $addf") - #expect(items.contains { $0.insertText == "$addFields" }) - } - - @Test("The db handle is offered in lower case") - func dbHandleKeepsItsCase() async { - let items = await completions("d") - #expect(items.contains { $0.insertText == "db" }) - #expect(!items.contains { $0.insertText == "DB" }) - } - - @Test("A collection method keeps its camel case") - func collectionMethodKeepsItsCase() async { - let items = await completions("db.orders.inserto") - #expect(items.contains { $0.insertText == "insertOne()" }) - } - - @Test("A query operator keeps its case") - func queryOperatorKeepsItsCase() async { - let items = await completions("db.orders.find({ status: { $g") - #expect(items.contains { $0.insertText == "$gte" }) - #expect(!items.contains { $0.insertText == "$GTE" }) - } - - @Test("Every item the Mongo service builds declares a fixed spelling") - func everyMongoItemIsFixed() async { - let items = await completions("") - #expect(!items.isEmpty) - #expect(items.allSatisfy { $0.caseFolding == .fixed }) - } -} diff --git a/TableProTests/Core/Autocomplete/MongoContextAnalyzerTests.swift b/TableProTests/Core/Autocomplete/MongoContextAnalyzerTests.swift deleted file mode 100644 index e436b3a943..0000000000 --- a/TableProTests/Core/Autocomplete/MongoContextAnalyzerTests.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// MongoContextAnalyzerTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -struct MongoContextAnalyzerTests { - private func analyze(_ text: String) -> MongoContext { - let ns = text as NSString - return MongoContextAnalyzer.analyze(text: ns, cursor: ns.length) - } - - // MARK: - Dot positions - - @Test("db. offers database members") - func testDatabaseMember() { - #expect(analyze("db.").position == .databaseMember) - } - - @Test("db.users. offers collection methods") - func testCollectionMethod() { - #expect(analyze("db.users.").position == .collectionMethod(collection: "users")) - } - - @Test("a dotted collection name is kept whole") - func testDottedCollectionMethod() { - #expect(analyze("db.my.coll.").position == .collectionMethod(collection: "my.coll")) - } - - @Test("a partial method name after the dot becomes the prefix") - func testCollectionMethodPrefix() { - let context = analyze("db.users.fin") - #expect(context.position == .collectionMethod(collection: "users")) - #expect(context.prefix == "fin") - } - - // MARK: - Documents - - @Test("the first find argument is a filter document") - func testFilterDocument() { - #expect(analyze("db.users.find({").position == .filterDocument(collection: "users")) - } - - @Test("the second find argument is a projection document") - func testProjectionDocument() { - #expect(analyze("db.users.find({}, {").position == .projectionDocument(collection: "users")) - } - - @Test("the second updateOne argument is an update document") - func testUpdateDocument() { - #expect(analyze("db.users.updateOne({}, {").position == .updateDocument) - } - - @Test("the first updateOne argument is still a filter") - func testUpdateFilterDocument() { - #expect(analyze("db.users.updateOne({").position == .filterDocument(collection: "users")) - } - - @Test("an array-form update is an aggregation pipeline stage list") - func testUpdatePipelineStage() { - #expect(analyze("db.users.updateOne({}, [{").position == .updatePipelineStage) - } - - @Test("insertOne takes a plain document, not operators") - func testPlainDocument() { - #expect(analyze("db.users.insertOne({").position == .plainDocument(collection: "users")) - } - - // MARK: - Pipelines - - @Test("the top level of an aggregate array is a stage position") - func testPipelineStage() { - #expect(analyze("db.orders.aggregate([{").position == .pipelineStage(collection: "orders")) - } - - @Test("a second stage is still a stage position") - func testSecondPipelineStage() { - #expect(analyze("db.orders.aggregate([{$match: {}}, {").position == .pipelineStage(collection: "orders")) - } - - @Test("inside a stage body is an expression position") - func testStageExpression() { - #expect(analyze("db.orders.aggregate([{$group: {").position == .stageExpression(collection: "orders")) - } - - @Test("aggregate without its array is not a stage position") - func testAggregateWithoutArray() { - #expect(analyze("db.orders.aggregate({").position == .suppressed) - } - - // MARK: - Prefixes - - @Test("a dollar operator prefix keeps its sigil") - func testDollarPrefix() { - let context = analyze("db.users.find({$g") - #expect(context.position == .filterDocument(collection: "users")) - #expect(context.prefix == "$g") - } - - @Test("a system variable prefix keeps both sigils") - func testDoubleDollarPrefix() { - #expect(analyze("db.o.aggregate([{$project: {a: \"$$N").prefix == "$$N") - } - - @Test("a field prefix has no sigil") - func testFieldPrefix() { - #expect(analyze("db.users.find({na").prefix == "na") - } - - // MARK: - Suppression - - @Test("a line comment suppresses completion") - func testLineCommentSuppressed() { - #expect(analyze("db.users.find({}) // db.").position == .suppressed) - } - - @Test("a block comment suppresses completion") - func testBlockCommentSuppressed() { - #expect(analyze("/* db.users.find({").position == .suppressed) - } - - @Test("braces inside a string literal do not open a document") - func testBraceInStringIgnored() { - #expect(analyze("db.users.find({name: \"{\"}, {").position == .projectionDocument(collection: "users")) - } - - @Test("an escaped quote does not end the string") - func testEscapedQuoteInString() { - #expect(analyze("db.users.find({name: \"a\\\"b\"}, {").position == .projectionDocument(collection: "users")) - } - - @Test("an empty document is a statement start") - func testEmptyIsStatementStart() { - #expect(analyze("").position == .statementStart) - } - - @Test("a bare word at the top level is a statement start") - func testBareWordStatementStart() { - let context = analyze("us") - #expect(context.position == .statementStart) - #expect(context.prefix == "us") - } -} diff --git a/TableProTests/Core/Autocomplete/MongoVocabularyConstructorTests.swift b/TableProTests/Core/Autocomplete/MongoVocabularyConstructorTests.swift deleted file mode 100644 index 2bcc353f00..0000000000 --- a/TableProTests/Core/Autocomplete/MongoVocabularyConstructorTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// MongoVocabularyConstructorTests.swift -// TableProTests -// - -import Foundation -import JavaScriptCore -@testable import TablePro -import Testing - -/// Autocomplete offered `Int32`, `Long`, `Decimal128` and `BSONRegExp` while the shell defined none -/// of them, so accepting the suggestion ended the statement in a ReferenceError, and it never -/// offered `MinKey`, `Code` or the legacy UUID helpers the shell does define. -struct MongoVocabularyConstructorTests { - private static let shellObjectTypes: Set = ["Cursor", "DB", "DBCollection"] - - private func globalNames(of context: JSContext) -> Set { - let names = context.evaluateScript("Object.getOwnPropertyNames(this)")?.toArray() as? [String] - return Set(names ?? []) - } - - @Test("Autocomplete offers exactly the value constructors the shell defines") - func offeredConstructorsAreTheShellsOwn() throws { - let plain = try #require(JSContext()) - let shell = try MongoScriptContext.make(execute: { _ in #"{"ok":true,"v":"shop"}"# }, emit: { _ in true }) - - let defined = globalNames(of: shell).subtracting(globalNames(of: plain)).filter { name in - guard name.first?.isUppercase == true, !Self.shellObjectTypes.contains(name) else { return false } - return shell.evaluateScript("typeof \(name)")?.toString() == "function" - } - let offered = Set(MongoVocabulary.bsonConstructors.map(\.name)) - - #expect(defined.count > 20) - #expect(offered == defined) - } -} diff --git a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift index eca99767d0..005fad2961 100644 --- a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift @@ -288,13 +288,6 @@ struct SQLCompletionProviderTests { #expect(items.isEmpty) } - @Test("Normal context returns items") - func testNormalContextReturnsItems() async { - let text = "SELECT * FROM users WHERE " - let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) - #expect(!items.isEmpty) - } - // MARK: - P0: CF-1 - DatabaseType Threading @Test("Provider accepts databaseType parameter") @@ -1035,13 +1028,6 @@ struct SQLCompletionProviderTests { #expect(hasHaving) } - @Test("SELECT with FROM preserves explicit table column resolution") - func testSelectWithFromPreservesExplicit() async { - let text = "SELECT * FROM users WHERE " - let (items, _) = await provider.getCompletions(text: text, cursorPosition: text.count) - #expect(!items.isEmpty) - } - @Test("CASE expression without FROM returns CASE keywords") func testCaseWithoutFrom() async { let text = "SELECT CASE " diff --git a/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift b/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift index cff631c635..735a92e94f 100644 --- a/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift +++ b/TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift @@ -278,12 +278,6 @@ struct SQLContextAnalyzerTests { #expect(context.prefix == "id") } - @Test("Extracts empty prefix at exact position") - func testEmptyPrefixAtPosition() { - let context = analyzer.analyze(query: "SELECT ", cursorPosition: 7) - #expect(context.prefix == "") - } - @Test("Extracts full identifier prefix") func testFullIdentifierPrefix() { let context = analyzer.analyze(query: "SELECT abc", cursorPosition: 10) @@ -548,12 +542,6 @@ struct SQLContextAnalyzerTests { // MARK: - P0: CF-4 - Function context detection - @Test("COUNT( detected as function context") - func testCountFunctionContext() { - let context = analyzer.analyze(query: "SELECT COUNT(", cursorPosition: 13) - #expect(context.currentFunction == "COUNT") - } - @Test("SUM( detected as function context") func testSumFunctionContext() { let context = analyzer.analyze(query: "SELECT SUM(", cursorPosition: 11) diff --git a/TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift b/TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift index dcb98c2fb6..7cb74e1035 100644 --- a/TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift +++ b/TableProTests/Core/Autocomplete/SQLContextAnalyzerWindowingTests.swift @@ -15,20 +15,6 @@ struct SQLContextAnalyzerWindowingTests { // MARK: - Normal Short Queries - @Test("Short SELECT WHERE query detects WHERE clause") - func shortSelectWhereDetectsWhereClause() { - let query = "SELECT * FROM users WHERE " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .where_) - } - - @Test("Short SELECT query detects SELECT clause") - func shortSelectDetectsSelectClause() { - let query = "SELECT " - let context = analyzer.analyze(query: query, cursorPosition: query.count) - #expect(context.clauseType == .select) - } - @Test("Short FROM query detects FROM clause") func shortFromDetectsFromClause() { let query = "SELECT id FROM " @@ -92,12 +78,6 @@ struct SQLContextAnalyzerWindowingTests { // MARK: - Edge Cases - @Test("Empty text returns unknown clause type") - func emptyTextReturnsUnknown() { - let context = analyzer.analyze(query: "", cursorPosition: 0) - #expect(context.clauseType == .unknown) - } - @Test("Whitespace-only text returns unknown clause type") func whitespaceOnlyReturnsUnknown() { let query = " \t\n " diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift index 22caa4e886..1e6da3421e 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift @@ -124,14 +124,6 @@ struct DataChangeManagerExtendedTests { #expect(!manager.isRowDeleted(.existing(0))) } - @Test("isRowInserted returns true for inserted row, false for others") - func isRowInsertedCorrectness() { - let manager = makeManager() - manager.recordRowInsertion(rowID: insertedID(5), values: ["a", "b", "c"]) - #expect(manager.isRowInserted(insertedID(5))) - #expect(!manager.isRowInserted(.existing(0))) - } - @Test("isCellModified returns true after edit, false for unmodified cells") func isCellModifiedTrueAfterEdit() { let manager = makeManager() diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift deleted file mode 100644 index 65c8aadeb6..0000000000 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorMSSQLTests.swift +++ /dev/null @@ -1,187 +0,0 @@ -// -// SQLStatementGeneratorMSSQLTests.swift -// TableProTests -// -// Tests for SQLStatementGenerator with databaseType: .mssql -// - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -struct SQLStatementGeneratorMSSQLTests { - // MARK: - Helpers - - private func makeGenerator( - tableName: String = "users", - columns: [String] = ["id", "name", "email"], - primaryKeyColumns: [String] = ["id"] - ) throws -> SQLStatementGenerator { - try SQLStatementGenerator( - tableName: tableName, - columns: columns, - primaryKeyColumns: primaryKeyColumns, - databaseType: .mssql, - dialect: nil - ) - } - - private func makeInsertChange(rowID: RowID = .existing(0)) -> RowChange { - RowChange(rowID: rowID, type: .insert, cellChanges: [], originalRow: nil) - } - - private func makeUpdateChange( - rowID: RowID = .existing(0), - columnName: String = "name", - oldValue: String? = "old", - newValue: String? = "new", - originalRow: [String?]? = ["1", "old", "a@b.com"] - ) -> RowChange { - RowChange( - rowID: rowID, - type: .update, - cellChanges: [ - CellChange( - columnIndex: 1, - columnName: columnName, - oldValue: PluginCellValue.fromOptional(oldValue), - newValue: PluginCellValue.fromOptional(newValue) - ) - ], - originalRow: originalRow.map { row in row.map(PluginCellValue.fromOptional) } - ) - } - - private func makeDeleteChange( - rowID: RowID = .existing(0), - originalRow: [String?]? = ["1", "John", "john@example.com"] - ) -> RowChange { - RowChange( - rowID: rowID, type: .delete, cellChanges: [], - originalRow: originalRow.map { row in row.map(PluginCellValue.fromOptional) } - ) - } - - // MARK: - Placeholder Tests - - @Test("INSERT statement uses question mark placeholders") - func insertUsesQuestionMarkPlaceholders() throws { - let generator = try makeGenerator() - let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] - let statements = generator.generateStatements( - from: [makeInsertChange()], - insertedRowData: insertedRowData, - deletedRowIDs: [], - insertedRowIDs: [.existing(0)] - ) - - #expect(statements.count == 1) - #expect(statements[0].sql.contains("?")) - #expect(!statements[0].sql.contains("$1")) - } - - @Test("UPDATE statement uses question mark placeholders") - func updateUsesQuestionMarkPlaceholders() throws { - let generator = try makeGenerator() - let statements = generator.generateStatements( - from: [makeUpdateChange()], - insertedRowData: [:], - deletedRowIDs: [], - insertedRowIDs: [] - ) - - #expect(statements.count == 1) - #expect(statements[0].sql.contains("?")) - #expect(!statements[0].sql.contains("$1")) - } - - // MARK: - INSERT Tests - - @Test("INSERT uses bracket-quoted table and column names") - func insertBracketQuoting() throws { - let generator = try makeGenerator() - let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] - let statements = generator.generateStatements( - from: [makeInsertChange()], - insertedRowData: insertedRowData, - deletedRowIDs: [], - insertedRowIDs: [.existing(0)] - ) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("INSERT INTO [users]")) - #expect(sql.contains("[id]")) - #expect(sql.contains("[name]")) - #expect(sql.contains("[email]")) - } - - @Test("INSERT with multiple columns produces correct number of placeholders") - func insertMultipleColumnsPlaceholders() throws { - let generator = try makeGenerator(columns: ["id", "name", "email"]) - let insertedRowData: [RowID: [PluginCellValue]] = [.existing(0): ["1", "John", "john@example.com"]] - let statements = generator.generateStatements( - from: [makeInsertChange()], - insertedRowData: insertedRowData, - deletedRowIDs: [], - insertedRowIDs: [.existing(0)] - ) - - #expect(statements.count == 1) - let sql = statements[0].sql - let placeholderCount = sql.components(separatedBy: "?").count - 1 - #expect(placeholderCount == 3) - #expect(statements[0].parameters.count == 3) - } - - // MARK: - UPDATE Tests - - @Test("UPDATE uses bracket-quoted table and column names") - func updateBracketQuoting() throws { - let generator = try makeGenerator() - let statements = generator.generateStatements( - from: [makeUpdateChange()], - insertedRowData: [:], - deletedRowIDs: [], - insertedRowIDs: [] - ) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("UPDATE [users] SET [name] = ?")) - } - - @Test("UPDATE WHERE clause uses primary key") - func updateWhereClauseUsesPrimaryKey() throws { - let generator = try makeGenerator() - let statements = generator.generateStatements( - from: [makeUpdateChange()], - insertedRowData: [:], - deletedRowIDs: [], - insertedRowIDs: [] - ) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("WHERE [id] = ?")) - } - - // MARK: - DELETE Tests - - @Test("DELETE uses bracket-quoted table name") - func deleteBracketQuoting() throws { - let generator = try makeGenerator() - let statements = generator.generateStatements( - from: [makeDeleteChange()], - insertedRowData: [:], - deletedRowIDs: [.existing(0)], - insertedRowIDs: [] - ) - - #expect(statements.count == 1) - let sql = statements[0].sql - #expect(sql.contains("DELETE FROM [users]")) - #expect(sql.contains("WHERE [id] = ?")) - } -} diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift index 4e1c7df618..919dfdbe24 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift @@ -1020,31 +1020,6 @@ struct SQLStatementGeneratorTests { #expect(!stmt.sql.contains("?")) } - @Test("Redshift uses $1, $2, $3 sequentially for insert") - func testRedshiftSequentialPlaceholders() throws { - let generator = try makeGenerator(databaseType: .redshift) - let insertedRowData: [RowID: [PluginCellValue]] = [ - .existing(0): ["1", "John", "john@example.com"] - ] - let changes: [RowChange] = [ - RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) - ] - - let statements = generator.generateStatements( - from: changes, - insertedRowData: insertedRowData, - deletedRowIDs: [], - insertedRowIDs: [.existing(0)] - ) - - #expect(statements.count == 1) - let stmt = statements[0] - #expect(stmt.sql.contains("$1")) - #expect(stmt.sql.contains("$2")) - #expect(stmt.sql.contains("$3")) - #expect(!stmt.sql.contains("?")) - } - @Test("Redshift parameter order matches placeholder order") func testRedshiftParameterOrder() throws { let generator = try makeGenerator(databaseType: .redshift) diff --git a/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift b/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift deleted file mode 100644 index 0eafd7a97a..0000000000 --- a/TableProTests/Core/CloudflareD1/CloudflareD1DriverHelperTests.swift +++ /dev/null @@ -1,245 +0,0 @@ -// -// CloudflareD1DriverHelperTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct CloudflareD1DriverHelperTests { - - // MARK: - Local copies of helper functions for testing - - private static func quoteIdentifier(_ name: String) -> String { - let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") - return "\"\(escaped)\"" - } - - private static func escapeStringLiteral(_ value: String) -> String { - var result = value - result = result.replacingOccurrences(of: "'", with: "''") - result = result.replacingOccurrences(of: "\0", with: "") - return result - } - - private static func castColumnToText(_ column: String) -> String { - "CAST(\(column) AS TEXT)" - } - - private static func isUuid(_ string: String) -> Bool { - let uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" - return string.range(of: uuidPattern, options: .regularExpression) != nil - } - - private static func formatDDL(_ ddl: String) -> String { - guard ddl.uppercased().hasPrefix("CREATE TABLE") else { - return ddl - } - - var formatted = ddl - - if let range = formatted.range(of: "(") { - let before = String(formatted[.. Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - Self.lock.withLock { Self.lastRequest = request } - guard !Self.bodyText(of: request).contains("SELECT hang"), - let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) - else { return } - let body = #"{"result":[{"results":{"columns":["n"],"rows":[[1]]},"success":true}],"success":true}"# - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data(body.utf8)) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} - - private static func bodyText(of request: URLRequest) -> String { - guard let stream = request.httpBodyStream else { - return String(bytes: request.httpBody ?? Data(), encoding: .utf8) ?? "" - } - stream.open() - defer { stream.close() } - var data = Data() - var buffer = [UInt8](repeating: 0, count: 1_024) - while stream.hasBytesAvailable { - let read = stream.read(&buffer, maxLength: buffer.count) - guard read > 0 else { break } - data.append(buffer, count: read) - } - return String(bytes: data, encoding: .utf8) ?? "" - } -} - -@Suite("Cloudflare D1 HTTP client cancellation", .serialized) -struct D1HttpClientCancellationTests { - private func connectedClient() -> D1HttpClient { - let client = D1HttpClient(accountId: "account", apiToken: "token", databaseId: "database") - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [D1StubProtocol.self] - client.createSession(configuration: configuration) - return client - } - - private func waitUntil(_ condition: () -> Bool) async { - for _ in 0 ..< 300 where !condition() { - try? await Task.sleep(for: .milliseconds(10)) - } - } - - @Test("A statement carries the token and the query timeout and returns its rows") - func roundTrip() async throws { - let client = connectedClient() - client.setQueryTimeout(300) - - let payload = try await client.executeRaw(sql: "SELECT 1") - - #expect(payload.results.columns == ["n"]) - #expect(payload.results.rows?.first?.first?.stringValue == "1") - let request = try #require(D1StubProtocol.recorded) - #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer token") - #expect(request.timeoutInterval == HttpQueryTimeout(serverTimeoutSeconds: 300).requestTimeoutInterval) - #expect(client.inFlightCount == 0) - } - - @Test("Connection requests use the remaining deadline then restore the query timeout") - func connectionDeadlineDoesNotLeakIntoQueries() async throws { - let client = D1HttpClient(accountId: "account", apiToken: "token", databaseId: "database") - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [D1StubProtocol.self] - client.createSession( - configuration: configuration, - connectDeadline: PluginConnectDeadline(milliseconds: 45_000) - ) - - _ = try await client.executeRaw(sql: "SELECT 1") - let connectTimeout = try #require(D1StubProtocol.recorded?.timeoutInterval) - #expect(connectTimeout > 44) - #expect(connectTimeout <= 45) - - client.finishConnecting() - client.setQueryTimeout(300) - _ = try await client.executeRaw(sql: "SELECT 1") - #expect( - D1StubProtocol.recorded?.timeoutInterval - == HttpQueryTimeout(serverTimeoutSeconds: 300).requestTimeoutInterval - ) - } - - @Test("Cancelling everything stops every request in flight, not just the latest") - func cancelAllStopsEveryRequest() async throws { - let client = connectedClient() - let query = Task { try await client.executeRaw(sql: "SELECT hang") } - let sidebarRead = Task { try await client.executeRaw(sql: "SELECT hang") } - await waitUntil { client.inFlightCount == 2 } - - client.cancelAll() - - await #expect(throws: CancellationError.self) { try await query.value } - await #expect(throws: CancellationError.self) { try await sidebarRead.value } - #expect(client.inFlightCount == 0) - } - - @Test("A request that finishes leaves the others cancellable") - func finishedRequestKeepsOthersTracked() async throws { - let client = connectedClient() - let query = Task { try await client.executeRaw(sql: "SELECT hang") } - await waitUntil { client.inFlightCount == 1 } - - _ = try await client.executeRaw(sql: "SELECT 1") - client.cancelAll() - - await #expect(throws: CancellationError.self) { try await query.value } - } - - @Test("Cancelling the awaiting task cancels its request") - func taskCancellation() async throws { - let client = connectedClient() - let query = Task { try await client.executeRaw(sql: "SELECT hang") } - await waitUntil { client.inFlightCount == 1 } - - query.cancel() - - await #expect(throws: CancellationError.self) { try await query.value } - #expect(client.inFlightCount == 0) - } - - @Test("Disconnecting cancels every request in flight") - func invalidateSessionCancels() async throws { - let client = connectedClient() - let query = Task { try await client.executeRaw(sql: "SELECT hang") } - await waitUntil { client.inFlightCount == 1 } - - client.invalidateSession() - - await #expect(throws: CancellationError.self) { try await query.value } - } -} diff --git a/TableProTests/Core/CloudflareD1/D1ResponseParsingTests.swift b/TableProTests/Core/CloudflareD1/D1ResponseParsingTests.swift deleted file mode 100644 index f5d00ccc8b..0000000000 --- a/TableProTests/Core/CloudflareD1/D1ResponseParsingTests.swift +++ /dev/null @@ -1,326 +0,0 @@ -// -// D1ResponseParsingTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct D1ResponseParsingTests { - - // MARK: - Local copies of Codable types for testing - - private struct D1ApiResponse: Decodable { - let result: T? - let success: Bool - let errors: [D1ApiErrorDetail]? - } - - private struct D1ApiErrorDetail: Decodable { - let code: Int? - let message: String - } - - private struct D1RawResultPayload: Decodable { - let results: D1RawResults - let meta: D1QueryMeta? - let success: Bool - } - - private struct D1RawResults: Decodable { - let columns: [String]? - let rows: [[D1Value]]? - } - - private struct D1QueryMeta: Decodable { - let duration: Double? - let changes: Int? - let rowsRead: Int? - let rowsWritten: Int? - - enum CodingKeys: String, CodingKey { - case duration, changes - case rowsRead = "rows_read" - case rowsWritten = "rows_written" - } - } - - private struct D1DatabaseInfo: Decodable { - let uuid: String - let name: String - let createdAt: String? - let version: String? - - enum CodingKeys: String, CodingKey { - case uuid, name, version - case createdAt = "created_at" - } - } - - private struct D1ListResponse: Decodable { - let result: [D1DatabaseInfo] - let success: Bool - } - - private enum D1Value: Decodable { - case string(String) - case int(Int) - case double(Double) - case bool(Bool) - case null - - var stringValue: String? { - switch self { - case .string(let val): return val - case .int(let val): return String(val) - case .double(let val): return String(val) - case .bool(let val): return val ? "1" : "0" - case .null: return nil - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { self = .null; return } - if let v = try? container.decode(Int.self) { self = .int(v); return } - if let v = try? container.decode(Double.self) { self = .double(v); return } - if let v = try? container.decode(Bool.self) { self = .bool(v); return } - if let v = try? container.decode(String.self) { self = .string(v); return } - self = .null - } - } - - // MARK: - /raw Endpoint Response - - @Test("Parses raw query response with columns and rows") - func parsesRawResponse() throws { - let json = """ - { - "result": [{ - "results": { - "columns": ["id", "name", "age"], - "rows": [[1, "Alice", 30], [2, "Bob", null]] - }, - "meta": { - "duration": 0.5, - "changes": 0, - "rows_read": 2, - "rows_written": 0 - }, - "success": true - }], - "success": true, - "errors": [] - } - """.data(using: .utf8)! - - let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: json) - #expect(envelope.success) - - guard let results = envelope.result, let first = results.first else { - Issue.record("Expected non-nil result") - return - } - - #expect(first.success) - #expect(first.results.columns == ["id", "name", "age"]) - - guard let rows = first.results.rows else { - Issue.record("Expected non-nil rows") - return - } - - #expect(rows.count == 2) - #expect(rows[0][0].stringValue == "1") - #expect(rows[0][1].stringValue == "Alice") - #expect(rows[0][2].stringValue == "30") - #expect(rows[1][0].stringValue == "2") - #expect(rows[1][1].stringValue == "Bob") - #expect(rows[1][2].stringValue == nil) - - #expect(first.meta?.duration == 0.5) - #expect(first.meta?.changes == 0) - #expect(first.meta?.rowsRead == 2) - #expect(first.meta?.rowsWritten == 0) - } - - @Test("Parses raw response with empty results") - func parsesEmptyRawResponse() throws { - let json = """ - { - "result": [{ - "results": { - "columns": [], - "rows": [] - }, - "meta": {"duration": 0.1, "changes": 0}, - "success": true - }], - "success": true - } - """.data(using: .utf8)! - - let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: json) - guard let first = envelope.result?.first else { - Issue.record("Expected result") - return - } - - #expect(first.results.columns?.isEmpty == true) - #expect(first.results.rows?.isEmpty == true) - } - - @Test("Parses mutation response with changes count") - func parsesMutationResponse() throws { - let json = """ - { - "result": [{ - "results": { - "columns": [], - "rows": [] - }, - "meta": { - "duration": 0.3, - "changes": 5, - "rows_read": 0, - "rows_written": 5 - }, - "success": true - }], - "success": true - } - """.data(using: .utf8)! - - let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: json) - guard let first = envelope.result?.first else { - Issue.record("Expected result") - return - } - - #expect(first.meta?.changes == 5) - #expect(first.meta?.rowsWritten == 5) - } - - // MARK: - Error Response - - @Test("Parses error response with error details") - func parsesErrorResponse() throws { - let json = """ - { - "result": null, - "success": false, - "errors": [ - {"code": 7500, "message": "no such table: nonexistent"} - ] - } - """.data(using: .utf8)! - - let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: json) - #expect(!envelope.success) - #expect(envelope.result == nil) - #expect(envelope.errors?.count == 1) - #expect(envelope.errors?.first?.code == 7500) - #expect(envelope.errors?.first?.message == "no such table: nonexistent") - } - - @Test("Parses error response without error code") - func parsesErrorWithoutCode() throws { - let json = """ - { - "success": false, - "errors": [{"message": "Something went wrong"}] - } - """.data(using: .utf8)! - - let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: json) - #expect(!envelope.success) - #expect(envelope.errors?.first?.code == nil) - #expect(envelope.errors?.first?.message == "Something went wrong") - } - - // MARK: - Database List Response - - @Test("Parses list databases response") - func parsesListDatabases() throws { - let json = """ - { - "result": [ - {"uuid": "abc-123", "name": "my-db", "created_at": "2025-01-01T00:00:00Z", "version": "production"}, - {"uuid": "def-456", "name": "staging-db", "created_at": "2025-06-15T12:00:00Z", "version": "production"} - ], - "success": true - } - """.data(using: .utf8)! - - let response = try JSONDecoder().decode(D1ListResponse.self, from: json) - #expect(response.success) - #expect(response.result.count == 2) - #expect(response.result[0].uuid == "abc-123") - #expect(response.result[0].name == "my-db") - #expect(response.result[0].createdAt == "2025-01-01T00:00:00Z") - #expect(response.result[0].version == "production") - #expect(response.result[1].uuid == "def-456") - #expect(response.result[1].name == "staging-db") - } - - @Test("Parses database details response (single object)") - func parsesDatabaseDetails() throws { - let json = """ - { - "result": {"uuid": "abc-123", "name": "my-db", "version": "production"}, - "success": true - } - """.data(using: .utf8)! - - let envelope = try JSONDecoder().decode(D1ApiResponse.self, from: json) - #expect(envelope.success) - guard let db = envelope.result else { - Issue.record("Expected result") - return - } - #expect(db.uuid == "abc-123") - #expect(db.name == "my-db") - #expect(db.version == "production") - } - - @Test("Parses database info with missing optional fields") - func parsesDatabaseInfoMissingOptionals() throws { - let json = """ - { - "result": [{"uuid": "abc-123", "name": "my-db"}], - "success": true - } - """.data(using: .utf8)! - - let response = try JSONDecoder().decode(D1ListResponse.self, from: json) - #expect(response.result[0].createdAt == nil) - #expect(response.result[0].version == nil) - } - - // MARK: - QueryMeta snake_case Decoding - - @Test("QueryMeta decodes snake_case fields correctly") - func queryMetaSnakeCase() throws { - let json = """ - {"duration": 1.5, "changes": 3, "rows_read": 100, "rows_written": 3} - """.data(using: .utf8)! - - let meta = try JSONDecoder().decode(D1QueryMeta.self, from: json) - #expect(meta.duration == 1.5) - #expect(meta.changes == 3) - #expect(meta.rowsRead == 100) - #expect(meta.rowsWritten == 3) - } - - @Test("QueryMeta handles missing optional fields") - func queryMetaMissingFields() throws { - let json = "{}".data(using: .utf8)! - - let meta = try JSONDecoder().decode(D1QueryMeta.self, from: json) - #expect(meta.duration == nil) - #expect(meta.changes == nil) - #expect(meta.rowsRead == nil) - #expect(meta.rowsWritten == nil) - } -} diff --git a/TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift b/TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift deleted file mode 100644 index 4df5ce6838..0000000000 --- a/TableProTests/Core/CloudflareD1/D1ValueDecodingTests.swift +++ /dev/null @@ -1,237 +0,0 @@ -// -// D1ValueDecodingTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct D1ValueDecodingTests { - - // MARK: - Local copy of D1Value for testing - - private enum D1Value: Decodable { - case string(String) - case int(Int) - case double(Double) - case bool(Bool) - case null - - var stringValue: String? { - switch self { - case .string(let val): return val - case .int(let val): return String(val) - case .double(let val): return String(val) - case .bool(let val): return val ? "1" : "0" - case .null: return nil - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if container.decodeNil() { - self = .null - return - } - - if let intVal = try? container.decode(Int.self) { - self = .int(intVal) - return - } - - if let doubleVal = try? container.decode(Double.self) { - self = .double(doubleVal) - return - } - - if let boolVal = try? container.decode(Bool.self) { - self = .bool(boolVal) - return - } - - if let stringVal = try? container.decode(String.self) { - self = .string(stringVal) - return - } - - self = .null - } - } - - // MARK: - Null - - @Test("Decodes JSON null as .null") - func decodesNull() throws { - let json = "[null]".data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - #expect(values.count == 1) - if case .null = values[0] { - // correct - } else { - Issue.record("Expected .null, got \(values[0])") - } - } - - @Test("Null stringValue returns nil") - func nullStringValue() throws { - let json = "[null]".data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - #expect(values[0].stringValue == nil) - } - - // MARK: - Integers - - @Test("Decodes integer as .int") - func decodesInteger() throws { - let json = "[42]".data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - if case .int(let val) = values[0] { - #expect(val == 42) - } else { - Issue.record("Expected .int, got \(values[0])") - } - } - - @Test("Decodes zero as .int not .bool") - func decodesZeroAsInt() throws { - let json = "[0]".data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - if case .int(let val) = values[0] { - #expect(val == 0) - } else { - Issue.record("Expected .int(0), got \(values[0])") - } - } - - @Test("Decodes one as .int not .bool") - func decodesOneAsInt() throws { - let json = "[1]".data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - if case .int(let val) = values[0] { - #expect(val == 1) - } else { - Issue.record("Expected .int(1), got \(values[0])") - } - } - - @Test("Decodes negative integer") - func decodesNegativeInt() throws { - let json = "[-100]".data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - if case .int(let val) = values[0] { - #expect(val == -100) - } else { - Issue.record("Expected .int(-100), got \(values[0])") - } - } - - @Test("Integer stringValue returns string representation") - func intStringValue() throws { - let json = "[42]".data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - #expect(values[0].stringValue == "42") - } - - // MARK: - Doubles - - @Test("Decodes float as .double") - func decodesFloat() throws { - let json = "[3.14]".data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - if case .double(let val) = values[0] { - #expect(abs(val - 3.14) < 0.001) - } else { - Issue.record("Expected .double, got \(values[0])") - } - } - - @Test("Double stringValue returns string representation") - func doubleStringValue() throws { - let json = "[3.14]".data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - guard let str = values[0].stringValue else { - Issue.record("Expected non-nil stringValue") - return - } - #expect(str.hasPrefix("3.14")) - } - - // MARK: - Booleans - - @Test("Decodes JSON true as .bool (not when it could be int)") - func decodesTrue() throws { - let json = "[true]".data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - // JSON true is distinct from integer 1 in JSON spec - // Foundation's JSONDecoder may decode true as Int(1) since Int is tried first - // This is acceptable — the stringValue is "1" either way - let str = values[0].stringValue - #expect(str == "1") - } - - @Test("Decodes JSON false") - func decodesFalse() throws { - let json = "[false]".data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - let str = values[0].stringValue - #expect(str == "0") - } - - // MARK: - Strings - - @Test("Decodes string as .string") - func decodesString() throws { - let json = #"["hello"]"#.data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - if case .string(let val) = values[0] { - #expect(val == "hello") - } else { - Issue.record("Expected .string, got \(values[0])") - } - } - - @Test("String stringValue returns the string") - func stringStringValue() throws { - let json = #"["hello"]"#.data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - #expect(values[0].stringValue == "hello") - } - - @Test("Decodes empty string") - func decodesEmptyString() throws { - let json = #"[""]"#.data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - if case .string(let val) = values[0] { - #expect(val == "") - } else { - Issue.record("Expected .string, got \(values[0])") - } - } - - @Test("Decodes numeric string as .string not .int") - func decodesNumericString() throws { - let json = #"["42"]"#.data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - if case .string(let val) = values[0] { - #expect(val == "42") - } else { - Issue.record("Expected .string(\"42\"), got \(values[0])") - } - } - - // MARK: - Mixed Array - - @Test("Decodes mixed-type array from D1 row response") - func decodesMixedRow() throws { - let json = #"[1, "Alice", 30, null, 3.14]"#.data(using: .utf8)! - let values = try JSONDecoder().decode([D1Value].self, from: json) - #expect(values.count == 5) - #expect(values[0].stringValue == "1") - #expect(values[1].stringValue == "Alice") - #expect(values[2].stringValue == "30") - #expect(values[3].stringValue == nil) - #expect(values[4].stringValue?.hasPrefix("3.14") == true) - } -} diff --git a/TableProTests/Core/Compare/CompareCapabilityDeclarationTests.swift b/TableProTests/Core/Compare/CompareCapabilityDeclarationTests.swift deleted file mode 100644 index 748ba0b954..0000000000 --- a/TableProTests/Core/Compare/CompareCapabilityDeclarationTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// CompareCapabilityDeclarationTests.swift -// TableProTests -// -// Compare & Sync is gated on `.schemaCompare` and `.dataCompare`, and both bits shipped for two -// releases with no driver setting either, so the feature refused every connection it was offered. -// A driver lives in a plugin bundle the test target cannot import, so the declaration is checked by -// reading the driver's source. -// - -import Foundation -import Testing - -struct CompareCapabilityDeclarationTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 4 { - url.deleteLastPathComponent() - } - return url - }() - - private static let bundledSQLDriverSources = [ - "Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift", - "Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift", - "Plugins/SQLiteDriverPlugin/SQLitePlugin.swift", - ] - - private func capabilitiesDeclaration(in source: String) -> String? { - guard let start = source.range(of: "var capabilities: PluginCapabilities") else { return nil } - let body = source[start.upperBound...] - guard let end = body.range(of: "\n }") else { return nil } - return String(body[.. [Int] { - lines.indices.filter { index in - let line = lines[index] - guard dispatchHandlerSetters.contains(where: line.contains), line.contains("{") else { return false } - guard !line.contains("@Sendable") else { return false } - return !nextNonEmptyLine(in: lines, after: index).hasPrefix("@Sendable") - } - } - - private static func fsEventsOffenders(in lines: [String]) -> [Int] { - var offending: [Int] = [] - var argumentDepth = 0 - for (index, line) in lines.enumerated() { - let trimmed = line.trimmingCharacters(in: .whitespaces) - let arguments = line.range(of: "FSEventStreamCreate(").map { String(line[$0.upperBound...]) } - if argumentDepth > 0, trimmed.hasPrefix("{"), !trimmed.contains("@Sendable") { - offending.append(index) - } else if let arguments, arguments.contains("{"), !arguments.contains("@Sendable") { - offending.append(index) - } - if declaresIsolatedCallback(lines, index) { - offending.append(index) - } - guard argumentDepth > 0 || arguments != nil else { continue } - argumentDepth += line.filter { $0 == "(" }.count - line.filter { $0 == ")" }.count - argumentDepth = max(argumentDepth, 0) - } - return offending - } - - private static func declaresIsolatedCallback(_ lines: [String], _ index: Int) -> Bool { - let line = lines[index] - guard line.contains("FSEventStreamCallback"), line.contains("="), !line.contains("nonisolated") else { - return false - } - return line.contains("= {") || nextNonEmptyLine(in: lines, after: index).hasPrefix("{") - } - - private static func nextNonEmptyLine(in lines: [String], after index: Int) -> String { - lines[(index + 1)...] - .lazy - .map { $0.trimmingCharacters(in: .whitespaces) } - .first { !$0.isEmpty } ?? "" - } - - private static func offenders(_ rule: ([String]) -> [Int]) throws -> [String] { - let root = try repoRoot() - var matches: [String] = [] - for relativePath in appSourceRoots { - let sourceRoot = root.appendingPathComponent(relativePath) - guard let enumerator = FileManager.default.enumerator( - at: sourceRoot, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { continue } - - for case let url as URL in enumerator - where url.pathExtension == "swift" && !url.lastPathComponent.hasSuffix("Tests.swift") { - let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) - matches += rule(lines).map { "\(url.lastPathComponent):\($0 + 1)" } - } - } - return matches - } - - private static func repoRoot(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("CLAUDE.md").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw HandlerGuardError.repoRootNotFound - } - - private enum HandlerGuardError: Error { - case repoRootNotFound - } -} diff --git a/TableProTests/Core/DataWrite/FieldAbsenceWriteTests.swift b/TableProTests/Core/DataWrite/FieldAbsenceWriteTests.swift deleted file mode 100644 index 12c92f5621..0000000000 --- a/TableProTests/Core/DataWrite/FieldAbsenceWriteTests.swift +++ /dev/null @@ -1,471 +0,0 @@ -// -// FieldAbsenceWriteTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -/// Writes through the MongoDB plugin's own generator, so a test sees the statement the plugin -/// would send for what the host handed it. -private final class MongoGeneratorStubDriver: PluginDatabaseDriver, @unchecked Sendable { - func generateRowWrites( - table: String, - schema: String?, - columns: [String], - primaryKeyColumns: [String], - changes: [PluginRowChange], - insertedRowData: [Int: [PluginCellValue]], - deletedRowIndices: Set, - insertedRowIndices: Set - ) throws -> [PluginRowWrite]? { - try MongoDBStatementGenerator(collectionName: table, columns: columns).generateRowWrites( - from: changes, - insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices - ) - } - - func generateIdentityPreservingInsert( - table: String, - schema: String?, - columns: [String], - primaryKeyColumns: [String], - rows: [[PluginCellValue]], - absentCells: [Int: Set] - ) -> [(statement: String, parameters: [PluginCellValue])]? { - MongoDBStatementGenerator(collectionName: table, columns: columns) - .generateRestore(rows: rows, absentCells: absentCells) - } - - func quoteIdentifier(_ name: String) -> String { name } - func connect() async throws {} - func disconnect() {} - - func execute(query: String) async throws -> PluginQueryResult { - PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) - } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - - func fetchDatabases() async throws -> [String] { [] } - - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } -} - -@MainActor -struct FieldAbsenceStatementTests { - private static let identity: PluginCellValue = "507f1f77bcf86cd799439011" - private let columns = ["_id", "nick", "deletedAt"] - - private func factory() -> RowChangeStatementFactory { - RowChangeStatementFactory( - tableName: "items", - schemaName: nil, - columns: columns, - primaryKeyColumns: ["_id"], - databaseType: .mongodb, - pluginDriver: MongoGeneratorStubDriver() - ) - } - - @Test("Remove Field reaches the driver as a missing field, and Set NULL as a value") - func removalAndNullReachTheDriverApart() throws { - let change = RowChange( - rowID: .existing(0), - type: .update, - cellChanges: [ - CellChange(columnIndex: 1, columnName: "nick", oldValue: "Ada", newValue: .null, newIsAbsent: true), - CellChange(columnIndex: 2, columnName: "deletedAt", oldValue: "2024-05-01", newValue: .null) - ], - originalRow: [Self.identity, "Ada", "2024-05-01"] - ) - - let statements = try factory().statements(for: [change]).map(\.sql) - - #expect(statements == [ - #"db.items.updateOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}}, {"$set": {"deletedAt": null}, "$unset": {"nick": ""}})"# - ]) - } - - @Test("A new row's missing fields are left out and its NULL fields are written") - func insertKeepsNullAndLeavesOutMissing() throws { - let inserted = RowID.inserted(UUID()) - let change = RowChange(rowID: inserted, type: .insert, absentColumns: [1]) - - let statements = try factory().statements( - for: [change], - insertedRowData: [inserted: ["__DEFAULT__", .null, .null]], - insertedRowIDs: [inserted] - ).map(\.sql) - - #expect(statements == [#"db.items.insertOne({"deletedAt": null})"#]) - } - - @Test("Every other engine is handed no missing fields") - func otherEnginesSeeNoAbsence() { - let keyed = PluginKeyedChanges( - changes: [ - RowChange( - rowID: .existing(0), type: .update, - cellChanges: [CellChange(columnIndex: 1, columnName: "name", oldValue: "a", newValue: .null)], - originalRow: ["1", "a"] - ), - RowChange(rowID: .existing(1), type: .delete, originalRow: ["2", "b"]) - ], - insertedRowData: [:], - deletedRowIDs: [.existing(1)], - insertedRowIDs: [] - ) - - #expect(keyed.changes.allSatisfy { $0.absentColumns == nil }) - } -} - -struct FieldAbsenceRewindRecordTests { - private let target = DataWriteTarget(database: "shop", schema: nil, table: "items") - private let columns = ["_id", "nick", "deletedAt"] - - private func operations( - changes: [RowChange], - insertedRowData: [RowID: [PluginCellValue]] = [:], - deletedRowIDs: Set = [], - insertedRowIDs: Set = [] - ) -> [RowWriteOperation] { - RowWriteOperationBuilder.operations( - from: changes, - insertedRowData: insertedRowData, - deletedRowIDs: deletedRowIDs, - insertedRowIDs: insertedRowIDs, - target: target, - columns: columns, - primaryKeyColumns: ["_id"], - generatedColumns: [], - containsTableOperation: false - ) - } - - @Test("An edit records which fields the row lacked before and after it") - func updateRecordsAbsenceOnBothSides() throws { - let change = RowChange( - rowID: .existing(0), - type: .update, - cellChanges: [ - CellChange(columnIndex: 1, columnName: "nick", oldValue: .null, newValue: "Ada", oldIsAbsent: true), - CellChange(columnIndex: 2, columnName: "deletedAt", oldValue: "2024", newValue: .null, newIsAbsent: true) - ], - originalRow: ["1", .null, "2024"], - absentColumns: [1] - ) - - let operation = try #require(operations(changes: [change]).first) - - #expect(operation.preImageAbsentColumns == [1]) - #expect(operation.postImageAbsentColumns == [2]) - } - - @Test("A delete records what the row lacked, and a new row what it was saved without") - func deleteAndInsertRecordAbsence() { - let inserted = RowID.inserted(UUID()) - let result = operations( - changes: [ - RowChange(rowID: .existing(0), type: .delete, originalRow: ["1", .null, .null], absentColumns: [2]), - RowChange(rowID: inserted, type: .insert, absentColumns: [1]) - ], - insertedRowData: [inserted: ["2", .null, .null]], - deletedRowIDs: [.existing(0)], - insertedRowIDs: [inserted] - ) - - #expect(result.first?.preImageAbsentColumns == [2]) - #expect(result.last?.postImageAbsentColumns == [1]) - } - - @Test("A record saved before absence was captured reads every NULL as a missing field, as it was rewound then") - func legacyRecordReadsNullsAsMissing() throws { - let current = RowWriteOperation( - kind: .update, target: target, columns: columns, primaryKeyColumns: ["_id"], - preImage: ["1", .null, "x"], postImage: ["1", "Ada", .null], - writtenColumns: ["nick", "deletedAt"], refusal: nil - ) - var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(current)) as? [String: Any]) - json.removeValue(forKey: "preImageAbsentColumns") - json.removeValue(forKey: "postImageAbsentColumns") - - let legacy = try JSONDecoder().decode(RowWriteOperation.self, from: JSONSerialization.data(withJSONObject: json)) - - #expect(legacy.preImageAbsentColumns == nil) - #expect(legacy.absentColumnsBeforeWrite == [1]) - #expect(legacy.absentColumnsAfterWrite == [2]) - } -} - -@MainActor -struct FieldAbsenceRewindTests { - private static let identity: PluginCellValue = "507f1f77bcf86cd799439011" - private let columns = ["_id", "nick", "deletedAt"] - private let target = DataWriteTarget(database: "shop", schema: nil, table: "items") - - private func planner(_ operation: RowWriteOperation) -> RewindPlanner { - RewindPlanner( - record: RewindRecord( - id: UUID(), historyId: nil, connectionId: UUID(), databaseType: .mongodb, - target: target, capturedAt: Date(timeIntervalSince1970: 0), - generatedColumns: [], identityColumns: [], operations: [operation] - ), - factory: RowChangeStatementFactory( - tableName: target.table, schemaName: nil, columns: columns, - primaryKeyColumns: ["_id"], databaseType: .mongodb, pluginDriver: MongoGeneratorStubDriver() - ), - queryBuilder: TableQueryBuilder(databaseType: .mongodb, pagination: .offset) - ) - } - - private func update( - pre: [PluginCellValue], post: [PluginCellValue], - preAbsent: Set?, postAbsent: Set?, written: [String] - ) -> RowWriteOperation { - RowWriteOperation( - kind: .update, target: target, columns: columns, primaryKeyColumns: ["_id"], - preImage: pre, postImage: post, writtenColumns: written, refusal: nil, - preImageAbsentColumns: preAbsent, postImageAbsentColumns: postAbsent - ) - } - - private func inverse(of operation: RowWriteOperation, current: RewindCurrentRow?) throws -> [String] { - try planner(operation).plan(currentRows: current.map { [$0] } ?? []).statements.map(\.sql) - } - - private func outcome(of operation: RowWriteOperation, current: RewindCurrentRow) throws -> RewindRowOutcome? { - try planner(operation).plan(currentRows: [current]).rows.first?.outcome - } - - @Test("Rewinding Remove Field puts the value back") - func rewindOfRemovalSetsTheValue() throws { - let operation = update( - pre: [Self.identity, "Ada", .null], post: [Self.identity, .null, .null], - preAbsent: [], postAbsent: [1], written: ["nick"] - ) - - let current = RewindCurrentRow(values: [Self.identity, .null, .null], absentColumns: [1]) - #expect(try inverse(of: operation, current: current) == [ - #"db.items.updateOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}}, {"$set": {"nick": "Ada"}})"# - ]) - } - - @Test("Rewinding a value typed into a missing field removes the field again") - func rewindOfFillRemovesTheField() throws { - let operation = update( - pre: [Self.identity, .null, .null], post: [Self.identity, "Ada", .null], - preAbsent: [1], postAbsent: [], written: ["nick"] - ) - - #expect(try inverse(of: operation, current: RewindCurrentRow(values: [Self.identity, "Ada", .null])) == [ - #"db.items.updateOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}}, {"$unset": {"nick": ""}})"# - ]) - } - - @Test("Rewinding an edit of a field that held NULL stores NULL again") - func rewindOfNullFieldStoresNull() throws { - let operation = update( - pre: [Self.identity, .null, .null], post: [Self.identity, "Ada", .null], - preAbsent: [], postAbsent: [], written: ["nick"] - ) - - #expect(try inverse(of: operation, current: RewindCurrentRow(values: [Self.identity, "Ada", .null])) == [ - #"db.items.updateOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}}, {"$set": {"nick": null}})"# - ]) - } - - @Test("Rewinding a delete puts NULL fields back as null and leaves missing fields out") - func rewindOfDeleteKeepsNullAndMissingApart() throws { - let operation = RowWriteOperation( - kind: .delete, target: target, columns: columns, primaryKeyColumns: ["_id"], - preImage: [Self.identity, .null, .null], postImage: nil, writtenColumns: columns, refusal: nil, - preImageAbsentColumns: [1] - ) - - #expect(try inverse(of: operation, current: nil) == [ - #"db.items.insertOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}, "deletedAt": null})"# - ]) - } - - @Test("Rewinding Set NULL on a missing field removes the field again, though the value never changed") - func rewindOfNullOverMissingFieldRemovesIt() throws { - let operation = update( - pre: [Self.identity, .null, .null], post: [Self.identity, .null, .null], - preAbsent: [1], postAbsent: [], written: ["nick"] - ) - - #expect(try inverse(of: operation, current: RewindCurrentRow(values: [Self.identity, .null, .null])) == [ - #"db.items.updateOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}}, {"$unset": {"nick": ""}})"# - ]) - } - - @Test("Rewinding Remove Field on a NULL field stores null again, though the value never changed") - func rewindOfRemovalOfNullStoresNull() throws { - let operation = update( - pre: [Self.identity, .null, .null], post: [Self.identity, .null, .null], - preAbsent: [], postAbsent: [1], written: ["nick"] - ) - let current = RewindCurrentRow(values: [Self.identity, .null, .null], absentColumns: [1]) - - #expect(try inverse(of: operation, current: current) == [ - #"db.items.updateOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}}, {"$set": {"nick": null}})"# - ]) - } - - @Test("A field set to NULL since the save is a change, not the missing field the rewind would restore") - func presenceChangedSinceTheSaveIsAConflict() throws { - let filled = update( - pre: [Self.identity, .null, .null], post: [Self.identity, "Ada", .null], - preAbsent: [1], postAbsent: [], written: ["nick"] - ) - - let nulled = RewindCurrentRow(values: [Self.identity, .null, .null]) - let missing = RewindCurrentRow(values: [Self.identity, .null, .null], absentColumns: [1]) - #expect(try outcome(of: filled, current: nulled) == .changedSinceSave) - #expect(try outcome(of: filled, current: missing) == .alreadyRestored) - } - - @Test("A new document whose left-out field has been added since is a change") - func insertComparesPresence() throws { - let inserted = RowWriteOperation( - kind: .insert, target: target, columns: columns, primaryKeyColumns: ["_id"], - preImage: nil, postImage: [Self.identity, .null, .null], writtenColumns: columns, refusal: nil, - postImageAbsentColumns: [1] - ) - - let untouched = RewindCurrentRow(values: [Self.identity, .null, .null], absentColumns: [1]) - let nickAdded = RewindCurrentRow(values: [Self.identity, .null, .null]) - #expect(try outcome(of: inserted, current: untouched) == .willRestore) - #expect(try outcome(of: inserted, current: nickAdded) == .changedSinceSave) - } -} - -struct RewindCurrentRowTests { - private static let identity: PluginCellValue = "507f1f77bcf86cd799439011" - - private func result(columns: [String], rows: [[PluginCellValue]], absentCells: [Int: Set] = [:]) -> QueryResult { - var result = QueryResult( - columns: columns, - columnTypes: columns.map { _ in .text(rawType: nil) }, - rows: rows, - rowsAffected: 0, - executionTime: 0, - error: nil - ) - result.absentCells = absentCells - return result - } - - @Test("Documents read back line up with the record by field name, and a field none returned is missing") - func documentsAlignByName() { - let read = result( - columns: ["_id", "deletedAt"], - rows: [[Self.identity, .null], [Self.identity, .null]], - absentCells: [1: [1]] - ) - - let rows = RewindCurrentRow.rows(of: read, alignedTo: ["_id", "nick", "deletedAt"], matchingByName: true) - - #expect(rows == [ - RewindCurrentRow(values: [Self.identity, .null, .null], absentColumns: [1]), - RewindCurrentRow(values: [Self.identity, .null, .null], absentColumns: [1, 2]) - ]) - } - - @Test("Fields read back in another order are put in the recorded order") - func documentsAreReordered() { - let read = result(columns: ["nick", "_id"], rows: [["Ada", Self.identity]]) - - let rows = RewindCurrentRow.rows(of: read, alignedTo: ["_id", "nick"], matchingByName: true) - - #expect(rows == [RewindCurrentRow(values: [Self.identity, "Ada"])]) - } - - @Test("A SQL read is already in the recorded order and has no missing fields") - func sqlRowsStayAsRead() { - let read = result(columns: ["ID", "NAME"], rows: [["7", .null]]) - - let rows = RewindCurrentRow.rows(of: read, alignedTo: ["id", "name"], matchingByName: false) - - #expect(rows == [RewindCurrentRow(values: ["7", .null])]) - } -} - -@MainActor -struct FieldAbsenceSidebarSaveTests { - private static let identity: PluginCellValue = "507f1f77bcf86cd799439011" - - private func makeCoordinator() -> MainContentCoordinator { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(type: .mongodb), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) - var tab = QueryTab(title: "items", query: "db.items.find({})", tabType: .table, tableName: "items") - tab.execution.lastExecutedAt = Date() - tabManager.tabs.append(tab) - tabManager.selectedTabId = tab.id - - coordinator.setActiveTableRows( - TableRows.from( - queryRows: [[Self.identity, "Ada", .null]], - columns: ["_id", "nick", "deletedAt"], - columnTypes: Array(repeating: .text(rawType: nil), count: 3), - hasAuthoritativeSchema: true, - absentCells: [0: [2]] - ), - for: tab.id - ) - coordinator.changeManager.configureForTable( - tableName: "items", - columns: ["_id", "nick", "deletedAt"], - primaryKeyColumns: ["_id"], - databaseType: .mongodb, - generatedColumns: [] - ) - coordinator.changeManager.pluginDriver = MongoGeneratorStubDriver() - coordinator.selectionState.indices = [0] - return coordinator - } - - @Test("An inspector save of Remove Field removes the field rather than storing null in it") - func removalIsWrittenAsARemoval() throws { - let statements = try makeCoordinator().sidebarEditStatements(editedFields: [ - InspectorFieldEdit(columnIndex: 1, columnName: "nick", newValue: nil, removesField: true) - ]) - - #expect(statements.map(\.sql) == [ - #"db.items.updateOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}}, {"$unset": {"nick": ""}})"# - ]) - } - - @Test("An inspector save of NULL into a missing field stores null in it") - func nullIntoMissingFieldIsStored() throws { - let statements = try makeCoordinator().sidebarEditStatements(editedFields: [ - InspectorFieldEdit(columnIndex: 2, columnName: "deletedAt", newValue: nil) - ]) - - #expect(statements.map(\.sql) == [ - #"db.items.updateOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}}, {"$set": {"deletedAt": null}})"# - ]) - } -} diff --git a/TableProTests/Core/DataWrite/RowChangeStatementFactoryCoverageTests.swift b/TableProTests/Core/DataWrite/RowChangeStatementFactoryCoverageTests.swift index 481738c3f3..27a3be7a3c 100644 --- a/TableProTests/Core/DataWrite/RowChangeStatementFactoryCoverageTests.swift +++ b/TableProTests/Core/DataWrite/RowChangeStatementFactoryCoverageTests.swift @@ -42,45 +42,6 @@ struct RowChangeStatementFactoryCoverageTests { ) } - private let personColumns = ["_id", "identifiers", "identifiers.type", "personId"] - - /// The Elasticsearch driver as the host reaches it: its generator's own `generateRowWrites`, - /// which writes a nested array through its parent column and refuses a value typed into a leaf. - private func elasticsearchDriver() -> RowWriteStubDriver { - let generator = ElasticsearchStatementGenerator( - index: "persons", - columns: personColumns, - columnTypeNames: ["keyword", "nested", "keyword", "keyword"] - ) - return RowWriteStubDriver { changes, insertedRowData, deletedRowIndices, insertedRowIndices in - try generator.generateRowWrites( - from: changes, - insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices - ) - } - } - - private let nestedLeafReason = "'identifiers.type' is a field of a nested array. Edit the array in 'identifiers' instead." - - private func personEdit(_ cells: [CellChange]) -> RowChange { - RowChange( - rowID: .existing(0), - type: .update, - cellChanges: cells, - originalRow: ["doc1", "[{\"type\":\"CPF\"}]", "[\"CPF\"]", "p1"] - ) - } - - private var leafEdit: CellChange { - CellChange(columnIndex: 2, columnName: "identifiers.type", oldValue: "[\"CPF\"]", newValue: "[\"X\"]") - } - - private var personIdEdit: CellChange { - CellChange(columnIndex: 3, columnName: "personId", oldValue: "p1", newValue: "p2") - } - private func writesOnlyUpdates() -> RowWriteStubDriver { RowWriteStubDriver { changes, _, _, _ in changes.filter { $0.type == .update }.map { @@ -240,60 +201,6 @@ struct RowChangeStatementFactoryCoverageTests { #expect(driver.cellsHanded == rowCount * 2 * 2) } - @Test("An edit to a nested leaf beside a writable edit refuses the save and names the leaf") - func elasticsearchMixedEditRefusesTheLeafItWouldDrop() { - let refusal = DataWriteError.changeRefused(table: "persons", kind: .update, reason: nestedLeafReason) - #expect(throws: refusal) { - _ = try factory( - table: "persons", columns: personColumns, - databaseType: DatabaseType(rawValue: "Elasticsearch"), driver: elasticsearchDriver() - ).statements(for: [personEdit([leafEdit, personIdEdit])]) - } - #expect( - refusal.errorDescription - == "Cannot save the edited row in 'persons'. \(nestedLeafReason)" - ) - } - - @Test("A new Elasticsearch row with only a nested leaf typed is refused rather than saved empty") - func elasticsearchNewRowWithOnlyALeafIsRefused() { - let inserted = RowID.inserted(UUID()) - let change = RowChange( - rowID: inserted, - type: .insert, - cellChanges: [CellChange(columnIndex: 2, columnName: "identifiers.type", oldValue: nil, newValue: "[\"X\"]")] - ) - - #expect(throws: DataWriteError.changeRefused(table: "persons", kind: .insert, reason: nestedLeafReason)) { - _ = try factory( - table: "persons", columns: personColumns, - databaseType: DatabaseType(rawValue: "Elasticsearch"), driver: elasticsearchDriver() - ).statements( - for: [change], - insertedRowData: [inserted: [.null, .null, "[\"X\"]", .null]], - insertedRowIDs: [inserted] - ) - } - } - - @Test("An edit the driver writes in every value keeps the one statement the driver wrote for it") - func elasticsearchEditWrittenInFullIsKept() throws { - let parentEdit = CellChange( - columnIndex: 1, columnName: "identifiers", - oldValue: "[{\"type\":\"CPF\"}]", newValue: "[{\"type\":\"X\"}]" - ) - - let statements = try factory( - table: "persons", columns: personColumns, - databaseType: DatabaseType(rawValue: "Elasticsearch"), driver: elasticsearchDriver() - ).statements(for: [personEdit([parentEdit, personIdEdit])]) - - try #require(statements.count == 1) - let body = ElasticsearchStatementGenerator.decode(statements[0].sql)?.body - #expect(body?.contains("\"personId\":\"p2\"") == true) - #expect(body?.contains("\"identifiers\":[{\"type\":\"X\"}]") == true) - } - @Test("A Redis Value edit on a list key refuses the save even beside a TTL edit it could write") func redisMixedEditRefusesTheValueItWouldDrop() { let generator = RedisStatementGenerator(namespaceName: "0", columns: ["Key", "Type", "TTL", "Length", "Value"]) @@ -420,77 +327,6 @@ struct RowChangeStatementFactoryCoverageTests { #expect(build.steps.allSatisfy { $0.expectedRowCount == nil }) } - private func sqlServerDriver(primaryKeyColumns: [String]) -> RowWriteStubDriver { - let generator = MSSQLStatementGenerator( - qualifiedTable: "[dbo].[items]", columns: columns, primaryKeyColumns: primaryKeyColumns - ) - return RowWriteStubDriver { changes, insertedRowData, deletedRowIndices, insertedRowIndices in - try generator.rowWrites( - for: changes, - insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, - insertedRowIndices: insertedRowIndices - ) - } - } - - private func keylessManager(databaseType: DatabaseType, driver: any PluginDatabaseDriver) -> DataChangeManager { - let manager = DataChangeManager() - manager.configureForTable( - tableName: "items", columns: columns, primaryKeyColumns: [], - databaseType: databaseType, generatedColumns: [] - ) - manager.pluginDriver = driver - return manager - } - - @Test("A SQL Server save to a table with no primary key is held to the row it matches") - func keylessDriverStatementsAreHeldToTheirRow() throws { - let manager = keylessManager(databaseType: .mssql, driver: sqlServerDriver(primaryKeyColumns: [])) - manager.recordCellChange( - rowID: .existing(0), columnIndex: 1, columnName: "name", - oldValue: "", newValue: "z", originalRow: ["0", ""] - ) - manager.recordRowDeletion(rowID: .existing(1), originalRow: ["1", "b"]) - - let build = try manager.buildRowWrites(database: "d", schema: nil, containsTableOperation: false) - - #expect(build.steps.count == 2) - #expect(build.steps.allSatisfy { $0.expectedRowCount == 1 && $0.matchesRowsWithoutKey }) - } - - @Test("A new row on a table with no primary key is not held to a count") - func keylessDriverInsertIsNotHeld() throws { - let inserted = RowID.inserted(UUID()) - let written = try factory( - primaryKeyColumns: [], databaseType: .mssql, driver: sqlServerDriver(primaryKeyColumns: []) - ).rowWriteStatements( - for: [RowChange(rowID: inserted, type: .insert)], - insertedRowData: [inserted: ["1", "n"]], - insertedRowIDs: [inserted] - ) - - guard case .driverWritten(let statements) = written else { - Issue.record("the driver's statements came back as the host's") - return - } - #expect(statements.count == 1) - #expect(statements.allSatisfy { $0.keylessRowCount == nil }) - } - - @Test("A SQL Server save to a keyed table is not held to a lower bound") - func keyedDriverStatementIsNotHeld() throws { - let written = try factory( - databaseType: .mssql, driver: sqlServerDriver(primaryKeyColumns: ["_id"]) - ).rowWriteStatements(for: [nameEdit()]) - - guard case .driverWritten(let statements) = written else { - Issue.record("the driver's statements came back as the host's") - return - } - #expect(statements.map(\.keylessRowCount) == [nil]) - } - @Test("A keyless driver statement is not held where the engine reports no real count") func keylessDriverStatementOnAnEngineWithoutCountsIsNotHeld() throws { let written = try factory( diff --git a/TableProTests/Core/Database/ConnectionHealthCheckTests.swift b/TableProTests/Core/Database/ConnectionHealthCheckTests.swift index d9673d838d..41b78ea78e 100644 --- a/TableProTests/Core/Database/ConnectionHealthCheckTests.swift +++ b/TableProTests/Core/Database/ConnectionHealthCheckTests.swift @@ -34,13 +34,6 @@ struct ConnectionHealthCheckTests { #expect(!ConnectionHealthCheck.isFresh(answered, now: answered.addingTimeInterval(3_600))) } - /// The default has to be today's behaviour, or every user who never opens Settings gets a - /// change they did not ask for. - @Test("the default keeps the 30-second check") - func defaultIsTheThirtySecondCheck() { - #expect(GeneralSettings.default.connectionHealthCheck == .every30Seconds) - } - @Test("settings saved before the option existed decode to the default") func absentKeyDecodesToTheDefault() throws { let json = Data(#"{"startupBehavior":"reopenLast"}"#.utf8) diff --git a/TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift b/TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift deleted file mode 100644 index 59d7e79325..0000000000 --- a/TableProTests/Core/Database/FilterSQLGeneratorMSSQLTests.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// FilterSQLGeneratorMSSQLTests.swift -// TableProTests -// -// Tests for FilterSQLGenerator with databaseType: .mssql -// - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -struct FilterSQLGeneratorMSSQLTests { - private static let mssqlDialect = SQLDialectDescriptor( - identifierQuote: "[", keywords: [], functions: [], dataTypes: [], - regexSyntax: .unsupported, booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, paginationStyle: .offsetFetch - ) - - private let generator = FilterSQLGenerator( - dialect: Self.mssqlDialect, - stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(.mssql) - ) - - // MARK: - Helpers - - private func makeFilter( - column: String = "name", - op: FilterOperator, - value: String = "test", - secondValue: String? = nil - ) -> TableFilter { - TestFixtures.makeTableFilter(column: column, op: op, value: value, secondValue: secondValue) - } - - // MARK: - Operator Tests - - @Test("Equal operator uses bracket-quoted column") - func equalOperator() { - let filter = makeFilter(op: .equal) - let result = generator.generateCondition(from: filter) - #expect(result == "[name] = N'test'") - } - - @Test("Not equal operator uses bracket-quoted column") - func notEqualOperator() { - let filter = makeFilter(op: .notEqual) - let result = generator.generateCondition(from: filter) - #expect(result == "[name] != N'test'") - } - - @Test("Contains operator generates LIKE with ESCAPE clause") - func containsOperator() { - let filter = makeFilter(op: .contains) - let result = generator.generateCondition(from: filter) - #expect(result?.contains("[name] LIKE N'%test%'") == true) - #expect(result?.contains("ESCAPE") == true) - } - - @Test("Not contains operator generates NOT LIKE with ESCAPE clause") - func notContainsOperator() { - let filter = makeFilter(op: .notContains) - let result = generator.generateCondition(from: filter) - #expect(result?.contains("[name] NOT LIKE N'%test%'") == true) - #expect(result?.contains("ESCAPE") == true) - } - - @Test("Starts with operator generates LIKE prefix pattern with ESCAPE clause") - func startsWithOperator() { - let filter = makeFilter(op: .startsWith) - let result = generator.generateCondition(from: filter) - #expect(result?.contains("[name] LIKE N'test%'") == true) - #expect(result?.contains("ESCAPE") == true) - } - - @Test("Ends with operator generates LIKE suffix pattern with ESCAPE clause") - func endsWithOperator() { - let filter = makeFilter(op: .endsWith) - let result = generator.generateCondition(from: filter) - #expect(result?.contains("[name] LIKE N'%test'") == true) - #expect(result?.contains("ESCAPE") == true) - } - - @Test("Is null operator generates IS NULL") - func isNullOperator() { - let filter = makeFilter(op: .isNull, value: "") - let result = generator.generateCondition(from: filter) - #expect(result == "[name] IS NULL") - } - - @Test("Is not null operator generates IS NOT NULL") - func isNotNullOperator() { - let filter = makeFilter(op: .isNotNull, value: "") - let result = generator.generateCondition(from: filter) - #expect(result == "[name] IS NOT NULL") - } - - @Test("Greater than operator generates correct condition") - func greaterThanOperator() { - let filter = makeFilter(column: "age", op: .greaterThan, value: "30") - let result = generator.generateCondition(from: filter) - #expect(result == "[age] > 30") - } - - @Test("Less than operator generates correct condition") - func lessThanOperator() { - let filter = makeFilter(column: "age", op: .lessThan, value: "30") - let result = generator.generateCondition(from: filter) - #expect(result == "[age] < 30") - } - - @Test("Between operator generates BETWEEN clause with numeric values unquoted") - func betweenOperator() { - // Numeric values are passed through without quotes by escapeValue - let filter = makeFilter(column: "age", op: .between, value: "18", secondValue: "65") - let result = generator.generateCondition(from: filter) - #expect(result == "[age] BETWEEN 18 AND 65") - } - - @Test("Regex falls back to LIKE for MSSQL") - func regexFallsBackToLike() { - let filter = makeFilter(column: "email", op: .regex, value: "test") - let result = generator.generateCondition(from: filter) - #expect(result?.contains("LIKE") == true) - #expect(result?.contains("REGEXP") == false) - #expect(result?.contains("~") == false) - } - - // MARK: - Value Escaping Tests - - @Test("Value with single quote is escaped") - func singleQuoteEscaping() { - let filter = makeFilter(column: "name", op: .equal, value: "O'Brien") - let result = generator.generateCondition(from: filter) - #expect(result == "[name] = N'O''Brien'") - } - - // MARK: - WHERE Clause Tests - - @Test("generateWhereClause with multiple filters joins with AND") - func whereClauseAndMode() { - let filters = [ - makeFilter(column: "name", op: .equal, value: "Alice"), - makeFilter(column: "age", op: .greaterThan, value: "18") - ] - let result = generator.generateWhereClause(from: filters, logicMode: .and) - #expect(result.contains("WHERE")) - #expect(result.contains("AND")) - #expect(result.contains("[name] = N'Alice'")) - #expect(result.contains("[age] > 18")) - } - - @Test("generateWhereClause with OR logic mode") - func whereClauseOrMode() { - let filters = [ - makeFilter(column: "name", op: .equal, value: "Alice"), - makeFilter(column: "name", op: .equal, value: "Bob") - ] - let result = generator.generateWhereClause(from: filters, logicMode: .or) - #expect(result.contains("WHERE")) - #expect(result.contains("OR")) - #expect(result.contains("[name] = N'Alice'")) - #expect(result.contains("[name] = N'Bob'")) - } - - // MARK: - Identifier Quoting Tests - - @Test("MSSQL uses bracket quoting for column identifiers") - func mssqlBracketQuoting() { - let filter = makeFilter(column: "user_name", op: .equal, value: "test") - let result = generator.generateCondition(from: filter) - #expect(result?.hasPrefix("[user_name]") == true) - } - - // MARK: - Unicode Literals - - @Test("A non-ASCII value is an nvarchar literal, so a non-Unicode collation cannot flatten it") - func nonAsciiValueIsANationalLiteral() { - let filter = makeFilter(op: .equal, value: "日本語メール") - #expect(generator.generateCondition(from: filter) == "[name] = N'日本語メール'") - } - - @Test("Every value-carrying operator writes a national literal") - func everyValueOperatorWritesANationalLiteral() { - let operators: [FilterOperator] = [ - .equal, .notEqual, .contains, .notContains, .startsWith, .endsWith, .regex - ] - for op in operators { - let result = generator.generateCondition(from: makeFilter(op: op, value: "メール")) ?? "" - let body = result.replacingOccurrences(of: " ESCAPE '!'", with: "") - #expect(body.contains("N'"), "\(op) wrote no national literal") - #expect(!body.contains(" '"), "\(op) wrote a plain literal: \(result)") - } - } - - @Test("An IN list prefixes every element") - func inListPrefixesEveryElement() { - let filter = makeFilter(op: .inList, value: "メール,alpha") - let result = generator.generateCondition(from: filter) - #expect(result == "[name] IN (N'メール', N'alpha')") - } - - @Test("Numbers and NULL never take the prefix") - func numbersAndNullAreNotPrefixed() { - #expect(generator.generateCondition(from: makeFilter(column: "age", op: .greaterThan, value: "30")) - == "[age] > 30") - #expect(generator.generateCondition(from: makeFilter(op: .isNull)) == "[name] IS NULL") - } - - @Test("A driver with no prefix is untouched") - func otherEnginesKeepPlainLiterals() { - let plain = FilterSQLGenerator(dialect: Self.mssqlDialect) - #expect(plain.generateCondition(from: makeFilter(op: .equal)) == "[name] = 'test'") - } -} diff --git a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift index 9b8a1289dd..9126b678f0 100644 --- a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift +++ b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift @@ -616,22 +616,6 @@ struct FilterSQLGeneratorTests { // MARK: - Identifier Quoting Per DB Type - @Test("MySQL uses backtick quoting") - func testMySQLQuoting() { - let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) - let filter = TableFilter( - id: UUID(), - columnName: "name", - filterOperator: .equal, - value: "test", - secondValue: nil, - isEnabled: true, - rawSQL: nil - ) - let result = generator.generateCondition(from: filter) - #expect(result == "`name` = 'test'") - } - @Test("PostgreSQL uses double quote quoting") func testPostgreSQLQuoting() { let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) @@ -664,22 +648,6 @@ struct FilterSQLGeneratorTests { #expect(result == "`name` = 'test'") } - @Test("MariaDB uses backtick quoting") - func testMariaDBQuoting() { - let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) - let filter = TableFilter( - id: UUID(), - columnName: "name", - filterOperator: .equal, - value: "test", - secondValue: nil, - isEnabled: true, - rawSQL: nil - ) - let result = generator.generateCondition(from: filter) - #expect(result == "`name` = 'test'") - } - // MARK: - LIKE Wildcard Escaping @Test("Contains with percent in value escapes percent") @@ -732,22 +700,6 @@ struct FilterSQLGeneratorTests { // MARK: - Regex Per DB Type - @Test("MySQL regex uses REGEXP") - func testMySQLRegex() { - let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) - let filter = TableFilter( - id: UUID(), - columnName: "email", - filterOperator: .regex, - value: "^[a-z]+@", - secondValue: nil, - isEnabled: true, - rawSQL: nil - ) - let result = generator.generateCondition(from: filter) - #expect(result == "`email` REGEXP '^[a-z]+@'") - } - @Test("PostgreSQL regex uses tilde operator") func testPostgreSQLRegex() { let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) @@ -919,70 +871,6 @@ struct FilterSQLGeneratorTests { // MARK: - Redshift Tests - @Test("Redshift uses double-quote identifier quoting") - func testRedshiftQuoting() { - let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) - let filter = TableFilter( - id: UUID(), - columnName: "name", - filterOperator: .equal, - value: "test", - secondValue: nil, - isEnabled: true, - rawSQL: nil - ) - let result = generator.generateCondition(from: filter) - #expect(result == "\"name\" = 'test'") - } - - @Test("Redshift regex uses tilde operator") - func testRedshiftRegex() { - let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) - let filter = TableFilter( - id: UUID(), - columnName: "email", - filterOperator: .regex, - value: "^[a-z]+@", - secondValue: nil, - isEnabled: true, - rawSQL: nil - ) - let result = generator.generateCondition(from: filter) - #expect(result == "\"email\" ~ '^[a-z]+@'") - } - - @Test("Redshift TRUE literal generates TRUE") - func testTrueLiteralRedshift() { - let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) - let filter = TableFilter( - id: UUID(), - columnName: "active", - filterOperator: .equal, - value: "TRUE", - secondValue: nil, - isEnabled: true, - rawSQL: nil - ) - let result = generator.generateCondition(from: filter) - #expect(result == "\"active\" = TRUE") - } - - @Test("Redshift FALSE literal generates FALSE") - func testFalseLiteralRedshift() { - let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) - let filter = TableFilter( - id: UUID(), - columnName: "active", - filterOperator: .equal, - value: "FALSE", - secondValue: nil, - isEnabled: true, - rawSQL: nil - ) - let result = generator.generateCondition(from: filter) - #expect(result == "\"active\" = FALSE") - } - @Test("Redshift LIKE uses a non-backslash ESCAPE clause") func testRedshiftLikeEscape() { let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect) @@ -1105,17 +993,6 @@ struct FilterSQLGeneratorTests { #expect(result == "`name` IS NOT NULL") } - @Test("Equal with regular string value unchanged") - func testEqualRegularStringUnchanged() { - let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) - let filter = TableFilter( - id: UUID(), columnName: "name", filterOperator: .equal, - value: "hello", secondValue: nil, isEnabled: true, rawSQL: nil - ) - let result = generator.generateCondition(from: filter) - #expect(result == "`name` = 'hello'") - } - // MARK: - IN/NOT IN with NULL Values (Fix D) @Test("IN list containing NULL generates OR IS NULL") diff --git a/TableProTests/Core/Database/MSSQLDriverTests.swift b/TableProTests/Core/Database/MSSQLDriverTests.swift deleted file mode 100644 index 7aac9ddb2a..0000000000 --- a/TableProTests/Core/Database/MSSQLDriverTests.swift +++ /dev/null @@ -1,217 +0,0 @@ -// -// MSSQLDriverTests.swift -// TableProTests -// -// Tests for MSSQL driver plugin — parts that don't require a live connection. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -// MARK: - Mock MSSQL Plugin Driver - -private final class MockMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { - private var schema: String? - var cancelQueryCallCount = 0 - var applyQueryTimeoutValues: [Int] = [] - var executedQueries: [String] = [] - var shouldFailExecute = true - - init(initialSchema: String?) { - schema = initialSchema - } - - var currentSchema: String? { schema } - var supportsSchemas: Bool { true } - - func switchSchema(to schema: String) async throws { - self.schema = schema - } - - func connect() async throws {} - func disconnect() {} - - func cancelQuery() throws { - cancelQueryCallCount += 1 - } - - func applyQueryTimeout(_ seconds: Int) async throws { - applyQueryTimeoutValues.append(seconds) - executedQueries.append("SET LOCK_TIMEOUT \(seconds * 1_000)") - } - - func execute(query: String) async throws -> PluginQueryResult { - executedQueries.append(query) - if shouldFailExecute { - throw NSError( - domain: "MockMSSQLPluginDriver", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Not connected"] - ) - } - return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) - } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } -} - -@MainActor -struct MSSQLDriverTests { - // MARK: - Helpers - - private func makeConnection(mssqlSchema: String? = nil) -> DatabaseConnection { - var conn = TestFixtures.makeConnection(type: .mssql) - conn.mssqlSchema = mssqlSchema - return conn - } - - private func makeAdapter(mssqlSchema: String? = nil) -> PluginDriverAdapter { - let (adapter, _) = makeAdapterWithMock(mssqlSchema: mssqlSchema) - return adapter - } - - private func makeAdapterWithMock(mssqlSchema: String? = nil) -> (PluginDriverAdapter, MockMSSQLPluginDriver) { - let conn = makeConnection(mssqlSchema: mssqlSchema) - let effectiveSchema: String? = if let s = mssqlSchema, !s.isEmpty { s } else { "dbo" } - let mock = MockMSSQLPluginDriver(initialSchema: effectiveSchema) - let adapter = PluginDriverAdapter(connection: conn, pluginDriver: mock) - return (adapter, mock) - } - - // MARK: - Initialization Tests - - @Test("Init sets currentSchema to dbo when mssqlSchema is nil") - func initDefaultSchemaNil() { - let adapter = makeAdapter(mssqlSchema: nil) - #expect(adapter.currentSchema == "dbo") - } - - @Test("Init sets currentSchema to dbo when mssqlSchema is empty string") - func initDefaultSchemaEmpty() { - let adapter = makeAdapter(mssqlSchema: "") - #expect(adapter.currentSchema == "dbo") - } - - @Test("Init uses mssqlSchema when provided and non-empty") - func initCustomSchema() { - let adapter = makeAdapter(mssqlSchema: "sales") - #expect(adapter.currentSchema == "sales") - } - - // MARK: - escapedSchema Tests - - @Test("escapedSchema returns schema unchanged when no single quotes") - func escapedSchemaNoQuotes() { - let adapter = makeAdapter(mssqlSchema: "sales") - #expect(adapter.escapedSchema == "sales") - } - - @Test("escapedSchema doubles single quote in schema name") - func escapedSchemaDoublesSingleQuote() { - let adapter = makeAdapter(mssqlSchema: "O'Brien") - #expect(adapter.escapedSchema == "O''Brien") - } - - @Test("escapedSchema doubles multiple single quotes") - func escapedSchemaMultipleQuotes() { - let adapter = makeAdapter(mssqlSchema: "O'Bri'en") - #expect(adapter.escapedSchema == "O''Bri''en") - } - - // MARK: - switchSchema Tests - - @Test("switchSchema updates currentSchema") - func switchSchemaUpdatesCurrentSchema() async throws { - let adapter = makeAdapter() - try await adapter.switchSchema(to: "hr") - #expect(adapter.currentSchema == "hr") - } - - @Test("switchSchema updates escapedSchema accordingly") - func switchSchemaUpdatesEscapedSchema() async throws { - let adapter = makeAdapter() - try await adapter.switchSchema(to: "O'Connor") - #expect(adapter.escapedSchema == "O''Connor") - } - - // MARK: - Status Tests - - @Test("Status starts as disconnected") - func statusStartsDisconnected() { - let adapter = makeAdapter() - if case .disconnected = adapter.status { - #expect(true) - } else { - Issue.record("Expected .disconnected status, got \(adapter.status)") - } - } - - // MARK: - Execute Tests - - @Test("Execute throws when not connected") - func executeThrowsWhenNotConnected() async throws { - let adapter = makeAdapter() - await #expect(throws: (any Error).self) { - _ = try await adapter.execute(query: "SELECT 1") - } - } - - // MARK: - cancelQuery Tests - - @Test("cancelQuery delegates to plugin driver") - func cancelQueryDelegatesToPlugin() throws { - let (adapter, mock) = makeAdapterWithMock() - try adapter.cancelQuery() - #expect(mock.cancelQueryCallCount == 1) - } - - @Test("cancelQuery can be called multiple times") - func cancelQueryMultipleCalls() throws { - let (adapter, mock) = makeAdapterWithMock() - try adapter.cancelQuery() - try adapter.cancelQuery() - try adapter.cancelQuery() - #expect(mock.cancelQueryCallCount == 3) - } - - // MARK: - applyQueryTimeout Tests - - @Test("applyQueryTimeout delegates to plugin driver with correct value") - func applyQueryTimeoutDelegates() async throws { - let (adapter, mock) = makeAdapterWithMock() - mock.shouldFailExecute = false - try await adapter.applyQueryTimeout(30) - #expect(mock.applyQueryTimeoutValues == [30]) - } - - @Test("applyQueryTimeout with zero is handled by plugin") - func applyQueryTimeoutZero() async throws { - let (adapter, mock) = makeAdapterWithMock() - mock.shouldFailExecute = false - try await adapter.applyQueryTimeout(0) - #expect(mock.applyQueryTimeoutValues == [0]) - } - - @Test("applyQueryTimeout with different values records each call") - func applyQueryTimeoutMultipleCalls() async throws { - let (adapter, mock) = makeAdapterWithMock() - mock.shouldFailExecute = false - try await adapter.applyQueryTimeout(10) - try await adapter.applyQueryTimeout(60) - #expect(mock.applyQueryTimeoutValues == [10, 60]) - } -} diff --git a/TableProTests/Core/Database/PostgreSQLDriverTests.swift b/TableProTests/Core/Database/PostgreSQLDriverTests.swift index 03c8f1940a..ae06dcdf3b 100644 --- a/TableProTests/Core/Database/PostgreSQLDriverTests.swift +++ b/TableProTests/Core/Database/PostgreSQLDriverTests.swift @@ -2,13 +2,8 @@ // PostgreSQLDriverTests.swift // TableProTests // -// Regression tests for PostgreSQL DDL functionality. -// Validates source-level guards against PG16 breakage, correct SQL escaping, -// DDL assembly logic, and DDL loading flow behavior. -// import Foundation -import TableProPluginKit import Testing @testable import TablePro @@ -47,256 +42,3 @@ struct PostgreSQLSQLEscapingCorrectness { #expect(result.contains("''"), "ANSI escaping should double single quotes") } } - -// MARK: - DDL Assembly - -struct PostgreSQLDDLAssembly { - - /// Mirrors how `PostgreSQLPluginDriver.fetchTableDDL` assembles its statement. Indexes are no - /// longer part of it: they come back from `fetchIndexDDL` so the dump can write them after the - /// data, so there is nothing to append here. - private func assembleDDL( - schema: String, - table: String, - columns: [String], - constraints: [String] = [] - ) -> String? { - guard !columns.isEmpty else { return nil } - - let quotedSchema = "\"\(schema.replacingOccurrences(of: "\"", with: "\"\""))\"" - let quotedTable = "\"\(table.replacingOccurrences(of: "\"", with: "\"\""))\"" - - var parts = columns - parts.append(contentsOf: constraints) - - return "CREATE TABLE \(quotedSchema).\(quotedTable) (\n " + - parts.joined(separator: ",\n ") + - "\n);" - } - - @Test("Basic CREATE TABLE with columns only") - func basicCreateTableColumnsOnly() { - let columns = [ - "\"id\" integer NOT NULL DEFAULT nextval('users_id_seq'::regclass)", - "\"name\" character varying(255)" - ] - - let result = assembleDDL(schema: "public", table: "users", columns: columns) - - let expected = """ - CREATE TABLE "public"."users" ( - "id" integer NOT NULL DEFAULT nextval('users_id_seq'::regclass), - "name" character varying(255) - ); - """ - .replacingOccurrences(of: " ", with: "") - - #expect(result == expected) - } - - @Test("CREATE TABLE with constraints appears after columns") - func createTableWithConstraints() { - let columns = [ - "\"id\" integer NOT NULL", - "\"email\" character varying(255) NOT NULL" - ] - let constraints = [ - "PRIMARY KEY (\"id\")", - "UNIQUE (\"email\")" - ] - - let result = assembleDDL(schema: "public", table: "users", columns: columns, constraints: constraints)! - - #expect(result.contains("\"id\" integer NOT NULL,")) - #expect(result.contains("\"email\" character varying(255) NOT NULL,")) - #expect(result.contains("PRIMARY KEY (\"id\"),")) - #expect(result.contains("UNIQUE (\"email\")")) - #expect(result.hasSuffix(");")) - - let idPos = (result as NSString).range(of: "\"id\" integer").location - let pkPos = (result as NSString).range(of: "PRIMARY KEY").location - #expect(idPos < pkPos, "Columns should appear before constraints") - } - - @Test("Empty columns returns nil — no empty CREATE TABLE produced") - func emptyColumnsReturnsNil() { - let result = assembleDDL(schema: "public", table: "users", columns: []) - #expect(result == nil) - } - - @Test("Schema and table names with double quotes are properly escaped") - func schemaAndTableNameQuoting() { - let result = assembleDDL( - schema: "my\"schema", - table: "my\"table", - columns: ["\"col\" integer"] - )! - - #expect(result.contains("\"my\"\"schema\"")) - #expect(result.contains("\"my\"\"table\"")) - } -} - -// MARK: - DDL Loading Flow Mock - -private final class MockPostgreSQLDriver: DatabaseDriver, @unchecked Sendable { - let connection: DatabaseConnection - var status: ConnectionStatus = .connected - var serverVersion: String? = "16.0.0" - - var ddlToReturn: String = "" - var sequencesToReturn: [(name: String, ddl: String)] = [] - var enumTypesToReturn: [(name: String, labels: [String])] = [] - - var shouldFailSequences = false - var shouldFailDDL = false - var sequenceError: Error = DatabaseError.queryFailed("column ad.adsrc does not exist") - - init(connection: DatabaseConnection = TestFixtures.makeConnection(type: .postgresql)) { - self.connection = connection - } - - func connect() async throws {} - func disconnect() {} - func testConnection() async throws -> Bool { true } - func applyQueryTimeout(_ seconds: Int) async throws {} - - func execute(query: String) async throws -> QueryResult { .empty } - func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { .empty } - func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { .empty } - - func fetchTables() async throws -> [TableInfo] { [] } - func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } - func fetchAllColumns() async throws -> [String: [ColumnInfo]] { [:] } - func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } - func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } - func fetchApproximateRowCount(table: String) async throws -> Int? { nil } - - func fetchTableDDL(table: String) async throws -> String { - if shouldFailDDL { - throw DatabaseError.queryFailed("Failed to fetch DDL for table '\(table)'") - } - return ddlToReturn - } - - func fetchDependentSequences(forTable table: String) async throws -> [(name: String, ddl: String)] { - if shouldFailSequences { - throw sequenceError - } - return sequencesToReturn - } - - func fetchDependentTypes(forTable table: String) async throws -> [(name: String, labels: [String])] { - enumTypesToReturn - } - - func fetchViewDefinition(view: String) async throws -> String { "" } - func fetchTableMetadata(tableName: String) async throws -> TableMetadata { - TableMetadata( - tableName: tableName, dataSize: nil, indexSize: nil, totalSize: nil, - avgRowLength: nil, rowCount: nil, comment: nil, engine: nil, - collation: nil, createTime: nil, updateTime: nil - ) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchSchemas() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { - DatabaseMetadata( - id: database, name: database, tableCount: nil, sizeBytes: nil, - lastAccessed: nil, isSystemDatabase: false, icon: "cylinder" - ) - } - func createDatabase(name: String, charset: String, collation: String?) async throws {} - func cancelQuery() throws {} - func beginTransaction() async throws {} - func commitTransaction() async throws {} - func rollbackTransaction() async throws {} -} - -struct DDLLoadingFlowTests { - - private func loadDDL(using driver: MockPostgreSQLDriver, table: String) async throws -> String { - let sequences = try await driver.fetchDependentSequences(forTable: table) - let enumTypes = try await driver.fetchDependentTypes(forTable: table) - let baseDDL = try await driver.fetchTableDDL(table: table) - - if sequences.isEmpty && enumTypes.isEmpty { - return baseDDL - } - - var preamble = "" - for seq in sequences { - preamble += seq.ddl + "\n\n" - } - for enumType in enumTypes { - let quotedName = "\"\(enumType.name.replacingOccurrences(of: "\"", with: "\"\""))\"" - let quotedLabels = enumType.labels.map { "'\(SQLEscaping.escapeStringLiteral($0))'" } - preamble += "CREATE TYPE \(quotedName) AS ENUM (\(quotedLabels.joined(separator: ", ")));\n" - } - - return preamble + "\n" + baseDDL - } - - @Test("Successful flow — all three methods return valid data and DDL is assembled correctly") - func successfulFlow() async throws { - let driver = MockPostgreSQLDriver() - driver.ddlToReturn = """ - CREATE TABLE "public"."users" ( - "id" integer NOT NULL DEFAULT nextval('users_id_seq'::regclass), - "name" character varying(255) - ); - """ - driver.sequencesToReturn = [ - (name: "users_id_seq", ddl: "CREATE SEQUENCE \"users_id_seq\" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1;") - ] - driver.enumTypesToReturn = [ - (name: "user_role", labels: ["admin", "editor", "viewer"]) - ] - - let result = try await loadDDL(using: driver, table: "users") - - #expect(result.contains("CREATE SEQUENCE \"users_id_seq\"")) - #expect(result.contains("CREATE TYPE \"user_role\" AS ENUM ('admin', 'editor', 'viewer')")) - #expect(result.contains("CREATE TABLE \"public\".\"users\"")) - - let seqPos = (result as NSString).range(of: "CREATE SEQUENCE").location - let typePos = (result as NSString).range(of: "CREATE TYPE").location - let tablePos = (result as NSString).range(of: "CREATE TABLE").location - #expect(seqPos < typePos, "Sequences should appear before types") - #expect(typePos < tablePos, "Types should appear before CREATE TABLE") - } - - @Test("fetchDependentSequences failure propagates — proves the original PG16 bug") - func sequenceFailurePropagates() async throws { - let driver = MockPostgreSQLDriver() - driver.shouldFailSequences = true - driver.ddlToReturn = "CREATE TABLE \"public\".\"users\" (\"id\" integer);" - - await #expect(throws: DatabaseError.self) { - _ = try await loadDDL(using: driver, table: "users") - } - } - - @Test("fetchTableDDL returns empty columns — throws error") - func emptyDDLThrowsError() async throws { - let driver = MockPostgreSQLDriver() - driver.shouldFailDDL = true - - await #expect(throws: DatabaseError.self) { - _ = try await loadDDL(using: driver, table: "nonexistent") - } - } - - @Test("No sequences or types — baseDDL returned directly without preamble") - func noSequencesOrTypesReturnsBaseDDL() async throws { - let driver = MockPostgreSQLDriver() - let baseDDL = "CREATE TABLE \"public\".\"orders\" (\"id\" integer NOT NULL);" - driver.ddlToReturn = baseDDL - - let result = try await loadDDL(using: driver, table: "orders") - - #expect(result == baseDDL) - #expect(!result.contains("CREATE SEQUENCE")) - #expect(!result.contains("CREATE TYPE")) - } -} diff --git a/TableProTests/Core/Database/SchemaCompositionGuardTests.swift b/TableProTests/Core/Database/SchemaCompositionGuardTests.swift deleted file mode 100644 index acf5e0c0ef..0000000000 --- a/TableProTests/Core/Database/SchemaCompositionGuardTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// SchemaCompositionGuardTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct SchemaCompositionGuardTests { - private static let appDirectory: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 4 { - url.deleteLastPathComponent() - } - return url.appendingPathComponent("TablePro") - }() - - private static let composerEntryPoints = [ - "SchemaStatementGenerator(", - "CreateTableStatementComposer.compose(", - "generateCreateTableStatements(" - ] - - /// `CreateTableStatementComposer` is reached only through the scoped lease in - /// `DatabaseManager+SchemaComposition.swift`. - private static let scopedComposers: Set = [ - "DatabaseManager+SchemaComposition.swift", - "StructureTableRebuildHandler.swift", - "CreateTableStatementComposer.swift" - ] - - private static let knownUnscopedComposers: Set = [ - "SchemaSyncScriptBuilder.swift" - ] - - private static func appSources() throws -> [(name: String, text: String)] { - guard let enumerator = FileManager.default.enumerator( - at: appDirectory, includingPropertiesForKeys: nil - ) else { return [] } - return try enumerator.compactMap { $0 as? URL } - .filter { $0.pathExtension == "swift" } - .map { ($0.lastPathComponent, try String(contentsOf: $0, encoding: .utf8)) } - } - - @Test("The scan reaches the app sources and the scoped composer") - func sourcesAreReachable() throws { - let sources = try Self.appSources() - #expect(sources.count > 100, "The app sources were not found; the guard below would pass vacuously") - #expect(sources.contains { $0.name == "DatabaseManager+SchemaComposition.swift" }) - } - - @Test("DDL is composed only on a driver leased for the statement's own scope") - func ddlIsComposedOnlyOnAScopedDriver() throws { - let allowed = Self.scopedComposers.union(Self.knownUnscopedComposers) - let offenders = try Self.appSources() - .filter { !allowed.contains($0.name) } - .filter { source in Self.composerEntryPoints.contains { source.text.contains($0) } } - .map(\.name) - - #expect(offenders.isEmpty, "These files compose DDL outside a scoped lease: \(offenders)") - } -} diff --git a/TableProTests/Core/Database/SwitchDatabasePooledConnectionTests.swift b/TableProTests/Core/Database/SwitchDatabasePooledConnectionTests.swift index cf04efb62f..cea2061dbf 100644 --- a/TableProTests/Core/Database/SwitchDatabasePooledConnectionTests.swift +++ b/TableProTests/Core/Database/SwitchDatabasePooledConnectionTests.swift @@ -16,8 +16,7 @@ import Testing @Suite("Switch database and pooled connections", .serialized) @MainActor struct SwitchDatabasePooledConnectionTests { - /// Reopens its connection to change database and has no driver plugin registered, so every switch - /// reaches the reconnect and fails there with no network and no waiting. + // No driver plugin and no plugin load in the test host, so every switch's reconnect fails at once, with no network. private static let typeId = "PooledSwitchReconnectFake" private func registerTypeIfNeeded() { diff --git a/TableProTests/Core/Database/SwitchDatabaseReconnectFailureTests.swift b/TableProTests/Core/Database/SwitchDatabaseReconnectFailureTests.swift index 9da0c78b51..380c647fe8 100644 --- a/TableProTests/Core/Database/SwitchDatabaseReconnectFailureTests.swift +++ b/TableProTests/Core/Database/SwitchDatabaseReconnectFailureTests.swift @@ -16,8 +16,7 @@ import Testing @Suite("Switch database reconnect failure", .serialized) @MainActor struct SwitchDatabaseReconnectFailureTests { - /// A type that reopens its connection to change database, and whose driver plugin is not - /// registered, so the reconnect fails on its first step with no network and no waiting. + // No driver plugin and no plugin load in the test host, so the reconnect fails on its first step, with no network. private static let typeId = "ReconnectSwitchFake" private func registerTypeIfNeeded() { diff --git a/TableProTests/Core/Diagnostics/MongoDiagnosticsProducerTests.swift b/TableProTests/Core/Diagnostics/MongoDiagnosticsProducerTests.swift deleted file mode 100644 index 0c507febe7..0000000000 --- a/TableProTests/Core/Diagnostics/MongoDiagnosticsProducerTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// MongoDiagnosticsProducerTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -struct MongoDiagnosticsProducerTests { - private let producer = MongoDiagnosticsProducer() - - @Test("A query with conditions is not flagged") - func conditionsAreValid() { - #expect(producer.diagnostics(for: "db.dt_DispatchRule.find({status: 1})").isEmpty) - } - - @Test("A script that iterates a cursor is not flagged") - func scriptIsValid() { - let script = """ - var seen = 0; - db.orders.find({status: "new"}).forEach(function (o) { seen += o.total; }); - print(seen); - """ - #expect(producer.diagnostics(for: script).isEmpty) - } - - @Test("A shell command is not flagged as a syntax error") - func shellCommandIsValid() { - #expect(producer.diagnostics(for: "use reporting").isEmpty) - #expect(producer.diagnostics(for: "show collections").isEmpty) - } - - @Test("A shell command followed by a query is not flagged") - func shellCommandInScript() { - #expect(producer.diagnostics(for: "use reporting\ndb.orders.find({})").isEmpty) - } - - @Test("A real syntax error is reported") - func syntaxErrorIsReported() { - let diagnostics = producer.diagnostics(for: "db.orders.find({status: })") - #expect(diagnostics.count == 1) - #expect(diagnostics.first?.message.isEmpty == false) - } - - @Test("An unmatched closing bracket is named as such") - func unmatchedClose() { - let diagnostics = producer.diagnostics(for: "db.orders.find({}))") - #expect(diagnostics.first?.message.contains("matching") == true) - } - - @Test("A decrement is an operator, not the start of a comment") - func decrementIsNotAComment() { - #expect(producer.diagnostics(for: "var i = 3; while (i-- > 0) { print(i); }").isEmpty) - // The rest of the line has to still be scanned, or a real mistake after one goes unreported. - #expect(!producer.diagnostics(for: "var i = 3; i--; db.orders.find({status: })").isEmpty) - } - - @Test("A regular expression holding a bracket is not reported as unmatched") - func regexBracketsAreNotStructure() { - #expect(producer.diagnostics(for: "db.c.find({x: /[)]/})").isEmpty) - #expect(producer.diagnostics(for: "db.c.find({x: /a{2,3}/})").isEmpty) - #expect(producer.diagnostics(for: "db.c.find({name: /^ab/i})").isEmpty) - } - - @Test("An empty document produces nothing") - func emptyDocument() { - #expect(producer.diagnostics(for: "").isEmpty) - #expect(producer.diagnostics(for: " \n ").isEmpty) - } - - @Test("A half-typed query is left alone rather than underlined while typing") - func incompleteQueryIsNotFlagged() { - #expect(producer.diagnostics(for: "db.orders.find({status: ").isEmpty) - } -} - -struct MongoShellCommandRecognizerTests { - @Test("The two shell lines are recognised") - func shellLines() { - #expect(MongoShellCommandRecognizer.isShellCommand("use orders")) - #expect(MongoShellCommandRecognizer.isShellCommand("use orders;")) - #expect(MongoShellCommandRecognizer.isShellCommand("SHOW TABLES")) - #expect(MongoShellCommandRecognizer.isShellCommand("show collections")) - #expect(MongoShellCommandRecognizer.isShellCommand("show dbs")) - } - - @Test("JavaScript that starts with the same word is not one") - func javaScriptIsNotShell() { - #expect(!MongoShellCommandRecognizer.isShellCommand("use = 1")) - #expect(!MongoShellCommandRecognizer.isShellCommand("use(\"orders\")")) - #expect(!MongoShellCommandRecognizer.isShellCommand("show.me()")) - #expect(!MongoShellCommandRecognizer.isShellCommand("db.orders.find({})")) - #expect(!MongoShellCommandRecognizer.isShellCommand("show profile")) - } -} - -struct JavaScriptSyntaxCheckerTests { - @Test("Valid JavaScript reports nothing") - func valid() { - #expect(JavaScriptSyntaxChecker.firstFailure(in: "var a = 1; function f() { return a; }") == nil) - } - - @Test("A syntax error reports a message and a line") - func invalid() throws { - let failure = try #require(JavaScriptSyntaxChecker.firstFailure(in: "var a = ;")) - #expect(!failure.message.isEmpty) - #expect(failure.line >= 1) - } - - @Test("The reported line is the line the mistake is on") - func lineNumber() throws { - let failure = try #require(JavaScriptSyntaxChecker.firstFailure(in: "var a = 1;\nvar b = ;\n")) - #expect(failure.line == 2) - } - - @Test("A reference to something undefined is not a syntax error") - func runtimeErrorsAreNotSyntaxErrors() { - #expect(JavaScriptSyntaxChecker.firstFailure(in: "db.orders.find({})") == nil) - } -} diff --git a/TableProTests/Core/EllipsisConventionTests.swift b/TableProTests/Core/EllipsisConventionTests.swift deleted file mode 100644 index 3ee6541d77..0000000000 --- a/TableProTests/Core/EllipsisConventionTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// EllipsisConventionTests.swift -// TableProTests -// -// macOS spells an ellipsis with U+2026, and AppKit's own strings do it without exception: its -// English tables carry 69 strings containing "…" and none containing three periods. TablePro used -// both, split by subsystem, so the same command read "New Table..." in the menu bar and "New -// Table…" in the sidebar's context menu. -// - -import Foundation -import Testing - -struct EllipsisConventionTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - - /// Three periods are correct in these: they are SQL notation rather than a UI ellipsis, in the - /// form SQL documentation itself uses. Anything else with three periods is a UI string that - /// should carry U+2026. - private static let technicalNotation = ["ALTER...DROP", "OVER(...)", ", ...)"] - - private func isTechnical(_ key: String) -> Bool { - Self.technicalNotation.contains { key.contains($0) } - } - - @Test("No live string in the catalog spells an ellipsis with three periods") - func catalogUsesTheUnicodeEllipsis() throws { - let url = Self.repositoryRoot - .appendingPathComponent("TablePro/Resources/Localizable.xcstrings") - let raw = try Data(contentsOf: url) - let parsed = try JSONSerialization.jsonObject(with: raw) as? [String: Any] - let strings = try #require(parsed?["strings"] as? [String: [String: Any]]) - - let offenders = strings.filter { key, entry in - guard key.contains("...") else { return false } - /// A stale key is not shipping: it stayed behind when its UI was removed or renamed, so - /// it is a separate cleanup rather than a spelling the user can see. - guard (entry["extractionState"] as? String) != "stale" else { return false } - return !isTechnical(key) - } - - #expect( - offenders.isEmpty, - "These strings should spell their ellipsis \"…\": \(offenders.keys.sorted())" - ) - } -} diff --git a/TableProTests/Core/Execution/CancelledExecutionOwnershipTests.swift b/TableProTests/Core/Execution/CancelledExecutionOwnershipTests.swift index c91f719e67..dcc112ceca 100644 --- a/TableProTests/Core/Execution/CancelledExecutionOwnershipTests.swift +++ b/TableProTests/Core/Execution/CancelledExecutionOwnershipTests.swift @@ -191,44 +191,4 @@ struct CancelledExecutionOwnershipTests { #expect(coordinator.queryTasks.hasTask(for: other)) #expect(coordinator.tabExecution.isCurrent(otherClaim)) } - - /// The behaviour above is only reached if the close path asks for it, and driving - /// `closeTabsByUser` from a unit test would write to the reader's own recently-closed store. - @Test("The user close path releases the execution") - func closeTabsByUserReleasesTheExecution() throws { - let body = try Self.functionBody(named: "closeTabsByUser", in: "MainContentCoordinator+TabClosing.swift") - #expect( - body.contains("releaseExecution(of: tab)"), - "closeTabsByUser must release the tab's execution before removing the tab" - ) - } - - private static func functionBody(named name: String, in fileName: String) throws -> String { - let url = try repoRoot() - .appendingPathComponent("TablePro/Views/Main/Extensions") - .appendingPathComponent(fileName) - let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) - guard let start = lines.firstIndex(where: { $0.contains("func \(name)(") }) else { - throw CocoaError(.fileNoSuchFile) - } - var depth = 0 - var body = "" - for line in lines[start...] { - body += line + "\n" - depth += line.filter { $0 == "{" }.count - line.filter { $0 == "}" }.count - if depth == 0, body.contains("{") { break } - } - return body - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("TablePro.xcodeproj").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } } diff --git a/TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift b/TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift deleted file mode 100644 index ead7e42d97..0000000000 --- a/TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift +++ /dev/null @@ -1,192 +0,0 @@ -// -// TabExecutionSettleGuardTests.swift -// TableProTests -// -// `settle` answers whether the claim still owned the tab, and the answer is the caller's authority -// to write. Discarding it is the whole bug: the callers that did went on to ask `isCurrent`, which -// settling had already made false, so their writes never ran. The compiler only warns about an -// unused result, and this trap has been walked into three times (#2055, #2068, #2120), so it is -// worth failing the build over. -// -// The scan is keyed on the receiver, not on the bare method name: `MCPHandlerOutcomeGate.settle` -// is an unrelated resume-once continuation that returns nothing, so there is no answer to consume -// there. `everyMutableRegistryUsesTheScannedPropertyName` keeps that narrowing fail-closed. -// - -import Foundation -@testable import TablePro -import Testing - -struct TabExecutionSettleGuardTests { - @Test("Every settle call consumes the ownership answer it returns") - func noSettleCallDiscardsItsAnswer() throws { - let offenders = try Self.settleCallSites().filter { !$0.consumesResult } - #expect( - offenders.isEmpty, - """ - Settling reports whether the claim still owned the tab. Write \ - `guard tabExecution.settle(claim) else { return }` and put every write below it: \ - \(offenders.map(\.description).sorted()) - """ - ) - } - - /// `settle` is `mutating`, so it can only run on a registry the compiler lets it mutate: a `var` - /// or an `inout` parameter. An immutable binding is inert whatever it is called, which is what - /// lets `ExecutionReadout` hold the registry as `let execution` and ask it for the busy bit. - @Test("Every registry a settle can run on is named tabExecution") - func everyMutableRegistryUsesTheScannedPropertyName() throws { - let references = try Self.registryReferences() - #expect(!references.isEmpty) - let escaping = references.filter { $0.isMutable && !$0.text.contains("var tabExecution") } - #expect( - escaping.isEmpty, - """ - The settle guard scans for `tabExecution.settle(`. A registry that can be mutated under \ - another name escapes it, so widen the scan: \(escaping.map(\.description).sorted()) - """ - ) - } - - /// `invalidate(_ tabId:reason:)` releases whatever the tab is running now, which is right for a - /// retarget, a close or a teardown and wrong for anything holding a claim: a cancelled execution - /// unwinding after its successor had claimed the tab deleted the successor's entry, and the - /// successor's own `settle` then refused the rows it had already fetched (#2342). A claim holder - /// releases through `settle`, which answers and releases in one step. - /// - /// Keyed on the enclosing function declaring a `TabExecutionClaim` parameter rather than on the - /// argument text: `TableLoadTraceToken` also carries a `tabId`, so a textual suffix would both - /// miss `let id = claim.tabId` and flag `token.tabId`. - @Test("No function holding a claim releases the tab by id") - func claimHoldersDoNotInvalidateByTabId() throws { - let offenders = try Self.invalidateCallSitesInsideClaimHolders() - #expect( - offenders.isEmpty, - """ - A function that holds a TabExecutionClaim must release through \ - `guard tabExecution.settle(claim) else { return }`, not `tabExecution.invalidate(tabId:)`, \ - which releases the tab from whoever owns it now: \(offenders.map(\.description).sorted()) - """ - ) - } - - /// Stop is the one ending that has to spare a claim whose `COMMIT` is already on the wire, and - /// `stop(_:)` is the only call that does. `invalidateAll(reason: .cancelledByUser)` ends every - /// claim regardless, so a Stop path written that way drops the results of a batch the server - /// has already committed, and it ends the claims of every other tab besides. - @Test("No Stop path ends every execution through invalidateAll") - func stopGoesThroughTheTabsOwnStop() throws { - let offenders = try Self.sourceLines(containing: "invalidateAll(reason: .cancelledByUser)") - .filter { $0.file != "TabExecutionRegistry.swift" } - #expect( - offenders.isEmpty, - """ - The user's Stop reports `tabExecution.stop(tabId)`, which acts on one tab and keeps a \ - claim whose commit is already on the wire: \(offenders.map(\.description).sorted()) - """ - ) - } - - private struct CallSite { - let file: String - let line: Int - let text: String - - /// A consumed answer decides something: a condition, or a binding the code goes on to read. - /// `_ =` is not consumption, it is the discard this guard exists to catch, and a bare call - /// is the same thing with a compiler warning nobody has to act on. - var consumesResult: Bool { - let trimmed = text.trimmingCharacters(in: .whitespaces) - guard !trimmed.hasPrefix("_ ="), !trimmed.hasPrefix("_=") else { return false } - return trimmed.hasPrefix("guard ") - || trimmed.hasPrefix("if ") - || trimmed.hasPrefix("return ") - || trimmed.hasPrefix("while ") - || trimmed.contains(" = ") - } - - /// Whether a `mutating` member is reachable through the binding this line declares. - var isMutable: Bool { - text.contains("var ") || text.contains("inout ") - } - - var description: String { "\(file):\(line)" } - } - - private static func settleCallSites() throws -> [CallSite] { - try sourceLines(containing: "tabExecution.settle(") - } - - /// Walks back from each `tabExecution.invalidate(` to the `func` that encloses it and reads the - /// signature between them, so the test asks "does this function hold a claim" rather than - /// pattern-matching the argument. - private static func invalidateCallSitesInsideClaimHolders() throws -> [CallSite] { - let sourceRoot = try repoRoot().appendingPathComponent("TablePro") - guard let enumerator = FileManager.default.enumerator( - at: sourceRoot, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - - var offenders: [CallSite] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) - for (offset, line) in lines.enumerated() where line.contains("tabExecution.invalidate(") { - guard let signature = enclosingSignature(of: offset, in: lines), - signature.contains("TabExecutionClaim") else { continue } - offenders.append(CallSite(file: url.lastPathComponent, line: offset + 1, text: line)) - } - } - return offenders - } - - /// The declaration text from the nearest `func` above `index` up to the brace that opens its - /// body, which is where a parameter list ends however many lines it spans. - private static func enclosingSignature(of index: Int, in lines: [String]) -> String? { - guard let start = (0 ... index).reversed().first(where: { - lines[$0].trimmingCharacters(in: .whitespaces).contains("func ") - }) else { return nil } - - var signature = "" - for line in lines[start ... index] { - signature += line - if line.contains("{") { break } - } - return signature - } - - private static func registryReferences() throws -> [CallSite] { - try sourceLines(containing: "TabExecutionRegistry").filter { - $0.file != "TabExecutionRegistry.swift" - && !$0.text.trimmingCharacters(in: .whitespaces).hasPrefix("//") - } - } - - private static func sourceLines(containing needle: String) throws -> [CallSite] { - let sourceRoot = try repoRoot().appendingPathComponent("TablePro") - guard let enumerator = FileManager.default.enumerator( - at: sourceRoot, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - - var sites: [CallSite] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - let text = try String(contentsOf: url, encoding: .utf8) - for (offset, line) in text.components(separatedBy: .newlines).enumerated() - where line.contains(needle) { - sites.append(CallSite(file: url.lastPathComponent, line: offset + 1, text: line)) - } - } - return sites - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("TablePro.xcodeproj").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } -} diff --git a/TableProTests/Core/Execution/TabQueryTaskGuardTests.swift b/TableProTests/Core/Execution/TabQueryTaskGuardTests.swift deleted file mode 100644 index b8239258c9..0000000000 --- a/TableProTests/Core/Execution/TabQueryTaskGuardTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// TabQueryTaskGuardTests.swift -// TableProTests -// -// Cancellation is per tab, and there is exactly one route to the driver's own abort. The defect -// this keeps out is not a wrong call, it is a second route: one window-wide handle, or one more -// place that asks the connection to cancel without saying whose work it is. -// - -import Foundation -import Testing - -struct TabQueryTaskGuardTests { - /// The window's single handle is gone. A reintroduced one is the bug: every start path cancels - /// whatever it holds, so tab B's Run kills tab A's batch and rolls it back. - @Test("No window-wide query handle survives") - func noWindowWideQueryHandle() throws { - for name in ["currentQueryTask", "currentQueryTaskOwner", "cancelInFlightQueryTask"] { - let offenders = try Self.sourceLines(containing: name) - #expect( - offenders.isEmpty, - """ - One query handle per window is what made every start path a Stop. Install and retire \ - through `queryTasks`, keyed by tab: \(offenders.map(\.description).sorted()) - """ - ) - } - } - - /// `cancelRunningQuery` names a lease owner now, and the coordinator reaches it from one place. - /// A second call site is a second owner-scoping decision, which is how the connection-wide - /// version spread in the first place. - @Test("Only the owner-scoped routes ask the driver to cancel") - func cancelRunningQueryHasOneRoutePerSubsystem() throws { - let allowed: Set = [ - "DatabaseManager+ScopedDriver.swift", - "MainContentCoordinator+QueryTasks.swift", - "DatabaseAccessBridge.swift", - ] - let offenders = try Self.sourceLines(containing: "cancelRunningQuery(") - .filter { !allowed.contains($0.file) } - #expect( - offenders.isEmpty, - """ - A cancel has to name the lease that owns the work. Route a new one through \ - `MainContentCoordinator.cancelQueryTask(for:delivery:)`: \ - \(offenders.map(\.description).sorted()) - """ - ) - } - - /// Cancelling every tab at once is a teardown, not a Stop. A Stop that reached for it would be - /// the window-wide behaviour again under a new name. - @Test("Every tab's task is only ever taken down by teardown") - func removeAllIsTeardownOnly() throws { - let allowed: Set = ["MainContentCoordinator+QueryTasks.swift"] - let offenders = try Self.sourceLines(containing: "queryTasks.removeAll") - .filter { !allowed.contains($0.file) } - #expect( - offenders.isEmpty, - """ - `cancelAllQueryTasks()` is the teardown path and the only caller: \ - \(offenders.map(\.description).sorted()) - """ - ) - } - - private struct SourceLine { - let file: String - let line: Int - var description: String { "\(file):\(line)" } - } - - private static func sourceLines(containing needle: String) throws -> [SourceLine] { - let sourceRoot = try repoRoot().appendingPathComponent("TablePro") - guard let enumerator = FileManager.default.enumerator( - at: sourceRoot, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - - var sites: [SourceLine] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - let text = try String(contentsOf: url, encoding: .utf8) - for (offset, line) in text.components(separatedBy: .newlines).enumerated() - where line.contains(needle) { - sites.append(SourceLine(file: url.lastPathComponent, line: offset + 1)) - } - } - return sites - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } -} diff --git a/TableProTests/Core/Execution/WindowBusyStateGuardTests.swift b/TableProTests/Core/Execution/WindowBusyStateGuardTests.swift index 2144fa9862..d9ca1e477a 100644 --- a/TableProTests/Core/Execution/WindowBusyStateGuardTests.swift +++ b/TableProTests/Core/Execution/WindowBusyStateGuardTests.swift @@ -2,13 +2,8 @@ // WindowBusyStateGuardTests.swift // TableProTests // -// "Executing…" and the Stop control beside it are a function of `TabExecutionRegistry`, and of -// nothing else. They used to be a stored bool on `ConnectionToolbarState`, raised and lowered by -// hand beside each execution and released only behind two ownership checks, so any path that ended -// an execution without satisfying both left the titlebar, Stop, `Cmd+.` and the disconnect warning -// describing work that was over. The only way back was pressing Stop (#2342, and #548 before it). -// -// A second copy of that answer cannot come back quietly, so the build fails on one. +// "Executing…" and the Stop control are a function of `TabExecutionRegistry` alone. A stored copy +// kept describing work that was over whenever a path ended an execution without lowering it. // import Foundation @@ -16,43 +11,7 @@ import Foundation import Testing struct WindowBusyStateGuardTests { - @Test("Nothing stores or writes a second copy of whether the window is busy") - func noStoredWindowExecutionFlag() throws { - let offenders = try Self.sourceLines { line in - line.contains("setExecuting(") || line.contains("toolbarState.isExecuting") - } - #expect( - offenders.isEmpty, - """ - Whether anything is running is derived from `tabExecution.isAnyExecuting`. A stored copy \ - desynchronizes the moment one path ends an execution without lowering it: \ - \(offenders.map(\.description).sorted()) - """ - ) - } - - @Test("The toolbar state holds no execution flag of its own") - func toolbarStateHoldsNoExecutionFlag() throws { - let offenders = try Self.sourceLines(in: "ConnectionToolbarState.swift") { line in - line.contains("isExecuting") && !line.trimmingCharacters(in: .whitespaces).hasPrefix("///") - } - #expect( - offenders.isEmpty, - """ - `ConnectionToolbarState` owns the connection's state. Whether a query is running belongs \ - to `TabExecutionRegistry`: \(offenders.map(\.description).sorted()) - """ - ) - } - - /// The scans above only prove the old flag is gone. This proves the readout the status bar - /// draws IS the registry, so a future edit cannot satisfy both scans by wiring it to some third - /// value. - /// - /// Behavioural rather than a source scan. The scan this replaces matched one call site's exact - /// spelling, so moving the readout out of the toolbar broke it while the invariant it guards - /// still held. `ExecutionReadout.isExecuting` is now computed from a stored registry rather - /// than a stored `Bool`, which is what makes this assertable: reintroducing the parameter + /// `ExecutionReadout.isExecuting` is computed from the registry, so reintroducing a stored `Bool` /// changes the memberwise initializer and this file stops compiling. @Test("The execution readout is a live read of the execution registry") func executionIndicatorReadsTheRegistry() { @@ -103,43 +62,4 @@ struct WindowBusyStateGuardTests { #expect(readout().canStop == false) #expect(before != readout()) } - - private struct SourceLine { - let file: String - let line: Int - - var description: String { "\(file):\(line)" } - } - - private static func sourceLines( - in fileName: String? = nil, - matching predicate: (String) -> Bool - ) throws -> [SourceLine] { - let sourceRoot = try repoRoot().appendingPathComponent("TablePro") - guard let enumerator = FileManager.default.enumerator( - at: sourceRoot, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - - var matches: [SourceLine] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - if let fileName, url.lastPathComponent != fileName { continue } - let text = try String(contentsOf: url, encoding: .utf8) - for (offset, line) in text.components(separatedBy: .newlines).enumerated() where predicate(line) { - matches.append(SourceLine(file: url.lastPathComponent, line: offset + 1)) - } - } - return matches - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("TablePro.xcodeproj").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } } diff --git a/TableProTests/Core/Export/ExportFormatCatalogTests.swift b/TableProTests/Core/Export/ExportFormatCatalogTests.swift index 326db9491e..e536e73280 100644 --- a/TableProTests/Core/Export/ExportFormatCatalogTests.swift +++ b/TableProTests/Core/Export/ExportFormatCatalogTests.swift @@ -117,13 +117,4 @@ struct ExportFormatCatalogTests { #expect(forwards == backwards) #expect(forwards == ["csv", "sql", "parquet", "zebra"]) } - - @Test("Parquet is offered for install until it is installed") - func parquetIsOfferedUntilInstalled() { - let offered = ExportFormatCatalog.notInstalled(installedFormatIds: ["csv", "sql"]) - #expect(offered.map(\.formatId) == ["parquet"]) - #expect(offered.first?.registryPluginId == "com.TablePro.ParquetExportPlugin") - - #expect(ExportFormatCatalog.notInstalled(installedFormatIds: ["csv", "parquet"]).isEmpty) - } } diff --git a/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift b/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift index bbb4a4893b..96e4a250d6 100644 --- a/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift +++ b/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift @@ -260,95 +260,10 @@ struct MCPAuditLogStorageTests { #expect(row.tokenName == "client") } - @Test("No log statement in the MCP surface interpolates token material") - func loggingNeverInterpolatesTokenMaterial() throws { - let offenders = try Self.loggedInterpolations().filter { interpolation in - Self.tokenMaterialNames.contains { interpolation.value.lowercased().contains($0) } - } - - #expect( - offenders.isEmpty, - """ - A log line must never carry token material, not even a prefix or a digest of one: \ - \(offenders.map(\.description).sorted()) - """ - ) - } - - private static let tokenMaterialNames = [ - "fingerprint", - "plaintext", - "tokenhash", - "salt", - "verifier", - "prefix", - "password", - "secret" - ] - - private struct LoggedInterpolation { - let file: String - let line: Int - let value: String - - var description: String { "\(file):\(line) \(value)" } - } - - private static func loggedInterpolations() throws -> [LoggedInterpolation] { - let root = try repositoryRoot().appendingPathComponent("TablePro/Core/MCP") - guard let enumerator = FileManager.default.enumerator( - at: root, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - - var found: [LoggedInterpolation] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - let text = try String(contentsOf: url, encoding: .utf8) - for (offset, line) in text.components(separatedBy: .newlines).enumerated() - where line.contains("privacy:") { - for value in interpolatedValues(in: line) { - found.append( - LoggedInterpolation(file: url.lastPathComponent, line: offset + 1, value: value) - ) - } - } - } - return found - } - - private static func interpolatedValues(in line: String) -> [String] { - var values: [String] = [] - var remainder = Substring(line) - while let start = remainder.range(of: "\\(") { - let body = remainder[start.upperBound...] - let end = body.firstIndex { $0 == "," || $0 == ")" } ?? body.endIndex - values.append(String(body[body.startIndex.. URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0..<12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } - private static func auditDirectory() -> URL { AppStorageEnvironment.shared.applicationSupportRoot.appendingPathComponent("TablePro") } - private static func auditFileNames() -> [String] { - let contents = (try? FileManager.default.contentsOfDirectory(atPath: auditDirectory().path)) ?? [] - let prefix = "mcp-audit-test_\(ProcessInfo.processInfo.processIdentifier)" - return contents.filter { $0.hasPrefix(prefix) } - } - private static func permissions(ofItemAt path: String) -> Int? { let attributes = try? FileManager.default.attributesOfItem(atPath: path) return (attributes?[.posixPermissions] as? NSNumber)?.intValue diff --git a/TableProTests/Core/MCP/MCPPairingValidationTests.swift b/TableProTests/Core/MCP/MCPPairingValidationTests.swift index 03c24ad123..8d6bccda6e 100644 --- a/TableProTests/Core/MCP/MCPPairingValidationTests.swift +++ b/TableProTests/Core/MCP/MCPPairingValidationTests.swift @@ -5,8 +5,7 @@ // The redirect decides who ends up holding the pairing code, so only the two shapes that reach the // machine the user is sitting at are accepted: a loopback HTTP listener (RFC 8252) and a // private-use scheme an installed app registered. Anything else is a web page. PKCE is checked -// against RFC 7636 on both halves before either value reaches a comparison, and approving a -// request never touches a token that happens to share the requester's client name. +// against RFC 7636 on both halves before either value reaches a comparison. // import Foundation @@ -253,24 +252,4 @@ struct MCPPairingValidationTests { #expect(error.localizedMessage.isEmpty == false) } } - - @Test("Approving a pairing request never revokes a token that shares the client name") - func approvingNeverRevokesBySharedName() throws { - let source = try Self.pairingServiceSource() - - #expect(source.contains("revokeExistingTokens") == false) - #expect(source.contains(".revoke(") == false) - } - - private static func pairingServiceSource() throws -> String { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0..<12 { - let candidate = directory.appendingPathComponent("TablePro/Core/MCP/MCPPairingService.swift") - if FileManager.default.fileExists(atPath: candidate.path) { - return try String(contentsOf: candidate, encoding: .utf8) - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } } diff --git a/TableProTests/Core/MCP/MCPReportedStatusTests.swift b/TableProTests/Core/MCP/MCPReportedStatusTests.swift deleted file mode 100644 index 5d5ad600ed..0000000000 --- a/TableProTests/Core/MCP/MCPReportedStatusTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// MCPReportedStatusTests.swift -// TableProTests -// -// `ConnectionSession.status` still reads `.connected` for a session whose driver stopped answering, -// because the driver handle is a handle rather than a live connection. `reportedStatus` is the one -// answer to "can this be believed", and CLAUDE.md's invariant is that everything reporting -// connection health goes through it. `ConnectionLivenessReportingTests` covers what the property -// does; this covers who asks it. -// -// The MCP surface disagreed with itself. `get_connection_status` used `reportedStatus`, while -// `list_connections`, `open_connection_window` and the subscription visibility set used the raw -// status, so one tool called an unreachable connection connected and another called it an error. -// - -import Foundation -import Testing - -struct MCPReportedStatusTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 4 { - url.deleteLastPathComponent() - } - return url - }() - - @Test("No MCP file reads a session's raw status to report health") - func mcpReportsThroughReportedStatus() throws { - let mcpRoot = Self.repositoryRoot.appendingPathComponent("TablePro/Core/MCP") - let enumerator = try #require( - FileManager.default.enumerator(at: mcpRoot, includingPropertiesForKeys: nil) - ) - - var scanned = 0 - var offenders: [String] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - guard let source = try? String(contentsOf: url, encoding: .utf8) else { continue } - scanned += 1 - let relativePath = url.path - .replacingOccurrences(of: Self.repositoryRoot.path + "/", with: "") - for (index, line) in source.components(separatedBy: .newlines).enumerated() { - guard !line.trimmingCharacters(in: .whitespaces).hasPrefix("//") else { continue } - guard line.contains(".status.isConnected") else { continue } - offenders.append("\(relativePath):\(index + 1)") - } - } - - /// A path that stops resolving would scan nothing and pass silently. - #expect(scanned > 20, "Expected to scan the MCP sources, scanned \(scanned) files") - #expect( - offenders.isEmpty, - "MCP reports connection health through reportedStatus, not status: \(offenders.sorted())" - ) - } -} diff --git a/TableProTests/Core/MCP/MCPTokenStoreTests.swift b/TableProTests/Core/MCP/MCPTokenStoreTests.swift index 262e0706e8..9e02e456b9 100644 --- a/TableProTests/Core/MCP/MCPTokenStoreTests.swift +++ b/TableProTests/Core/MCP/MCPTokenStoreTests.swift @@ -144,23 +144,6 @@ struct MCPTokenStoreTests { #expect(MCPTokenStore.defaultTokenLifetime > 0) } - @Test("Minting a token takes its connection scope and expiry with no default to fall back on") - func generateDeclaresNoDefaultGrant() throws { - let source = try Self.tokenStoreSource() - let signature = try #require( - source.range(of: #"func generate\([^)]*\)"#, options: .regularExpression).map { String(source[$0]) } - ) - - #expect(signature.contains("connectionAccess: ConnectionAccess")) - #expect(signature.contains("expiresAt: Date?")) - #expect(signature.contains("isBridgeCredential: Bool")) - #expect(signature.contains("extraScopes: Set")) - #expect(signature.contains("connectionAccess: ConnectionAccess = ") == false) - #expect(signature.contains("expiresAt: Date? = ") == false) - #expect(signature.contains("isBridgeCredential: Bool = ") == false) - #expect(signature.contains("=") == false) - } - @Test("Two generated tokens never share a secret or a salt") func generatedSecretsAreUnique() async throws { let store = makeStore() @@ -599,19 +582,6 @@ struct MCPTokenStoreTests { #expect(access.allows(UUID()) == false) #expect(ConnectionAccess.all.allows(UUID())) } - - private static func tokenStoreSource() throws -> String { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0..<12 { - let candidate = directory - .appendingPathComponent("TablePro/Core/MCP/MCPTokenStore.swift") - if FileManager.default.fileExists(atPath: candidate.path) { - return try String(contentsOf: candidate, encoding: .utf8) - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } } private actor RevocationRecorder { diff --git a/TableProTests/Core/MCP/Protocol/Tools/MCPStatementConsentGuardTests.swift b/TableProTests/Core/MCP/Protocol/Tools/MCPStatementConsentGuardTests.swift index a1f79421cb..ee8567b703 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/MCPStatementConsentGuardTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/MCPStatementConsentGuardTests.swift @@ -3,9 +3,7 @@ // TableProTests // // A client that declares elicitation answers its own approval prompt, and TablePro then treats the -// statement as pre-cleared. Only an admin-scoped token may buy out the Mac dialog that way. The -// enforcement lived in an overload nothing called, so the source scan is half the point: the -// regression is a missing argument, and the behavioural half cannot see one. +// statement as pre-cleared. Only an admin-scoped token may buy out the Mac dialog that way. // import Foundation @@ -13,15 +11,6 @@ import Foundation import Testing struct MCPStatementConsentGuardTests { - private static let gateSource: String = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 6 { - url.deleteLastPathComponent() - } - url.appendPathComponent("TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift") - return (try? String(contentsOf: url, encoding: .utf8)) ?? "" - }() - private func principal(scopes: Set, tokenId: UUID) -> MCPPrincipal { MCPPrincipal( tokenFingerprint: "test", @@ -50,18 +39,4 @@ struct MCPStatementConsentGuardTests { ) #expect(effective.contains(.confirmationPreCleared)) } - - /// The stripping only runs on the overload that takes a principal. Calling the other one from - /// the MCP path is the bypass, and it compiles cleanly. - @Test("The MCP statement path always names its principal at the Safe Mode gate") - func statementGatePassesItsPrincipal() throws { - #expect(!Self.gateSource.isEmpty, "MCPStatementGate.swift was not readable") - - let call = try #require( - Self.gateSource.range(of: "checkSafeModeDialog("), - "MCPStatementGate no longer reaches the Safe Mode gate" - ) - let arguments = Self.gateSource[call.upperBound...].prefix(300) - #expect(arguments.contains("principal:"), "MCPStatementGate must pass principal: to checkSafeModeDialog") - } } diff --git a/TableProTests/Core/MCP/Results/MCPResultTests.swift b/TableProTests/Core/MCP/Results/MCPResultTests.swift index 692d809f6a..713344c869 100644 --- a/TableProTests/Core/MCP/Results/MCPResultTests.swift +++ b/TableProTests/Core/MCP/Results/MCPResultTests.swift @@ -118,14 +118,6 @@ struct MCPResultTests { #expect(MCPResult.empty.meta.isEmpty) } - @Test("A result defaults to complete with no cache hint") - func defaultsAreComplete() { - let result = MCPResult() - #expect(result.kind == .complete) - #expect(result.cacheHint == nil) - #expect(result.meta.isEmpty) - } - @Test("Result metadata reports emptiness from its own fields") func resultMetaEmptiness() { #expect(MCPResultMeta().isEmpty) diff --git a/TableProTests/Core/Menu/ChromeSnapshotTests.swift b/TableProTests/Core/Menu/ChromeSnapshotTests.swift deleted file mode 100644 index f4f91676ff..0000000000 --- a/TableProTests/Core/Menu/ChromeSnapshotTests.swift +++ /dev/null @@ -1,190 +0,0 @@ -// -// ChromeSnapshotTests.swift -// TableProTests -// - -import AppKit -import Foundation -@testable import TablePro -import Testing - -// Re-record ChromeSnapshot.json after an intended change to the menu bar, the main window toolbar -// or the Settings panes: -// TEST_RUNNER_TABLEPRO_RECORD_CHROME_SNAPSHOT=1 .claude/skills/fix-issue/scripts/verify.sh test ChromeSnapshotTests -@MainActor -struct ChromeSnapshotTests { - @Test( - "Menu bar, toolbar sets and Settings panes match ChromeSnapshot.json", - .enabled(if: ChromeSnapshot.matchesTheRecordingMachine) - ) - func chromeMatchesTheRecordedSnapshot() throws { - let actual = ChromeSnapshot.render() - #expect((try? JSONSerialization.jsonObject(with: Data(actual.utf8))) != nil, "The snapshot is not valid JSON") - - if ProcessInfo.processInfo.environment["TABLEPRO_RECORD_CHROME_SNAPSHOT"] == "1" { - try actual.write(to: ChromeSnapshot.goldenURL, atomically: true, encoding: .utf8) - return - } - - let expected = try String(contentsOf: ChromeSnapshot.goldenURL, encoding: .utf8) - guard actual != expected else { return } - let actualURL = FileManager.default.temporaryDirectory.appendingPathComponent("ChromeSnapshot.actual.json") - try? actual.write(to: actualURL, atomically: true, encoding: .utf8) - Issue.record( - """ - The chrome changed. If that is intended, re-record with the command at the top of \ - ChromeSnapshotTests.swift. Full snapshot: \(actualURL.path) - \(ChromeSnapshot.lineDiff(from: expected, to: actual)) - """ - ) - } - - @Test("Taking the snapshot leaves the app's own menus in place") - func snapshotLeavesTheAppMenusAlone() { - let app = NSApplication.shared - let before = [app.mainMenu, app.servicesMenu, app.windowsMenu, app.helpMenu] - _ = ChromeSnapshot.render() - let after = [app.mainMenu, app.servicesMenu, app.windowsMenu, app.helpMenu] - #expect(zip(before, after).allSatisfy { $0 === $1 }) - } -} - -@MainActor -private enum ChromeSnapshot { - static let goldenURL = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .appendingPathComponent("ChromeSnapshot.json") - - // The golden is recorded in English, on a US key layout, on macOS 14 or later: titles are - // localized, default key equivalents follow the active layout, and macOS 13 builds a different - // toolbar. - nonisolated static var matchesTheRecordingMachine: Bool { - let macOS14 = OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0) - guard Bundle.main.preferredLocalizations.first == "en", - ProcessInfo.processInfo.isOperatingSystemAtLeast(macOS14) else { return false } - return (UInt16(0)...127).allSatisfy { code in - guard let usCharacter = KeyCode(rawValue: code)?.usBaseCharacter else { return true } - return KeyboardLayout.baseCharacter(for: code) == usCharacter - } - } - - static func render() -> String { - let menuLines = menuBarEntries().map { " \($0)" }.joined(separator: ",\n") - let panes = SettingsPaneTabViewController.paneOrder - .map { " {\"id\": \(quoted($0.rawValue)), \"title\": \(quoted($0.title))}" } - .joined(separator: ",\n") - return """ - { - "menuBar": [ - \(menuLines) - ], - "mainWindowToolbar": { - "default": [ - \(identifierLines(MainWindowToolbar.defaultItemIdentifiers)) - ], - "allowed": [ - \(identifierLines(MainWindowToolbar.allowedItemIdentifiers)) - ] - }, - "settingsPanes": [ - \(panes) - ] - } - - """ - } - - private static func menuBarEntries() -> [String] { - let built = MainMenuBuilder.build(keyboard: KeyboardSettings()) - return entries(in: built.bar, path: [], appKitOwned: built.servicesMenu) - } - - // AppKit fills the Services menu and appends open windows to the Window menu, so neither is - // the app's chrome. - private static func entries(in menu: NSMenu, path: [String], appKitOwned: NSMenu?) -> [String] { - menu.items.flatMap { item -> [String] in - if item.target is NSWindow { return [] } - guard !item.isSeparatorItem else { - return ["{\"path\": \(quoted(path.joined(separator: " > "))), \"separator\": true}"] - } - let itemPath = path + [item.title] - let line = entry(for: item, path: itemPath.joined(separator: " > ")) - guard let submenu = item.submenu, submenu !== appKitOwned else { return [line] } - return [line] + entries(in: submenu, path: itemPath, appKitOwned: appKitOwned) - } - } - - // AppKit names an item without an identifier after its action, and points a submenu's item at - // submenuAction:. Neither is authored, so neither is recorded. - private static func entry(for item: NSMenuItem, path: String) -> String { - var fields = ["\"path\": \(quoted(path))"] - let action = item.action.flatMap { $0 == #selector(NSMenu.submenuAction(_:)) ? nil : NSStringFromSelector($0) } - if let identifier = item.identifier?.rawValue, identifier != item.action.map(NSStringFromSelector) { - fields.append("\"identifier\": \(quoted(identifier))") - } - if let action { - fields.append("\"action\": \(quoted(action))") - } - if !item.keyEquivalent.isEmpty { - fields.append("\"key\": \(quoted(keyName(item.keyEquivalent)))") - let modifiers = modifierNames(item.keyEquivalentModifierMask).map(quoted).joined(separator: ", ") - fields.append("\"modifiers\": [\(modifiers)]") - } - if item.isAlternate { - fields.append("\"alternate\": true") - } - if item.isHidden { - fields.append("\"hidden\": true") - } - return "{\(fields.joined(separator: ", "))}" - } - - private static let specialKeys: [UInt32: String] = [ - 0x08: "backspace", 0x09: "tab", 0x0D: "return", 0x19: "backtab", 0x1B: "escape", 0x20: "space", - 0x7F: "delete", 0xF700: "up", 0xF701: "down", 0xF702: "left", 0xF703: "right", - 0xF728: "forward delete", 0xF729: "home", 0xF72B: "end", 0xF72C: "page up", 0xF72D: "page down", - ] - - private static func keyName(_ key: String) -> String { - guard key.unicodeScalars.count == 1, let scalar = key.unicodeScalars.first else { return key } - if let name = specialKeys[scalar.value] { return name } - if (0xF704...0xF726).contains(scalar.value) { return "F\(scalar.value - 0xF703)" } - if scalar.properties.generalCategory == .privateUse || scalar.value < 0x20 { - return String(format: "U+%04X", scalar.value) - } - return key - } - - private static func modifierNames(_ flags: NSEvent.ModifierFlags) -> [String] { - let names: [(NSEvent.ModifierFlags, String)] = [ - (.control, "control"), (.option, "option"), (.shift, "shift"), (.command, "command"), (.function, "function"), - ] - return names.filter { flags.contains($0.0) }.map(\.1) - } - - private static func identifierLines(_ identifiers: [NSToolbarItem.Identifier]) -> String { - identifiers.map { " \(quoted($0.rawValue))" }.joined(separator: ",\n") - } - - private static func quoted(_ text: String) -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = .withoutEscapingSlashes - guard let data = try? encoder.encode(text), let json = String(data: data, encoding: .utf8) else { - return "\"\"" - } - return json - } - - static func lineDiff(from expected: String, to actual: String) -> String { - let old = expected.components(separatedBy: "\n") - let new = actual.components(separatedBy: "\n") - let changes = new.difference(from: old).map { change -> String in - switch change { - case let .remove(_, line, _): "- \(line)" - case let .insert(_, line, _): "+ \(line)" - } - } - let shown = changes.prefix(80).joined(separator: "\n") - return changes.count > 80 ? "\(shown)\n… \(changes.count - 80) more" : shown - } -} diff --git a/TableProTests/Core/Menu/MoveTabToConnectionCommandTests.swift b/TableProTests/Core/Menu/MoveTabToConnectionCommandTests.swift index 1259b658b4..629a59961c 100644 --- a/TableProTests/Core/Menu/MoveTabToConnectionCommandTests.swift +++ b/TableProTests/Core/Menu/MoveTabToConnectionCommandTests.swift @@ -10,12 +10,6 @@ import Testing struct MoveTabToConnectionShortcutTests { private static let chord = BoundKey.character("c", command: true, option: true, control: true) - @Test("The default is Control-Option-Command-C, Switch Connection's chord plus Option") - func defaultChord() { - #expect(KeyboardSettings.defaultShortcuts[.moveTabToConnection] == Self.chord) - #expect(KeyboardSettings.default.shortcut(for: .moveTabToConnection) == Self.chord) - } - @Test("No other default and no reserved command claims the chord") func chordIsUnique() { let owners = KeyboardSettings.defaultShortcuts.filter { $0.value == Self.chord }.map { $0.key } diff --git a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift b/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift deleted file mode 100644 index ea41397038..0000000000 --- a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift +++ /dev/null @@ -1,653 +0,0 @@ -// -// BsonDocumentFlattenerTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct BsonDocumentFlattenerTests { - @Test("A field's kind is uniform only when every value that is there shares it") - func uniformKindNeedsEveryValueToAgree() { - let same: [[String: Any]] = [["_id": "a"], ["_id": "b"], ["other": 1]] - let mixed: [[String: Any]] = [["_id": "a"], ["_id": MongoDBObjectId(hex: "65a1b2c3d4e5f60718293a4b")]] - #expect(BsonDocumentFlattener.uniformKind(of: "_id", in: same, representation: .unspecified) == .string) - #expect(BsonDocumentFlattener.uniformKind(of: "_id", in: mixed, representation: .unspecified) == nil) - #expect(BsonDocumentFlattener.uniformKind(of: "_id", in: [], representation: .unspecified) == nil) - } - - @Test("A field a document does not have is absent, and a field holding null is not") - func absentCellsTellMissingFromNull() { - let documents: [[String: Any]] = [ - ["_id": "a", "deletedAt": NSNull(), "nick": "x"], - ["_id": "b", "nick": "y"], - ["_id": "c", "deletedAt": NSNull(), "nick": "z"] - ] - - let absent = BsonDocumentFlattener.absentCells(of: documents, columns: ["_id", "deletedAt", "nick", "declared"]) - - #expect(absent == [0: [3], 1: [1, 3], 2: [3]]) - #expect(BsonDocumentFlattener.absentCells(of: [["_id": "a"]], columns: ["_id"]).isEmpty) - } - - // MARK: - unionColumns(from:) - - @Suite("unionColumns") - struct UnionColumnsTests { - @Test("Empty array returns empty columns") - func emptyArray() { - let result = BsonDocumentFlattener.unionColumns(from: []) - #expect(result.isEmpty) - } - - @Test("Single document returns all keys with _id first") - func singleDocument() { - let doc: [String: Any] = ["_id": "abc", "name": "John", "age": 30] - let result = BsonDocumentFlattener.unionColumns(from: [doc]) - #expect(result == ["_id", "age", "name"]) - } - - @Test("_id is always placed first regardless of insertion order") - func idAlwaysFirst() { - let doc: [String: Any] = ["name": "John", "_id": "abc"] - let result = BsonDocumentFlattener.unionColumns(from: [doc]) - #expect(result == ["_id", "name"]) - } - - @Test("Multiple documents union their fields in first-seen order per doc") - func multipleDocumentsUnion() { - let doc1: [String: Any] = ["_id": "1", "name": "John"] - let doc2: [String: Any] = ["_id": "2", "email": "john@example.com"] - let result = BsonDocumentFlattener.unionColumns(from: [doc1, doc2]) - #expect(result == ["_id", "name", "email"]) - } - - @Test("Document without _id omits _id from columns") - func documentWithoutId() { - let doc: [String: Any] = ["name": "John"] - let result = BsonDocumentFlattener.unionColumns(from: [doc]) - #expect(result == ["name"]) - } - } - - // MARK: - flatten(documents:columns:kinds:representation:) - - @Suite("flatten") - struct FlattenTests { - @Test("Single document with all columns present returns all values as strings") - func allColumnsPresent() { - let doc: [String: Any] = ["_id": "abc", "name": "John", "age": 30] - let columns = ["_id", "name", "age"] - let result = FlattenFixture.rows([doc], columns: columns) - #expect(result.count == 1) - #expect(result[0] == ["abc", "John", "30"]) - } - - @Test("Missing field produces nil in that cell") - func missingField() { - let doc: [String: Any] = ["_id": "abc", "name": "John"] - let columns = ["_id", "name", "email"] - let result = FlattenFixture.rows([doc], columns: columns) - #expect(result[0][0] == "abc") - #expect(result[0][1] == "John") - #expect(result[0][2] == nil) - } - - @Test("Nested object is serialized as compact JSON string") - func nestedObject() { - let nested: [String: Any] = ["city": "NYC", "zip": "10001"] - let doc: [String: Any] = ["_id": "1", "address": nested] - let columns = ["_id", "address"] - let result = FlattenFixture.rows([doc], columns: columns) - #expect(result[0][0] == "1") - // Keys are sorted in JSON output - #expect(result[0][1] == "{\"city\":\"NYC\",\"zip\":\"10001\"}") - } - - @Test("Array value is serialized as compact JSON string") - func arrayValue() { - let tags: [Any] = ["swift", "macos"] - let doc: [String: Any] = ["_id": "1", "tags": tags] - let columns = ["_id", "tags"] - let result = FlattenFixture.rows([doc], columns: columns) - #expect(result[0][1] == "[\"swift\",\"macos\"]") - } - - @Test("Boolean values are serialized as true/false strings") - func booleanValue() { - let doc: [String: Any] = ["_id": "1", "active": NSNumber(value: true)] - let columns = ["_id", "active"] - let result = FlattenFixture.rows([doc], columns: columns) - #expect(result[0][1] == "true") - } - - @Test("Integer values are serialized as string representation") - func integerValue() { - let doc: [String: Any] = ["_id": "1", "count": 42] - let columns = ["_id", "count"] - let result = FlattenFixture.rows([doc], columns: columns) - #expect(result[0][1] == "42") - } - - @Test("NSNull produces nil") - func nsNullValue() { - let doc: [String: Any] = ["_id": "1", "deleted": NSNull()] - let columns = ["_id", "deleted"] - let result = FlattenFixture.rows([doc], columns: columns) - #expect(result[0][1] == nil) - } - - @Test("Date values are serialized as ISO8601 strings") - func dateValue() { - let date = Date(timeIntervalSince1970: 0) - let doc: [String: Any] = ["_id": "1", "created": date] - let columns = ["_id", "created"] - let result = FlattenFixture.rows([doc], columns: columns) - let expected = ISO8601DateFormatter().string(from: date) - #expect(result[0][1].asText == expected) - } - - @Test("Nested non-finite double does not crash and renders as a token") - func nestedNonFiniteDouble() { - let metrics: [String: Any] = ["score": Double.nan, "ratio": Double.infinity] - let doc: [String: Any] = ["_id": "1", "metrics": metrics] - let columns = ["_id", "metrics"] - let result = FlattenFixture.rows([doc], columns: columns) - #expect(result[0][1] == "{\"ratio\":\"Infinity\",\"score\":\"NaN\"}") - } - } - - // MARK: - columnKinds(for:documents:representation:) - - @Suite("columnKinds") - struct ColumnTypesTests { - @Test("String values produce kind string") - func stringType() { - let docs: [[String: Any]] = [["name": "Alice"], ["name": "Bob"]] - let result = FlattenFixture.kinds(docs, columns: ["name"]) - #expect(result == [.string]) - } - - @Test("Int32 values produce kind int32") - func int32Type() { - let docs: [[String: Any]] = [["val": Int32(42)], ["val": Int32(99)]] - let result = FlattenFixture.kinds(docs, columns: ["val"]) - #expect(result == [.int32]) - } - - @Test("Int64 values produce kind int64") - func int64Type() { - let docs: [[String: Any]] = [["val": Int64(9_999_999_999)], ["val": Int64(1_234_567_890)]] - let result = FlattenFixture.kinds(docs, columns: ["val"]) - #expect(result == [.int64]) - } - - @Test("Double values produce kind double") - func doubleType() { - let docs: [[String: Any]] = [["val": 3.14], ["val": 2.71]] - let result = FlattenFixture.kinds(docs, columns: ["val"]) - #expect(result == [.double]) - } - - @Test("Boolean NSNumber values produce kind boolean") - func booleanType() { - let docs: [[String: Any]] = [ - ["flag": NSNumber(value: true)], - ["flag": NSNumber(value: false)] - ] - let result = FlattenFixture.kinds(docs, columns: ["flag"]) - #expect(result == [.boolean]) - } - - @Test("Date values produce kind date") - func dateType() { - let docs: [[String: Any]] = [["ts": Date()], ["ts": Date(timeIntervalSince1970: 0)]] - let result = FlattenFixture.kinds(docs, columns: ["ts"]) - #expect(result == [.date]) - } - - @Test("Dictionary values produce kind document") - func dictType() { - let docs: [[String: Any]] = [ - ["meta": ["key": "value"] as [String: Any]], - ["meta": ["a": 1] as [String: Any]] - ] - let result = FlattenFixture.kinds(docs, columns: ["meta"]) - #expect(result == [.document]) - } - - @Test("Array values produce kind array") - func arrayType() { - let docs: [[String: Any]] = [ - ["tags": ["a", "b"] as [Any]], - ["tags": [1, 2] as [Any]] - ] - let result = FlattenFixture.kinds(docs, columns: ["tags"]) - #expect(result == [.array]) - } - - @Test("Missing field with no values defaults to kind string") - func missingFieldDefaultsToString() { - let docs: [[String: Any]] = [["other": "val"], ["other": "val2"]] - let result = FlattenFixture.kinds(docs, columns: ["nonexistent"]) - #expect(result == [.string]) - } - - @Test("Majority vote: 2 strings and 1 int yields kind string") - func majorityVote() { - let docs: [[String: Any]] = [ - ["val": "hello"], - ["val": "world"], - ["val": Int32(42)] - ] - let result = FlattenFixture.kinds(docs, columns: ["val"]) - #expect(result == [.string]) - } - } - - // MARK: - UUID columns - - @Suite("UUID columns") - struct UuidColumnTests { - private static func legacyDocs(count: Int = 2) -> [[String: Any]] { - (0 ..< count).map { _ in - ["id": MongoDBBinaryValue(data: Data(BsonUuidFixture.javaBytes), subtype: 0x03)] - } - } - - @Test("A legacy UUID column keeps the binary type name until a representation is set") - func undecodedColumnStaysBinary() { - let kinds = FlattenFixture.kinds(Self.legacyDocs(), columns: ["id"]) - #expect(kinds == [.binary(subtype: 3)]) - #expect( - BsonDocumentFlattener.typeName(for: .binary(subtype: 3), representation: .unspecified) - == "BLOB(3)" - ) - #expect( - BsonDocumentFlattener.typeName(for: .binary(subtype: 0), representation: .unspecified) - == "BLOB" - ) - } - - @Test("A configured legacy UUID column reports the representation in its type name") - func decodedColumnReportsUuidType() { - let kinds = FlattenFixture.kinds(Self.legacyDocs(), columns: ["id"], representation: .javaLegacy) - #expect(kinds == [.legacyUuid]) - #expect( - BsonDocumentFlattener.typeName(for: .legacyUuid, representation: .javaLegacy) - == "LegacyJavaUUID" - ) - } - - @Test("A standard UUID column reports UUID without any configuration") - func standardColumnReportsUuidType() { - let docs: [[String: Any]] = [ - ["id": MongoDBBinaryValue(data: Data(BsonUuidFixture.standardBytes), subtype: 0x04)] - ] - let kinds = FlattenFixture.kinds(docs, columns: ["id"]) - #expect(kinds == [.uuid]) - #expect(BsonDocumentFlattener.typeName(for: .uuid, representation: .unspecified) == "UUID") - } - - @Test("Cells stay binary while the column is undecoded") - func undecodedCellsStayBytes() { - let result = FlattenFixture.rows(Self.legacyDocs(count: 1), columns: ["id"]) - #expect(result[0][0] == .bytes(Data(BsonUuidFixture.javaBytes))) - } - - @Test("Cells become wrapper text once the column is a UUID column") - func decodedCellsBecomeText() { - let result = FlattenFixture.rows( - Self.legacyDocs(count: 1), columns: ["id"], representation: .javaLegacy - ) - #expect(result[0][0] == .text("LegacyJavaUUID(\"\(BsonUuidFixture.uuidString)\")")) - } - - /// A wrapper string in a column the app still types as BLOB would be hex-encoded for display, - /// so a minority UUID must not decode inside a binary column. - @Test("A minority UUID in a binary column is never decoded") - func minorityUuidStaysBytes() { - var docs: [[String: Any]] = (0 ..< 3).map { index in - ["id": MongoDBBinaryValue(data: Data(repeating: UInt8(index), count: 8), subtype: 0x00)] - } - docs.append(["id": MongoDBBinaryValue(data: Data(BsonUuidFixture.javaBytes), subtype: 0x03)]) - - let kinds = FlattenFixture.kinds(docs, columns: ["id"], representation: .javaLegacy) - #expect(kinds == [.binary(subtype: 0)]) - - let rows = FlattenFixture.rows(docs, columns: ["id"], representation: .javaLegacy) - #expect(rows.allSatisfy { $0[0].asText == nil }) - } - - @Test("A non-UUID binary inside a UUID column stays binary rather than being mislabelled") - func strayBinaryInUuidColumn() { - var docs = Self.legacyDocs(count: 3) - docs.append(["id": MongoDBBinaryValue(data: Data([0x01, 0x02]), subtype: 0x03)]) - - let rows = FlattenFixture.rows(docs, columns: ["id"], representation: .javaLegacy) - #expect(rows[3][0] == .bytes(Data([0x01, 0x02]))) - } - - @Test("A nested UUID decodes inside the serialized JSON of its parent") - func nestedUuidDecodes() { - let nested: [String: Any] = [ - "ref": MongoDBBinaryValue(data: Data(BsonUuidFixture.javaBytes), subtype: 0x03) - ] - let docs: [[String: Any]] = [["meta": nested]] - let rows = FlattenFixture.rows(docs, columns: ["meta"], representation: .javaLegacy) - #expect(rows[0][0] == .text("{\"ref\":\"LegacyJavaUUID(\\\"\(BsonUuidFixture.uuidString)\\\")\"}")) - } - } - - // MARK: - stringValue(for:) - - @Suite("stringValue") - struct StringValueTests { - @Test("nil returns nil") - func nilValue() { - let result = BsonDocumentFlattener.stringValue(for: nil, representation: .unspecified) - #expect(result == nil) - } - - @Test("NSNull returns nil") - func nsNullValue() { - let result = BsonDocumentFlattener.stringValue(for: NSNull(), representation: .unspecified) - #expect(result == nil) - } - - @Test("String returns itself") - func stringValue() { - let result = BsonDocumentFlattener.stringValue(for: "hello", representation: .unspecified) - #expect(result == "hello") - } - - @Test("Int returns string representation") - func intValue() { - let result = BsonDocumentFlattener.stringValue(for: 42, representation: .unspecified) - #expect(result == "42") - } - - @Test("Int32 returns string representation") - func int32Value() { - let result = BsonDocumentFlattener.stringValue(for: Int32(42), representation: .unspecified) - #expect(result == "42") - } - - @Test("Int64 returns string representation") - func int64Value() { - let result = BsonDocumentFlattener.stringValue(for: Int64(9_999_999_999), representation: .unspecified) - #expect(result == "9999999999") - } - - @Test("Double returns string representation") - func doubleValue() { - let result = BsonDocumentFlattener.stringValue(for: 3.14, representation: .unspecified) - #expect(result == "3.14") - } - - @Test("Double keeps every digit needed to round-trip") - func doubleRoundTrips() { - let value = -3.9192320754595876e-07 - let result = BsonDocumentFlattener.stringValue(for: value, representation: .unspecified) - #expect(result == "-3.9192320754595876e-07") - #expect(Double(result ?? "") == value) - } - - @Test("Double never gains binary floating point noise") - func doubleHasNoExcessDigits() { - #expect(BsonDocumentFlattener.stringValue(for: 1_847.27, representation: .unspecified) == "1847.27") - #expect(BsonDocumentFlattener.stringValue(for: 0.1, representation: .unspecified) == "0.1") - } - - @Test("Integral double keeps its whole number form") - func integralDoubleHasNoTrailingZero() { - #expect(BsonDocumentFlattener.stringValue(for: 1.0, representation: .unspecified) == "1") - #expect(BsonDocumentFlattener.stringValue(for: 100.0, representation: .unspecified) == "100") - } - - @Test("Double nested in a sub-document keeps its digits and stays a floating point value") - func nestedDoubleMatchesScalar() { - let document: [String: Any] = ["rate": 0.1, "total": 1_847.27, "qty": 3.0] - let result = BsonDocumentFlattener.stringValue(for: document, representation: .unspecified) - #expect(result == #"{"qty":3.0,"rate":0.1,"total":1847.27}"#) - } - - @Test("Integer nested in a sub-document is not turned into a floating point value") - func nestedIntegerStaysInteger() { - let document: [String: Any] = ["qty": Int64(3), "small": Int32(7)] - let result = BsonDocumentFlattener.stringValue(for: document, representation: .unspecified) - #expect(result == #"{"qty":3,"small":7}"#) - } - - @Test("A decimal128 that is not a JSON number is quoted rather than emitted bare") - func nonFiniteDecimal128StaysValidJson() { - for digits in ["NaN", "Infinity", "-Infinity"] { - let document: [String: Any] = ["price": MongoDBDecimal128(digits: digits)] - let result = BsonDocumentFlattener.stringValue(for: document, representation: .unspecified) - #expect(result == "{\"price\":\"\(digits)\"}") - } - } - - @Test("A decimal128 exponent beyond what Decimal can hold keeps its own digits") - func hugeExponentDecimal128KeepsItsDigits() { - let document: [String: Any] = ["price": MongoDBDecimal128(digits: "1E+400")] - let result = BsonDocumentFlattener.stringValue(for: document, representation: .unspecified) - #expect(result == #"{"price":1E+400}"#) - } - - @Test("A decimal128 keeps trailing zeros and its own exponent spelling") - func decimal128KeepsItsExactSpelling() { - let document: [String: Any] = ["a": MongoDBDecimal128(digits: "0.100")] - let result = BsonDocumentFlattener.stringValue(for: document, representation: .unspecified) - #expect(result == #"{"a":0.100}"#) - } - - @Test("Decimal128 keeps all of its digits, in a cell and nested") - func decimal128KeepsExactDigits() { - let digits = "1847.270000000000000000000000001" - let decimal = MongoDBDecimal128(digits: digits) - #expect(BsonDocumentFlattener.stringValue(for: decimal, representation: .unspecified) == digits) - - let document: [String: Any] = ["price": decimal] - let nested = BsonDocumentFlattener.stringValue(for: document, representation: .unspecified) - #expect(nested == "{\"price\":\(digits)}") - } - - @Test("Double nested in an array keeps the same digits as a top level one") - func nestedArrayDoubleMatchesScalar() { - let values: [Any] = [0.1, 1_847.27, 1.0 / 3.0] - let result = BsonDocumentFlattener.stringValue(for: values, representation: .unspecified) - #expect(result == "[0.1,1847.27,0.3333333333333333]") - } - - @Test("NaN double returns NaN token") - func nanValue() { - let result = BsonDocumentFlattener.stringValue(for: Double.nan, representation: .unspecified) - #expect(result == "NaN") - } - - @Test("Positive infinity returns Infinity token") - func positiveInfinityValue() { - let result = BsonDocumentFlattener.stringValue(for: Double.infinity, representation: .unspecified) - #expect(result == "Infinity") - } - - @Test("Negative infinity returns -Infinity token") - func negativeInfinityValue() { - let result = BsonDocumentFlattener.stringValue(for: -Double.infinity, representation: .unspecified) - #expect(result == "-Infinity") - } - - @Test("Bool true via NSNumber returns true") - func boolTrueValue() { - let result = BsonDocumentFlattener.stringValue(for: NSNumber(value: true), representation: .unspecified) - #expect(result == "true") - } - - @Test("Bool false via NSNumber returns false") - func boolFalseValue() { - let result = BsonDocumentFlattener.stringValue(for: NSNumber(value: false), representation: .unspecified) - #expect(result == "false") - } - - @Test("Date returns ISO8601 string") - func dateValue() { - let date = Date(timeIntervalSince1970: 0) - let result = BsonDocumentFlattener.stringValue(for: date, representation: .unspecified) - let expected = ISO8601DateFormatter().string(from: date) - #expect(result == expected) - } - - @Test("Untyped Data renders as generic BinData carrying its subtype") - func dataValue() { - let data = Data([0xDE, 0xAD, 0xBE, 0xEF]) - let result = BsonDocumentFlattener.stringValue(for: data, representation: .unspecified) - #expect(result == "BinData(0, \"3q2+7w==\")") - } - - @Test("A 16-byte generic binary is never guessed to be a UUID") - func genericBinaryIsNotAUuid() { - let binary = MongoDBBinaryValue(data: Data(repeating: 0xAB, count: 16), subtype: 0x00) - let result = BsonDocumentFlattener.stringValue(for: binary, representation: .javaLegacy) - #expect(result == "BinData(0, \"q6urq6urq6urq6urq6urqw==\")") - } - - @Test("Subtype 4 decodes to UUID with no representation configured") - func standardUuidValue() { - let binary = MongoDBBinaryValue( - data: Data(BsonUuidFixture.standardBytes), subtype: 0x04 - ) - let result = BsonDocumentFlattener.stringValue(for: binary, representation: .unspecified) - #expect(result == "UUID(\"\(BsonUuidFixture.uuidString)\")") - } - - @Test("Subtype 3 stays undecoded until a representation is configured") - func legacyUuidWithoutRepresentation() { - let binary = MongoDBBinaryValue(data: Data(BsonUuidFixture.javaBytes), subtype: 0x03) - let result = BsonDocumentFlattener.stringValue(for: binary, representation: .unspecified) - #expect(result == "BinData(3, \"\(Data(BsonUuidFixture.javaBytes).base64EncodedString())\")") - } - - @Test("Subtype 3 decodes with the configured legacy byte order") - func legacyUuidWithRepresentation() { - let binary = MongoDBBinaryValue(data: Data(BsonUuidFixture.javaBytes), subtype: 0x03) - let result = BsonDocumentFlattener.stringValue(for: binary, representation: .javaLegacy) - #expect(result == "LegacyJavaUUID(\"\(BsonUuidFixture.uuidString)\")") - } - - @Test("A legacy value read with the wrong representation does not produce the right UUID") - func legacyUuidWithWrongRepresentation() { - let binary = MongoDBBinaryValue(data: Data(BsonUuidFixture.javaBytes), subtype: 0x03) - let result = BsonDocumentFlattener.stringValue(for: binary, representation: .pythonLegacy) - #expect(result != "LegacyPythonUUID(\"\(BsonUuidFixture.uuidString)\")") - } - - @Test("Dictionary returns compact sorted-key JSON") - func dictValue() { - let dict: [String: Any] = ["b": 2, "a": 1] - let result = BsonDocumentFlattener.stringValue(for: dict, representation: .unspecified) - #expect(result == "{\"a\":1,\"b\":2}") - } - - @Test("Array returns compact JSON") - func arrayValue() { - let array: [Any] = [1, "two", 3] - let result = BsonDocumentFlattener.stringValue(for: array, representation: .unspecified) - #expect(result == "[1,\"two\",3]") - } - } - - // MARK: - serializeToJson(_:) - - @Suite("serializeToJson") - struct SerializeToJsonTests { - @Test("Simple dictionary produces compact JSON with sorted keys") - func simpleDict() { - let dict: [String: Any] = ["z": 1, "a": 2] - let result = BsonDocumentFlattener.serializeToJson(dict, representation: .unspecified) - #expect(result == "{\"a\":2,\"z\":1}") - } - - @Test("Simple array produces compact JSON") - func simpleArray() { - let array: [Any] = [1, "hello", true] - let result = BsonDocumentFlattener.serializeToJson(array, representation: .unspecified) - #expect(result == "[1,\"hello\",true]") - } - - @Test("Output is capped at 10k characters with ellipsis suffix") - func capsAtTenThousandChars() { - // Build a large dictionary that serializes to >10k chars - var largeDict: [String: Any] = [:] - for i in 0 ..< 2_000 { - largeDict["key_\(String(format: "%04d", i))"] = String(repeating: "x", count: 10) - } - let result = BsonDocumentFlattener.serializeToJson(largeDict, representation: .unspecified) - let nsResult = result as NSString - #expect(nsResult.length <= 10_003) // 10000 + "..." - #expect(result.hasSuffix("...")) - } - - @Test("NaN double in a dictionary serializes to NaN token instead of crashing") - func nanInDictionary() { - let dict: [String: Any] = ["v": Double.nan] - let result = BsonDocumentFlattener.serializeToJson(dict, representation: .unspecified) - #expect(result == "{\"v\":\"NaN\"}") - } - - @Test("Positive infinity in a dictionary serializes to Infinity token") - func positiveInfinityInDictionary() { - let dict: [String: Any] = ["v": Double.infinity] - let result = BsonDocumentFlattener.serializeToJson(dict, representation: .unspecified) - #expect(result == "{\"v\":\"Infinity\"}") - } - - @Test("Negative infinity in a dictionary serializes to -Infinity token") - func negativeInfinityInDictionary() { - let dict: [String: Any] = ["v": -Double.infinity] - let result = BsonDocumentFlattener.serializeToJson(dict, representation: .unspecified) - #expect(result == "{\"v\":\"-Infinity\"}") - } - - @Test("Non-finite double in an array keeps finite siblings as numbers") - func nonFiniteInArray() { - let array: [Any] = [Double.nan, 1.5, Double.infinity] - let result = BsonDocumentFlattener.serializeToJson(array, representation: .unspecified) - #expect(result == "[\"NaN\",1.5,\"Infinity\"]") - } - - @Test("Unsupported nested type is stringified instead of crashing") - func unsupportedTypeStringified() { - struct Custom: CustomStringConvertible { var description: String { "custom" } } - let dict: [String: Any] = ["v": Custom()] - let result = BsonDocumentFlattener.serializeToJson(dict, representation: .unspecified) - #expect(result == "{\"v\":\"custom\"}") - } - } -} - -private enum FlattenFixture { - static func kinds( - _ documents: [[String: Any]], - columns: [String], - representation: MongoDBUuidRepresentation = .unspecified - ) -> [BsonValueKind] { - BsonDocumentFlattener.columnKinds( - for: columns, documents: documents, representation: representation - ) - } - - static func rows( - _ documents: [[String: Any]], - columns: [String], - representation: MongoDBUuidRepresentation = .unspecified - ) -> [[PluginCellValue]] { - BsonDocumentFlattener.flatten( - documents: documents, - columns: columns, - kinds: kinds(documents, columns: columns, representation: representation), - representation: representation - ) - } -} diff --git a/TableProTests/Core/MongoDB/BsonFieldPathArrayTests.swift b/TableProTests/Core/MongoDB/BsonFieldPathArrayTests.swift deleted file mode 100644 index 6383692399..0000000000 --- a/TableProTests/Core/MongoDB/BsonFieldPathArrayTests.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// BsonFieldPathArrayTests.swift -// TableProTests -// -// Array-ness and addressability of the dotted paths the filter picker offers. -// - -import Foundation -import TableProPluginKit -import Testing - -struct BsonFieldPathArrayTests { - private func paths(_ documents: [[String: Any]], maxDepth: Int = 4) -> [PluginFieldPath] { - BsonDocumentFlattener.fieldPaths(from: documents, representation: .unspecified, maxDepth: maxDepth) - } - - private let order: [String: Any] = [ - "order_no": "ORD-001", - "customer": ["name": "Alice", "age": 28, "country": "US"], - "items": [ - ["sku": "A100", "name": "Laptop", "price": 999.99], - ["sku": "B200", "name": "Mouse", "price": 29.99], - ], - ] - - // MARK: - Array Prefixes - - @Test("a path inside an array records the array as its prefix") - func arrayPathRecordsPrefix() { - let result = paths([order]) - #expect(result.first { $0.path == "items.sku" }?.arrayPrefixes == ["items"]) - #expect(result.first { $0.path == "items.price" }?.arrayPrefixes == ["items"]) - } - - @Test("a path inside a plain object records no array prefix") - func objectPathRecordsNoPrefix() { - let result = paths([order]) - #expect(result.first { $0.path == "customer.country" }?.arrayPrefixes == []) - #expect(result.first { $0.path == "customer.age" }?.arrayPrefixes == []) - } - - @Test("the array field itself carries no prefix, only what sits inside it") - func arrayFieldItselfHasNoPrefix() { - let result = paths([order]) - #expect(result.first { $0.path == "items" }?.arrayPrefixes == []) - } - - @Test("an object nested under an array element still reports the array prefix") - func objectUnderArrayKeepsPrefix() { - let result = paths([["items": [["meta": ["origin": "EU"]]]]]) - #expect(result.first { $0.path == "items.meta.origin" }?.arrayPrefixes == ["items"]) - } - - @Test("two array levels are both reported, outermost first") - func nestedArraysReportBothPrefixes() { - let documents: [[String: Any]] = [[ - "orders": [["items": [["sku": "A100"]]]], - ]] - let result = paths(documents) - #expect(result.first { $0.path == "orders.items.sku" }?.arrayPrefixes == ["orders", "orders.items"]) - } - - @Test("an array of scalars contributes no child paths") - func scalarArrayHasNoChildren() { - let result = paths([["tags": ["a", "b"]]]) - #expect(result.map(\.path) == ["tags"]) - } - - // MARK: - Addressability - - @Test("a key holding a literal dot is not offered") - func literalDotKeyIsSkipped() { - let result = paths([["price.usd": 40, "plain": 1]]) - let names = result.map(\.path) - #expect(!names.contains("price.usd")) - #expect(names.contains("plain")) - } - - @Test("nothing beneath a literal-dot key is offered either") - func literalDotKeyChildrenAreSkipped() { - let result = paths([["a.b": ["c": 1]]]) - #expect(!result.map(\.path).contains { $0.hasPrefix("a.b") }) - } - - @Test("a key opening with a dollar sign is not offered") - func dollarPrefixedKeyIsSkipped() { - let result = paths([["$meta": ["x": 1], "ok": 2]]) - let names = result.map(\.path) - #expect(!names.contains("$meta")) - #expect(!names.contains("$meta.x")) - #expect(names.contains("ok")) - } - - @Test("addressability is decided per segment, not on the assembled path") - func addressabilityIsPerSegment() { - #expect(BsonDocumentFlattener.isAddressableSegment("country")) - #expect(!BsonDocumentFlattener.isAddressableSegment("price.usd")) - #expect(!BsonDocumentFlattener.isAddressableSegment("$meta")) - #expect(!BsonDocumentFlattener.isAddressableSegment("")) - } - - // MARK: - Kinds - - @Test("path kinds agree with the type names the same walk reports") - func kindsAgreeWithTypeNames() { - let documents: [[String: Any]] = [[ - "customer": ["registeredAt": Date(timeIntervalSince1970: 0), "age": 28], - ]] - let kinds = BsonDocumentFlattener.fieldPathKinds(from: documents, representation: .unspecified) - #expect(kinds["customer.registeredAt"] == .date) - #expect(kinds["customer.age"] == .int64) - #expect(kinds["customer"] == .document) - - let names = paths(documents) - #expect(names.first { $0.path == "customer.registeredAt" }?.typeName == "TIMESTAMP") - } - - @Test("path kinds cover the array's own children") - func kindsCoverArrayChildren() { - let kinds = BsonDocumentFlattener.fieldPathKinds(from: [order], representation: .unspecified) - #expect(kinds["items.price"] == .double) - #expect(kinds["items.sku"] == .string) - } -} diff --git a/TableProTests/Core/MongoDB/BsonFieldPathTests.swift b/TableProTests/Core/MongoDB/BsonFieldPathTests.swift deleted file mode 100644 index e621ba8557..0000000000 --- a/TableProTests/Core/MongoDB/BsonFieldPathTests.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// BsonFieldPathTests.swift -// TableProTests -// -// Nested dotted field paths, which the flat column list cannot express. -// - -import Foundation -import TableProPluginKit -import Testing - -struct BsonFieldPathTests { - private func paths(_ documents: [[String: Any]], maxDepth: Int = 4) -> [PluginFieldPath] { - BsonDocumentFlattener.fieldPaths(from: documents, representation: .unspecified, maxDepth: maxDepth) - } - - @Test("a nested object contributes its own dotted paths") - func testNestedObjectPaths() { - let result = paths([["address": ["city": "Hanoi", "zip": 100_000]]]) - let names = result.map(\.path) - #expect(names.contains("address")) - #expect(names.contains("address.city")) - #expect(names.contains("address.zip")) - } - - @Test("a flat column list still reports the nested object as one column") - func testUnionColumnsStaysFlat() { - let columns = BsonDocumentFlattener.unionColumns(from: [["address": ["city": "Hanoi"]]]) - #expect(columns == ["address"]) - } - - @Test("depth is recorded so shallower fields can rank first") - func testDepthRecorded() { - let result = paths([["a": ["b": ["c": 1]]]]) - #expect(result.first { $0.path == "a" }?.depth == 1) - #expect(result.first { $0.path == "a.b" }?.depth == 2) - #expect(result.first { $0.path == "a.b.c" }?.depth == 3) - } - - @Test("maxDepth stops the walk") - func testMaxDepthStopsWalk() { - let result = paths([["a": ["b": ["c": ["d": 1]]]]], maxDepth: 2) - let names = result.map(\.path) - #expect(names.contains("a.b")) - #expect(!names.contains("a.b.c")) - } - - @Test("objects inside an array contribute paths") - func testArrayOfObjects() { - let result = paths([["items": [["sku": "A1"], ["sku": "B2", "qty": 3]]]]) - let names = result.map(\.path) - #expect(names.contains("items.sku")) - #expect(names.contains("items.qty")) - } - - @Test("paths merge across documents with different shapes") - func testPathsMergeAcrossDocuments() { - let result = paths([["a": ["x": 1]], ["a": ["y": 2]], ["b": 3]]) - let names = result.map(\.path) - #expect(names.contains("a.x")) - #expect(names.contains("a.y")) - #expect(names.contains("b")) - } - - @Test("a null value contributes no path") - func testNullValueSkipped() { - let result = paths([["a": NSNull(), "b": 1]]) - let names = result.map(\.path) - #expect(!names.contains("a")) - #expect(names.contains("b")) - } - - @Test("the majority type wins for a path") - func testMajorityTypeWins() { - let result = paths([["n": 1], ["n": 2], ["n": "text"]]) - #expect(["INTEGER", "BIGINT"].contains(result.first { $0.path == "n" }?.typeName ?? "")) - } - - @Test("a nested object is still typed as JSON at its own path") - func testNestedObjectTypedAsJson() { - let result = paths([["address": ["city": "Hanoi"]]]) - #expect(result.first { $0.path == "address" }?.typeName == "JSON") - } - - @Test("no documents means no paths") - func testEmptyInput() { - #expect(paths([]).isEmpty) - } -} diff --git a/TableProTests/Core/MongoDB/MongoDBExtendedJsonTests.swift b/TableProTests/Core/MongoDB/MongoDBExtendedJsonTests.swift deleted file mode 100644 index 0863a86aa6..0000000000 --- a/TableProTests/Core/MongoDB/MongoDBExtendedJsonTests.swift +++ /dev/null @@ -1,218 +0,0 @@ -// -// MongoDBExtendedJsonTests.swift -// TableProTests -// -// Tests for MongoDBConnection.unwrapExtendedJson(_:) static method. -// - -#if canImport(CLibMongoc) - -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -struct MongoDBExtendedJsonTests { - - // MARK: - $oid - - @Test("$oid returns string value") - func oidReturnsString() { - let input: [String: Any] = ["$oid": "507f1f77bcf86cd799439011"] - let result = MongoDBConnection.unwrapExtendedJson(input) - #expect(result as? String == "507f1f77bcf86cd799439011") - } - - // MARK: - $numberInt - - @Test("$numberInt returns Int32") - func numberIntReturnsInt32() { - let input: [String: Any] = ["$numberInt": "42"] - let result = MongoDBConnection.unwrapExtendedJson(input) - #expect(result as? Int32 == Int32(42)) - } - - // MARK: - $numberLong - - @Test("$numberLong returns Int64") - func numberLongReturnsInt64() { - let input: [String: Any] = ["$numberLong": "9999999999"] - let result = MongoDBConnection.unwrapExtendedJson(input) - #expect(result as? Int64 == Int64(9999999999)) - } - - // MARK: - $numberDouble - - @Test("$numberDouble returns Double") - func numberDoubleReturnsDouble() { - let input: [String: Any] = ["$numberDouble": "3.14"] - let result = MongoDBConnection.unwrapExtendedJson(input) - #expect(result as? Double == 3.14) - } - - // MARK: - $numberDecimal - - @Test("$numberDecimal returns string representation") - func numberDecimalReturnsString() { - let input: [String: Any] = ["$numberDecimal": "99.99"] - let result = MongoDBConnection.unwrapExtendedJson(input) - #expect(result as? String == "99.99") - } - - // MARK: - $date - - @Test("$date with $numberLong returns Date") - func dateWithNumberLongReturnsDate() { - let input: [String: Any] = ["$date": ["$numberLong": "1609459200000"]] - let result = MongoDBConnection.unwrapExtendedJson(input) - if let date = result as? Date { - let expected = Date(timeIntervalSince1970: 1609459200) - #expect(abs(date.timeIntervalSince1970 - expected.timeIntervalSince1970) < 1) - } else { - Issue.record("Expected Date but got \(type(of: result))") - } - } - - @Test("$date with ISO string returns Date") - func dateWithIsoStringReturnsDate() { - let input: [String: Any] = ["$date": "2021-01-01T00:00:00.000Z"] - let result = MongoDBConnection.unwrapExtendedJson(input) - if let date = result as? Date { - let expected = Date(timeIntervalSince1970: 1609459200) - #expect(abs(date.timeIntervalSince1970 - expected.timeIntervalSince1970) < 1) - } else { - Issue.record("Expected Date but got \(type(of: result))") - } - } - - // MARK: - $binary - - @Test("$binary returns Data containing decoded bytes") - func binaryReturnsData() { - let input: [String: Any] = ["$binary": ["base64": "SGVsbG8=", "subType": "00"]] - let result = MongoDBConnection.unwrapExtendedJson(input) - if let data = result as? Data { - #expect(String(data: data, encoding: .utf8) == "Hello") - } else { - Issue.record("Expected Data but got \(type(of: result))") - } - } - - // MARK: - $timestamp - - @Test("$timestamp returns formatted string") - func timestampReturnsFormattedString() { - let input: [String: Any] = ["$timestamp": ["t": 1, "i": 1]] - let result = MongoDBConnection.unwrapExtendedJson(input) - #expect(result as? String == "Timestamp(1, 1)") - } - - // MARK: - $minKey / $maxKey - - @Test("$minKey returns MinKey string") - func minKeyReturnsString() { - let input: [String: Any] = ["$minKey": 1] - let result = MongoDBConnection.unwrapExtendedJson(input) - #expect(result as? String == "MinKey") - } - - @Test("$maxKey returns MaxKey string") - func maxKeyReturnsString() { - let input: [String: Any] = ["$maxKey": 1] - let result = MongoDBConnection.unwrapExtendedJson(input) - #expect(result as? String == "MaxKey") - } - - // MARK: - $undefined - - @Test("$undefined returns NSNull") - func undefinedReturnsNSNull() { - let input: [String: Any] = ["$undefined": true] - let result = MongoDBConnection.unwrapExtendedJson(input) - #expect(result is NSNull) - } - - // MARK: - $regularExpression - - @Test("$regularExpression returns formatted regex string") - func regularExpressionReturnsFormattedString() { - let input: [String: Any] = ["$regularExpression": ["pattern": "^abc", "options": "i"]] - let result = MongoDBConnection.unwrapExtendedJson(input) - #expect(result as? String == "/^abc/i") - } - - // MARK: - Recursive Unwrapping - - @Test("Nested dict recursively unwraps extended JSON values") - func nestedDictRecursivelyUnwraps() { - let input: [String: Any] = [ - "user": [ - "name": "John", - "age": ["$numberInt": "30"] - ] as [String: Any] - ] - let result = MongoDBConnection.unwrapExtendedJson(input) - - guard let dict = result as? [String: Any], - let user = dict["user"] as? [String: Any] else { - Issue.record("Expected nested dictionary structure") - return - } - - #expect(user["name"] as? String == "John") - #expect(user["age"] as? Int32 == Int32(30)) - } - - @Test("Array of extended JSON values recursively unwraps") - func arrayRecursivelyUnwraps() { - let input: [[String: Any]] = [ - ["$numberInt": "1"], - ["$numberInt": "2"] - ] - let result = MongoDBConnection.unwrapExtendedJson(input) - - guard let array = result as? [Any] else { - Issue.record("Expected array result") - return - } - - #expect(array.count == 2) - #expect(array[0] as? Int32 == Int32(1)) - #expect(array[1] as? Int32 == Int32(2)) - } - - // MARK: - Pass-Through - - @Test("Plain string passes through unchanged") - func plainStringPassesThrough() { - let result = MongoDBConnection.unwrapExtendedJson("hello") - #expect(result as? String == "hello") - } - - @Test("Plain integer passes through unchanged") - func plainIntPassesThrough() { - let result = MongoDBConnection.unwrapExtendedJson(42) - #expect(result as? Int == 42) - } - - // MARK: - Multi-Key Dict (Not Extended JSON) - - @Test("Multi-key dict recursively unwraps values but is not treated as extended JSON") - func multiKeyDictRecursivelyUnwrapsValues() { - let input: [String: Any] = [ - "name": "John", - "age": ["$numberInt": "30"] - ] - let result = MongoDBConnection.unwrapExtendedJson(input) - - guard let dict = result as? [String: Any] else { - Issue.record("Expected dictionary result") - return - } - - #expect(dict["name"] as? String == "John") - #expect(dict["age"] as? Int32 == Int32(30)) - } -} - -#endif diff --git a/TableProTests/Core/MongoDB/MongoDBFindLimitPolicyTests.swift b/TableProTests/Core/MongoDB/MongoDBFindLimitPolicyTests.swift deleted file mode 100644 index d272e2d4fd..0000000000 --- a/TableProTests/Core/MongoDB/MongoDBFindLimitPolicyTests.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// MongoDBFindLimitPolicyTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoDBFindLimitPolicyTests { - @Suite("fetchLimit") - struct FetchLimitTests { - @Test("A parsed limit is honoured as-is") - func parsedLimitWins() { - #expect(MongoDBFindLimitPolicy.fetchLimit(parsedLimit: 20, rowCap: nil) == 20) - } - - @Test("A row cap asks for one extra document so truncation is detectable") - func rowCapAsksForOneMore() { - #expect(MongoDBFindLimitPolicy.fetchLimit(parsedLimit: nil, rowCap: 500) == 501) - } - - @Test("The smaller of the parsed limit and the row cap wins") - func smallerBoundWins() { - #expect(MongoDBFindLimitPolicy.fetchLimit(parsedLimit: 20, rowCap: 500) == 20) - #expect(MongoDBFindLimitPolicy.fetchLimit(parsedLimit: 5_000, rowCap: 500) == 501) - } - - @Test("An unbounded query never asks for more than the emergency ceiling") - func unboundedFallsBackToCeiling() { - #expect(MongoDBFindLimitPolicy.fetchLimit(parsedLimit: nil, rowCap: nil) == PluginRowLimits.emergencyMax) - } - - @Test("A non-positive bound is ignored rather than sent to the server") - func ignoresNonPositiveBounds() { - #expect(MongoDBFindLimitPolicy.fetchLimit(parsedLimit: 0, rowCap: nil) == PluginRowLimits.emergencyMax) - #expect(MongoDBFindLimitPolicy.fetchLimit(parsedLimit: nil, rowCap: 0) == PluginRowLimits.emergencyMax) - } - - @Test("The emergency ceiling is never exceeded") - func neverExceedsCeiling() { - let limit = MongoDBFindLimitPolicy.fetchLimit( - parsedLimit: nil, rowCap: PluginRowLimits.emergencyMax - ) - #expect(limit == PluginRowLimits.emergencyMax) - } - } - - @Suite("isTruncated") - struct IsTruncatedTests { - @Test("Exactly the cap is not truncated") - func exactlyCapIsNotTruncated() { - #expect(!MongoDBFindLimitPolicy.isTruncated(rowCount: 500, rowCap: 500)) - } - - @Test("One beyond the cap is truncated") - func oneBeyondCapIsTruncated() { - #expect(MongoDBFindLimitPolicy.isTruncated(rowCount: 501, rowCap: 500)) - } - - @Test("No cap means never truncated") - func noCapIsNeverTruncated() { - #expect(!MongoDBFindLimitPolicy.isTruncated(rowCount: 1_000_000, rowCap: nil)) - } - } -} diff --git a/TableProTests/Core/MongoDB/MongoDBGeneratedDDLContainmentTests.swift b/TableProTests/Core/MongoDB/MongoDBGeneratedDDLContainmentTests.swift deleted file mode 100644 index fb959b5fe7..0000000000 --- a/TableProTests/Core/MongoDB/MongoDBGeneratedDDLContainmentTests.swift +++ /dev/null @@ -1,218 +0,0 @@ -// -// MongoDBGeneratedDDLContainmentTests.swift -// TableProTests -// -// Catalog fixtures spell each name the way libbson writes it into canonical Extended JSON: the -// quote, the backslash and the C0 controls escaped, and every other character raw, U+2028 and -// U+2029 included. -// - -import Foundation -import JavaScriptCore -import Testing - -@testable import TablePro - -/// Runs the DDL Show DDL and Edit View Definition write for a namespace whose name the server -/// chose, split by the editor's statement scanner and evaluated by the real prelude one statement -/// at a time, as a query tab runs it. Nothing but the statements the DDL is for may reach the host. -struct MongoDBGeneratedDDLContainmentTests { - private typealias RecordingHost = MongoScriptPreludeTests.RecordingHost - - private static let bookkeeping: Set = [ - "currentDatabase", "cursorConfigure", "cursorClose", "useDatabase", "sleep", "newObjectId" - ] - - private static let hostileNames = [ - "v\ndb.probe.drop()", - "v\rdb.probe.drop()", - "v\r\ndb.probe.drop()", - "v\u{2028}db.probe.drop()", - "v\u{2029}db.probe.drop()", - "v\u{85}db.probe.drop()", - "v\u{0B}db.probe.drop()", - "v */ db.probe.drop() /* ", - "v\"); db.probe.drop(); (\"", - "v'); db.probe.drop(); ('" - ] - - /// Names that also break an escape made by hand: a backslash before a quote, which an escape - /// of the quote alone turns into an escaped backslash and a closing quote, and the clusters an - /// escape by `Character` misreads. - private static let objectNames = hostileNames + [ - "v\\\"}); db.probe.drop(); //", - "v\\\"); db.probe.drop(); //", - "v\r\nb", - "v\"\u{301}); db.probe.drop(); //", - "v\\\u{301}b" - ] - - private func libbson(_ value: String) -> String { - var text = "\"" - for scalar in value.unicodeScalars { - switch scalar { - case "\"": text += "\\\"" - case "\\": text += "\\\\" - default: - if scalar.value < 0x20 { - text += String(format: "\\u%04x", scalar.value) - } else { - text.unicodeScalars.append(scalar) - } - } - } - return text + "\"" - } - - private func view(named name: String) throws -> MongoDBNamespaceEntry { - try #require(MongoDBNamespaceEntry(json: """ - { "name" : \(libbson(name)), "type" : "view", "options" : { "viewOn" : "src", "pipeline" : \ - [ { "$match" : { "tag" : \(libbson(name)) } } ] }, "info" : { "readOnly" : true } } - """)) - } - - private func collection(named name: String) throws -> MongoDBNamespaceEntry { - try #require(MongoDBNamespaceEntry(json: """ - { "name" : \(libbson(name)), "type" : "collection", "options" : { "timeseries" : \ - { "timeField" : \(libbson(name)), "granularity" : "hours" }, "validator" : \ - { \(libbson(name)) : { "$type" : "string" } } } } - """)) - } - - private func index(named name: String) throws -> MongoDBIndexEntry { - try #require(MongoDBIndexEntry(json: """ - { "v" : { "$numberInt" : "2" }, "key" : { \(libbson(name)) : { "$numberInt" : "1" } }, \ - "name" : \(libbson(name)), "partialFilterExpression" : { "tag" : \(libbson(name)) } } - """)) - } - - private func requests(runningEachStatementOf text: String, replies: [String] = []) throws -> [[String: Any]] { - let host = RecordingHost() - host.replies = replies - let context = try MongoScriptContext.make( - execute: { host.handle($0) }, - emit: { host.record(printed: $0) } - ) - for statement in JavaScriptStatementScanner.executableStatements(in: text) { - context.evaluateScript(statement.text) - #expect(context.exception == nil, "\(context.exception?.toString() ?? "")") - context.exception = nil - } - return host.requests.filter { !Self.bookkeeping.contains(($0["op"] as? String) ?? "") } - } - - private func decoded(_ key: String, in json: String) -> String? { - MongoScriptJson.member(of: json, key: key).flatMap(MongoScriptJson.decodedString) - } - - /// Whether the only character in the text that ends a line, for JavaScript or for the editor's - /// scanner, or that is a control character, is the line feed the layout put between lines. - private func onlyLayoutLineFeeds(_ text: String) -> Bool { - !text.unicodeScalars.contains { scalar in - scalar != "\n" && (Character(scalar).isNewline || scalar.properties.generalCategory == .control) - } - } - - @Test("A view's DDL creates that view and runs nothing its name holds") - func viewDDLRunsOnlyCreateView() throws { - for name in Self.hostileNames { - let text = MongoDBNamespaceDDL.text(name: name, entry: try view(named: name), indexes: []) - let header = try #require(text.components(separatedBy: "\n").first) - - #expect(header.hasPrefix("// View: v"), "\(name.debugDescription)") - #expect(onlyLayoutLineFeeds(text), "\(name.debugDescription)") - - let sent = try requests(runningEachStatementOf: text) - #expect(sent.map { $0["op"] as? String } == ["command"], "\(name.debugDescription)") - let command = try #require(sent.first?["command"] as? String) - #expect(decoded("create", in: command) == name) - #expect(decoded("viewOn", in: command) == "src") - let pipeline = try #require(MongoScriptJson.member(of: command, key: "pipeline")) - let stage = try #require(MongoScriptJson.topLevelElements(pipeline).first) - let match = try #require(MongoScriptJson.member(of: stage, key: "$match")) - #expect(decoded("tag", in: match) == name) - } - } - - @Test("Edit View Definition redefines that view and runs nothing its name holds") - func collModRunsOnlyCollMod() throws { - for name in Self.hostileNames { - let text = try #require(try view(named: name).collModStatement()) - #expect(onlyLayoutLineFeeds(text), "\(name.debugDescription)") - - let sent = try requests(runningEachStatementOf: text) - #expect(sent.map { $0["op"] as? String } == ["command"], "\(name.debugDescription)") - let command = try #require(sent.first?["command"] as? String) - #expect(decoded("collMod", in: command) == name) - } - } - - @Test("Drop drops the collection it names and nothing else") - func dropRunsOnlyItsDrop() throws { - for name in Self.objectNames { - let text = MongoDBObjectStatements.drop(name) - #expect(onlyLayoutLineFeeds(text) && !text.contains("\n"), "\(name.debugDescription)") - - let sent = try requests(runningEachStatementOf: text) - #expect(sent.map { $0["op"] as? String } == ["dropCollection"], "\(name.debugDescription)") - #expect(sent.first?["collection"] as? String == name, "\(name.debugDescription)") - } - } - - @Test("Truncate empties the collection it names and nothing else") - func truncateRunsOnlyItsDelete() throws { - for name in Self.objectNames { - let text = MongoDBObjectStatements.truncate(name) - #expect(onlyLayoutLineFeeds(text) && !text.contains("\n"), "\(name.debugDescription)") - - let sent = try requests(runningEachStatementOf: text, replies: [#"{"n":0}"#]) - #expect(sent.map { $0["op"] as? String } == ["delete"], "\(name.debugDescription)") - #expect(sent.first?["collection"] as? String == name, "\(name.debugDescription)") - #expect(sent.first?["multi"] as? Bool == true, "\(name.debugDescription)") - } - } - - @Test("Edit View Definition's fallback redefines the view it names and runs nothing its name holds") - func fallbackTemplateRunsOnlyCollMod() throws { - for name in Self.objectNames { - let text = MongoDBObjectStatements.redefineViewTemplate(name) - #expect(onlyLayoutLineFeeds(text) && !text.contains("\n"), "\(name.debugDescription)") - - let sent = try requests(runningEachStatementOf: text) - #expect(sent.map { $0["op"] as? String } == ["command"], "\(name.debugDescription)") - let command = try #require(sent.first?["command"] as? String) - #expect(decoded("collMod", in: command) == name, "\(name.debugDescription)") - #expect(decoded("viewOn", in: command) == "source_collection") - } - } - - @Test("A collection's DDL sets its validator and builds its index, and runs nothing a name or an option holds") - func collectionDDLRunsOnlyItsStatements() throws { - for name in Self.hostileNames { - let text = MongoDBNamespaceDDL.text( - name: name, entry: try collection(named: name), indexes: [try index(named: name)] - ) - let lines = text.components(separatedBy: "\n") - - #expect(lines.first?.hasPrefix("// Collection: v") == true, "\(name.debugDescription)") - #expect(lines.contains { $0.hasPrefix("// Time series: ") }, "\(name.debugDescription)") - #expect(onlyLayoutLineFeeds(text), "\(name.debugDescription)") - - let sent = try requests(runningEachStatementOf: text) - #expect(sent.map { $0["op"] as? String } == ["command", "createIndex"], "\(name.debugDescription)") - let command = try #require(sent.first?["command"] as? String) - #expect(decoded("collMod", in: command) == name) - let validator = try #require(MongoScriptJson.member(of: command, key: "validator")) - #expect(MongoScriptJson.members(of: validator).map(\.key) == [name]) - - let createIndex = try #require(sent.last) - #expect(createIndex["collection"] as? String == name) - let keys = try #require(createIndex["keys"] as? String) - #expect(MongoScriptJson.members(of: keys).map(\.key) == [name]) - let options = try #require(createIndex["options"] as? String) - #expect(decoded("name", in: options) == name) - let filter = try #require(MongoScriptJson.member(of: options, key: "partialFilterExpression")) - #expect(decoded("tag", in: filter) == name) - } - } -} diff --git a/TableProTests/Core/MongoDB/MongoDBSrvHostTests.swift b/TableProTests/Core/MongoDB/MongoDBSrvHostTests.swift deleted file mode 100644 index b48eb9e10e..0000000000 --- a/TableProTests/Core/MongoDB/MongoDBSrvHostTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// MongoDBSrvHostTests.swift -// TableProTests -// - -#if canImport(CLibMongoc) - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -struct MongoDBSrvHostTests { - @Test("strips trailing :port from SRV host") - func stripsTrailingPort() { - let result = MongoDBConnection.stripPort(fromSrvHost: "tablepro.7uzbwhl.mongodb.net:27017") - #expect(result == "tablepro.7uzbwhl.mongodb.net") - } - - @Test("leaves bare host unchanged") - func leavesBareHostUnchanged() { - let result = MongoDBConnection.stripPort(fromSrvHost: "tablepro.7uzbwhl.mongodb.net") - #expect(result == "tablepro.7uzbwhl.mongodb.net") - } - - @Test("trims surrounding whitespace") - func trimsWhitespace() { - let result = MongoDBConnection.stripPort(fromSrvHost: " cluster.mongodb.net:27017 ") - #expect(result == "cluster.mongodb.net") - } - - @Test("does not strip non-numeric trailing segment") - func preservesNonNumericSuffix() { - let result = MongoDBConnection.stripPort(fromSrvHost: "host.example.com:abc") - #expect(result == "host.example.com:abc") - } - - @Test("empty string passes through") - func emptyHost() { - #expect(MongoDBConnection.stripPort(fromSrvHost: "") == "") - } -} - -#endif diff --git a/TableProTests/Core/MongoDB/MongoDBTimeoutPolicyTests.swift b/TableProTests/Core/MongoDB/MongoDBTimeoutPolicyTests.swift deleted file mode 100644 index fcae977aa0..0000000000 --- a/TableProTests/Core/MongoDB/MongoDBTimeoutPolicyTests.swift +++ /dev/null @@ -1,154 +0,0 @@ -// -// MongoDBTimeoutPolicyTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoDBTimeoutPolicyTests { - @Suite("resolveMaxTimeMS") - struct ResolveMaxTimeMSTests { - @Test("Background counts are capped even when no ambient timeout is configured") - func backgroundWithNoAmbientTimeout() { - let resolved = MongoDBTimeoutPolicy.resolveMaxTimeMS(ambientMS: 0, background: true) - #expect(resolved == MongoDBTimeoutPolicy.backgroundMaxTimeMS) - } - - @Test("Background counts never exceed the cap") - func backgroundClampsLargeAmbientTimeout() { - let resolved = MongoDBTimeoutPolicy.resolveMaxTimeMS(ambientMS: 60_000, background: true) - #expect(resolved == MongoDBTimeoutPolicy.backgroundMaxTimeMS) - } - - @Test("Background counts honour an ambient timeout shorter than the cap") - func backgroundHonoursShorterAmbientTimeout() { - let resolved = MongoDBTimeoutPolicy.resolveMaxTimeMS(ambientMS: 2_000, background: true) - #expect(resolved == 2_000) - } - - @Test("User-initiated work uses the ambient timeout") - func foregroundUsesAmbientTimeout() { - let resolved = MongoDBTimeoutPolicy.resolveMaxTimeMS(ambientMS: 60_000, background: false) - #expect(resolved == 60_000) - } - - @Test("User-initiated work stays unbounded when the user chose no limit") - func foregroundRespectsNoLimit() { - let resolved = MongoDBTimeoutPolicy.resolveMaxTimeMS(ambientMS: 0, background: false) - #expect(resolved == nil) - } - } - - @Suite("shouldRetryWithoutHint") - struct RetryTests { - @Test("A rejected hint retries without it") - func retriesOnBadValue() { - #expect(MongoDBTimeoutPolicy.shouldRetryWithoutHint(errorCode: MongoDBServerErrorCode.badValue)) - #expect(MongoDBTimeoutPolicy.shouldRetryWithoutHint(errorCode: MongoDBServerErrorCode.indexNotFound)) - } - - @Test("A timeout is authoritative and never retried") - func doesNotRetryOnTimeout() { - #expect(!MongoDBTimeoutPolicy.shouldRetryWithoutHint(errorCode: MongoDBServerErrorCode.maxTimeMSExpired)) - } - - @Test("Unrelated failures are not retried") - func doesNotRetryOnOtherErrors() { - #expect(!MongoDBTimeoutPolicy.shouldRetryWithoutHint(errorCode: MongoDBServerErrorCode.cursorKilled)) - #expect(!MongoDBTimeoutPolicy.shouldRetryWithoutHint(errorCode: 13)) - } - } - - @Suite("timeoutMessage") - struct TimeoutMessageTests { - @Test("Reports the timeout in seconds") - func reportsSeconds() { - let message = MongoDBTimeoutPolicy.timeoutMessage(maxTimeMS: 60_000) - #expect(message.contains("60")) - } - - @Test("A sub-second timeout still reports at least one second") - func clampsToOneSecond() { - let message = MongoDBTimeoutPolicy.timeoutMessage(maxTimeMS: 200) - #expect(message.contains("1")) - } - - @Test("Names the missing index as the likely cause") - func mentionsIndex() { - let message = MongoDBTimeoutPolicy.timeoutMessage(maxTimeMS: 5_000) - #expect(message.localizedCaseInsensitiveContains("index")) - } - } - - @Test("A write that timed out says it was stopped and names the seconds, rounded to at least one") - func writeTimeoutMessage() { - let message = MongoDBTimeoutPolicy.writeTimeoutMessage(maxTimeMS: 30_000) - #expect(message.contains("30")) - #expect(message.localizedCaseInsensitiveContains("write")) - #expect(!message.localizedCaseInsensitiveContains("index")) - #expect(MongoDBTimeoutPolicy.writeTimeoutMessage(maxTimeMS: 200) - == MongoDBTimeoutPolicy.writeTimeoutMessage(maxTimeMS: 1_000)) - #expect(MongoDBTimeoutPolicy.writeTimeoutMessage(maxTimeMS: 1_600) - == MongoDBTimeoutPolicy.writeTimeoutMessage(maxTimeMS: 2_000)) - } - - @Test("The interruption category holds the timeout and a kill, and not a refusal") - func interruptionCategory() { - let category = MongoDBServerErrorCode.interruptionCategory - #expect(category.contains(MongoDBServerErrorCode.maxTimeMSExpired)) - #expect(category.contains(11_601)) - #expect(category.contains(11_602)) - #expect(!category.contains(MongoDBServerErrorCode.badValue)) - #expect(!category.contains(121)) - #expect(!category.contains(11_000)) - } - - @Test("The interruption category holds every Interruption code from MongoDB 4.0 through 9.0") - func interruptionCategoryCoversEveryRelease() { - let addedByRelease: [(release: String, codes: [UInt32])] = [ - ("4.0", [24, 50, 237, 262, 11_600, 11_601, 11_602]), - ("4.2", [279, 282, 46_841]), - ("4.4", [281, 290]), - ("5.1", [355]), - ("8.0", [91_331]), - ("8.1", [10_045_600]), - ("8.2", [453]), - ("8.3", [471, 473, 485]), - ("9.0", [509]) - ] - let category = MongoDBServerErrorCode.interruptionCategory - for (release, codes) in addedByRelease { - for code in codes { - #expect(category.contains(code), "MongoDB \(release) code \(code)") - } - } - #expect(category.count == addedByRelease.flatMap { $0.codes }.count) - } - - @Test("Only MaxTimeMSExpired counts as a timeout") - func timeoutCodeDetection() { - #expect(MongoDBTimeoutPolicy.isTimeoutCode(50)) - #expect(!MongoDBTimeoutPolicy.isTimeoutCode(0)) - #expect(!MongoDBTimeoutPolicy.isTimeoutCode(292)) - } -} - -struct MongoDBConnectTimeoutTests { - @Test("The same remaining budget configures TCP and server selection") - func uriParametersUseRemainingBudget() { - let timeout = MongoDBConnectTimeout(additionalFields: ["connectTimeoutMilliseconds": "4321"]) - - #expect(timeout.uriParameters == ["connectTimeoutMS=4321", "serverSelectionTimeoutMS=4321"]) - } - - @Test("The compatibility timeout is parsed in seconds and clamped") - func compatibilitySecondsClamp() { - #expect(MongoDBConnectTimeout(additionalFields: ["connectTimeoutSeconds": "7"]).milliseconds == 7_000) - #expect(MongoDBConnectTimeout(additionalFields: ["connectTimeoutSeconds": "-1"]).milliseconds == 1) - #expect( - MongoDBConnectTimeout(additionalFields: ["connectTimeoutMilliseconds": "999999999"]).milliseconds - == Int32(MongoDBConnectTimeout.maximumMilliseconds) - ) - } -} diff --git a/TableProTests/Core/MongoDB/MongoScriptCommandTests.swift b/TableProTests/Core/MongoDB/MongoScriptCommandTests.swift deleted file mode 100644 index 473acef7ae..0000000000 --- a/TableProTests/Core/MongoDB/MongoScriptCommandTests.swift +++ /dev/null @@ -1,714 +0,0 @@ -// -// MongoScriptCommandTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoScriptJsonTests { - @Test("Members come back in the order the document carries them, as text") - func membersKeepOrder() { - let members = MongoScriptJson.members(of: "{\"zeta\": 1, \"alpha\": {\"x\": [1, 2]}, \"last\": \"a, b\"}") - #expect(members.map(\.key) == ["zeta", "alpha", "last"]) - #expect(members.map(\.value) == ["1", "{\"x\": [1, 2]}", "\"a, b\""]) - } - - @Test("A member's value is the text it occupies, not a rebuilt value") - func memberIsVerbatim() { - let json = "{\"filter\": {\"b\": 1, \"a\": 2}}" - #expect(MongoScriptJson.member(of: json, key: "filter") == "{\"b\": 1, \"a\": 2}") - } - - @Test("A missing member is nil rather than an empty document") - func missingMember() { - #expect(MongoScriptJson.member(of: "{\"a\": 1}", key: "b") == nil) - } - - @Test("Array elements split at the top level only") - func topLevelElements() { - let elements = MongoScriptJson.topLevelElements("[{\"a\": [1, 2]}, {\"b\": \"x, y\"}, 3]") - #expect(elements == ["{\"a\": [1, 2]}", "{\"b\": \"x, y\"}", "3"]) - } - - @Test("An empty array yields no elements") - func emptyArray() { - #expect(MongoScriptJson.topLevelElements("[]").isEmpty) - } - - @Test("Control characters and quotes are escaped") - func stringEscaping() { - #expect(MongoScriptJson.jsonString("a\"b\\c\nd") == "\"a\\\"b\\\\c\\nd\"") - } - - @Test("A non-finite reply value reads as absent rather than trapping") - func nonFiniteReplyValues() { - #expect(MongoScriptJson.number(in: "{\"n\": {\"$numberDouble\": \"NaN\"}}", key: "n") == nil) - #expect(MongoScriptJson.number(in: "{\"n\": {\"$numberDouble\": \"Infinity\"}}", key: "n") == nil) - #expect(MongoScriptJson.whole(3.0) == 3) - #expect(MongoScriptJson.whole(.nan) == nil) - #expect(MongoScriptJson.whole(.infinity) == nil) - } - - @Test("A numeric reply field is read through whichever wrapper the server used") - func numericWrappers() { - #expect(MongoScriptJson.number(in: "{\"n\": 3}", key: "n") == 3) - #expect(MongoScriptJson.number(in: "{\"n\": {\"$numberInt\": \"4\"}}", key: "n") == 4) - #expect(MongoScriptJson.number(in: "{\"n\": {\"$numberLong\": \"5\"}}", key: "n") == 5) - #expect(MongoScriptJson.number(in: "{\"n\": {\"$numberDouble\": \"6\"}}", key: "n") == 6) - } -} - -struct MongoScriptCursorOptionsTests { - @Test("A sort written in shell syntax reaches the find options instead of being dropped") - func sortSurvives() throws { - var options = MongoScriptCursorOptions.none - try options.apply(key: "sort", value: "{\"createdAt\":{\"$numberInt\":\"-1\"}}") - let json = options.findOptionsJson(limit: 100, timeoutMS: 0) - #expect(json.contains("\"sort\": {\"createdAt\":{\"$numberInt\":\"-1\"}}")) - } - - @Test("A projection reaches the find options") - func projectionSurvives() throws { - var options = MongoScriptCursorOptions.none - try options.apply(key: "projection", value: "{\"name\":{\"$numberInt\":\"1\"}}") - #expect(options.findOptionsJson(limit: 10, timeoutMS: 0).contains("\"projection\":")) - } - - @Test("A whole number arrives wrapped and is read back as a number") - func wrappedNumbers() throws { - var options = MongoScriptCursorOptions.none - try options.apply(key: "limit", value: "{\"$numberInt\":\"25\"}") - try options.apply(key: "skip", value: "{\"$numberLong\":\"5\"}") - #expect(options.limit == 25) - #expect(options.skip == 5) - } - - @Test("A limit larger than the ceiling is capped, a smaller one is kept") - func limitRespectsCeiling() { - var options = MongoScriptCursorOptions.none - options.limit = 500 - #expect(options.effectiveLimit(ceiling: 201) == 201) - options.limit = 10 - #expect(options.effectiveLimit(ceiling: 201) == 10) - } - - @Test("No limit falls back to the ceiling") - func noLimitUsesCeiling() { - #expect(MongoScriptCursorOptions.none.effectiveLimit(ceiling: 201) == 201) - } - - @Test("NaN and infinity are refused rather than crashing the conversion") - func nonFiniteNumbersAreRefused() { - var options = MongoScriptCursorOptions.none - for value in ["{\"$numberDouble\":\"NaN\"}", "{\"$numberDouble\":\"Infinity\"}", - "{\"$numberDouble\":\"-Infinity\"}", "{\"$numberDouble\":\"1e30\"}"] { - #expect(throws: MongoScriptError.self) { - try options.apply(key: "limit", value: value) - } - } - } - - @Test("An unsupported modifier is refused by name") - func unsupportedModifier() { - var options = MongoScriptCursorOptions.none - #expect(throws: MongoScriptError.self) { - try options.apply(key: "readConcern", value: "{}") - } - } - - @Test("A modifier that wants a number refuses text") - func nonNumericModifier() { - var options = MongoScriptCursorOptions.none - #expect(throws: MongoScriptError.self) { - try options.apply(key: "limit", value: "\"ten\"") - } - } - - @Test("Aggregation takes ordering and paging as appended pipeline stages") - func aggregateStages() throws { - var options = MongoScriptCursorOptions.none - try options.apply(key: "sort", value: "{\"a\":1}") - try options.apply(key: "limit", value: "5") - let pipeline = options.decoratedPipeline("[{\"$match\":{}}]") - #expect(pipeline == "[{\"$match\":{}},{\"$sort\": {\"a\":1}},{\"$limit\": 5}]") - } - - @Test("An empty pipeline still takes its stages") - func emptyPipeline() throws { - var options = MongoScriptCursorOptions.none - try options.apply(key: "limit", value: "2") - #expect(options.decoratedPipeline("[]") == "[{\"$limit\": 2}]") - } - - @Test("The connection's query timeout becomes maxTimeMS when the script names none") - func timeoutFallback() { - let json = MongoScriptCursorOptions.none.findOptionsJson(limit: 10, timeoutMS: 3_000) - #expect(json.contains("\"maxTimeMS\": 3000")) - } - - @Test("allowDiskUse reaches a find as well as an aggregation") - func allowDiskUseOnFind() throws { - var options = MongoScriptCursorOptions.none - try options.apply(key: "allowDiskUse", value: "true") - #expect(options.findOptionsJson(limit: 10, timeoutMS: 0).contains("\"allowDiskUse\": true")) - #expect(options.aggregateOptionsJson(timeoutMS: 0)?.contains("\"allowDiskUse\": true") == true) - } - - @Test("A maxTimeMS the script set wins over the connection's") - func explicitTimeoutWins() throws { - var options = MongoScriptCursorOptions.none - try options.apply(key: "maxTimeMS", value: "500") - #expect(options.findOptionsJson(limit: 10, timeoutMS: 3_000).contains("\"maxTimeMS\": 500")) - } - - @Test("A count appends the cursor's paging and a $count stage, and leaves its sort out") - func countedPipelineKeepsThePaging() throws { - var options = MongoScriptCursorOptions.none - try options.apply(key: "sort", value: #"{"a":1}"#) - try options.apply(key: "skip", value: "5") - try options.apply(key: "limit", value: "10") - #expect(options.countedPipeline(#"[{"$match":{}}]"#) - == #"[{"$match":{}},{"$skip": 5},{"$limit": 10},{"$count": "n"}]"#) - } - - @Test("An empty pipeline is counted whole") - func emptyPipelineIsCountedWhole() { - #expect(MongoScriptCursorOptions.none.countedPipeline("[]") == #"[{"$count": "n"}]"#) - #expect(MongoScriptCursorOptions.none.countedPipeline(" [ ] ") == #"[{"$count": "n"}]"#) - } - - @Test("A pipeline that is not an array gets no $count stage") - func nonArrayPipelineIsNotCounted() { - #expect(MongoScriptCursorOptions.none.countedPipeline(#"{"pipeline": []}"#) == nil) - } - - @Test("The count goes out with the cursor's options but not its paging, so nothing follows $count") - func countingOptionsDropThePaging() throws { - var options = MongoScriptCursorOptions.none - let modifiers = [ - ("sort", #"{"a":1}"#), ("skip", "5"), ("limit", "10"), ("hint", #"{"a":1}"#), - ("collation", #"{"locale":"fr"}"#), ("allowDiskUse", "true"), ("maxTimeMS", "50"), ("batchSize", "7") - ] - for (key, value) in modifiers { - try options.apply(key: key, value: value) - } - let counted = try #require(options.countedPipeline("[]")) - #expect(options.countingOptions.decoratedPipeline(counted) == counted) - #expect(options.countingOptions.aggregateOptionsJson(timeoutMS: 0) == options.aggregateOptionsJson(timeoutMS: 0)) - } - - @Test("$count returns no document for an empty result, which counts as zero") - func countedTotalReadsTheReply() { - #expect(MongoScriptCursorOptions.countedTotal([]) == 0) - #expect(MongoScriptCursorOptions.countedTotal([#"{"n": {"$numberInt": "1500"}}"#]) == 1_500) - #expect(MongoScriptCursorOptions.countedTotal([#"{"n": 6000000}"#]) == 6_000_000) - #expect(MongoScriptCursorOptions.countedTotal([#"{"n": {"$numberLong": "9000000000"}}"#]) == 9_000_000_000) - } -} - -struct MongoScriptCommandBuilderTests { - @Test("updateMany becomes an update command with multi set") - func updateMany() { - let command = MongoScriptCommandBuilder.update( - collection: "orders", filter: "{\"a\":1}", update: "{\"$set\":{\"b\":2}}", - multi: true, options: [:], writeConcern: nil - ) - #expect(command.contains("\"update\": \"orders\"")) - #expect(command.contains("\"q\": {\"a\":1}")) - #expect(command.contains("\"u\": {\"$set\":{\"b\":2}}")) - #expect(command.contains("\"multi\": true")) - #expect(command.contains("\"upsert\": false")) - } - - @Test("upsert and arrayFilters ride along") - func updateOptions() { - let command = MongoScriptCommandBuilder.update( - collection: "orders", filter: "{}", update: "{}", multi: false, - options: ["upsert": true, "arrayFilters": [["x.y": 1]]], writeConcern: nil - ) - #expect(command.contains("\"upsert\": true")) - #expect(command.contains("\"arrayFilters\":")) - } - - /// mongosh sends `updateMany(filter, update, {writeConcern})` with that write concern. The - /// shell dropped the option, and `mongoc_client_command_simple` adds none of its own, so the - /// update went out with the server's default. - @Test("An update's writeConcern option goes on the command, not inside the update statement") - func updateWriteConcern() throws { - let statementConcern = MongoScriptCommandBuilder.writeConcern( - statementOptions: #"{"writeConcern": {"w": "majority", "wtimeout": 5000}}"#, connectionDefault: nil - ) - let command = MongoScriptCommandBuilder.update( - collection: "orders", filter: "{}", update: "{\"$set\":{\"b\":2}}", multi: true, - options: [:], writeConcern: statementConcern - ) - let parsed = try #require( - try JSONSerialization.jsonObject(with: Data(command.utf8)) as? [String: Any] - ) - let concern = try #require(parsed["writeConcern"] as? [String: Any]) - #expect(concern["w"] as? String == "majority") - #expect((concern["wtimeout"] as? NSNumber)?.intValue == 5_000) - let statement = try #require((parsed["updates"] as? [[String: Any]])?.first) - #expect(statement["writeConcern"] == nil) - #expect(!MongoScriptCommandBuilder.update( - collection: "orders", filter: "{}", update: "{}", multi: true, options: [:], writeConcern: nil - ).contains("writeConcern")) - } - - @Test("deleteOne limits to one document and deleteMany to none") - func deleteLimits() { - #expect( - MongoScriptCommandBuilder - .delete(collection: "orders", filter: "{}", multi: false, options: [:], writeConcern: nil) - .contains("\"limit\": 1") - ) - #expect( - MongoScriptCommandBuilder - .delete(collection: "orders", filter: "{}", multi: true, options: [:], writeConcern: nil) - .contains("\"limit\": 0") - ) - } - - @Test("findOneAndDelete asks the server to remove rather than update") - func findAndModifyRemove() { - let command = MongoScriptCommandBuilder.findAndModify( - collection: "orders", filter: "{\"a\":1}", update: nil, remove: true, options: [:], writeConcern: nil - ) - #expect(command.contains("\"remove\": true")) - #expect(!command.contains("\"update\"")) - } - - @Test("returnDocument after asks for the updated document") - func findAndModifyReturnsNew() { - let command = MongoScriptCommandBuilder.findAndModify( - collection: "orders", filter: "{}", update: "{\"$set\":{}}", remove: false, - options: ["returnDocument": "after"], writeConcern: nil - ) - #expect(command.contains("\"new\": true")) - } - - @Test("An unnamed index takes the name MongoDB gives it") - func indexNaming() { - #expect( - MongoScriptCommandBuilder.indexName(keys: "{\"a\":1,\"b\":-1}", optionsJson: nil) == "a_1_b_-1" - ) - #expect( - MongoScriptCommandBuilder.indexName(keys: "{\"loc\":\"2dsphere\"}", optionsJson: "{}") == "loc_2dsphere" - ) - #expect( - MongoScriptCommandBuilder.indexName(keys: "{\"a\":1}", optionsJson: "{\"name\":\"custom\"}") == "custom" - ) - #expect( - MongoScriptCommandBuilder.indexName(keys: "{\"a\":1}", optionsJson: "{\"name\":\"a\\\"b\"}") == "a\"b" - ) - } - - @Test("createIndex passes the options MongoDB accepts") - func createIndexOptions() { - let command = MongoScriptCommandBuilder.createIndex( - collection: "orders", keys: "{\"a\":1}", optionsJson: "{\"unique\": true, \"expireAfterSeconds\": 60}" - ) - #expect(command.contains("\"createIndexes\": \"orders\"")) - #expect(command.contains("\"unique\": true")) - #expect(command.contains("\"expireAfterSeconds\": 60")) - } - - @Test("Every index option reaches the spec as written, in the order written") - func createIndexKeepsEveryOption() throws { - let cases: [(keys: String, options: String, spec: String)] = [ - ( - "{\"$**\":1}", - "{\"wildcardProjection\":{\"zeta\":1,\"alpha\":1}}", - "{\"key\": {\"$**\":1}, \"name\": \"$**_1\", \"wildcardProjection\": {\"zeta\":1,\"alpha\":1}}" - ), - ( - "{\"p\":\"2d\"}", - "{\"bits\":20,\"min\":-500,\"max\":500}", - "{\"key\": {\"p\":\"2d\"}, \"name\": \"p_2d\", \"bits\": 20, \"min\": -500, \"max\": 500}" - ), - ( - "{\"age\":1}", - "{\"partialFilterExpression\":{\"b\":{\"$gt\":1},\"age\":{\"$exists\":true}}}", - "{\"key\": {\"age\":1}, \"name\": \"age_1\", \"partialFilterExpression\": {\"b\":{\"$gt\":1},\"age\":{\"$exists\":true}}}" - ), - ( - "{\"_fts\":\"text\",\"_ftsx\":1}", - "{\"name\":\"t\",\"weights\":{\"b\":2},\"language_override\":\"lang\",\"textIndexVersion\":3}", - "{\"key\": {\"_fts\":\"text\",\"_ftsx\":1}, \"name\": \"t\", \"weights\": {\"b\":2}, \"language_override\": \"lang\", \"textIndexVersion\": 3}" - ) - ] - - for testCase in cases { - let command = MongoScriptCommandBuilder.createIndex( - collection: "people", keys: testCase.keys, optionsJson: testCase.options - ) - #expect(command == "{\"createIndexes\": \"people\", \"indexes\": [\(testCase.spec)]}") - _ = try JSONSerialization.jsonObject(with: Data(command.utf8)) - } - } - - @Test("Options that belong to the createIndexes command are sent there, not in the index spec") - func createIndexCommandOptions() { - let command = MongoScriptCommandBuilder.createIndex( - collection: "people", - keys: "{\"a\":1}", - optionsJson: "{\"maxTimeMS\":5000,\"unique\":true,\"commitQuorum\":\"majority\",\"sparse\":null}" - ) - - #expect(command == """ - {"createIndexes": "people", "indexes": [{"key": {"a":1}, "name": "a_1", "unique": true}], \ - "maxTimeMS": 5000, "commitQuorum": "majority"} - """) - } - - @Test("A find for EXPLAIN carries the cursor's own modifiers") - func explainFind() throws { - var options = MongoScriptCursorOptions.none - try options.apply(key: "sort", value: "{\"a\":1}") - try options.apply(key: "skip", value: "3") - let command = MongoScriptCommandBuilder.find( - collection: "orders", filter: "{\"b\":2}", options: options, ceiling: 100 - ) - #expect(command.contains("\"find\": \"orders\"")) - #expect(command.contains("\"filter\": {\"b\":2}")) - #expect(command.contains("\"sort\": {\"a\":1}")) - #expect(command.contains("\"skip\": 3")) - } - - @Test("bulkWrite maps each operation to its own command") - func bulkOperations() throws { - let insert = try MongoScriptCommandBuilder.bulkOperation( - "{\"insertOne\": {\"document\": {\"a\": 1}}}", collection: "orders", writeConcern: nil - ) - #expect(insert.kind == .insert) - #expect(insert.document.contains("\"documents\": [{\"a\": 1}]")) - - let update = try MongoScriptCommandBuilder.bulkOperation( - "{\"updateMany\": {\"filter\": {\"a\": 1}, \"update\": {\"$set\": {\"b\": 2}}}}", - collection: "orders", writeConcern: nil - ) - #expect(update.kind == .update) - #expect(update.touchesMany) - #expect(update.document.contains("\"multi\": true")) - - let delete = try MongoScriptCommandBuilder.bulkOperation( - "{\"deleteOne\": {\"filter\": {\"a\": 1}}}", collection: "orders", writeConcern: nil - ) - #expect(delete.kind == .delete) - #expect(!delete.touchesMany) - #expect(delete.document.contains("\"limit\": 1")) - } - - @Test("A bulk delete keeps its collation instead of dropping it") - func bulkDeleteOptions() throws { - let delete = try MongoScriptCommandBuilder.bulkOperation( - "{\"deleteMany\": {\"filter\": {\"a\": 1}, \"collation\": {\"locale\": \"fr\"}}}", - collection: "orders", writeConcern: nil - ) - #expect(delete.touchesMany) - #expect(delete.document.contains("\"collation\": {\"locale\":\"fr\"}")) - } - - @Test("An unknown bulk operation is refused rather than silently skipped") - func unknownBulkOperation() { - #expect(throws: MongoScriptError.self) { - try MongoScriptCommandBuilder.bulkOperation("{\"upsertAll\": {}}", collection: "orders", writeConcern: nil) - } - } - - @Test("A bulk write with no filter, or no update, is refused rather than sent as {}") - func bulkOperationNeedsItsDocuments() { - let operations = [ - (#"{"deleteMany": {"filtr": {"a": 1}}}"#, "filter"), - (#"{"deleteOne": {}}"#, "filter"), - (#"{"deleteMany": {"filter": null}}"#, "filter"), - (#"{"updateMany": {"update": {"$set": {"b": 2}}}}"#, "filter"), - (#"{"updateOne": {"filter": {"a": 1}}}"#, "update"), - (#"{"updateOne": {"filter": {"a": 1}, "update": null}}"#, "update"), - (#"{"replaceOne": {"filter": {"a": 1}}}"#, "replacement") - ] - for (operation, missing) in operations { - #expect(throws: MongoScriptError(MongoScriptText.missingArgument(missing)), "\(operation)") { - try MongoScriptCommandBuilder.bulkOperation(operation, collection: "orders", writeConcern: nil) - } - } - } - - @Test("A bulk write given {} as its filter still means every document") - func bulkOperationKeepsAnExplicitEmptyFilter() throws { - let delete = try MongoScriptCommandBuilder.bulkOperation( - #"{"deleteMany": {"filter": {}}}"#, collection: "orders", writeConcern: nil - ) - #expect(delete.document.contains(#""q": {}"#)) - let replace = try MongoScriptCommandBuilder.bulkOperation( - #"{"replaceOne": {"filter": {}, "replacement": {"a": 1}}}"#, collection: "orders", writeConcern: nil - ) - #expect(replace.document.contains(#""q": {}"#)) - #expect(replace.document.contains(#""u": {"a": 1}"#)) - } -} - -struct MongoScriptWriteConcernTests { - private static let majority = "{\"w\": \"majority\"}" - - private static func occurrences(of field: String, in command: String) -> Int { - command.components(separatedBy: field).count - 1 - } - - @Test("A statement that names no write concern takes the connection's") - func connectionDefault() { - #expect(MongoScriptCommandBuilder.writeConcern(statementOptions: nil, connectionDefault: Self.majority) == Self.majority) - #expect( - MongoScriptCommandBuilder.writeConcern(statementOptions: "{\"upsert\":true}", connectionDefault: Self.majority) - == Self.majority - ) - } - - @Test("The statement's own write concern wins over the connection's, as it does in mongosh") - func statementWins() { - let options = "{\"writeConcern\":{\"w\":{\"$numberInt\":\"1\"},\"wtimeout\":{\"$numberInt\":\"777\"}}}" - #expect( - MongoScriptCommandBuilder.writeConcern(statementOptions: options, connectionDefault: Self.majority) - == "{\"w\": {\"$numberInt\":\"1\"}, \"wtimeout\": {\"$numberInt\":\"777\"}}" - ) - } - - @Test("mongosh's journal and wtimeoutMS go out as the j and wtimeout the server reads") - func mongoshSpellings() { - let options = "{\"writeConcern\":{\"journal\":true,\"wtimeoutMS\":{\"$numberInt\":\"1000\"}}}" - let canonical = "{\"j\": true, \"wtimeout\": {\"$numberInt\":\"1000\"}}" - #expect( - MongoScriptCommandBuilder.writeConcern(statementOptions: options, connectionDefault: Self.majority) - == canonical - ) - #expect(MongoScriptCommandBuilder.insertOptions(statementOptions: options) == "{\"writeConcern\": \(canonical)}") - } - - @Test("Each name takes the first spelling set, in mongosh's order, and fsync stands for j") - func spellingPrecedence() { - let cases = [ - ("{\"writeConcern\":{\"journal\":true,\"j\":false}}", "{\"j\": false}"), - ("{\"writeConcern\":{\"fsync\":true,\"w\":{\"$numberInt\":\"1\"}}}", "{\"w\": {\"$numberInt\":\"1\"}, \"j\": true}"), - ("{\"writeConcern\":{\"wtimeoutMS\":9,\"wtimeout\":5}}", "{\"wtimeout\": 5}"), - ("{\"writeConcern\":{\"j\":null,\"journal\":true}}", "{\"j\": true}") - ] - for (options, expected) in cases { - #expect(MongoScriptCommandBuilder.writeConcern(statementOptions: options, connectionDefault: nil) == expected) - } - } - - @Test("A write concern naming none of w, j and wtimeout falls back to the connection's, as in mongosh") - func emptyFallsBack() { - for options in ["{\"writeConcern\":{}}", "{\"writeConcern\":{\"foo\":1}}", "{\"writeConcern\":{\"w\":null}}"] { - #expect( - MongoScriptCommandBuilder.writeConcern(statementOptions: options, connectionDefault: Self.majority) - == Self.majority - ) - #expect(MongoScriptCommandBuilder.insertOptions(statementOptions: options) == nil) - } - } - - @Test("A write concern that names one field replaces the connection's whole, as in mongosh") - func partialReplacesWhole() { - #expect( - MongoScriptCommandBuilder.writeConcern( - statementOptions: "{\"writeConcern\":{\"wtimeoutMS\":{\"$numberInt\":\"555\"}}}", - connectionDefault: Self.majority - ) == "{\"wtimeout\": {\"$numberInt\":\"555\"}}" - ) - } - - @Test("Only w: 0, or libmongoc's legacy w: -1, without j: true goes unacknowledged, in any number wrapper") - func acknowledgement() { - let unacknowledged = [ - "{\"w\": {\"$numberInt\":\"0\"}}", - "{\"w\": {\"$numberDouble\":\"0.0\"}}", - "{\"w\": {\"$numberInt\":\"0\"}, \"j\": false}", - "{\"w\":{\"$numberInt\":\"0\"}}", - "{\"w\": {\"$numberInt\":\"-1\"}}", - "{\"w\": -1}" - ] - for concern in unacknowledged { - #expect(!MongoScriptCommandBuilder.isAcknowledged(writeConcern: concern), "\(concern)") - } - let acknowledged = [ - nil, - Self.majority, - "{\"w\": {\"$numberInt\":\"1\"}}", - "{\"w\": {\"$numberInt\":\"0\"}, \"j\": true}", - "{\"w\": \"0\"}", - "{\"w\": false}", - "{\"j\": true}" - ] - for concern in acknowledged { - #expect(MongoScriptCommandBuilder.isAcknowledged(writeConcern: concern), "\(String(describing: concern))") - } - } - - @Test("A statement's w: 0 decides acknowledgement the same way the connection's does") - func statementWZero() { - let concern = MongoScriptCommandBuilder.writeConcern( - statementOptions: "{\"writeConcern\":{\"w\":{\"$numberInt\":\"0\"}}}", connectionDefault: Self.majority - ) - #expect(!MongoScriptCommandBuilder.isAcknowledged(writeConcern: concern)) - #expect(!MongoScriptCommandBuilder.isAcknowledged( - writeConcern: MongoScriptCommandBuilder.writeConcern( - statementOptions: nil, connectionDefault: "{ \"w\" : { \"$numberInt\" : \"0\" } }" - ) - )) - } - - @Test("A null write concern counts as unset, the way mongosh reads it") - func nullIsUnset() { - let concern = MongoScriptCommandBuilder.writeConcern( - statementOptions: "{\"writeConcern\":null}", connectionDefault: Self.majority - ) - #expect(concern == Self.majority) - let command = MongoScriptCommandBuilder.update( - collection: "orders", filter: "{}", update: "{\"$set\":{}}", multi: false, - options: [:], writeConcern: concern - ) - #expect(Self.occurrences(of: "\"writeConcern\"", in: command) == 1) - #expect(command.contains("\"writeConcern\": \(Self.majority)")) - } - - @Test("With neither set, no write concern is sent and the server's default applies") - func neitherSet() { - #expect(MongoScriptCommandBuilder.writeConcern(statementOptions: nil, connectionDefault: nil) == nil) - let command = MongoScriptCommandBuilder.delete( - collection: "orders", filter: "{}", multi: true, options: [:], writeConcern: nil - ) - #expect(!command.contains("writeConcern")) - } - - @Test("Every write command carries the write concern exactly once, at the top level") - func everyWriteCarriesIt() throws { - let commands = [ - MongoScriptCommandBuilder.update( - collection: "orders", filter: "{}", update: "{\"$set\":{}}", multi: true, - options: [:], writeConcern: Self.majority - ), - MongoScriptCommandBuilder.delete( - collection: "orders", filter: "{}", multi: false, options: [:], writeConcern: Self.majority - ), - MongoScriptCommandBuilder.findAndModify( - collection: "orders", filter: "{}", update: "{\"$set\":{}}", remove: false, - options: [:], writeConcern: Self.majority - ), - try MongoScriptCommandBuilder.bulkOperation( - "{\"insertOne\": {\"document\": {\"a\": 1}}}", collection: "orders", writeConcern: Self.majority - ).document, - try MongoScriptCommandBuilder.bulkOperation( - "{\"updateMany\": {\"filter\": {}, \"update\": {\"$set\": {\"b\": 2}}}}", - collection: "orders", writeConcern: Self.majority - ).document, - try MongoScriptCommandBuilder.bulkOperation( - "{\"deleteOne\": {\"filter\": {}}}", collection: "orders", writeConcern: Self.majority - ).document - ] - for command in commands { - #expect(Self.occurrences(of: "\"writeConcern\"", in: command) == 1) - #expect(MongoScriptJson.member(of: command, key: "writeConcern") == Self.majority) - } - } - - @Test("An insert sends w: 0 beside j: true as w: 1, which libmongoc accepts and the server treats the same") - func insertJournaledUnacknowledged() { - let cases = [ - ("{\"writeConcern\":{\"w\":{\"$numberInt\":\"0\"},\"j\":true}}", "{\"w\": 1, \"j\": true}"), - ("{\"writeConcern\":{\"w\":0,\"journal\":true,\"wtimeoutMS\":5}}", "{\"w\": 1, \"j\": true, \"wtimeout\": 5}"), - ("{\"writeConcern\":{\"fsync\":true,\"w\":0}}", "{\"w\": 1, \"j\": true}"), - ("{\"writeConcern\":{\"w\":0,\"j\":false}}", "{\"w\": 0, \"j\": false}"), - ("{\"writeConcern\":{\"w\":-1,\"j\":true}}", "{\"w\": -1, \"j\": true}") - ] - for (options, concern) in cases { - #expect(MongoScriptCommandBuilder.insertOptions(statementOptions: options) == "{\"writeConcern\": \(concern)}") - } - let commandConcern = MongoScriptCommandBuilder.writeConcern( - statementOptions: "{\"writeConcern\":{\"w\":0,\"j\":true}}", connectionDefault: Self.majority - ) - #expect(commandConcern == "{\"w\": 0, \"j\": true}") - #expect(MongoScriptCommandBuilder.isAcknowledged(writeConcern: commandConcern)) - } - - @Test("An insert takes only the statement's own write concern and ordered flag") - func insertOptions() { - #expect(MongoScriptCommandBuilder.insertOptions(statementOptions: nil) == nil) - #expect(MongoScriptCommandBuilder.insertOptions(statementOptions: "null") == nil) - #expect(MongoScriptCommandBuilder.insertOptions(statementOptions: "{\"comment\":\"x\"}") == nil) - #expect( - MongoScriptCommandBuilder.insertOptions( - statementOptions: "{\"ordered\":false,\"comment\":\"x\",\"writeConcern\":{\"w\":\"majority\"}}" - ) == "{\"writeConcern\": {\"w\": \"majority\"}, \"ordered\": false}" - ) - #expect( - MongoScriptCommandBuilder.insertOptions(statementOptions: "{\"writeConcern\":null,\"ordered\":true}") - == "{\"ordered\": true}" - ) - } -} - -struct MongoScriptObjectIdTests { - @Test("A generated id is 24 lowercase hex characters") - func shape() { - let hex = MongoScriptObjectId.generate() - #expect(hex.count == 24) - #expect(hex.allSatisfy { $0.isHexDigit && !$0.isUppercase }) - } - - @Test("Two generated ids differ") - func uniqueness() { - let first = MongoScriptObjectId.generate() - let second = MongoScriptObjectId.generate() - #expect(first != second) - } - - @Test("The leading four bytes are the current time") - func timestampPrefix() throws { - let hex = MongoScriptObjectId.generate() - let seconds = try #require(UInt32(hex.prefix(8), radix: 16)) - #expect(abs(Double(seconds) - Date().timeIntervalSince1970) < 5) - } - - @Test("Hex payloads decode, and odd-length ones are refused") - func hexDecoding() { - #expect(MongoScriptObjectId.data(fromHex: "00ff10")?.count == 3) - #expect(MongoScriptObjectId.data(fromHex: "abc") == nil) - #expect(MongoScriptObjectId.data(fromHex: "zz") == nil) - } -} - -struct MongoShellCommandLineTests { - @Test("use becomes a call") - func useBecomesCall() { - #expect(MongoShellCommandLine.rewrite("use orders") == "use(\"orders\")") - #expect(MongoShellCommandLine.rewrite("use orders;") == "use(\"orders\")") - #expect(MongoShellCommandLine.rewrite(" use orders ") == "use(\"orders\")") - } - - @Test("show becomes a call for the topics MongoDB has") - func showBecomesCall() { - #expect(MongoShellCommandLine.rewrite("show dbs") == "show(\"dbs\")") - #expect(MongoShellCommandLine.rewrite("show collections") == "show(\"collections\")") - #expect(MongoShellCommandLine.rewrite("SHOW TABLES") == "show(\"tables\")") - } - - @Test("An unknown show topic is left alone") - func unknownTopic() { - #expect(MongoShellCommandLine.rewrite("show profile") == "show profile") - } - - @Test("JavaScript that merely starts with the word is left alone") - func javaScriptIsNotRewritten() { - #expect(MongoShellCommandLine.rewrite("use = 1") == "use = 1") - #expect(MongoShellCommandLine.rewrite("use(\"orders\")") == "use(\"orders\")") - #expect(MongoShellCommandLine.rewrite("show.me()") == "show.me()") - #expect(MongoShellCommandLine.rewrite("db.orders.find({})") == "db.orders.find({})") - } - - @Test("A quoted database name loses its quotes rather than gaining a second pair") - func quotedName() { - #expect(MongoShellCommandLine.rewrite("use \"orders\"") == "use(\"orders\")") - } -} diff --git a/TableProTests/Core/MongoDB/MongoScriptDefinitionRoundTripTests.swift b/TableProTests/Core/MongoDB/MongoScriptDefinitionRoundTripTests.swift deleted file mode 100644 index 13287fdce0..0000000000 --- a/TableProTests/Core/MongoDB/MongoScriptDefinitionRoundTripTests.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// MongoScriptDefinitionRoundTripTests.swift -// TableProTests -// -// The catalog fixtures are canonical Extended JSON as libmongoc 1.28 renders it, read from -// MongoDB 7.0.43. -// - -import Foundation -import JavaScriptCore -import Testing - -/// Runs the text Show DDL and Edit View Definition write through the real prelude, and compares -/// what reaches the host with the catalog entry it was written from, one BSON type at a time. -/// Written as relaxed Extended JSON, that text sent `NumberLong(1)` and a whole Double back as -/// Int32s, `9007199254740993` as `9007199254740992`, and a view holding a MinKey or a Timestamp -/// as a document the server could not read. -struct MongoScriptDefinitionRoundTripTests { - private typealias RecordingHost = MongoScriptPreludeTests.RecordingHost - - private static let pipeline = """ - [ { "$match" : { "a" : { "$gte" : { "$numberLong" : "1" } }, "w" : { "$eq" : { "$numberDouble" : "1.0" } }, \ - "big" : { "$eq" : { "$numberLong" : "9007199254740993" } }, "min" : { "$numberLong" : "-9223372036854775808" }, \ - "i" : { "$numberInt" : "7" }, "imin" : { "$numberInt" : "-2147483648" }, \ - "dec" : { "$numberDecimal" : "1.50" }, "decnan" : { "$numberDecimal" : "NaN" }, \ - "f" : { "$numberDouble" : "2.5" }, "tenth" : { "$numberDouble" : "0.10000000000000000555" }, \ - "neg0" : { "$numberDouble" : "-0.0" }, "bigd" : { "$numberDouble" : "1e+20" }, \ - "wide" : { "$numberDouble" : "9007199254740992.0" }, \ - "inf" : { "$numberDouble" : "Infinity" }, "ninf" : { "$numberDouble" : "-Infinity" }, \ - "nan" : { "$numberDouble" : "NaN" }, \ - "when" : { "$date" : { "$numberLong" : "1577934245678" } }, \ - "reform" : { "$date" : { "$numberLong" : "-12219292800001" } }, \ - "first" : { "$date" : { "$numberLong" : "-62135596800000" } }, \ - "old" : { "$date" : { "$numberLong" : "-62198755200000" } }, \ - "late" : { "$date" : { "$numberLong" : "253402300800000" } }, \ - "decinf" : { "$numberDecimal" : "-Infinity" }, "sym" : { "$symbol" : "q" }, \ - "__proto__" : { "$numberInt" : "1" }, "inner" : { "__proto__" : { "x" : { "$numberLong" : "3" } } }, \ - "oid" : { "$oid" : "5f1d7a3b2c4e5a6b7c8d9e0f" }, \ - "bin" : { "$binary" : { "base64" : "AAECAwQFBgcICQoLDA0ODw==", "subType" : "04" } }, \ - "user" : { "$binary" : { "base64" : "AAEC", "subType" : "80" } }, \ - "code" : { "$code" : "x", "$scope" : { "y" : { "$numberLong" : "2" } } }, \ - "many" : { "$in" : [ { "$minKey" : 1 }, { "$maxKey" : 1 }, { "$timestamp" : { "t" : 5, "i" : 6 } }, \ - { "$regularExpression" : { "pattern" : "a\\\\/b", "options" : "i" } }, null, true, "s" ] } } }, \ - { "$sort" : { "a" : { "$numberInt" : "1" }, "w" : { "$numberInt" : "-1" } } } ] - """ - - private static let collation = """ - { "locale" : "en", "caseLevel" : false, "strength" : { "$numberInt" : "2" }, "version" : "57.1" } - """ - - private static var view: String { - """ - { "name" : "typed", "type" : "view", "options" : { "viewOn" : "src", "pipeline" : \(pipeline), \ - "collation" : \(collation) }, "info" : { "readOnly" : true } } - """ - } - - private static let index = """ - { "v" : { "$numberInt" : "2" }, "key" : { "a" : { "$numberInt" : "1" }, "w" : { "$numberDouble" : "1.0" } }, \ - "name" : "typed_idx", "partialFilterExpression" : { "big" : { "$gt" : { "$numberLong" : "9007199254740993" } }, \ - "w" : { "$gte" : { "$numberDouble" : "1.0" } }, "a" : { "$gt" : { "$numberLong" : "2" } } }, \ - "expireAfterSeconds" : { "$numberLong" : "3600" }, "collation" : \(collation) } - """ - - private func requests(sentBy statement: String) throws -> RecordingHost { - let host = RecordingHost() - let context = try MongoScriptContext.make( - execute: { host.handle($0) }, - emit: { host.record(printed: $0) } - ) - context.evaluateScript(statement) - #expect(context.exception == nil, "\(context.exception?.toString() ?? "")") - return host - } - - private func sentCommand(_ statement: String) throws -> String { - let commands = try requests(sentBy: statement).requests(op: "command") - #expect(commands.count == 1) - return try #require(commands.first?["command"] as? String) - } - - private func member(_ key: String, of json: String) throws -> String { - try #require(MongoScriptJson.member(of: json, key: key)) - } - - @Test("Edit View Definition sends the pipeline the catalog holds, every value in its own type") - func collModSendsTheCatalogPipeline() throws { - let statement = try #require(MongoDBNamespaceEntry(json: Self.view)?.collModStatement()) - let command = try sentCommand(statement) - - #expect(BsonTypedText.of(try member("pipeline", of: command)) == BsonTypedText.of(Self.pipeline)) - #expect(BsonTypedText.of(try member("viewOn", of: command)) == "\"src\"") - } - - @Test("A view's Show DDL sends the catalog's pipeline and its collation, less the ICU version") - func createViewSendsTheCatalogPipeline() throws { - let statement = try #require(MongoDBNamespaceEntry(json: Self.view)?.createViewStatement()) - let command = try sentCommand(statement) - - #expect(BsonTypedText.of(try member("pipeline", of: command)) == BsonTypedText.of(Self.pipeline)) - #expect(BsonTypedText.of(try member("collation", of: command)) - == BsonTypedText.of(MongoDBCollation.portable(Self.collation))) - } - - @Test("An index's Show DDL builds the spec the catalog holds, every value in its own type") - func createIndexBuildsTheCatalogSpec() throws { - let entry = try #require(MongoDBIndexEntry(json: Self.index)) - let host = try requests(sentBy: entry.createIndexStatement(collection: "src")) - let request = try #require(host.requests(op: "createIndex").first) - let keys = try #require(request["keys"] as? String) - let options = try #require(request["options"] as? String) - - let command = MongoScriptCommandBuilder.createIndex(collection: "src", keys: keys, optionsJson: options) - let indexes = try member("indexes", of: command) - let spec = try #require(MongoScriptJson.topLevelElements(indexes).first) - let expected = MongoDBJsonLayout.object([(key: "key", value: entry.keyJson)] + entry.options) - #expect(BsonTypedText.of(spec) == BsonTypedText.of(expected)) - #expect(expected.contains("\"expireAfterSeconds\" : { \"$numberLong\" : \"3600\" }")) - #expect(!expected.contains("57.1")) - } - - @Test("The view's DDL names every value through a constructor mongosh also has, so no wrapper reaches it as a document") - func definitionsCarryNoWrappers() throws { - let entry = try #require(MongoDBNamespaceEntry(json: Self.view)) - let statements = [try #require(entry.createViewStatement()), try #require(entry.collModStatement())] - let wrappers = [ - "$numberInt", "$numberLong", "$numberDouble", "$numberDecimal", "$date", "$oid", "$binary", - "$timestamp", "$regularExpression", "$symbol", "$minKey", "$maxKey", "$code" - ] - - for statement in statements { - for wrapper in wrappers { - #expect(!statement.contains("\"\(wrapper)\""), "\(wrapper)") - } - #expect(statement.contains("[\"__proto__\"]: 1")) - #expect(statement.contains(#"BSONRegExp("a\\/b", "i")"#)) - } - } - - @Test("Double keeps a whole value and a negative zero a Double") - func doubleConstructorSendsADouble() throws { - let command = try sentCommand(""" - db.runCommand({a: Double(1), b: Double(-0.0), c: Double("2.5"), d: Double(1e21), \ - e: Double(NumberLong("5")), f: Double(NaN), g: Double(-Infinity)}) - """) - - #expect(command == """ - {"a":{"$numberDouble":"1"},"b":{"$numberDouble":"-0.0"},"c":{"$numberDouble":"2.5"},\ - "d":{"$numberDouble":"1e+21"},"e":{"$numberDouble":"5"},"f":{"$numberDouble":"NaN"},\ - "g":{"$numberDouble":"-Infinity"}} - """) - } - - @Test("Double reads as its number in arithmetic and refuses text that is not one") - func doubleConstructorIsANumber() throws { - let host = RecordingHost() - let context = try MongoScriptContext.make(execute: { host.handle($0) }, emit: { host.record(printed: $0) }) - - #expect(context.evaluateScript("Double(2.5) + 1")?.toDouble() == 3.5) - #expect(context.evaluateScript("new Double(4) instanceof Double")?.toBool() == true) - context.evaluateScript("Double(\"abc\")") - #expect(context.exception?.toString() == "Error: Double takes a number") - } -} - -/// Extended JSON text reduced to what BSON holds: each number and date names its type and value, -/// so `1` and `1.0` differ when they are two types, and `"1"` and `"1.0"` agree when both spell -/// the same Double. -private enum BsonTypedText { - static func of(_ json: String) -> String { - let trimmed = json.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("[") { - return "[" + MongoScriptJson.topLevelElements(trimmed).map(of).joined(separator: ",") + "]" - } - guard trimmed.hasPrefix("{") else { return trimmed } - let members = MongoScriptJson.members(of: trimmed) - if members.count == 1, let typed = typed(members[0]) { return typed } - return "{" + members.map { "\($0.key):\(of($0.value))" }.joined(separator: ",") + "}" - } - - private static func typed(_ member: (key: String, value: String)) -> String? { - let text = MongoScriptJson.decodedString(member.value) - switch member.key { - case "$numberInt": return text.map { "int32(\($0))" } - case "$numberLong": return text.map { "int64(\($0))" } - case "$numberDouble": return text.flatMap { Double($0) }.map { "double(\($0.bitPattern))" } - case "$date": - return MongoScriptJson.member(of: member.value, key: "$numberLong") - .flatMap(MongoScriptJson.decodedString) - .map { "date(\($0))" } - default: return nil - } - } -} diff --git a/TableProTests/Core/MongoDB/MongoScriptJsonEscapeTests.swift b/TableProTests/Core/MongoDB/MongoScriptJsonEscapeTests.swift deleted file mode 100644 index aa7adc6303..0000000000 --- a/TableProTests/Core/MongoDB/MongoScriptJsonEscapeTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// MongoScriptJsonEscapeTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoScriptJsonEscapeTests { - @Test("Member names are decoded, so a quote or a backslash in a field name survives") - func memberNamesAreDecoded() { - let members = MongoScriptJson.members(of: "{\"a\\\"b\": 1, \"c\\\\d\": 2, \"e\\u00e9\": 3, \"f\\ng\": 4}") - - #expect(members.map(\.key) == ["a\"b", "c\\d", "e\u{e9}", "f\ng"]) - #expect(members.map(\.value) == ["1", "2", "3", "4"]) - } - - @Test("A member is found by its decoded name") - func memberByDecodedName() { - #expect(MongoScriptJson.member(of: "{\"x\": 0, \"a\\\"b\": {\"c\": 1}}", key: "a\"b") == "{\"c\": 1}") - } - - @Test("A surrogate pair decodes to the character it encodes") - func surrogatePair() { - #expect(MongoScriptJson.members(of: "{\"\\ud83d\\ude00\": 1}").map(\.key) == ["\u{1F600}"]) - } - - @Test("A string is written with every character that ends a line or cannot be seen escaped, and reads back whole") - func jsonStringEscapesLineEndings() throws { - let value = "a\nb\rc\td\u{0B}e\u{85}f\u{2028}g\u{2029}h\u{7F}i\u{9F}j\"k\\l */ m' n" - let written = MongoScriptJson.jsonString(value) - - #expect(written == #""a\nb\rc\td\u000be\u0085f\u2028g\u2029h\u007fi\u009fj\"k\\l */ m' n""#) - #expect(!written.contains { $0.isNewline }) - #expect(try JSONDecoder().decode(String.self, from: Data(written.utf8)) == value) - #expect(MongoScriptJson.decodedString(written) == value) - } - - @Test("A string value's text decodes to the string, and anything else to nil") - func decodedString() { - #expect(MongoScriptJson.decodedString("\"x\\ny\"") == "x\ny") - #expect(MongoScriptJson.decodedString(" \"a\\\"b\" ") == "a\"b") - #expect(MongoScriptJson.decodedString("1") == nil) - #expect(MongoScriptJson.decodedString("{ \"a\" : 1 }") == nil) - #expect(MongoScriptJson.decodedString("\"unterminated") == nil) - } -} diff --git a/TableProTests/Core/MongoDB/MongoScriptPreludeMemberTests.swift b/TableProTests/Core/MongoDB/MongoScriptPreludeMemberTests.swift deleted file mode 100644 index 7754e55e9f..0000000000 --- a/TableProTests/Core/MongoDB/MongoScriptPreludeMemberTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// MongoScriptPreludeMemberTests.swift -// TableProTests -// - -import Foundation -import JavaScriptCore -import Testing - -@testable import TablePro - -/// What the shell sends for members and values that JavaScript itself treats specially: a member -/// named `__proto__`, which an assignment turns into the object's prototype, and the BSON types a -/// canonical wrapper used to stand in for. -struct MongoScriptPreludeMemberTests { - private typealias RecordingHost = MongoScriptPreludeTests.RecordingHost - - private func run(_ script: String, replies: [String] = []) throws -> (RecordingHost, JSContext) { - let host = RecordingHost() - host.replies = replies - let context = try MongoScriptContext.make(execute: { host.handle($0) }, emit: { host.record(printed: $0) }) - context.evaluateScript(script) - #expect(context.exception == nil, "\(context.exception?.toString() ?? "")") - return (host, context) - } - - private func commands(_ host: RecordingHost) -> [String] { - host.requests(op: "command").compactMap { $0["command"] as? String } - } - - @Test("A member named __proto__ is sent as a member, at any depth") - func protoMembersAreSent() throws { - let (host, _) = try run(""" - db.runCommand({ insert: "c", documents: [{ ["__proto__"]: 1, a: { ["__proto__"]: { b: 2 } } }] }) - """) - - #expect(commands(host) == [ - #"{"insert":"c","documents":[{"__proto__":{"$numberInt":"1"},"a":{"__proto__":{"b":{"$numberInt":"2"}}}}]}"# - ]) - } - - @Test("A member named __proto__ read from the server stays a member, and is sent back as one") - func protoMembersSurviveAReadAndAWrite() throws { - let reply = #"{"cursor":{"firstBatch":[{"__proto__":{"$numberInt":"7"},"k":"v"}]}}"# - let (host, context) = try run(""" - var found = db.runCommand({ find: "c" }).cursor.firstBatch[0]; - db.runCommand({ insert: "c", documents: [found] }); - """, replies: [reply, "{}"]) - - #expect(context.evaluateScript("Object.keys(found).join()")?.toString() == "__proto__,k") - #expect(context.evaluateScript("found.__proto__ === 7")?.toBool() == true) - #expect(commands(host).last == #"{"insert":"c","documents":[{"__proto__":{"$numberInt":"7"},"k":"v"}]}"#) - } - - @Test("createCollection and createView pass an option named __proto__ on as a member") - func protoOptionsAreSent() throws { - let (host, _) = try run(""" - db.createCollection("c", { ["__proto__"]: 1 }); - db.createView("v", "c", [], { ["__proto__"]: 2 }); - """, replies: ["{}", "{}"]) - - #expect(commands(host) == [ - #"{"create":"c","__proto__":{"$numberInt":"1"}}"#, - #"{"create":"v","viewOn":"c","pipeline":[],"__proto__":{"$numberInt":"2"}}"# - ]) - } - - @Test("BSONRegExp, BSONSymbol and a NaN or infinite NumberDecimal send the BSON types they name") - func constructorsSendTheirTypes() throws { - let (host, _) = try run(""" - db.runCommand({ r: BSONRegExp("(?i)a\\\\/b", "xi"), s: BSONSymbol("q"), n: NumberDecimal("NaN"), - p: NumberDecimal("Infinity"), m: NumberDecimal("-Infinity"), d: new Date(-62198755200000) }) - """) - - #expect(commands(host) == [ - #"{"r":{"$regularExpression":{"pattern":"(?i)a\\/b","options":"ix"}},"s":{"$symbol":"q"},"# - + #""n":{"$numberDecimal":"NaN"},"p":{"$numberDecimal":"Infinity"},"# - + #""m":{"$numberDecimal":"-Infinity"},"d":{"$date":{"$numberLong":"-62198755200000"}}}"# - ]) - } - - @Test("BSONRegExp refuses options BSON has no flag for, and NumberDecimal still refuses a word") - func constructorsRefuseWhatBsonCannotHold() throws { - let host = RecordingHost() - let context = try MongoScriptContext.make(execute: { host.handle($0) }, emit: { host.record(printed: $0) }) - - context.evaluateScript("BSONRegExp(\"a\", \"g\")") - #expect(context.exception?.toString() == "Error: BSONRegExp takes options from i, l, m, s, u and x") - context.exception = nil - context.evaluateScript("NumberDecimal(\"abc\")") - #expect(context.exception?.toString() == "Error: NumberDecimal takes a number") - } -} diff --git a/TableProTests/Core/MongoDB/MongoScriptPreludeTests.swift b/TableProTests/Core/MongoDB/MongoScriptPreludeTests.swift deleted file mode 100644 index 5fe9097818..0000000000 --- a/TableProTests/Core/MongoDB/MongoScriptPreludeTests.swift +++ /dev/null @@ -1,800 +0,0 @@ -// -// MongoScriptPreludeTests.swift -// TableProTests -// - -import Foundation -import JavaScriptCore -import TableProPluginKit -import Testing - -/// Drives the real prelude in a real `JSContext` against a recording host. -/// -/// This is where the reported bug is pinned: `db.dt_DispatchRule.find()` worked and -/// `db.dt_DispatchRule.find({status: 1})` did not, because the condition was a JavaScript object -/// literal and the old path handed its text straight to libbson's strict JSON parser. -struct MongoScriptPreludeTests { - /// A stand-in for the driver: records every request and answers from a script of replies. - final class RecordingHost { - private(set) var requests: [[String: Any]] = [] - private(set) var printed: [String] = [] - var replies: [String] = [] - var refusal: (message: String, code: Int)? - var refusals: [String: String] = [:] - var database = "shop" - /// Held to the driver's policy, so a test sees what the real host would refuse. - var access = MongoScriptAccess.readWrite - var repliesByOp: [String: String] = [:] - private(set) var refused: [String] = [] - - func handle(_ requestJson: String) -> String { - guard let data = requestJson.data(using: .utf8), - let request = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return "{\"ok\":false,\"e\":{\"m\":\"bad request\",\"c\":0}}" - } - requests.append(request) - - let op = request["op"] as? String ?? "" - guard MongoScriptAccessPolicy.allows(op: op, request: request, access: access) else { - refused.append(op) - let name = MongoScriptAccessPolicy.refusedName(op: op, request: request) - return MongoScriptJson.failure(message: MongoScriptText.refusedUnderRead(name), code: 0) - } - if let reply = repliesByOp[op] { - return "{\"ok\":true,\"v\":\(reply)}" - } - - switch request["op"] as? String { - case "currentDatabase": - return "{\"ok\":true,\"v\":\"\(database)\"}" - case "newObjectId": - return "{\"ok\":true,\"v\":\"507f1f77bcf86cd799439011\"}" - // Bookkeeping calls answer without drawing on the scripted replies, so a `.limit(1)` - // between opening a cursor and reading it does not shift what the read gets back. - case "cursorConfigure", "cursorClose", "useDatabase", "sleep": - return "{\"ok\":true,\"v\":null}" - default: - if let op = request["op"] as? String, let refused = refusals[op] { - return refused - } - if let refusal { - return "{\"ok\":false,\"e\":{\"m\":\"\(refusal.message)\",\"c\":\(refusal.code)}}" - } - let reply = replies.isEmpty ? "null" : replies.removeFirst() - return "{\"ok\":true,\"v\":\(reply)}" - } - } - - func record(printed line: String) -> Bool { - printed.append(line) - return true - } - - func requests(op: String) -> [[String: Any]] { - requests.filter { ($0["op"] as? String) == op } - } - } - - private func makeContext(_ host: RecordingHost) throws -> JSContext { - try MongoScriptContext.make( - execute: { host.handle($0) }, - emit: { host.record(printed: $0) } - ) - } - - @Test("A Create Table statement reaches the driver as one create command carrying its validator") - func createCollectionCarriesTheValidator() throws { - let host = RecordingHost() - host.replies = ["{\"ok\": 1}"] - let context = try makeContext(host) - let columns = [ - PluginColumnDefinition( - name: "title", dataType: "string", isNullable: false, defaultValue: nil, isPrimaryKey: false, - autoIncrement: false, comment: nil, unsigned: false, onUpdate: nil, charset: nil, collation: nil - ), - PluginColumnDefinition( - name: "tags", dataType: "array", isNullable: true, defaultValue: nil, isPrimaryKey: false, - autoIncrement: false, comment: nil, unsigned: false, onUpdate: nil, charset: nil, collation: nil - ) - ] - let statement = MongoDBCollectionDDL.createCollectionStatement( - for: PluginCreateTableDefinition(tableName: "articles", columns: columns) - ) - - context.evaluateScript(statement) - #expect(context.exception == nil) - - let request = try #require(host.requests(op: "command").first) - let commandText = try #require(request["command"] as? String) - let commandData = try #require(commandText.data(using: .utf8)) - let command = try #require(try JSONSerialization.jsonObject(with: commandData) as? [String: Any]) - #expect(command["create"] as? String == "articles") - let validator = try #require(command["validator"] as? [String: Any]) - let schema = try #require(validator["$jsonSchema"] as? [String: Any]) - #expect(schema["required"] as? [String] == ["title"]) - let properties = try #require(schema["properties"] as? [String: Any]) - #expect((properties["tags"] as? [String: Any])?["bsonType"] as? [String] == ["array", "null"]) - let propertiesAt = try #require(commandText.range(of: "\"properties\"")) - let tail = commandText[propertiesAt.upperBound...] - let titleAt = try #require(tail.range(of: "\"title\"")) - let tagsAt = try #require(tail.range(of: "\"tags\"")) - #expect(titleAt.lowerBound < tagsAt.lowerBound) - } - - @Test("A Create Table index reaches the driver with its keys in order and its options") - func createIndexKeepsKeysAndOptions() throws { - let host = RecordingHost() - let context = try makeContext(host) - let statement = try #require(MongoDBCollectionDDL.createIndexStatement( - collection: "articles", - index: PluginIndexDefinition(name: "lang_date", columns: ["lang", "date"], isUnique: true) - )) - - context.evaluateScript(statement) - #expect(context.exception == nil) - - let request = try #require(host.requests(op: "createIndex").first) - #expect(request["keys"] as? String == "{\"lang\":{\"$numberInt\":\"1\"},\"date\":{\"$numberInt\":\"1\"}}") - let optionsText = try #require(request["options"] as? String) - let optionsData = try #require(optionsText.data(using: .utf8)) - let options = try #require(try JSONSerialization.jsonObject(with: optionsData) as? [String: Any]) - #expect(options["name"] as? String == "lang_date") - #expect(options["unique"] as? Bool == true) - } - - @Test("The prelude loads without a syntax error") - func preludeLoads() throws { - let host = RecordingHost() - let context = try makeContext(host) - #expect(context.objectForKeyedSubscript("db")?.isUndefined == false) - } - - @Test("A statement that throws leaves its exception on the context") - func thrownStatementSurfaces() throws { - let context = try makeContext(RecordingHost()) - - context.evaluateScript("throw new Error('boom')") - #expect(context.exception?.toString() == "Error: boom") - } - - @Test("A statement that does not parse leaves an exception on the context") - func syntaxErrorSurfaces() throws { - let context = try makeContext(RecordingHost()) - - context.evaluateScript("this is not javascript (") - #expect(context.exception != nil) - } - - @Test("A write the server refuses reaches the script as an exception carrying its code") - func refusedWriteSurfaces() throws { - let host = RecordingHost() - let context = try makeContext(host) - host.refusal = (message: "Document failed validation", code: 121) - - context.evaluateScript("db.orders.updateOne({a: 1}, {$set: {b: 2}})") - let exception = try #require(context.exception) - #expect(exception.objectForKeyedSubscript("message")?.toString() == "Document failed validation") - #expect(exception.objectForKeyedSubscript("code")?.toInt32() == 121) - } - - @Test("A find with an unquoted condition reaches the driver as valid Extended JSON") - func findWithCondition() throws { - let host = RecordingHost() - let context = try makeContext(host) - - context.evaluateScript("db.dt_DispatchRule.find({status: 1})") - #expect(context.exception == nil) - - let opened = try #require(host.requests(op: "openCursor").first) - #expect(opened["collection"] as? String == "dt_DispatchRule") - let filter = try #require(opened["filter"] as? String) - #expect(filter == "{\"status\":{\"$numberInt\":\"1\"}}") - - let parsed = try JSONSerialization.jsonObject(with: #require(filter.data(using: .utf8))) - #expect(parsed is [String: Any]) - } - - @Test("Operators, regular expressions and nested documents survive the crossing") - func richFilter() throws { - let host = RecordingHost() - let context = try makeContext(host) - - context.evaluateScript(""" - db.orders.find({ total: {$gt: 10.5}, name: /ab.c/i, tags: ["a", "b"], meta: {seen: true} }) - """) - #expect(context.exception == nil) - - let filter = try #require(host.requests(op: "openCursor").first?["filter"] as? String) - #expect(filter.contains("\"$gt\":{\"$numberDouble\":\"10.5\"}")) - #expect(filter.contains("\"$regularExpression\":{\"pattern\":\"ab.c\",\"options\":\"i\"}")) - #expect(filter.contains("\"tags\":[\"a\",\"b\"]")) - #expect(filter.contains("\"meta\":{\"seen\":true}")) - } - - @Test("Single quotes, which libbson rejects outright, are accepted") - func singleQuotedValues() throws { - let host = RecordingHost() - let context = try makeContext(host) - - context.evaluateScript("db.orders.find({status: 'new'})") - #expect(context.exception == nil) - - let filter = try #require(host.requests(op: "openCursor").first?["filter"] as? String) - #expect(filter == "{\"status\":\"new\"}") - } - - @Test("Value constructors serialise to their Extended JSON wrappers") - func valueConstructors() throws { - let host = RecordingHost() - let context = try makeContext(host) - - context.evaluateScript(""" - db.orders.find({ - _id: ObjectId("507f1f77bcf86cd799439011"), - at: ISODate("2020-01-02T03:04:05Z"), - big: NumberLong("9007199254740993"), - small: NumberInt(7), - exact: NumberDecimal("1.25"), - low: MinKey, - high: MaxKey - }) - """) - #expect(context.exception == nil) - - let filter = try #require(host.requests(op: "openCursor").first?["filter"] as? String) - #expect(filter.contains("\"_id\":{\"$oid\":\"507f1f77bcf86cd799439011\"}")) - #expect(filter.contains("\"at\":{\"$date\":{\"$numberLong\":\"1577934245000\"}}")) - #expect(filter.contains("\"big\":{\"$numberLong\":\"9007199254740993\"}")) - #expect(filter.contains("\"small\":{\"$numberInt\":\"7\"}")) - #expect(filter.contains("\"exact\":{\"$numberDecimal\":\"1.25\"}")) - #expect(filter.contains("\"low\":{\"$minKey\":1}")) - #expect(filter.contains("\"high\":{\"$maxKey\":1}")) - } - - @Test("A bare Date(value) is the date it names, not today as a string") - func bareDateKeepsWorking() throws { - let host = RecordingHost() - let context = try makeContext(host) - - context.evaluateScript("db.orders.find({at: Date(\"2020-01-02T03:04:05Z\")})") - #expect(context.exception == nil) - - let filter = try #require(host.requests(op: "openCursor").first?["filter"] as? String) - #expect(filter == "{\"at\":{\"$date\":{\"$numberLong\":\"1577934245000\"}}}") - } - - @Test("new Date and instanceof still reach the native Date") - func nativeDateIsIntact() throws { - let host = RecordingHost() - let context = try makeContext(host) - - let value = context.evaluateScript( - "(new Date(0) instanceof Date) + \",\" + (Date(0) instanceof Date) + \",\" + new Date(0).getTime()" - ) - #expect(context.exception == nil) - #expect(value?.toString() == "true,true,0") - } - - @Test("A number constructor refuses what it cannot represent") - func numberConstructorsValidate() throws { - let host = RecordingHost() - let context = try makeContext(host) - - for statement in ["NumberLong(\"abc\")", "NumberInt(\"abc\")", "NumberDecimal(\"abc\")"] { - context.exception = nil - context.evaluateScript(statement) - #expect(context.exception != nil, "\(statement) should have thrown") - } - } - - @Test("Chained cursor modifiers reach the driver instead of being dropped") - func chainedModifiers() throws { - let host = RecordingHost() - let context = try makeContext(host) - - context.evaluateScript("db.orders.find({}).sort({createdAt: -1}).skip(20).limit(10)") - #expect(context.exception == nil) - - var applied: [String: String] = [:] - for request in host.requests(op: "cursorConfigure") { - guard let key = request["key"] as? String, let value = request["value"] as? String else { continue } - applied[key] = value - } - #expect(applied["sort"] == "{\"createdAt\":{\"$numberInt\":\"-1\"}}") - #expect(applied["skip"] == "{\"$numberInt\":\"20\"}") - #expect(applied["limit"] == "{\"$numberInt\":\"10\"}") - } - - @Test("forEach walks the cursor and print collects the lines") - func cursorIterationAndPrinting() throws { - let host = RecordingHost() - host.replies = [ - "1", - """ - {"docs": [{"_id": {"$oid": "507f1f77bcf86cd799439011"}, "name": "first"}, \ - {"_id": {"$oid": "507f1f77bcf86cd799439012"}, "name": "second"}], "done": true} - """ - ] - let context = try makeContext(host) - - context.evaluateScript("db.orders.find({}).forEach(function (order) { print(order.name); })") - #expect(context.exception == nil) - #expect(host.printed == ["first", "second"]) - } - - @Test("A document read back carries an ObjectId, not a wrapper object") - func documentsRevive() throws { - let host = RecordingHost() - host.replies = [ - "1", - "{\"docs\": [{\"_id\": {\"$oid\": \"507f1f77bcf86cd799439011\"}}], \"done\": true}" - ] - let context = try makeContext(host) - - let value = context.evaluateScript("db.orders.findOne({})._id.toString()") - #expect(context.exception == nil) - #expect(value?.toString() == "507f1f77bcf86cd799439011") - } - - @Test("A document whose only field is named like a wrapper reads back as that document") - func wrapperNamedDocumentStaysADocument() throws { - let host = RecordingHost() - host.replies = [ - "1", - "{\"docs\": [{\"$oid\": \"not an id\"}, {\"$date\": \"2024\"}], \"done\": true}" - ] - let context = try makeContext(host) - - let value = context.evaluateScript(""" - db.orders.find({}, {_id: 0}).toArray().map(function (d) { return Object.keys(d)[0] + "=" + d[Object.keys(d)[0]]; }).join(",") - """) - #expect(context.exception == nil) - #expect(value?.toString() == "$oid=not an id,$date=2024") - } - - @Test("A findOneAndUpdate document named like a wrapper comes back as that document") - func findAndModifyDocumentStaysADocument() throws { - let host = RecordingHost() - host.replies = ["{\"value\": {\"$oid\": \"507f1f77bcf86cd799439011\"}}"] - let context = try makeContext(host) - - let value = context.evaluateScript(""" - var found = db.orders.findOneAndUpdate({}, {$set: {a: 1}}, {projection: {_id: 0}}); typeof found.$oid - """) - #expect(context.exception == nil) - #expect(value?.toString() == "string") - } - - @Test("A findOneAndUpdate that matches nothing returns null") - func findAndModifyWithNoMatchIsNull() throws { - let host = RecordingHost() - host.replies = ["{\"value\": null}"] - let context = try makeContext(host) - - let value = context.evaluateScript("db.orders.findOneAndUpdate({}, {$set: {a: 1}}) === null") - #expect(context.exception == nil) - #expect(value?.toBool() == true) - } - - @Test("Variables and functions survive from one evaluated statement to the next") - func shellStateSurvives() throws { - let host = RecordingHost() - let context = try makeContext(host) - - context.evaluateScript("var total = 0; function add(n) { total += n; }") - context.evaluateScript("add(4); add(6);") - let value = context.evaluateScript("total") - #expect(context.exception == nil) - #expect(value?.toInt32() == 10) - } - - @Test("An update reports the counts mongosh reports") - func updateResultShape() throws { - let host = RecordingHost() - host.replies = ["{\"n\": 3, \"nModified\": 2}"] - let context = try makeContext(host) - - let value = context.evaluateScript( - "var r = db.orders.updateMany({a: 1}, {$set: {b: 2}}); r.matchedCount + \"/\" + r.modifiedCount" - ) - #expect(context.exception == nil) - #expect(value?.toString() == "3/2") - - let request = try #require(host.requests(op: "update").first) - #expect(request["multi"] as? Bool == true) - #expect(request["update"] as? String == "{\"$set\":{\"b\":{\"$numberInt\":\"2\"}}}") - } - - @Test("An insert keeps the document's field order") - func insertPreservesFieldOrder() throws { - let host = RecordingHost() - host.replies = ["{\"insertedIds\": [{\"$oid\": \"507f1f77bcf86cd799439011\"}], \"insertedCount\": 1}"] - let context = try makeContext(host) - - context.evaluateScript("db.orders.insertOne({zeta: 1, alpha: 2, middle: 3})") - #expect(context.exception == nil) - - let document = try #require(host.requests(op: "insertOne").first?["document"] as? String) - #expect(document == "{\"zeta\":{\"$numberInt\":\"1\"},\"alpha\":{\"$numberInt\":\"2\"},\"middle\":{\"$numberInt\":\"3\"}}") - } - - @Test("A cursor cannot be re-sorted once it has started") - func modifiersLockAfterStart() throws { - let host = RecordingHost() - host.replies = ["1", "{\"docs\": [], \"done\": true}"] - let context = try makeContext(host) - - context.evaluateScript("var c = db.orders.find({}); c.hasNext(); c.sort({a: 1});") - #expect(context.exception != nil) - } - - @Test("A driver error surfaces as a JavaScript error carrying its code") - func driverErrorsPropagate() throws { - final class FailingHost { - func handle(_ requestJson: String) -> String { - requestJson.contains("currentDatabase") - ? "{\"ok\":true,\"v\":\"shop\"}" - : "{\"ok\":false,\"e\":{\"m\":\"ns not found\",\"c\":26}}" - } - } - let failing = FailingHost() - let context = try #require(JSContext()) - let execute: @convention(block) (String) -> String = { failing.handle($0) } - let emit: @convention(block) (String) -> Void = { _ in } - context.setObject(execute, forKeyedSubscript: "__tp_exec" as NSString) - context.setObject(emit, forKeyedSubscript: "__tp_print" as NSString) - context.evaluateScript(MongoScriptPrelude.source) - - let value = context.evaluateScript(""" - (function () { - try { db.missing.find({}).hasNext(); } catch (e) { return e.message + "/" + e.code; } - return "no error"; - })() - """) - #expect(value?.toString() == "ns not found/26") - } - - @Test("use switches the database the shell writes to") - func useSwitchesDatabase() throws { - let host = RecordingHost() - let context = try makeContext(host) - - context.evaluateScript("use(\"reporting\"); db.orders.find({});") - #expect(context.exception == nil) - - let switched = try #require(host.requests(op: "useDatabase").first) - #expect(switched["db"] as? String == "reporting") - #expect(host.requests(op: "openCursor").first?["db"] as? String == "reporting") - } - - @Test("EJSON.parse reads a string, the way mongosh's does") - func ejsonParseTakesText() throws { - let host = RecordingHost() - let context = try makeContext(host) - - let value = context.evaluateScript( - "EJSON.parse('{\"_id\": {\"$oid\": \"507f1f77bcf86cd799439011\"}}')._id.toString()" - ) - #expect(context.exception == nil) - #expect(value?.toString() == "507f1f77bcf86cd799439011") - } - - @Test( - "EJSON.parse refuses a wrapper its constructor would refuse", - arguments: [ - #"{"n": {"$numberLong": "abc"}}"#, - #"{"n": {"$numberLong": "9223372036854775808"}}"#, - #"{"n": {"$numberDecimal": "abc"}}"#, - #"{"n": {"$numberInt": "12abc"}}"#, - #"{"n": {"$numberInt": "2147483648"}}"#, - #"{"n": {"$numberDouble": "abc"}}"#, - #"{"r": {"$regularExpression": {"pattern": "a", "options": "z"}}}"# - ] - ) - func ejsonParseChecksWrappers(json: String) throws { - let host = RecordingHost() - let context = try makeContext(host) - - context.evaluateScript("EJSON.parse(\(Self.javaScriptString(json)))") - #expect(context.exception != nil) - } - - @Test("EJSON.parse still reads every well-formed wrapper, NaN and Infinity included") - func ejsonParseReadsWellFormedWrappers() throws { - let host = RecordingHost() - let context = try makeContext(host) - let json = #"{"l": {"$numberLong": "9007199254740993"}, "d": {"$numberDecimal": "NaN"}, "# - + #""i": {"$numberInt": "-7"}, "f": {"$numberDouble": "-Infinity"}, "# - + #""r": {"$regularExpression": {"pattern": "^a", "options": "xi"}}}"# - - let value = context.evaluateScript(""" - var parsed = EJSON.parse(\(Self.javaScriptString(json))); - [String(parsed.l), String(parsed.d), parsed.i, parsed.f, parsed.r.options].join("|") - """) - #expect(context.exception == nil) - #expect(value?.toString() == "9007199254740993|NaN|-7|-Infinity|ix") - } - - @Test("A value the server sends is read back as stored, without the parse-time checks") - func serverRepliesAreNotRechecked() throws { - let host = RecordingHost() - host.replies = [ - "1", - #"{"docs": [{"_id": 1, "r": {"$regularExpression": {"pattern": "a", "options": "g"}}}], "done": true}"# - ] - let context = try makeContext(host) - - let value = context.evaluateScript("db.orders.findOne({}).r.options") - #expect(context.exception == nil) - #expect(value?.toString() == "g") - } - - private static func javaScriptString(_ text: String) -> String { - let data = (try? JSONSerialization.data(withJSONObject: [text])) ?? Data() - let array = String(data: data, encoding: .utf8) ?? "[]" - return String(array.dropFirst().dropLast()) - } - - @Test("A write with no document is refused rather than sent as null") - func writesNeedADocument() throws { - let host = RecordingHost() - let context = try makeContext(host) - - for statement in [ - "db.orders.updateOne({a: 1})", - "db.orders.replaceOne({a: 1})", - "db.orders.findOneAndUpdate({a: 1})", - "db.orders.insertOne()" - ] { - context.exception = nil - context.evaluateScript(statement) - #expect(context.exception != nil, "\(statement) should have thrown") - } - #expect(host.requests(op: "update").isEmpty) - #expect(host.requests(op: "insertOne").isEmpty) - } - - /// mongosh refuses these too. Reading an undefined filter as `{}` turned a misspelt property into - /// a write to every document. - @Test("A write whose filter is undefined throws mongosh's error and sends nothing") - func writesNeedAFilter() throws { - let host = RecordingHost() - let context = try makeContext(host) - - let statements = [ - ("db.orders.deleteMany(undefined)", "deleteMany"), - ("db.orders.deleteOne()", "deleteOne"), - ("db.orders.remove()", "remove"), - ("db.orders.remove(undefined, true)", "remove"), - ("var opts = {filter: {status: 'old'}}; db.orders.deleteMany(opts.filtr)", "deleteMany"), - ("db.orders.updateOne(undefined, {$set: {a: 1}})", "updateOne"), - ("db.orders.updateMany(undefined, {$set: {a: 1}})", "updateMany"), - ("db.orders.update(undefined, {$set: {a: 1}})", "update"), - ("db.orders.replaceOne(undefined, {a: 1})", "replaceOne"), - ("db.orders.findOneAndUpdate(undefined, {$set: {a: 1}})", "findOneAndUpdate"), - ("db.orders.findOneAndReplace(undefined, {a: 1})", "findOneAndReplace"), - ("db.orders.findOneAndDelete()", "findOneAndDelete") - ] - for (statement, method) in statements { - context.exception = nil - context.evaluateScript(statement) - #expect( - context.exception?.objectForKeyedSubscript("message")?.toString() - == "Missing required argument at position 0 (Collection.\(method))", - "\(statement)" - ) - } - for op in ["delete", "update", "replace", "findAndModify"] { - #expect(host.requests(op: op).isEmpty, "\(op)") - } - } - - @Test("An explicit {} filter still reaches the host, meaning every document") - func explicitEmptyFilterStillWrites() throws { - let host = RecordingHost() - host.repliesByOp = [ - "delete": "{\"n\": 0}", - "update": "{\"n\": 0, \"nModified\": 0}", - "replace": "{\"n\": 0, \"nModified\": 0}", - "findAndModify": "{\"value\": null}" - ] - let context = try makeContext(host) - - for statement in [ - "db.orders.deleteMany({})", - "db.orders.remove({})", - "db.orders.updateMany({}, {$set: {a: 1}})", - "db.orders.replaceOne({}, {a: 1})", - "db.orders.findOneAndDelete({})" - ] { - context.evaluateScript(statement) - #expect(context.exception == nil, "\(statement) threw") - } - #expect(host.requests(op: "delete").map { $0["filter"] as? String } == ["{}", "{}"]) - #expect(host.requests(op: "update").first?["filter"] as? String == "{}") - #expect(host.requests(op: "replace").first?["filter"] as? String == "{}") - #expect(host.requests(op: "findAndModify").first?["filter"] as? String == "{}") - } - - @Test("insertOne, insertMany and bulkWrite pass their options on, and send null without them") - func writeOptionsReachTheHost() throws { - let host = RecordingHost() - host.replies = [ - "{\"insertedIds\": [1], \"insertedCount\": 1}", - "{\"insertedIds\": [1], \"insertedCount\": 1}", - "{\"insertedIds\": [1], \"insertedCount\": 1}", - "{\"insertedCount\": 0}", - "{\"insertedIds\": [1], \"insertedCount\": 1}", - "{\"insertedCount\": 0}" - ] - let context = try makeContext(host) - - for statement in [ - "db.orders.insertMany([{a: 1}], {ordered: false, writeConcern: {w: 'majority'}})", - "db.orders.insertOne({a: 1}, {writeConcern: {w: 'majority'}})", - "db.orders.insertOne({a: 1})", - "db.orders.bulkWrite([{deleteOne: {filter: {a: 1}}}], {writeConcern: {w: 'majority'}})", - "db.orders.insertMany([{a: 1}])", - "db.orders.bulkWrite([{deleteOne: {filter: {a: 1}}}])" - ] { - context.evaluateScript(statement) - #expect(context.exception == nil, "\(statement) threw") - } - - let insertMany = host.requests(op: "insertMany") - #expect(insertMany.first?["options"] as? String == "{\"ordered\":false,\"writeConcern\":{\"w\":\"majority\"}}") - #expect(insertMany.last?["options"] is NSNull) - let insertOne = host.requests(op: "insertOne") - #expect(insertOne.first?["options"] as? String == "{\"writeConcern\":{\"w\":\"majority\"}}") - #expect(insertOne.last?["options"] is NSNull) - let bulkWrite = host.requests(op: "bulkWrite") - #expect(bulkWrite.first?["options"] as? String == "{\"writeConcern\":{\"w\":\"majority\"}}") - #expect(bulkWrite.last?["options"] is NSNull) - } - - @Test("The legacy insert passes its options to whichever insert it becomes") - func legacyInsertOptions() throws { - let host = RecordingHost() - host.replies = [ - "{\"insertedIds\": [1], \"insertedCount\": 1}", - "{\"insertedIds\": [1, 2], \"insertedCount\": 2}" - ] - let context = try makeContext(host) - - context.evaluateScript("db.orders.insert({a: 1}, {writeConcern: {w: 1}})") - context.evaluateScript("db.orders.insert([{a: 1}, {a: 2}], {ordered: false})") - #expect(context.exception == nil) - - #expect(host.requests(op: "insertOne").first?["options"] as? String - == "{\"writeConcern\":{\"w\":{\"$numberInt\":\"1\"}}}") - #expect(host.requests(op: "insertMany").first?["options"] as? String == "{\"ordered\":false}") - } - - @Test("remove with {justOne: true} deletes one document, and its options reach the host") - func legacyRemoveOptions() throws { - let host = RecordingHost() - host.replies = ["{\"n\": 1}", "{\"n\": 1}", "{\"n\": 3}", "{\"n\": 3}", "{\"n\": 3}"] - let context = try makeContext(host) - - for statement in [ - "db.orders.remove({x: 1}, {justOne: true, writeConcern: {w: 'majority'}})", - "db.orders.remove({x: 1}, true)", - "db.orders.remove({x: 1}, {writeConcern: {w: 'majority'}})", - "db.orders.remove({x: 1}, false)", - "db.orders.remove({x: 1})" - ] { - context.evaluateScript(statement) - #expect(context.exception == nil, "\(statement) threw") - } - - let deletes = host.requests(op: "delete") - try #require(deletes.count == 5) - #expect(deletes.map { $0["multi"] as? Bool } == [false, false, true, true, true]) - #expect(deletes[0]["options"] as? String == "{\"justOne\":true,\"writeConcern\":{\"w\":\"majority\"}}") - #expect(deletes[2]["options"] as? String == "{\"writeConcern\":{\"w\":\"majority\"}}") - #expect(deletes[4]["options"] is NSNull) - } - - @Test("A write sent with w: 0 reports acknowledged false and no counts, as mongosh does") - func unacknowledgedWriteResults() throws { - let host = RecordingHost() - host.replies = [ - "{\"acknowledged\": false}", - "{\"acknowledged\": false}", - "{\"acknowledged\": false, \"insertedIds\": [{\"$numberInt\": \"7\"}]}", - "{\"acknowledged\": false, \"insertedIds\": [{\"$numberInt\": \"7\"}, {\"$numberInt\": \"8\"}]}", - "{\"acknowledged\": false}" - ] - let context = try makeContext(host) - let expected = [ - ("db.orders.updateMany({}, {$set: {b: 1}}, {writeConcern: {w: 0}})", "{\"acknowledged\":false}"), - ("db.orders.deleteOne({a: 1}, {writeConcern: {w: 0}})", "{\"acknowledged\":false}"), - ( - "db.orders.insertOne({_id: 7}, {writeConcern: {w: 0}})", - "{\"acknowledged\":false,\"insertedId\":{\"$numberInt\":\"7\"}}" - ), - ( - "db.orders.insertMany([{_id: 7}, {_id: 8}], {writeConcern: {w: 0}})", - "{\"acknowledged\":false,\"insertedIds\":[{\"$numberInt\":\"7\"},{\"$numberInt\":\"8\"}]}" - ), - ("db.orders.bulkWrite([{deleteOne: {filter: {}}}], {writeConcern: {w: 0}})", "{\"acknowledged\":false}") - ] - for (statement, result) in expected { - let value = context.evaluateScript("EJSON.stringify(\(statement))") - #expect(context.exception == nil, "\(statement) threw") - #expect(value?.toString() == result, "\(statement)") - } - } - - @Test("An acknowledged write still reports acknowledged true with its counts") - func acknowledgedWriteResults() throws { - let host = RecordingHost() - host.replies = ["{\"n\": 1}", "{\"insertedIds\": [{\"$numberInt\": \"7\"}], \"insertedCount\": 1}"] - let context = try makeContext(host) - - let deleted = context.evaluateScript("EJSON.stringify(db.orders.deleteOne({a: 1}))") - let inserted = context.evaluateScript("EJSON.stringify(db.orders.insertMany([{_id: 7}]))") - #expect(context.exception == nil) - #expect(deleted?.toString() == "{\"acknowledged\":true,\"deletedCount\":{\"$numberInt\":\"1\"}}") - #expect(inserted?.toString() - == "{\"acknowledged\":true,\"insertedIds\":[{\"$numberInt\":\"7\"}],\"insertedCount\":{\"$numberInt\":\"1\"}}") - } - - @Test("A write's failure reaches the script marked as a write's, with its stage") - func writeFailureCarriesItsStage() throws { - let host = RecordingHost() - let stopped = MongoWriteFailure(code: 50, message: "operation exceeded time limit", stage: .command) - host.refusals = ["update": stopped.scriptReplyJson] - let context = try makeContext(host) - - context.evaluateScript("db.orders.updateMany({}, {$set: {b: 1}})") - let exception = try #require(context.exception) - #expect(MongoScriptContext.writeFailure(in: exception) == stopped) - } - - @Test("A read that fails after the script caught a write's failure is not taken for the write") - func caughtWriteFailureDoesNotMarkALaterRead() throws { - let host = RecordingHost() - let stopped = MongoWriteFailure(code: 50, message: "operation exceeded time limit", stage: .command) - host.refusals = [ - "update": stopped.scriptReplyJson, - "command": MongoScriptJson.failure(message: "operation exceeded time limit", code: 50) - ] - let context = try makeContext(host) - - context.evaluateScript(""" - try { db.orders.updateMany({}, {$set: {b: 1}}) } catch (e) {} - db.runCommand({count: "orders"}) - """) - let read = try #require(context.exception) - #expect(read.objectForKeyedSubscript("code")?.toInt32() == 50) - #expect(read.objectForKeyedSubscript("message")?.toString() == "operation exceeded time limit") - #expect(MongoScriptContext.writeFailure(in: read) == nil) - - context.exception = nil - context.evaluateScript("try { db.orders.updateMany({}, {$set: {b: 1}}) } catch (e) { throw e }") - let rethrown = try #require(context.exception) - #expect(MongoScriptContext.writeFailure(in: rethrown) == stopped) - } - - @Test("An error a script throws itself is never taken for a write's failure") - func scriptErrorIsNotAWrite() throws { - let context = try makeContext(RecordingHost()) - context.evaluateScript("var e = new Error('x'); e.code = 50; throw e") - let exception = try #require(context.exception) - #expect(MongoScriptContext.writeFailure(in: exception) == nil) - } - - @Test("An aggregation pipeline crosses as an array of stages") - func aggregatePipeline() throws { - let host = RecordingHost() - let context = try makeContext(host) - - context.evaluateScript("db.orders.aggregate([{$match: {status: 'new'}}, {$count: 'n'}])") - #expect(context.exception == nil) - - let request = try #require(host.requests(op: "openCursor").first) - #expect(request["kind"] as? String == "aggregate") - #expect(request["pipeline"] as? String == "[{\"$match\":{\"status\":\"new\"}},{\"$count\":\"n\"}]") - } -} diff --git a/TableProTests/Core/MongoDB/MongoScriptPreludeViewTests.swift b/TableProTests/Core/MongoDB/MongoScriptPreludeViewTests.swift deleted file mode 100644 index 4ad07abbe1..0000000000 --- a/TableProTests/Core/MongoDB/MongoScriptPreludeViewTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// MongoScriptPreludeViewTests.swift -// TableProTests -// - -import Foundation -import JavaScriptCore -import Testing - -/// Drives the real prelude, where `db.createView` used to resolve to a collection named -/// `createView` and throw "db.createView is not a function". -struct MongoScriptPreludeViewTests { - private typealias RecordingHost = MongoScriptPreludeTests.RecordingHost - - private func makeContext(_ host: RecordingHost) throws -> JSContext { - try MongoScriptContext.make( - execute: { host.handle($0) }, - emit: { host.record(printed: $0) } - ) - } - - private func command(sentBy statement: String) throws -> String { - let host = RecordingHost() - let context = try makeContext(host) - - context.evaluateScript(statement) - #expect(context.exception == nil) - - let commands = host.requests(op: "command") - #expect(commands.count == 1) - return try #require(commands.first?["command"] as? String) - } - - @Test("createView sends create with viewOn, the pipeline and the options, in that order") - func createViewSendsCreateWithViewOn() throws { - let command = try command( - sentBy: "db.createView(\"adults\", \"people\", [{$match: {}}], {collation: {locale: \"en\"}})" - ) - - #expect(command == """ - {"create":"adults","viewOn":"people","pipeline":[{"$match":{}}],"collation":{"locale":"en"}} - """) - } - - @Test("createView without a pipeline sends an empty one") - func createViewDefaultsToEmptyPipeline() throws { - #expect(try command(sentBy: "db.createView(\"v\", \"s\")") == "{\"create\":\"v\",\"viewOn\":\"s\",\"pipeline\":[]}") - } -} diff --git a/TableProTests/Core/MongoDB/MongoScriptReadEnforcementTests.swift b/TableProTests/Core/MongoDB/MongoScriptReadEnforcementTests.swift deleted file mode 100644 index c513136ae1..0000000000 --- a/TableProTests/Core/MongoDB/MongoScriptReadEnforcementTests.swift +++ /dev/null @@ -1,231 +0,0 @@ -// -// MongoScriptReadEnforcementTests.swift -// TableProTests -// - -import Foundation -import JavaScriptCore -import Testing - -@testable import TablePro - -/// The grammar that proves a MongoDB statement a read and the plugin host's read policy are two lists -/// kept by hand. Each read runs through the real prelude against a host held to the policy, so a read -/// the grammar accepts and the host would refuse fails here rather than at every Safe Mode level. -struct MongoScriptReadEnforcementTests { - private typealias RecordingHost = MongoScriptPreludeTests.RecordingHost - - /// The reads `QueryClassifierMongoDBTests` pins, copied. - private static let classifierReads: [String] = [ - #"db.c.aggregate([{"$limit": 1}, {"$project": {"_id": 1}}])"#, - #"db.c.aggregate([{"$match": {"k": {"$nin": ["x"]}}}])"#, - #"db.c.aggregate([{"$project": {"m": {"$mergeObjects": [{"a": 1}, {"b": 2}]}}}])"#, - #"db.c.aggregate([{"$project": {"o": "$outcome"}}])"#, - #"db.c.aggregate([{"$match": {}}]).toArray()"#, - #"db.c.find({}, {"_id": 1}).limit(1)"#, - "db.users.find({a: 1})", - "db.users.find().sort({a: -1}).skip(10).limit(5).batchSize(100).toArray()", - "db.users.findOne({_id: 1}, {name: 1})", - "db.users.countDocuments({active: true})", - "db.users.distinct(\"city\", {active: true})", - "db.users.find({tags: {$in: [\"drop\", \"deleteMany\"]}})", - "db.users.find({\"note\": \"call x.drop() later\"})", - "db.getCollection(\"user-events\").find({})", - "db[\"my-coll\"].find()", - "db.getSiblingDB(\"reporting\").orders.find({}).itcount()", - "db.getMongo().getDB(\"reporting\").orders.estimatedDocumentCount()", - "db.getCollectionNames()", - "db.stats()", - "db.users.getIndexes()", - "db.users.find({}).explain(\"executionStats\")", - "db.users.explain(\"executionStats\").aggregate([{$match: {a: 1}}])", - "db.users.find({}).pretty();", - "/* lead */ db.users.find({}) // trail", - "db.users\n .find({a: 1})\n .limit(10)", - "db . users . find ( { a : 1 , } , )", - "show dbs", - "show collections", - "use reporting", - "use reporting;" - ] - - private static let appWrittenReads: [String] = [ - #"db.c.find({_id: LegacyJavaUUID("0e3b0c1e-6c8f-4c1a-9a0e-2f1c3d4e5f60")})"#, - #"db.c.find({_id: CSUUID("0e3b0c1e-6c8f-4c1a-9a0e-2f1c3d4e5f60")})"#, - #"db.c.find({ref: DBRef("users", ObjectId("507f1f77bcf86cd799439011"))})"#, - #"db.c.find({s: BSONSymbol("x")})"#, - "db.c.find({v: NaN})", - "db.c.find({v: {$in: [Infinity, -Infinity]}})", - #"db.c.find({at: new Date(1700000000000), b: BinData(4, "AAAA"), t: Timestamp(1, 2)})"# - ] - - private static let literalShapes = """ - db.events.find({ - _id: ObjectId("507f1f77bcf86cd799439011"), - at: {$gte: ISODate("2024-01-01T00:00:00Z"), $lt: new Date("2025-01-01")}, - n: NumberLong("5"), d: NumberDecimal("1.5"), i: NumberInt(3), u: UUID("0e3b0c1e-6c8f-4c1a-9a0e-2f1c3d4e5f60"), - r: /^ab[/]c\\/d/i, low: MinKey, high: MaxKey(), ratio: -0.5e-3, none: null, ok: true, - name: "caf\\u00e9 \\"quoted\\" \\uD83D\\uDE00", single: 'it\\'s', 1: [1, 2, [3, {x: false}],] - }) - """ - - /// A call for every name the grammar proves a read that the reads above leave out. - private static let grammarReads: [String] = [ - "db.getCollectionInfos()", - "db.version()", - "db.serverStatus()", - "db.hostInfo()", - "db.currentOp()", - "db.getName()", - "db.getMongo().getDB(\"reporting\").stats()", - "db.users.count({a: 1})", - "db.users.getIndices()", - "db.users.stats()", - "db.users.dataSize()", - "db.users.storageSize()", - "db.users.totalIndexSize()", - "db.users.totalSize()", - "db.users.isCapped()", - "db.users.getName()", - "db.users.getFullName()", - "db.users.find({}).projection({a: 1}).collation({locale: \"fr\"}).hint({a: 1}).maxTimeMS(100).allowDiskUse().toArray()", - "db.users.find({}).size()", - "db.users.find({}).count()", - "db.users.find({}).next()", - "db.users.find({}).hasNext()", - "db.users.find({}).tryNext()", - "db.users.find({}).isExhausted()", - "db.users.find({}).objsLeftInBatch()", - "db.users.aggregate([{$match: {}}]).count()", - "db.users.aggregate([{$match: {}}]).explain()", - "db.users.explain().find({a: 1})", - "db.users.explain(\"queryPlanner\").count({a: 1})", - "db.c.find({h: HexData(0, \"00ff\")})", - "db.inventory.aggregate([{$group: {_id: \"$out\", n: {$sum: 1}}}])", - "db.c.aggregate([{$project: {m: \"$merge\"}}])", - "show databases", - "show tables" - ] - - /// Shaped like the host's answers, so each read runs to its end and every call it makes is seen. - private static let replies: [String: String] = [ - "openCursor": "1", - "cursorFetch": #"{"docs": [{"_id": {"$numberInt": "1"}}], "done": true}"#, - "cursorCount": "1", - "cursorExplain": #"{"queryPlanner": {}}"#, - "countDocuments": "1", - "estimatedDocumentCount": "1", - "distinct": #"["a"]"#, - "listCollections": #"["users"]"#, - "listIndexes": #"[{"v": 2, "key": {"_id": 1}, "name": "_id_"}]"#, - "collectionStats": #"{"size": 1, "storageSize": 1, "totalIndexSize": 1, "capped": false}"#, - "command": #"{"ok": 1, "cursor": {"firstBatch": []}, "databases": [], "version": "7.0.0"}"#, - "hexToBase64": #""AP8=""#, - "encodeUuid": #"{"base64": "DjsMHmyPTBqaDi8cPU5fYA==", "subtype": 4, "text": "0e3b0c1e-6c8f-4c1a-9a0e-2f1c3d4e5f60"}"# - ] - - private func makeContext(_ host: RecordingHost) throws -> JSContext { - try MongoScriptContext.make(execute: { host.handle($0) }, emit: { host.record(printed: $0) }) - } - - private func isRead(_ statement: String) -> Bool { - QueryClassifier.classifyTier(statement, databaseType: .mongodb) == .safe - } - - @Test("Every statement the grammar proves a read runs to its end sending only what a read may send") - func provenReadsStayWithinThePolicy() throws { - let reads = Self.classifierReads + Self.appWrittenReads + [Self.literalShapes] + Self.grammarReads - for statement in reads { - #expect(isRead(statement), "\(statement)") - - let host = RecordingHost() - host.access = .read - host.repliesByOp = Self.replies - let context = try makeContext(host) - context.evaluateScript(MongoShellCommandLine.rewrite(statement)) - - #expect(context.exception == nil, "\(statement): \(context.exception?.toString() ?? "")") - #expect(host.refused.isEmpty, "\(statement) sent \(host.refused)") - for opened in host.requests(op: "openCursor") where opened["kind"] as? String == "aggregate" { - let pipeline = opened["pipeline"] as? String ?? "" - let options = opened["options"] as? String - #expect(!MongoScriptAccessPolicy.writes(pipeline: pipeline, options: options), "\(statement)") - } - } - } - - @Test("A read whose find an earlier statement pointed at deleteMany is refused before it deletes") - func poisonedFindIsRefusedUnderRead() throws { - let host = RecordingHost() - let context = try makeContext(host) - context.evaluateScript("DBCollection.prototype.find = DBCollection.prototype.deleteMany") - #expect(context.exception == nil) - - let read = "db.c.find({})" - #expect(isRead(read)) - host.access = .read - context.evaluateScript(read) - - let delete = try #require(host.requests(op: "delete").first) - #expect(!MongoScriptAccessPolicy.allows(op: "delete", request: delete, access: .read)) - #expect(host.refused == ["delete"]) - #expect(context.exception?.objectForKeyedSubscript("message")?.toString() == MongoScriptText.refusedUnderRead("delete")) - } - - @Test("The same statement sent as a write still writes") - func poisonedFindWritesUnderReadWrite() throws { - let host = RecordingHost() - host.repliesByOp = ["delete": "{\"n\": 3}"] - let context = try makeContext(host) - context.evaluateScript("DBCollection.prototype.find = DBCollection.prototype.deleteMany") - context.evaluateScript("db.c.find({})") - - #expect(context.exception == nil) - #expect(host.requests(op: "delete").count == 1) - #expect(host.refused.isEmpty) - } - - @Test("A command, a constructor or a shell command an earlier statement redefined cannot write in a read") - func otherRedefinitionsAreRefusedUnderRead() throws { - let cases: [(redefinition: String, read: String, refused: String)] = [ - ( - "DB.prototype.stats = function () { return this.runCommand({dropDatabase: 1}); }", - "db.stats()", - "dropDatabase" - ), - ( - "ObjectId = function () { db.users.drop(); return 1; }", - #"db.c.find({_id: ObjectId("507f1f77bcf86cd799439011")})"#, - "dropCollection" - ), - ( - #"DB.prototype.getCollectionInfos = function () { return this.runCommand({explain: "# - + #"{aggregate: "c", pipeline: [{$merge: {into: "x"}}], cursor: {}}, verbosity: "executionStats"}); }"#, - "db.getCollectionInfos()", - "explain" - ), - ( - "show = function () { return db.users.insertOne({a: 1}); }", - "show collections", - "insertOne" - ) - ] - for (redefinition, read, refused) in cases { - let host = RecordingHost() - let context = try makeContext(host) - context.evaluateScript(redefinition) - #expect(context.exception == nil, "\(redefinition)") - - #expect(isRead(read), "\(read)") - host.access = .read - context.evaluateScript(MongoShellCommandLine.rewrite(read)) - - #expect(host.refused.count == 1, "\(read)") - #expect( - context.exception?.objectForKeyedSubscript("message")?.toString() - == MongoScriptText.refusedUnderRead(refused), - "\(read)" - ) - } - } -} diff --git a/TableProTests/Core/MongoDB/MongoShellParserChainedMethodTests.swift b/TableProTests/Core/MongoDB/MongoShellParserChainedMethodTests.swift deleted file mode 100644 index 37cc6bbdfc..0000000000 --- a/TableProTests/Core/MongoDB/MongoShellParserChainedMethodTests.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// MongoShellParserChainedMethodTests.swift -// TableProTests -// -// Cursor methods chained onto a query: .sort(), .limit(), .skip(). -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct MongoShellParserChainedMethodTests { - @Test("aggregate keeps a chained limit as a pipeline stage") - func testAggregateChainedLimitBecomesStage() throws { - let op = try MongoShellParser.parse("db.orders.aggregate([{\"$match\": {}}]).limit(10)") - guard case .aggregate(let collection, let pipeline) = op else { - Issue.record("Expected .aggregate operation") - return - } - #expect(collection == "orders") - #expect(pipeline == "[{\"$match\": {}},{\"$limit\":10}]") - } - - @Test("aggregate keeps chained sort, skip and limit in cursor order") - func testAggregateChainedSortSkipLimit() throws { - let op = try MongoShellParser.parse("db.orders.aggregate([]).sort({a: 1}).skip(5).limit(10)") - guard case .aggregate(_, let pipeline) = op else { - Issue.record("Expected .aggregate operation") - return - } - #expect(pipeline == "[{\"$sort\":{a: 1}},{\"$skip\":5},{\"$limit\":10}]") - } - - @Test("aggregate without chained methods keeps its pipeline untouched") - func testAggregateWithoutChainUnchanged() throws { - let op = try MongoShellParser.parse("db.orders.aggregate([{\"$match\": {}}])") - guard case .aggregate(_, let pipeline) = op else { - Issue.record("Expected .aggregate operation") - return - } - #expect(pipeline == "[{\"$match\": {}}]") - } - - @Test("find still applies chained cursor options") - func testFindChainedOptionsStillApply() throws { - let op = try MongoShellParser.parse("db.users.find({}).sort({a: -1}).skip(2).limit(7)") - guard case .find(_, _, let options) = op else { - Issue.record("Expected .find operation") - return - } - #expect(options.sort == "{a: -1}") - #expect(options.skip == 2) - #expect(options.limit == 7) - } - - @Test("an unknown chained method is rejected instead of dropped") - func testUnknownChainedMethodThrows() { - #expect(throws: MongoShellParseError.self) { - try MongoShellParser.parse("db.users.find({}).collation({locale: \"en\"})") - } - } - - @Test("chaining onto an operation that returns no cursor is rejected") - func testChainOnNonCursorOperationThrows() { - #expect(throws: MongoShellParseError.self) { - try MongoShellParser.parse("db.users.insertOne({a: 1}).limit(5)") - } - } - - @Test("a non-numeric limit is rejected") - func testNonNumericLimitThrows() { - #expect(throws: MongoShellParseError.self) { - try MongoShellParser.parse("db.users.find({}).limit(abc)") - } - } - - // MARK: - Write Options - - @Test("updateOne without options keeps the original operation case") - func testUpdateOneWithoutOptionsUnchanged() throws { - let op = try MongoShellParser.parse("db.users.updateOne({a: 1}, {$set: {b: 2}})") - guard case .updateOne(let collection, let filter, let update) = op else { - Issue.record("Expected .updateOne operation") - return - } - #expect(collection == "users") - #expect(filter == "{a: 1}") - #expect(update == "{$set: {b: 2}}") - } - - @Test("upsert in the third argument is carried, not dropped") - func testUpsertCarried() throws { - let op = try MongoShellParser.parse("db.users.updateOne({a: 1}, {$set: {b: 2}}, {upsert: true})") - guard case .write(let kind, let collection, _, _, let options) = op else { - Issue.record("Expected .write operation") - return - } - #expect(kind == .updateOne) - #expect(collection == "users") - #expect(options.upsert) - } - - @Test("upsert false is carried as false") - func testUpsertFalse() throws { - let op = try MongoShellParser.parse("db.users.updateMany({}, {$set: {b: 2}}, {upsert: false})") - guard case .write(let kind, _, _, _, let options) = op else { - Issue.record("Expected .write operation") - return - } - #expect(kind == .updateMany) - #expect(!options.upsert) - } - - @Test("arrayFilters in the third argument is carried") - func testArrayFiltersCarried() throws { - let query = "db.users.updateOne({}, {$set: {\"g.$[e].v\": 1}}, {arrayFilters: [{\"e.v\": {$gt: 5}}]})" - guard case .write(_, _, _, _, let options) = try MongoShellParser.parse(query) else { - Issue.record("Expected .write operation") - return - } - #expect(options.arrayFilters == "[{\"e.v\": {$gt: 5}}]") - } - - @Test("replaceOne and findOneAndUpdate report their own kind") - func testWriteKinds() throws { - guard case .write(let replaceKind, _, _, _, _) = - try MongoShellParser.parse("db.u.replaceOne({}, {a: 1}, {upsert: true})") else { - Issue.record("Expected .write operation") - return - } - #expect(replaceKind == .replaceOne) - - guard case .write(let findKind, _, _, _, _) = - try MongoShellParser.parse("db.u.findOneAndUpdate({}, {$set: {a: 1}}, {upsert: true})") else { - Issue.record("Expected .write operation") - return - } - #expect(findKind == .findOneAndUpdate) - } - - @Test("a non-document third argument is rejected") - func testNonDocumentOptionsThrows() { - #expect(throws: MongoShellParseError.self) { - try MongoShellParser.parse("db.users.updateOne({}, {$set: {a: 1}}, true)") - } - } -} diff --git a/TableProTests/Core/MongoDB/MongoShellParserTests.swift b/TableProTests/Core/MongoDB/MongoShellParserTests.swift deleted file mode 100644 index 8284639995..0000000000 --- a/TableProTests/Core/MongoDB/MongoShellParserTests.swift +++ /dev/null @@ -1,1264 +0,0 @@ -// -// MongoShellParserTests.swift -// TableProTests -// -// Tests for MongoShellParser -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct MongoShellParserTests { - // MARK: - Find Operations - - @Test("find with empty filter") - func testFindWithEmptyFilter() throws { - let op = try MongoShellParser.parse("db.users.find({})") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "users") - #expect(filter == "{}") - #expect(options.sort == nil) - #expect(options.skip == nil) - #expect(options.limit == nil) - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with filter") - func testFindWithFilter() throws { - let op = try MongoShellParser.parse("db.users.find({\"name\": \"John\"})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "users") - #expect(filter == "{\"name\": \"John\"}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with projection") - func testFindWithProjection() throws { - let op = try MongoShellParser.parse("db.users.find({}, {\"name\": 1})") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "users") - #expect(filter == "{}") - #expect(options.projection == "{\"name\": 1}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with chained sort, limit, skip") - func testFindWithChainedOptions() throws { - let op = try MongoShellParser.parse("db.users.find({}).sort({\"name\": 1}).limit(10).skip(5)") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "users") - #expect(filter == "{}") - #expect(options.sort == "{\"name\": 1}") - #expect(options.limit == 10) - #expect(options.skip == 5) - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with just limit") - func testFindWithJustLimit() throws { - let op = try MongoShellParser.parse("db.users.find({}).limit(100)") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "users") - #expect(filter == "{}") - #expect(options.limit == 100) - #expect(options.sort == nil) - #expect(options.skip == nil) - } else { - Issue.record("Expected .find operation") - } - } - - @Test("bare collection reference treated as find all") - func testBareCollectionAsFindAll() throws { - let op = try MongoShellParser.parse("db.users") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "users") - #expect(filter == "{}") - #expect(options.sort == nil) - #expect(options.skip == nil) - #expect(options.limit == nil) - } else { - Issue.record("Expected .find operation") - } - } - - // MARK: - findOne - - @Test("findOne operation") - func testFindOne() throws { - let op = try MongoShellParser.parse("db.users.findOne({\"_id\": \"abc\"})") - if case .findOne(let collection, let filter) = op { - #expect(collection == "users") - #expect(filter == "{\"_id\": \"abc\"}") - } else { - Issue.record("Expected .findOne operation") - } - } - - // MARK: - Aggregate - - @Test("aggregate operation") - func testAggregate() throws { - let op = try MongoShellParser.parse("db.orders.aggregate([{\"$group\": {\"_id\": \"$status\"}}])") - if case .aggregate(let collection, let pipeline) = op { - #expect(collection == "orders") - #expect(pipeline == "[{\"$group\": {\"_id\": \"$status\"}}]") - } else { - Issue.record("Expected .aggregate operation") - } - } - - // MARK: - Count Operations - - @Test("countDocuments operation") - func testCountDocuments() throws { - let op = try MongoShellParser.parse("db.users.countDocuments({})") - if case .countDocuments(let collection, let filter) = op { - #expect(collection == "users") - #expect(filter == "{}") - } else { - Issue.record("Expected .countDocuments operation") - } - } - - @Test("count as alias for countDocuments") - func testCountAlias() throws { - let op = try MongoShellParser.parse("db.users.count({})") - if case .countDocuments(let collection, let filter) = op { - #expect(collection == "users") - #expect(filter == "{}") - } else { - Issue.record("Expected .countDocuments operation") - } - } - - // MARK: - Write Operations - - @Test("insertOne operation") - func testInsertOne() throws { - let op = try MongoShellParser.parse("db.users.insertOne({\"name\": \"John\"})") - if case .insertOne(let collection, let document) = op { - #expect(collection == "users") - #expect(document == "{\"name\": \"John\"}") - } else { - Issue.record("Expected .insertOne operation") - } - } - - @Test("insertMany operation") - func testInsertMany() throws { - let op = try MongoShellParser.parse("db.users.insertMany([{\"name\": \"A\"}, {\"name\": \"B\"}])") - if case .insertMany(let collection, let documents) = op { - #expect(collection == "users") - #expect(documents == "[{\"name\": \"A\"}, {\"name\": \"B\"}]") - } else { - Issue.record("Expected .insertMany operation") - } - } - - @Test("updateOne operation") - func testUpdateOne() throws { - let op = try MongoShellParser.parse("db.users.updateOne({\"_id\": 1}, {\"$set\": {\"name\": \"Jane\"}})") - if case .updateOne(let collection, let filter, let update) = op { - #expect(collection == "users") - #expect(filter == "{\"_id\": 1}") - #expect(update == "{\"$set\": {\"name\": \"Jane\"}}") - } else { - Issue.record("Expected .updateOne operation") - } - } - - @Test("updateMany operation") - func testUpdateMany() throws { - let op = try MongoShellParser.parse("db.users.updateMany({\"active\": true}, {\"$set\": {\"status\": \"ok\"}})") - if case .updateMany(let collection, let filter, let update) = op { - #expect(collection == "users") - #expect(filter == "{\"active\": true}") - #expect(update == "{\"$set\": {\"status\": \"ok\"}}") - } else { - Issue.record("Expected .updateMany operation") - } - } - - @Test("replaceOne operation") - func testReplaceOne() throws { - let op = try MongoShellParser.parse("db.users.replaceOne({\"_id\": 1}, {\"name\": \"Jane\"})") - if case .replaceOne(let collection, let filter, let replacement) = op { - #expect(collection == "users") - #expect(filter == "{\"_id\": 1}") - #expect(replacement == "{\"name\": \"Jane\"}") - } else { - Issue.record("Expected .replaceOne operation") - } - } - - @Test("deleteOne operation") - func testDeleteOne() throws { - let op = try MongoShellParser.parse("db.users.deleteOne({\"_id\": 1})") - if case .deleteOne(let collection, let filter) = op { - #expect(collection == "users") - #expect(filter == "{\"_id\": 1}") - } else { - Issue.record("Expected .deleteOne operation") - } - } - - @Test("deleteMany operation") - func testDeleteMany() throws { - let op = try MongoShellParser.parse("db.users.deleteMany({\"active\": false})") - if case .deleteMany(let collection, let filter) = op { - #expect(collection == "users") - #expect(filter == "{\"active\": false}") - } else { - Issue.record("Expected .deleteMany operation") - } - } - - // MARK: - FindOneAnd Operations - - @Test("findOneAndUpdate operation") - func testFindOneAndUpdate() throws { - let op = try MongoShellParser.parse("db.users.findOneAndUpdate({\"_id\": 1}, {\"$set\": {\"name\": \"Jane\"}})") - if case .findOneAndUpdate(let collection, let filter, let update) = op { - #expect(collection == "users") - #expect(filter == "{\"_id\": 1}") - #expect(update == "{\"$set\": {\"name\": \"Jane\"}}") - } else { - Issue.record("Expected .findOneAndUpdate operation") - } - } - - @Test("findOneAndReplace operation") - func testFindOneAndReplace() throws { - let op = try MongoShellParser.parse("db.users.findOneAndReplace({\"_id\": 1}, {\"name\": \"Jane\", \"age\": 30})") - if case .findOneAndReplace(let collection, let filter, let replacement) = op { - #expect(collection == "users") - #expect(filter == "{\"_id\": 1}") - #expect(replacement == "{\"name\": \"Jane\", \"age\": 30}") - } else { - Issue.record("Expected .findOneAndReplace operation") - } - } - - @Test("findOneAndDelete operation") - func testFindOneAndDelete() throws { - let op = try MongoShellParser.parse("db.users.findOneAndDelete({\"_id\": 1})") - if case .findOneAndDelete(let collection, let filter) = op { - #expect(collection == "users") - #expect(filter == "{\"_id\": 1}") - } else { - Issue.record("Expected .findOneAndDelete operation") - } - } - - // MARK: - Index Operations - - @Test("createIndex with keys only") - func testCreateIndexKeysOnly() throws { - let op = try MongoShellParser.parse("db.users.createIndex({\"name\": 1})") - if case .createIndex(let collection, let keys, let options) = op { - #expect(collection == "users") - #expect(keys == "{\"name\": 1}") - #expect(options == nil) - } else { - Issue.record("Expected .createIndex operation") - } - } - - @Test("createIndex with options") - func testCreateIndexWithOptions() throws { - let op = try MongoShellParser.parse("db.users.createIndex({\"name\": 1}, {\"unique\": true})") - if case .createIndex(let collection, let keys, let options) = op { - #expect(collection == "users") - #expect(keys == "{\"name\": 1}") - #expect(options == "{\"unique\": true}") - } else { - Issue.record("Expected .createIndex operation") - } - } - - @Test("dropIndex operation") - func testDropIndex() throws { - let op = try MongoShellParser.parse("db.users.dropIndex(\"name_1\")") - if case .dropIndex(let collection, let indexName) = op { - #expect(collection == "users") - #expect(indexName == "\"name_1\"") - } else { - Issue.record("Expected .dropIndex operation") - } - } - - // MARK: - Other Operations - - @Test("drop collection") - func testDropCollection() throws { - let op = try MongoShellParser.parse("db.users.drop()") - if case .drop(let collection) = op { - #expect(collection == "users") - } else { - Issue.record("Expected .drop operation") - } - } - - @Test("getCollection with drop") - func testGetCollectionDrop() throws { - let op = try MongoShellParser.parse("db.getCollection(\"audit-logs.2026\").drop()") - if case .drop(let collection) = op { - #expect(collection == "audit-logs.2026") - } else { - Issue.record("Expected .drop operation") - } - } - - @Test("SQL DROP TABLE fallback is rejected for MongoDB collections") - func testDropTableFallbackIsRejected() { - // The sidebar's drop used to fall through to the SQL fallback and emit - // `DROP TABLE ""`, which the Mongo shell parser rejects. Dropping a - // collection must go through `db.getCollection("").drop()` instead. - do { - _ = try MongoShellParser.parse("DROP TABLE \"users\"") - Issue.record("Expected the SQL DROP TABLE fallback to be rejected") - } catch { - // Any parse failure is the point: a Mongo connection must never send SQL. - } - } - - @Test("runCommand") - func testRunCommand() throws { - let op = try MongoShellParser.parse("db.runCommand({\"ping\": 1})") - if case .runCommand(let command) = op { - #expect(command == "{\"ping\": 1}") - } else { - Issue.record("Expected .runCommand operation") - } - } - - @Test("adminCommand") - func testAdminCommand() throws { - let op = try MongoShellParser.parse("db.adminCommand({\"ping\": 1})") - if case .runCommand(let command) = op { - #expect(command == "{\"ping\": 1}") - } else { - Issue.record("Expected .runCommand operation") - } - } - - @Test("raw JSON as runCommand") - func testRawJsonAsRunCommand() throws { - let op = try MongoShellParser.parse("{\"ping\": 1}") - if case .runCommand(let command) = op { - #expect(command == "{\"ping\": 1}") - } else { - Issue.record("Expected .runCommand operation") - } - } - - @Test("show dbs") - func testShowDbs() throws { - let op = try MongoShellParser.parse("show dbs") - if case .listDatabases = op { - // pass - } else { - Issue.record("Expected .listDatabases operation") - } - } - - @Test("show databases") - func testShowDatabases() throws { - let op = try MongoShellParser.parse("show databases") - if case .listDatabases = op { - // pass - } else { - Issue.record("Expected .listDatabases operation") - } - } - - @Test("show collections") - func testShowCollections() throws { - let op = try MongoShellParser.parse("show collections") - if case .listCollections = op { - // pass - } else { - Issue.record("Expected .listCollections operation") - } - } - - @Test("show tables") - func testShowTables() throws { - let op = try MongoShellParser.parse("show tables") - if case .listCollections = op { - // pass - } else { - Issue.record("Expected .listCollections operation") - } - } - - // MARK: - Database-Level Methods - - @Test("db.getCollectionNames() returns listCollections") - func testGetCollectionNames() throws { - let op = try MongoShellParser.parse("db.getCollectionNames()") - if case .listCollections = op { - // pass - } else { - Issue.record("Expected .listCollections operation") - } - } - - @Test("db.listCollections() returns listCollections") - func testDbListCollections() throws { - let op = try MongoShellParser.parse("db.listCollections()") - if case .listCollections = op { - // pass - } else { - Issue.record("Expected .listCollections operation") - } - } - - @Test("db.createCollection creates collection via runCommand") - func testCreateCollection() throws { - let op = try MongoShellParser.parse("db.createCollection(\"myCollection\")") - if case .runCommand(let command) = op { - #expect(command == "{ \"create\": \"myCollection\" }") - } else { - Issue.record("Expected .runCommand operation") - } - } - - @Test("db.createCollection with single quotes") - func testCreateCollectionSingleQuotes() throws { - let op = try MongoShellParser.parse("db.createCollection('myCollection')") - if case .runCommand(let command) = op { - #expect(command == "{ \"create\": \"myCollection\" }") - } else { - Issue.record("Expected .runCommand operation") - } - } - - @Test("db.createCollection with no argument throws missingArgument") - func testCreateCollectionNoArg() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.createCollection()") - } - } - - @Test("db.dropDatabase() returns runCommand with dropDatabase") - func testDropDatabase() throws { - let op = try MongoShellParser.parse("db.dropDatabase()") - if case .runCommand(let command) = op { - #expect(command == "{ \"dropDatabase\": 1 }") - } else { - Issue.record("Expected .runCommand operation") - } - } - - @Test("db.version() returns runCommand with buildInfo") - func testVersion() throws { - let op = try MongoShellParser.parse("db.version()") - if case .runCommand(let command) = op { - #expect(command == "{ \"buildInfo\": 1 }") - } else { - Issue.record("Expected .runCommand operation") - } - } - - @Test("db.stats() returns runCommand with dbStats") - func testStats() throws { - let op = try MongoShellParser.parse("db.stats()") - if case .runCommand(let command) = op { - #expect(command == "{ \"dbStats\": 1 }") - } else { - Issue.record("Expected .runCommand operation") - } - } - - @Test("unknown db-level method throws unsupportedMethod") - func testUnknownDbLevelMethod() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.unknownDbMethod()") - } - } - - // MARK: - Additional Find Operations - - @Test("find with no arguments returns all documents") - func testFindNoArguments() throws { - let op = try MongoShellParser.parse("db.collection.find()") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "collection") - #expect(filter == "{}") - #expect(options.projection == nil) - #expect(options.sort == nil) - #expect(options.limit == nil) - #expect(options.skip == nil) - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with unquoted key filter") - func testFindWithUnquotedKeyFilter() throws { - let op = try MongoShellParser.parse("db.collection.find({name: \"test\"})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "collection") - #expect(filter == "{name: \"test\"}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with projection using unquoted keys") - func testFindWithUnquotedProjection() throws { - let op = try MongoShellParser.parse("db.collection.find({}, {name: 1})") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "collection") - #expect(filter == "{}") - #expect(options.projection == "{name: 1}") - } else { - Issue.record("Expected .find operation") - } - } - - // MARK: - Additional findOne Operations - - @Test("findOne with empty filter") - func testFindOneEmptyFilter() throws { - let op = try MongoShellParser.parse("db.collection.findOne({})") - if case .findOne(let collection, let filter) = op { - #expect(collection == "collection") - #expect(filter == "{}") - } else { - Issue.record("Expected .findOne operation") - } - } - - // MARK: - Additional Aggregate Operations - - @Test("aggregate with $match stage") - func testAggregateWithMatch() throws { - let op = try MongoShellParser.parse("db.collection.aggregate([{$match: {}}])") - if case .aggregate(let collection, let pipeline) = op { - #expect(collection == "collection") - #expect(pipeline == "[{$match: {}}]") - } else { - Issue.record("Expected .aggregate operation") - } - } - - // MARK: - Additional Chained Method Operations - - @Test("find with just sort") - func testFindWithJustSort() throws { - let op = try MongoShellParser.parse("db.collection.find().sort({a: 1})") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "collection") - #expect(filter == "{}") - #expect(options.sort == "{a: 1}") - #expect(options.limit == nil) - #expect(options.skip == nil) - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with just skip") - func testFindWithJustSkip() throws { - let op = try MongoShellParser.parse("db.collection.find().skip(5)") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "collection") - #expect(filter == "{}") - #expect(options.skip == 5) - #expect(options.sort == nil) - #expect(options.limit == nil) - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with filter and all chained options") - func testFindWithFilterAndAllChained() throws { - let op = try MongoShellParser.parse("db.collection.find({}).sort({a:1}).limit(10).skip(5)") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "collection") - #expect(filter == "{}") - #expect(options.sort == "{a:1}") - #expect(options.limit == 10) - #expect(options.skip == 5) - } else { - Issue.record("Expected .find operation") - } - } - - // MARK: - Dotted Collection Names - - @Test("dotted collection name with find") - func testDottedCollectionFind() throws { - let op = try MongoShellParser.parse("db.system.version.find()") - if case .find(let collection, let filter, _) = op { - #expect(collection == "system.version") - #expect(filter == "{}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("dotted collection name with find and filter") - func testDottedCollectionFindWithFilter() throws { - let op = try MongoShellParser.parse("db.system.profile.find({})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "system.profile") - #expect(filter == "{}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("dotted collection name with chained methods") - func testDottedCollectionWithChained() throws { - let op = try MongoShellParser.parse("db.system.version.find().sort({a:1})") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "system.version") - #expect(filter == "{}") - #expect(options.sort == "{a:1}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("bare dotted collection name treated as find all") - func testBareDottedCollection() throws { - let op = try MongoShellParser.parse("db.system.version") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "system.version") - #expect(filter == "{}") - #expect(options.sort == nil) - #expect(options.limit == nil) - #expect(options.skip == nil) - } else { - Issue.record("Expected .find operation") - } - } - - @Test("multiple dots in collection name") - func testMultipleDottedCollectionName() throws { - let op = try MongoShellParser.parse("db.a.b.c.find()") - if case .find(let collection, let filter, _) = op { - #expect(collection == "a.b.c") - #expect(filter == "{}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("dotted collection with deleteMany") - func testDottedCollectionDeleteMany() throws { - let op = try MongoShellParser.parse("db.system.profile.deleteMany({})") - if case .deleteMany(let collection, let filter) = op { - #expect(collection == "system.profile") - #expect(filter == "{}") - } else { - Issue.record("Expected .deleteMany operation") - } - } - - @Test("dotted collection with insertOne") - func testDottedCollectionInsertOne() throws { - let op = try MongoShellParser.parse("db.my.collection.insertOne({\"a\": 1})") - if case .insertOne(let collection, let document) = op { - #expect(collection == "my.collection") - #expect(document == "{\"a\": 1}") - } else { - Issue.record("Expected .insertOne operation") - } - } - - @Test("dotted collection with countDocuments") - func testDottedCollectionCountDocuments() throws { - let op = try MongoShellParser.parse("db.system.users.countDocuments({})") - if case .countDocuments(let collection, let filter) = op { - #expect(collection == "system.users") - #expect(filter == "{}") - } else { - Issue.record("Expected .countDocuments operation") - } - } - - @Test("dotted collection with drop") - func testDottedCollectionDrop() throws { - let op = try MongoShellParser.parse("db.system.profile.drop()") - if case .drop(let collection) = op { - #expect(collection == "system.profile") - } else { - Issue.record("Expected .drop operation") - } - } - - // MARK: - Bracket Notation for Collections - - @Test("bracket notation with dotted collection name and find") - func testBracketNotationDottedFind() throws { - let op = try MongoShellParser.parse("db[\"system.version\"].find()") - if case .find(let collection, let filter, _) = op { - #expect(collection == "system.version") - #expect(filter == "{}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("bracket notation with deleteMany") - func testBracketNotationDeleteMany() throws { - let op = try MongoShellParser.parse("db[\"my.collection\"].deleteMany({})") - if case .deleteMany(let collection, let filter) = op { - #expect(collection == "my.collection") - #expect(filter == "{}") - } else { - Issue.record("Expected .deleteMany operation") - } - } - - @Test("bracket notation with simple collection name") - func testBracketNotationSimpleName() throws { - let op = try MongoShellParser.parse("db[\"collection\"].find()") - if case .find(let collection, let filter, _) = op { - #expect(collection == "collection") - #expect(filter == "{}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("bracket notation with single quotes") - func testBracketNotationSingleQuotes() throws { - let op = try MongoShellParser.parse("db['my.collection'].find({})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "my.collection") - #expect(filter == "{}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("bracket notation bare reference treated as find all") - func testBracketNotationBareReference() throws { - let op = try MongoShellParser.parse("db[\"my.collection\"]") - if case .find(let collection, let filter, _) = op { - #expect(collection == "my.collection") - #expect(filter == "{}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("bracket notation with chained methods") - func testBracketNotationChained() throws { - let op = try MongoShellParser.parse("db[\"my.collection\"].find({}).sort({a: 1}).limit(10)") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "my.collection") - #expect(filter == "{}") - #expect(options.sort == "{a: 1}") - #expect(options.limit == 10) - } else { - Issue.record("Expected .find operation") - } - } - - @Test("bracket notation with backticks") - func testBracketNotationBackticks() throws { - let op = try MongoShellParser.parse("db[`my.collection`].find({})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "my.collection") - #expect(filter == "{}") - } else { - Issue.record("Expected .find operation") - } - } - - // MARK: - getCollection Accessor - - @Test("getCollection with find returns the collection") - func testGetCollectionFind() throws { - let op = try MongoShellParser.parse("db.getCollection(\"users\").find({})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "users") - #expect(filter == "{}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("getCollection with single quotes") - func testGetCollectionSingleQuotes() throws { - let op = try MongoShellParser.parse("db.getCollection('users').find({})") - if case .find(let collection, _, _) = op { - #expect(collection == "users") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("getCollection with backticks") - func testGetCollectionBackticks() throws { - let op = try MongoShellParser.parse("db.getCollection(`users`).find({})") - if case .find(let collection, _, _) = op { - #expect(collection == "users") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("getCollection bare reference treated as find all") - func testGetCollectionBareReference() throws { - let op = try MongoShellParser.parse("db.getCollection(\"users\")") - if case .find(let collection, let filter, let options) = op { - #expect(collection == "users") - #expect(filter == "{}") - #expect(options.limit == nil) - } else { - Issue.record("Expected .find operation") - } - } - - @Test("getCollection with chained options") - func testGetCollectionChainedOptions() throws { - let op = try MongoShellParser.parse( - "db.getCollection(\"users\").find({\"active\": true}).sort({\"name\": 1}).skip(5).limit(10)" - ) - if case .find(let collection, let filter, let options) = op { - #expect(collection == "users") - #expect(filter == "{\"active\": true}") - #expect(options.sort == "{\"name\": 1}") - #expect(options.skip == 5) - #expect(options.limit == 10) - } else { - Issue.record("Expected .find operation") - } - } - - @Test("getCollection keeps a dotted name as one collection") - func testGetCollectionDottedName() throws { - let op = try MongoShellParser.parse("db.getCollection(\"logs.2024.06\").find({})") - if case .find(let collection, _, _) = op { - #expect(collection == "logs.2024.06") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("getCollection reaches a name that collides with a database method") - func testGetCollectionNameCollidingWithDbMethod() throws { - let op = try MongoShellParser.parse("db.getCollection(\"stats\").find({})") - if case .find(let collection, _, _) = op { - #expect(collection == "stats") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("getCollection with an escaped quote in the name") - func testGetCollectionEscapedQuote() throws { - let op = try MongoShellParser.parse("db.getCollection(\"say\\\"hi\").find({})") - if case .find(let collection, _, _) = op { - #expect(collection == "say\"hi") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("getCollection with a write method") - func testGetCollectionDeleteMany() throws { - let op = try MongoShellParser.parse("db.getCollection(\"users\").deleteMany({\"active\": false})") - if case .deleteMany(let collection, let filter) = op { - #expect(collection == "users") - #expect(filter == "{\"active\": false}") - } else { - Issue.record("Expected .deleteMany operation") - } - } - - @Test("getCollection tolerates whitespace before the argument list") - func testGetCollectionWhitespaceBeforeParen() throws { - let op = try MongoShellParser.parse("db.getCollection (\"users\").find({})") - if case .find(let collection, _, _) = op { - #expect(collection == "users") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("getCollection with no argument throws") - func testGetCollectionNoArgument() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.getCollection().find({})") - } - } - - @Test("getCollection with an unquoted argument throws") - func testGetCollectionUnquotedArgument() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.getCollection(users).find({})") - } - } - - @Test("getCollection with an unterminated name throws") - func testGetCollectionUnterminatedName() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.getCollection(\"users).find({})") - } - } - - @Test("getSiblingDB stays unsupported so queries never run against another database") - func testGetSiblingDBRemainsUnsupported() { - do { - _ = try MongoShellParser.parse("db.getSiblingDB(\"other\").getCollection(\"users\").find({})") - Issue.record("Expected a parse error") - } catch let error as MongoShellParseError { - guard case .unsupportedMethod(let method) = error else { - Issue.record("Expected .unsupportedMethod") - return - } - #expect(method == "getSiblingDB") - } catch { - Issue.record("Expected MongoShellParseError") - } - } - - // MARK: - Additional Special Commands - - @Test("raw JSON command with unquoted key") - func testRawJsonUnquotedKey() throws { - let op = try MongoShellParser.parse("{ping: 1}") - if case .runCommand(let command) = op { - #expect(command == "{ping: 1}") - } else { - Issue.record("Expected .runCommand operation") - } - } - - @Test("runCommand with unquoted key") - func testRunCommandUnquotedKey() throws { - let op = try MongoShellParser.parse("db.runCommand({ping: 1})") - if case .runCommand(let command) = op { - #expect(command == "{ping: 1}") - } else { - Issue.record("Expected .runCommand operation") - } - } - - @Test("adminCommand with serverStatus") - func testAdminCommandServerStatus() throws { - let op = try MongoShellParser.parse("db.adminCommand({serverStatus: 1})") - if case .runCommand(let command) = op { - #expect(command == "{serverStatus: 1}") - } else { - Issue.record("Expected .runCommand operation") - } - } - - // MARK: - Error Cases - - @Test("empty string throws invalidSyntax") - func testEmptyStringThrowsInvalidSyntax() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("") - } - } - - @Test("whitespace only throws invalidSyntax") - func testWhitespaceOnlyThrowsInvalidSyntax() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse(" ") - } - } - - @Test("SQL query throws invalidSyntax") - func testSqlQueryThrowsInvalidSyntax() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("SELECT * FROM users") - } - } - - @Test("unknown method throws unsupportedMethod") - func testUnknownMethodThrowsUnsupportedMethod() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.users.unknownMethod()") - } - } - - @Test("insertOne with no argument throws missingArgument") - func testInsertOneNoArgThrowsMissingArgument() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.users.insertOne()") - } - } - - @Test("updateOne with single argument throws missingArgument") - func testUpdateOneSingleArgThrowsMissingArgument() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.users.updateOne({\"_id\": 1})") - } - } - - @Test("db. with no collection or method throws") - func testDbDotIncompleteThrows() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.") - } - } - - @Test("insertMany with no argument throws missingArgument") - func testInsertManyNoArgThrows() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.users.insertMany()") - } - } - - @Test("updateMany with single argument throws missingArgument") - func testUpdateManySingleArgThrows() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.users.updateMany({\"active\": true})") - } - } - - @Test("replaceOne with single argument throws missingArgument") - func testReplaceOneSingleArgThrows() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.users.replaceOne({\"_id\": 1})") - } - } - - @Test("findOneAndUpdate with single argument throws missingArgument") - func testFindOneAndUpdateSingleArgThrows() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.users.findOneAndUpdate({\"_id\": 1})") - } - } - - @Test("findOneAndReplace with single argument throws missingArgument") - func testFindOneAndReplaceSingleArgThrows() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.users.findOneAndReplace({\"_id\": 1})") - } - } - - // MARK: - Edge Cases with Strings and Nested Objects - - @Test("find with dot inside string argument") - func testFindWithDotInsideString() throws { - let op = try MongoShellParser.parse("db.collection.find({name: \"test.value\"})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "collection") - #expect(filter == "{name: \"test.value\"}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with parentheses inside string argument") - func testFindWithParensInsideString() throws { - let op = try MongoShellParser.parse("db.collection.find({name: \"has(parens)\"})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "collection") - #expect(filter == "{name: \"has(parens)\"}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with nested $and operator") - func testFindWithNestedAndOperator() throws { - let op = try MongoShellParser.parse("db.collection.find({$and: [{a: 1}, {b: 2}]})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "collection") - #expect(filter == "{$and: [{a: 1}, {b: 2}]}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with deeply nested objects") - func testFindWithDeeplyNestedObjects() throws { - let op = try MongoShellParser.parse("db.collection.find({\"a\": {\"b\": {\"c\": 1}}})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "collection") - #expect(filter == "{\"a\": {\"b\": {\"c\": 1}}}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("updateOne with nested $set and nested objects") - func testUpdateOneNestedSet() throws { - let op = try MongoShellParser.parse("db.collection.updateOne({a:1}, {$set: {b:2}})") - if case .updateOne(let collection, let filter, let update) = op { - #expect(collection == "collection") - #expect(filter == "{a:1}") - #expect(update == "{$set: {b:2}}") - } else { - Issue.record("Expected .updateOne operation") - } - } - - @Test("updateMany with empty filter and $set") - func testUpdateManyEmptyFilterSet() throws { - let op = try MongoShellParser.parse("db.collection.updateMany({}, {$set: {active: true}})") - if case .updateMany(let collection, let filter, let update) = op { - #expect(collection == "collection") - #expect(filter == "{}") - #expect(update == "{$set: {active: true}}") - } else { - Issue.record("Expected .updateMany operation") - } - } - - @Test("replaceOne with full replacement document") - func testReplaceOneFullReplacement() throws { - let op = try MongoShellParser.parse("db.collection.replaceOne({a:1}, {a:2, b:3})") - if case .replaceOne(let collection, let filter, let replacement) = op { - #expect(collection == "collection") - #expect(filter == "{a:1}") - #expect(replacement == "{a:2, b:3}") - } else { - Issue.record("Expected .replaceOne operation") - } - } - - @Test("findOneAndUpdate with $set") - func testFindOneAndUpdateWithSet() throws { - let op = try MongoShellParser.parse("db.collection.findOneAndUpdate({a:1}, {$set:{b:2}})") - if case .findOneAndUpdate(let collection, let filter, let update) = op { - #expect(collection == "collection") - #expect(filter == "{a:1}") - #expect(update == "{$set:{b:2}}") - } else { - Issue.record("Expected .findOneAndUpdate operation") - } - } - - @Test("findOneAndReplace with replacement") - func testFindOneAndReplaceWithReplacement() throws { - let op = try MongoShellParser.parse("db.collection.findOneAndReplace({a:1}, {a:2})") - if case .findOneAndReplace(let collection, let filter, let replacement) = op { - #expect(collection == "collection") - #expect(filter == "{a:1}") - #expect(replacement == "{a:2}") - } else { - Issue.record("Expected .findOneAndReplace operation") - } - } - - @Test("findOneAndDelete with filter") - func testFindOneAndDeleteWithFilter() throws { - let op = try MongoShellParser.parse("db.collection.findOneAndDelete({a:1})") - if case .findOneAndDelete(let collection, let filter) = op { - #expect(collection == "collection") - #expect(filter == "{a:1}") - } else { - Issue.record("Expected .findOneAndDelete operation") - } - } - - @Test("deleteMany with empty filter") - func testDeleteManyEmptyFilter() throws { - let op = try MongoShellParser.parse("db.collection.deleteMany({})") - if case .deleteMany(let collection, let filter) = op { - #expect(collection == "collection") - #expect(filter == "{}") - } else { - Issue.record("Expected .deleteMany operation") - } - } - - @Test("createIndex with unquoted key") - func testCreateIndexUnquotedKey() throws { - let op = try MongoShellParser.parse("db.collection.createIndex({name: 1})") - if case .createIndex(let collection, let keys, let options) = op { - #expect(collection == "collection") - #expect(keys == "{name: 1}") - #expect(options == nil) - } else { - Issue.record("Expected .createIndex operation") - } - } - - @Test("dropIndex with string index name") - func testDropIndexStringName() throws { - let op = try MongoShellParser.parse("db.collection.dropIndex(\"idx_name\")") - if case .dropIndex(let collection, let indexName) = op { - #expect(collection == "collection") - #expect(indexName == "\"idx_name\"") - } else { - Issue.record("Expected .dropIndex operation") - } - } - - @Test("find with single-quoted string in filter") - func testFindWithSingleQuotedString() throws { - let op = try MongoShellParser.parse("db.collection.find({name: 'test'})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "collection") - #expect(filter == "{name: 'test'}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("find with comma inside nested object does not split incorrectly") - func testFindCommaInsideNestedObject() throws { - let op = try MongoShellParser.parse("db.collection.find({a: 1, b: 2})") - if case .find(let collection, let filter, _) = op { - #expect(collection == "collection") - #expect(filter == "{a: 1, b: 2}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("input with leading and trailing whitespace is trimmed") - func testInputWithWhitespace() throws { - let op = try MongoShellParser.parse(" db.users.find({}) ") - if case .find(let collection, let filter, _) = op { - #expect(collection == "users") - #expect(filter == "{}") - } else { - Issue.record("Expected .find operation") - } - } - - @Test("unmatched parenthesis throws invalidSyntax") - func testUnmatchedParenthesisThrows() { - #expect(throws: MongoShellParseError.self) { - _ = try MongoShellParser.parse("db.users.find({name: \"test\"") - } - } - - @Test("deleteOne with filter containing nested array") - func testDeleteOneNestedArray() throws { - let op = try MongoShellParser.parse("db.collection.deleteOne({tags: [\"a\", \"b\"]})") - if case .deleteOne(let collection, let filter) = op { - #expect(collection == "collection") - #expect(filter == "{tags: [\"a\", \"b\"]}") - } else { - Issue.record("Expected .deleteOne operation") - } - } - - @Test("error descriptions substitute the interpolated value") - func testErrorDescriptionsFormatArgument() { - #expect(MongoShellParseError.invalidSyntax("bad{").errorDescription == "Invalid MongoDB syntax: bad{") - #expect(MongoShellParseError.unsupportedMethod("foo").errorDescription == "Unsupported MongoDB method: foo") - #expect(MongoShellParseError.invalidJson("oops").errorDescription == "Invalid JSON: oops") - #expect(MongoShellParseError.missingArgument("id").errorDescription == "Missing argument: id") - } -} diff --git a/TableProTests/Core/MongoDB/MongoShellValueTests.swift b/TableProTests/Core/MongoDB/MongoShellValueTests.swift deleted file mode 100644 index 715fc1b159..0000000000 --- a/TableProTests/Core/MongoDB/MongoShellValueTests.swift +++ /dev/null @@ -1,230 +0,0 @@ -// -// MongoShellValueTests.swift -// TableProTests -// -// Expected values were measured against mongosh 2.10.0 on MongoDB 7.0.43. Where the shell refuses -// what mongosh wraps, clamps or zeroes, the test says so. -// - -import Foundation -import JavaScriptCore -import Testing - -/// Drives the real prelude's number rules and value constructors. -/// -/// A whole number past int64 went out as `$numberLong` in its shortest spelling, such as -/// `"9223372036854776000"`, which libbson refuses, so `insertOne({n: 1e20})` failed as a document -/// MongoDB could not read. `Int32`, `Long`, `Decimal128` and `BSONRegExp` were offered by -/// autocomplete and undefined, and a stored `NaN` decimal or an `x` regular expression stopped -/// `forEach` partway through. -struct MongoShellValueTests { - private typealias RecordingHost = MongoScriptPreludeTests.RecordingHost - - private func makeContext(_ host: RecordingHost = RecordingHost()) throws -> JSContext { - try MongoScriptContext.make(execute: { host.handle($0) }, emit: { host.record(printed: $0) }) - } - - private func extendedJson(of expression: String, in context: JSContext) -> String? { - context.exception = nil - let value = context.evaluateScript("EJSON.stringify(\(expression))") - return context.exception == nil ? value?.toString() : nil - } - - private func refusal(of statement: String, in context: JSContext) -> String? { - context.exception = nil - context.evaluateScript(statement) - let message = context.exception?.objectForKeyedSubscript("message")?.toString() - context.exception = nil - return message - } - - @Test("A whole number past 2^53 is sent as the double it is, never as a different int64") - func wholeNumbersPastTheExactRangeAreDoubles() throws { - let host = RecordingHost() - host.replies = [#"{"insertedIds": [1], "insertedCount": 1}"#] - let context = try makeContext(host) - - context.evaluateScript(""" - db.n.insertOne({a: 1e20, b: 9223372036854775807, c: -9223372036854775808, d: 1e21, e: -0, \ - f: 2147483648, g: 9007199254740993, h: 9007199254740991, i: -2147483648, j: 5}) - """) - #expect(context.exception == nil) - - let document = try #require(host.requests(op: "insertOne").first?["document"] as? String) - #expect(document == """ - {"a":{"$numberDouble":"100000000000000000000"},"b":{"$numberDouble":"9223372036854776000"},\ - "c":{"$numberDouble":"-9223372036854776000"},"d":{"$numberDouble":"1e+21"},\ - "e":{"$numberDouble":"-0.0"},"f":{"$numberLong":"2147483648"},\ - "g":{"$numberDouble":"9007199254740992"},"h":{"$numberLong":"9007199254740991"},\ - "i":{"$numberInt":"-2147483648"},"j":{"$numberInt":"5"}} - """) - #expect(Double("9223372036854776000") == 0x1p63) - #expect(Double("-9223372036854776000") == -0x1p63) - #expect(Double("9007199254740992") == 0x1p53) - } - - @Test("A Long holds exactly the integer it was given") - func longKeepsTheExactInteger() throws { - let context = try makeContext() - let expected: [(statement: String, digits: String)] = [ - ("NumberLong(2 ** 62)", "4611686018427387904"), - ("Long(2 ** 62)", "4611686018427387904"), - ("NumberLong(9007199254740993)", "9007199254740992"), - ("NumberLong(-(2 ** 63))", "-9223372036854775808"), - (#"Long("9223372036854775807")"#, "9223372036854775807"), - (#"NumberLong(" 5")"#, "5"), - (#"Long("+007")"#, "7"), - ("NumberLong(5.9)", "5"), - ("Long(-5.9)", "-5"), - ("Long(5, 1)", "4294967301"), - ("Long(-1, -1)", "-1"), - ("NumberLong(NumberInt(3))", "3"), - (#"Long(NumberLong("9007199254740993"))"#, "9007199254740993"), - ("NumberLong()", "0") - ] - for (statement, digits) in expected { - #expect( - extendedJson(of: statement, in: context) == #"{"$numberLong":"\#(digits)"}"#, - "\(statement)" - ) - } - } - - @Test("Int32, Decimal128, Timestamp, BSONRegExp and MinKey build the values mongosh builds") - func constructorsMatchMongosh() throws { - let context = try makeContext() - let expected: [(statement: String, json: String)] = [ - ("Int32(5)", #"{"$numberInt":"5"}"#), - (#"new Int32("7")"#, #"{"$numberInt":"7"}"#), - ("NumberInt(5.9)", #"{"$numberInt":"5"}"#), - ("NumberInt(0.0000005)", #"{"$numberInt":"0"}"#), - (#"NumberInt(" 5 ")"#, #"{"$numberInt":"5"}"#), - (#"Decimal128("1.50")"#, #"{"$numberDecimal":"1.50"}"#), - (#"NumberDecimal("NaN")"#, #"{"$numberDecimal":"NaN"}"#), - (#"Decimal128("-inf")"#, #"{"$numberDecimal":"-Infinity"}"#), - ("NumberDecimal(1.5)", #"{"$numberDecimal":"1.5"}"#), - ("Timestamp(1, 2)", #"{"$timestamp":{"t":1,"i":2}}"#), - ("Timestamp({t: 3, i: 4})", #"{"$timestamp":{"t":3,"i":4}}"#), - (#"Timestamp(NumberLong("8589934593"))"#, #"{"$timestamp":{"t":2,"i":1}}"#), - ("Timestamp(1.5, 2.7)", #"{"$timestamp":{"t":1,"i":2}}"#), - ("Timestamp(1)", #"{"$timestamp":{"t":1,"i":0}}"#), - ("Timestamp()", #"{"$timestamp":{"t":0,"i":0}}"#), - (#"BSONRegExp("a", "mi")"#, #"{"$regularExpression":{"pattern":"a","options":"im"}}"#), - ("MinKey()", #"{"$minKey":1}"#), - ("new MinKey()", #"{"$minKey":1}"#) - ] - for (statement, json) in expected { - #expect(extendedJson(of: statement, in: context) == json, "\(statement)") - } - } - - @Test("A constructor refuses a value it cannot hold instead of wrapping, clamping or zeroing it") - func constructorsRefuseWhatTheyCannotHold() throws { - let context = try makeContext() - let int32Range = "takes a whole number from -2147483648 to 2147483647" - let int64Range = "takes a whole number from -9223372036854775808 to 9223372036854775807" - let notTimestamp = "Timestamp takes (t, i), { t, i }, a Long or a bigint" - let expected: [(statement: String, message: String)] = [ - ("Int32(2147483648)", "Int32 \(int32Range)"), - (#"NumberInt("12abc")"#, "NumberInt takes a number"), - ("NumberInt(NaN)", "NumberInt takes a number"), - (#"Long("9223372036854775808")"#, "Long \(int64Range)"), - ("NumberLong(1e20)", "NumberLong \(int64Range)"), - ("NumberLong(9223372036854775807)", "NumberLong \(int64Range)"), - (#"NumberLong("1e3")"#, "NumberLong takes a whole number"), - ("Long(5.5, 1)", "Long takes its low and high halves as whole numbers from -2147483648 to 4294967295"), - (#"Decimal128("abc")"#, "Decimal128 takes a number"), - ("Timestamp(4294967296, 0)", "Timestamp takes t from 0 to 4294967295"), - ("Timestamp(-1, 0)", "Timestamp takes t from 0 to 4294967295"), - (#"Timestamp("5", "6")"#, "Timestamp takes t as a number"), - ("Timestamp(NumberInt(5), NumberInt(6))", "Timestamp takes t as a number"), - ("Timestamp(Double(5), 1)", "Timestamp takes t as a number"), - ("Timestamp(new Date(1700000000000), 1)", "Timestamp takes t as a number"), - ("Timestamp({})", "Timestamp takes t as a number"), - ("Timestamp({t: 1})", "Timestamp takes i as a number"), - ("Timestamp([1, 2])", notTimestamp), - ("Timestamp(null)", notTimestamp), - (#"BSONRegExp("a", "g")"#, "BSONRegExp takes options from i, l, m, s, u and x"), - ("BSONRegExp(/a/)", "BSONRegExp takes its pattern as a string"), - (#"BSONRegExp("a\u0000")"#, "A BSONRegExp pattern cannot hold a null character") - ] - for (statement, message) in expected { - #expect(refusal(of: statement, in: context) == message, "\(statement)") - } - } - - @Test("A legacy name and its type are one type, whichever built the value") - func aliasesShareOneType() throws { - let context = try makeContext() - - let shared = context.evaluateScript(""" - [NumberInt(5) instanceof Int32, Int32(5) instanceof NumberInt, - NumberLong(5) instanceof Long, Long(5) instanceof NumberLong, - NumberDecimal("1") instanceof Decimal128, Decimal128("1") instanceof NumberDecimal, - NumberLong(5).constructor === Long, NumberInt(5).constructor === Int32].join() - """) - #expect(context.exception == nil) - #expect(shared?.toString() == "true,true,true,true,true,true,true,true") - } - - @Test("A document read and written back keeps what JavaScript can hold, and reads through the rest") - func readBackRoundTrip() throws { - let host = RecordingHost() - host.replies = [ - "1", - """ - {"docs": [{"_id": {"$numberInt": "1"}, "d": {"$numberDouble": "5.0"}, \ - "wide": {"$numberDouble": "3000000000.0"}, "neg": {"$numberDouble": "-0.0"}, \ - "big": {"$numberDouble": "1e+20"}, "nan": {"$numberDouble": "NaN"}, \ - "l": {"$numberLong": "9007199254740993"}, "dec": {"$numberDecimal": "NaN"}, \ - "x": {"$regularExpression": {"pattern": "a b", "options": "x"}}, \ - "pcre": {"$regularExpression": {"pattern": "(?i)a", "options": ""}}, \ - "slash": {"$regularExpression": {"pattern": "a/b", "options": "i"}}, \ - "empty": {"$regularExpression": {"pattern": "", "options": ""}}, \ - "ts": {"$timestamp": {"t": 4294967295, "i": 1}}}], "done": true} - """, - #"{"n": 1, "nModified": 1}"# - ] - let context = try makeContext(host) - - context.evaluateScript("db.t.find().forEach(function (doc) { db.t.replaceOne({_id: doc._id}, doc); })") - #expect(context.exception == nil) - - let replacement = try #require(host.requests(op: "replace").first?["update"] as? String) - #expect(replacement == """ - {"_id":{"$numberInt":"1"},"d":{"$numberInt":"5"},"wide":{"$numberLong":"3000000000"},\ - "neg":{"$numberDouble":"-0.0"},"big":{"$numberDouble":"100000000000000000000"},\ - "nan":{"$numberDouble":"NaN"},"l":{"$numberLong":"9007199254740993"},\ - "dec":{"$numberDecimal":"NaN"},\ - "x":{"$regularExpression":{"pattern":"a b","options":"x"}},\ - "pcre":{"$regularExpression":{"pattern":"(?i)a","options":""}},\ - "slash":{"$regularExpression":{"pattern":"a/b","options":"i"}},\ - "empty":{"$regularExpression":{"pattern":"","options":""}},\ - "ts":{"$timestamp":{"t":4294967295,"i":1}}} - """) - } - - @Test("A value read back compares the way it does in mongosh") - func readBackComparisons() throws { - let host = RecordingHost() - host.replies = [ - "1", - """ - {"docs": [{"d": {"$numberDouble": "5.0"}, "neg": {"$numberDouble": "-0.0"}, \ - "i": {"$numberInt": "7"}, "l": {"$numberLong": "5"}, "dec": {"$numberDecimal": "1.50"}, \ - "slash": {"$regularExpression": {"pattern": "a/b", "options": "i"}}, \ - "x": {"$regularExpression": {"pattern": "a b", "options": "x"}}}], "done": true} - """ - ] - let context = try makeContext(host) - - let seen = context.evaluateScript(""" - var doc = db.t.findOne(); - [doc.d === 5, typeof doc.d, Object.is(doc.neg, -0), doc.i === 7, - doc.l === 5, doc.l == 5, doc.l instanceof Long, String(doc.dec), - doc.slash instanceof RegExp, doc.slash.test("A/B"), doc.x instanceof BSONRegExp, doc.x.options].join() - """) - #expect(context.exception == nil) - #expect(seen?.toString() == "true,number,true,true,false,true,true,1.50,true,true,true,x") - } -} diff --git a/TableProTests/Core/MongoDB/MongoShellValueTranslatorTests.swift b/TableProTests/Core/MongoDB/MongoShellValueTranslatorTests.swift deleted file mode 100644 index dc3e4abfb8..0000000000 --- a/TableProTests/Core/MongoDB/MongoShellValueTranslatorTests.swift +++ /dev/null @@ -1,238 +0,0 @@ -// -// MongoShellValueTranslatorTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoShellValueTranslatorTests { - private static let oid = "507f1f77bcf86cd799439011" - private static let uuid = "8cd003eb-4a25-4324-9332-88fce2da0d1a" - - @Suite("Constructors") - struct ConstructorTests { - @Test("ObjectId becomes $oid") - func objectId() throws { - let result = try MongoShellValueTranslator.translate("{\"_id\": ObjectId(\"507f1f77bcf86cd799439011\")}") - #expect(result == "{\"_id\": {\"$oid\": \"507f1f77bcf86cd799439011\"}}") - } - - @Test("ISODate and Date become $date") - func dates() throws { - let iso = try MongoShellValueTranslator.translate("{at: ISODate(\"2026-01-01T00:00:00Z\")}") - #expect(iso == "{at: {\"$date\": \"2026-01-01T00:00:00Z\"}}") - let date = try MongoShellValueTranslator.translate("{at: Date(\"2026-01-01T00:00:00Z\")}") - #expect(date == "{at: {\"$date\": \"2026-01-01T00:00:00Z\"}}") - } - - @Test("Numeric helpers accept both quoted and bare arguments") - func numbers() throws { - #expect(try MongoShellValueTranslator.translate("NumberInt(42)") == "{\"$numberInt\": \"42\"}") - #expect(try MongoShellValueTranslator.translate("NumberInt(\"42\")") == "{\"$numberInt\": \"42\"}") - #expect( - try MongoShellValueTranslator.translate("NumberLong(9007199254740993)") - == "{\"$numberLong\": \"9007199254740993\"}" - ) - #expect( - try MongoShellValueTranslator.translate("NumberDecimal(\"1.25\")") - == "{\"$numberDecimal\": \"1.25\"}" - ) - } - - @Test("Timestamp becomes $timestamp") - func timestamp() throws { - let result = try MongoShellValueTranslator.translate("{ts: Timestamp(1700000000, 1)}") - #expect(result == "{ts: {\"$timestamp\": {\"t\": 1700000000, \"i\": 1}}}") - } - - @Test("BinData keeps its subtype") - func binData() throws { - let result = try MongoShellValueTranslator.translate("{b: BinData(5, \"3q2+7w==\")}") - #expect(result == "{b: {\"$binary\": {\"base64\": \"3q2+7w==\", \"subType\": \"05\"}}}") - } - - @Test("HexData converts to base64") - func hexData() throws { - let result = try MongoShellValueTranslator.translate("{b: HexData(0, \"deadbeef\")}") - #expect(result == "{b: {\"$binary\": {\"base64\": \"3q2+7w==\", \"subType\": \"00\"}}}") - } - - @Test("MinKey and MaxKey work with and without parentheses") - func extremeKeys() throws { - #expect(try MongoShellValueTranslator.translate("{a: MinKey}") == "{a: {\"$minKey\": 1}}") - #expect(try MongoShellValueTranslator.translate("{a: MinKey()}") == "{a: {\"$minKey\": 1}}") - #expect(try MongoShellValueTranslator.translate("{a: MaxKey}") == "{a: {\"$maxKey\": 1}}") - } - - @Test("The UUID family routes through the shared codec") - func uuidFamily() throws { - let standard = try MongoShellValueTranslator.translate("UUID(\"8cd003eb-4a25-4324-9332-88fce2da0d1a\")") - #expect(standard == "{\"$binary\": {\"base64\": \"jNAD60olQySTMoj84toNGg==\", \"subType\": \"04\"}}") - - let java = try MongoShellValueTranslator - .translate("LegacyJavaUUID(\"8cd003eb-4a25-4324-9332-88fce2da0d1a\")") - #expect(java == "{\"$binary\": {\"base64\": \"JEMlSusD0IwaDdri/Igykw==\", \"subType\": \"03\"}}") - - let alias = try MongoShellValueTranslator.translate("JUUID(\"8cd003eb-4a25-4324-9332-88fce2da0d1a\")") - #expect(alias == java) - } - } - - @Suite("Scanning") - struct ScanningTests { - /// A method call is always preceded by a dot; a constructor never is. - @Test("Method names that look like helpers are left alone") - func methodsAreNotTranslated() throws { - let input = "db.users.find({}).sort({a: 1}).limit(10)" - #expect(try MongoShellValueTranslator.translate(input) == input) - } - - @Test("A helper inside a double-quoted string is not translated") - func doubleQuotedStringsAreInert() throws { - let input = "{name: \"ObjectId(\\\"abc\\\")\"}" - #expect(try MongoShellValueTranslator.translate(input) == input) - } - - @Test("A helper inside a single-quoted string is not translated") - func singleQuotedStringsAreInert() throws { - let input = "{name: 'MinKey and ISODate(1)'}" - #expect(try MongoShellValueTranslator.translate(input) == input) - } - - @Test("A field literally named like a helper is untouched") - func fieldNamesAreInert() throws { - let input = "{\"ObjectId\": 1, \"MinKey\": 2}" - #expect(try MongoShellValueTranslator.translate(input) == input) - } - - @Test("Helpers nested in arrays and subdocuments are translated") - func nestedHelpers() throws { - let result = try MongoShellValueTranslator.translate( - "{$or: [{_id: ObjectId(\"507f1f77bcf86cd799439011\")}, {n: NumberInt(1)}]}" - ) - #expect(result == "{$or: [{_id: {\"$oid\": \"507f1f77bcf86cd799439011\"}}, {n: {\"$numberInt\": \"1\"}}]}") - } - - @Test("Translation is idempotent") - func idempotent() throws { - let once = try MongoShellValueTranslator.translate( - "db.c.find({_id: ObjectId(\"507f1f77bcf86cd799439011\"), at: ISODate(\"2026-01-01T00:00:00Z\")})" - ) - #expect(try MongoShellValueTranslator.translate(once) == once) - } - - @Test("Text with no helper is returned unchanged") - func passthrough() throws { - let input = "db.users.find({age: {$gt: 21}}).limit(5)" - #expect(try MongoShellValueTranslator.translate(input) == input) - } - - @Test("An unknown constructor is left for the driver to reject") - func unknownConstructorUntouched() throws { - let input = "{a: SomethingElse(1)}" - #expect(try MongoShellValueTranslator.translate(input) == input) - } - } - - @Suite("Errors") - struct ErrorTests { - @Test("A malformed ObjectId is rejected by name") - func badObjectId() { - #expect(throws: MongoShellValueError.malformedArguments(helper: "ObjectId")) { - try MongoShellValueTranslator.translate("{_id: ObjectId(\"nope\")}") - } - } - - @Test("A non-numeric NumberInt is rejected") - func badNumber() { - #expect(throws: MongoShellValueError.malformedArguments(helper: "NumberInt")) { - try MongoShellValueTranslator.translate("{n: NumberInt(\"abc\")}") - } - } - - @Test("An out-of-range NumberInt is rejected") - func overflowingNumber() { - #expect(throws: MongoShellValueError.malformedArguments(helper: "NumberInt")) { - try MongoShellValueTranslator.translate("{n: NumberInt(99999999999)}") - } - } - - @Test("A helper missing its closing parenthesis is reported") - func unterminatedCall() { - #expect(throws: MongoShellValueError.unterminatedCall(helper: "ObjectId")) { - try MongoShellValueTranslator.translate("{_id: ObjectId(\"507f1f77bcf86cd799439011\"") - } - } - - @Test("An unterminated string is reported once a helper puts the scanner to work") - func unterminatedString() { - #expect(throws: MongoShellValueError.unterminatedString) { - try MongoShellValueTranslator - .translate("{a: ObjectId(\"507f1f77bcf86cd799439011\"), name: \"oops}") - } - } - - /// The translator is not a JSON validator: text carrying no helper is handed to the - /// driver untouched so libbson reports the syntax error. - @Test("Malformed text with no helper is passed through rather than rejected here") - func malformedTextWithoutHelperPassesThrough() throws { - let input = "{name: \"oops}" - #expect(try MongoShellValueTranslator.translate(input) == input) - } - } - - @Suite("Parser integration") - struct ParserIntegrationTests { - @Test("A find filter carrying ObjectId reaches the driver as Extended JSON") - func findFilter() throws { - let operation = try MongoShellParser.parse( - "db.users.find({_id: ObjectId(\"507f1f77bcf86cd799439011\")})" - ) - guard case .find(let collection, let filter, _) = operation else { - Issue.record("expected a find operation") - return - } - #expect(collection == "users") - #expect(filter.contains("\"$oid\"")) - #expect(!filter.contains("ObjectId(")) - } - - @Test("A UUID pasted from the grid is accepted in a filter") - func uuidFilter() throws { - let operation = try MongoShellParser.parse( - "db.docs.find({ref: LegacyJavaUUID(\"8cd003eb-4a25-4324-9332-88fce2da0d1a\")})" - ) - guard case .find(_, let filter, _) = operation else { - Issue.record("expected a find operation") - return - } - #expect(filter.contains("\"subType\": \"03\"")) - } - - @Test("A helper inside sort is translated too") - func sortOption() throws { - let operation = try MongoShellParser.parse( - "db.docs.find({}).sort({at: NumberInt(-1)})" - ) - guard case .find(_, _, let options) = operation else { - Issue.record("expected a find operation") - return - } - #expect(options.sort?.contains("$numberInt") == true) - } - - @Test("A runCommand payload is translated") - func runCommand() throws { - let operation = try MongoShellParser.parse( - "db.runCommand({find: \"c\", filter: {_id: ObjectId(\"507f1f77bcf86cd799439011\")}})" - ) - guard case .runCommand(let command) = operation else { - Issue.record("expected a runCommand operation") - return - } - #expect(command.contains("\"$oid\"")) - } - } -} diff --git a/TableProTests/Core/Operations/AppSettingsCategoryParityTests.swift b/TableProTests/Core/Operations/AppSettingsCategoryParityTests.swift index b3b7145f60..40b2ebd815 100644 --- a/TableProTests/Core/Operations/AppSettingsCategoryParityTests.swift +++ b/TableProTests/Core/Operations/AppSettingsCategoryParityTests.swift @@ -3,52 +3,12 @@ // TableProTests // -import Foundation @testable import TablePro import Testing -/// Guards the shape that let `mcp` and `sync` sit in the dirty set forever. -/// -/// A settings category is spelled out in four places: the `markDirty` call in its `didSet`, the -/// seed list that runs when sync is switched on, and the encode and decode switches. A category -/// in the first and missing from the rest is marked dirty, never encodes, never clears, and never -/// reaches the user's other Mac, with nothing reporting a problem. struct AppSettingsCategoryParityTests { - private static let managerSource = sourceFile("TablePro/Core/Storage/AppSettingsManager.swift") - private static let syncSource = sourceFile("TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift") - - @Test("Every synced category can be encoded and decoded") - func syncedCategoriesRoundTrip() throws { - let source = try #require(Self.syncSource) - for category in AppSettingsCategory.synced { - #expect( - source.contains("case AppSettingsCategory.\(category):"), - "Category '\(category)' is missing from SyncCoordinator's encode or decode switch" - ) - } - } - - @Test("No category is marked dirty by a raw string that bypasses the shared names") - func noRawCategoryStrings() throws { - let source = try #require(Self.managerSource) - #expect( - !source.contains("markDirty(.settings, id: \""), - "A settings category is marked dirty by a raw string instead of AppSettingsCategory" - ) - } - - /// Not syncing these is the documented contract, so they must not be marked dirty at all. - /// Marking them was the live defect: the flags were set and could never be cleared, because - /// clearing only happens for a record the server actually saved. - @Test("Device-local categories are never marked dirty") - func deviceLocalCategoriesAreNotMarkedDirty() throws { - let source = try #require(Self.managerSource) - for category in AppSettingsCategory.deviceLocal { - #expect( - !source.contains("markDirty(.settings, id: AppSettingsCategory.\(category))"), - "Device-local category '\(category)' must not be marked dirty" - ) - } + @Test("No category is both synced and device-local") + func deviceLocalCategoriesAreNotSynced() { #expect(AppSettingsCategory.deviceLocal.isDisjoint(with: Set(AppSettingsCategory.synced))) } @@ -56,14 +16,4 @@ struct AppSettingsCategoryParityTests { func notificationsSync() { #expect(AppSettingsCategory.synced.contains(AppSettingsCategory.notifications)) } - - private static func sourceFile(_ relativePath: String) -> String? { - var directory = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 8 { - directory.deleteLastPathComponent() - let candidate = directory.appendingPathComponent(relativePath) - if let contents = try? String(contentsOf: candidate, encoding: .utf8) { return contents } - } - return nil - } } diff --git a/TableProTests/Core/Operations/ResultVisibilityTests.swift b/TableProTests/Core/Operations/ResultVisibilityTests.swift index 2f091e5266..f33117241e 100644 --- a/TableProTests/Core/Operations/ResultVisibilityTests.swift +++ b/TableProTests/Core/Operations/ResultVisibilityTests.swift @@ -31,31 +31,4 @@ struct ResultVisibilityTests { #expect(!occluded.contains(.visible)) #expect(occluded != .visible) } - - /// Source-scanned rather than behavioural, because reproducing a covered window in a unit test - /// needs a real window server. The rule above is the one that breaks silently. - @Test("The resolver never compares occlusionState for equality") - func resolverUsesMembership() throws { - let source = try #require(Self.resolverSource) - /// Comments are stripped first: the resolver's own doc comment names the wrong form in - /// order to warn about it, and scanning the raw file flags that as a violation. - let code = source - .split(separator: "\n", omittingEmptySubsequences: false) - .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } - .joined(separator: "\n") - #expect(code.contains("occlusionState.contains(.visible)")) - #expect(!code.contains("occlusionState == .visible")) - #expect(!code.contains("occlusionState != .visible")) - } - - private static var resolverSource: String? { - var directory = URL(fileURLWithPath: #filePath) - let relative = "TablePro/Core/Services/Operations/ResultVisibilityResolver.swift" - for _ in 0 ..< 8 { - directory.deleteLastPathComponent() - let candidate = directory.appendingPathComponent(relative) - if let contents = try? String(contentsOf: candidate, encoding: .utf8) { return contents } - } - return nil - } } diff --git a/TableProTests/Core/Plugins/ConnectionFieldTests.swift b/TableProTests/Core/Plugins/ConnectionFieldTests.swift index 7f47d63c79..0f2ce4f4a2 100644 --- a/TableProTests/Core/Plugins/ConnectionFieldTests.swift +++ b/TableProTests/Core/Plugins/ConnectionFieldTests.swift @@ -3,16 +3,6 @@ import TableProPluginKit import Testing struct ConnectionFieldTests { - @Test("Default values: placeholder, isRequired, defaultValue, fieldType") - func defaultValues() { - let field = ConnectionField(id: "host", label: "Host") - - #expect(field.placeholder == "") - #expect(field.isRequired == false) - #expect(field.defaultValue == nil) - #expect(field.fieldType == .text) - } - @Test("isSecure is false for .text") func isSecureForText() { let field = ConnectionField(id: "host", label: "Host", fieldType: .text) @@ -52,32 +42,6 @@ struct ConnectionFieldTests { #expect(field.isSecure == false) } - @Test("DropdownOption stores value and label") - func dropdownOption() { - let option = ConnectionField.DropdownOption(value: "utf8", label: "UTF-8") - #expect(option.value == "utf8") - #expect(option.label == "UTF-8") - } - - @Test("All properties stored correctly") - func allPropertiesStored() { - let field = ConnectionField( - id: "port", - label: "Port", - placeholder: "3306", - required: true, - defaultValue: "3306", - fieldType: .text - ) - - #expect(field.id == "port") - #expect(field.label == "Port") - #expect(field.placeholder == "3306") - #expect(field.isRequired == true) - #expect(field.defaultValue == "3306") - #expect(field.fieldType == .text) - } - @Test("Codable round-trip for .text field") func codableText() throws { let field = ConnectionField( @@ -154,27 +118,12 @@ struct ConnectionFieldTests { // MARK: - IntRange - @Test("IntRange init from ClosedRange") - func intRangeFromClosedRange() { - let range = ConnectionField.IntRange(0...15) - #expect(range.lowerBound == 0) - #expect(range.upperBound == 15) - } - @Test("IntRange closedRange round-trip") func intRangeClosedRangeRoundTrip() { let range = ConnectionField.IntRange(3...42) #expect(range.closedRange == 3...42) } - @Test("IntRange init from bounds") - func intRangeFromBounds() { - let range = ConnectionField.IntRange(lowerBound: 1, upperBound: 100) - #expect(range.lowerBound == 1) - #expect(range.upperBound == 100) - #expect(range.closedRange == 1...100) - } - @Test("IntRange decoding rejects invalid bounds") func intRangeDecodingRejectsInvalidBounds() throws { let json = #"{"lowerBound":10,"upperBound":0}"# diff --git a/TableProTests/Core/Plugins/ContainerEntityNameTests.swift b/TableProTests/Core/Plugins/ContainerEntityNameTests.swift index 9cadbb315b..9ec48e3479 100644 --- a/TableProTests/Core/Plugins/ContainerEntityNameTests.swift +++ b/TableProTests/Core/Plugins/ContainerEntityNameTests.swift @@ -52,12 +52,6 @@ struct ContainerEntityNameTests { #expect(PluginManager.shared.containerEntityNamePlural(for: .mysql) == "Databases") } - @Test("DriverPlugin defaults provide Database container") - func driverPluginDefaultIsDatabase() { - #expect(snapshot(forRegisteredTypeId: "MySQL")?.schema.containerEntityName == "Database") - #expect(PluginMetadataSnapshot.SchemaInfo.defaults.containerEntityName == "Database") - } - // MARK: - Container switch target @Test("Database-switching engines target databases") diff --git a/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift b/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift index 22eb03d54f..7c8c4ee5ba 100644 --- a/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift +++ b/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift @@ -8,93 +8,9 @@ import TableProPluginKit import Testing @testable import TablePro -// MARK: - Mock Plugin for Default Verification - -private final class MockDefaultPlugin: NSObject, TableProPlugin, DriverPlugin { - static let pluginName = "Mock Default" - static let pluginVersion = "1.0.0" - static let pluginDescription = "Plugin with all defaults" - static let capabilities: [PluginCapability] = [.databaseDriver] - - static let databaseTypeId = "MockDB" - static let databaseDisplayName = "Mock Database" - static let iconName = "cylinder.fill" - static let defaultPort = 9999 - - func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { - fatalError("Not used in tests") - } -} - -// MARK: - Mock Plugin with Custom Overrides - -private final class MockCustomPlugin: NSObject, TableProPlugin, DriverPlugin { - static let pluginName = "Mock Custom" - static let pluginVersion = "1.0.0" - static let pluginDescription = "Plugin with custom values" - static let capabilities: [PluginCapability] = [.databaseDriver] - - static let databaseTypeId = "CustomDB" - static let databaseDisplayName = "Custom Database" - static let iconName = "doc.fill" - static let defaultPort = 0 - - static let requiresAuthentication = false - static let connectionMode: ConnectionMode = .fileBased - static let urlSchemes: [String] = ["customdb"] - static let fileExtensions: [String] = ["cdb", "customdb"] - static let brandColorHex = "#FF0000" - static let queryLanguageName = "CQL" - static let editorLanguage: EditorLanguage = .custom("cypher") - static let supportsForeignKeys = false - static let supportsSchemaEditing = false - static let supportsDatabaseSwitching = false - static let supportsImport = false - static let supportsExport = false - static let databaseGroupingStrategy: GroupingStrategy = .flat - static let defaultGroupName = "default" - static let systemDatabaseNames: [String] = ["system", "internal"] - static let columnTypesByCategory: [String: [String]] = [ - "String": ["text"], - "Number": ["integer"], - "Binary": [] - ] - - func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { - fatalError("Not used in tests") - } -} - -// MARK: - ConnectionMode Tests - -struct ConnectionModeTests { - @Test("Raw values match expected strings") - func rawValues() { - #expect(ConnectionMode.network.rawValue == "network") - #expect(ConnectionMode.fileBased.rawValue == "fileBased") - } - - @Test("Codable round-trip") - func codable() throws { - for mode in [ConnectionMode.network, .fileBased] { - let data = try JSONEncoder().encode(mode) - let decoded = try JSONDecoder().decode(ConnectionMode.self, from: data) - #expect(decoded == mode) - } - } -} - // MARK: - EditorLanguage Tests struct EditorLanguageTests { - @Test("Equatable for known cases") - func equatable() { - #expect(EditorLanguage.sql == EditorLanguage.sql) - #expect(EditorLanguage.javascript == EditorLanguage.javascript) - #expect(EditorLanguage.bash == EditorLanguage.bash) - #expect(EditorLanguage.sql != EditorLanguage.javascript) - } - @Test("Custom case with associated value") func customCase() { let lang = EditorLanguage.custom("graphql") @@ -114,175 +30,6 @@ struct EditorLanguageTests { } } -// MARK: - GroupingStrategy Tests - -struct GroupingStrategyTests { - @Test("Raw values match expected strings") - func rawValues() { - #expect(GroupingStrategy.byDatabase.rawValue == "byDatabase") - #expect(GroupingStrategy.bySchema.rawValue == "bySchema") - #expect(GroupingStrategy.flat.rawValue == "flat") - #expect(GroupingStrategy.hierarchicalSchema.rawValue == "hierarchicalSchema") - } - - @Test("Codable round-trip") - func codable() throws { - for strategy in [GroupingStrategy.byDatabase, .bySchema, .flat, .hierarchicalSchema] { - let data = try JSONEncoder().encode(strategy) - let decoded = try JSONDecoder().decode(GroupingStrategy.self, from: data) - #expect(decoded == strategy) - } - } -} - -// MARK: - DriverPlugin Protocol Defaults - -struct DriverPluginDefaultsTests { - @Test("Default requiresAuthentication is true") - func requiresAuthentication() { - #expect(MockDefaultPlugin.requiresAuthentication == true) - } - - @Test("Default connectionMode is .network") - func connectionMode() { - #expect(MockDefaultPlugin.connectionMode == .network) - } - - @Test("Default urlSchemes is empty") - func urlSchemes() { - #expect(MockDefaultPlugin.urlSchemes.isEmpty) - } - - @Test("Default fileExtensions is empty") - func fileExtensions() { - #expect(MockDefaultPlugin.fileExtensions.isEmpty) - } - - @Test("Default brandColorHex is gray") - func brandColorHex() { - #expect(MockDefaultPlugin.brandColorHex == "#808080") - } - - @Test("Default queryLanguageName is SQL") - func queryLanguageName() { - #expect(MockDefaultPlugin.queryLanguageName == "SQL") - } - - @Test("Default editorLanguage is .sql") - func editorLanguage() { - #expect(MockDefaultPlugin.editorLanguage == .sql) - } - - @Test("Default supportsForeignKeys is true") - func supportsForeignKeys() { - #expect(MockDefaultPlugin.supportsForeignKeys == true) - } - - @Test("Default supportsSchemaEditing is true") - func supportsSchemaEditing() { - #expect(MockDefaultPlugin.supportsSchemaEditing == true) - } - - @Test("Default supportsDatabaseSwitching is true") - func supportsDatabaseSwitching() { - #expect(MockDefaultPlugin.supportsDatabaseSwitching == true) - } - - @Test("Default supportsSchemaSwitching is false") - func supportsSchemaSwitching() { - #expect(MockDefaultPlugin.supportsSchemaSwitching == false) - } - - @Test("Default supportsImport is true") - func supportsImport() { - #expect(MockDefaultPlugin.supportsImport == true) - } - - @Test("Default supportsExport is true") - func supportsExport() { - #expect(MockDefaultPlugin.supportsExport == true) - } - - @Test("Default supportsHealthMonitor is true") - func supportsHealthMonitor() { - #expect(MockDefaultPlugin.supportsHealthMonitor == true) - } - - @Test("Default systemDatabaseNames is empty") - func systemDatabaseNames() { - #expect(MockDefaultPlugin.systemDatabaseNames.isEmpty) - } - - @Test("Default systemSchemaNames is empty") - func systemSchemaNames() { - #expect(MockDefaultPlugin.systemSchemaNames.isEmpty) - } - - @Test("Default databaseGroupingStrategy is .byDatabase") - func databaseGroupingStrategy() { - #expect(MockDefaultPlugin.databaseGroupingStrategy == .byDatabase) - } - - @Test("Default defaultGroupName is main") - func defaultGroupName() { - #expect(MockDefaultPlugin.defaultGroupName == "main") - } - - @Test("Default columnTypesByCategory contains standard SQL categories") - func columnTypesByCategory() { - let types = MockDefaultPlugin.columnTypesByCategory - #expect(types["Integer"] != nil) - #expect(types["Float"] != nil) - #expect(types["String"] != nil) - #expect(types["Date"] != nil) - #expect(types["Binary"] != nil) - #expect(types["Boolean"] != nil) - #expect(types["JSON"] != nil) - } -} - -// MARK: - Custom Override Verification - -struct DriverPluginCustomOverridesTests { - @Test("Custom plugin overrides all defaults correctly") - func customOverrides() { - #expect(MockCustomPlugin.requiresAuthentication == false) - #expect(MockCustomPlugin.connectionMode == .fileBased) - #expect(MockCustomPlugin.urlSchemes == ["customdb"]) - #expect(MockCustomPlugin.fileExtensions == ["cdb", "customdb"]) - #expect(MockCustomPlugin.brandColorHex == "#FF0000") - #expect(MockCustomPlugin.queryLanguageName == "CQL") - #expect(MockCustomPlugin.editorLanguage == .custom("cypher")) - #expect(MockCustomPlugin.supportsForeignKeys == false) - #expect(MockCustomPlugin.supportsSchemaEditing == false) - #expect(MockCustomPlugin.supportsDatabaseSwitching == false) - #expect(MockCustomPlugin.supportsImport == false) - #expect(MockCustomPlugin.supportsExport == false) - #expect(MockCustomPlugin.databaseGroupingStrategy == .flat) - #expect(MockCustomPlugin.defaultGroupName == "default") - #expect(MockCustomPlugin.systemDatabaseNames == ["system", "internal"]) - } - - @Test("Empty arrays in columnTypesByCategory are preserved") - func emptyArraysPreserved() { - let types = MockCustomPlugin.columnTypesByCategory - #expect(types["Binary"] == []) - #expect(types["String"] == ["text"]) - #expect(types["Number"] == ["integer"]) - } - - @Test("Non-overridden values still use defaults") - func nonOverriddenDefaults() { - #expect(MockCustomPlugin.supportsSchemaSwitching == false) - #expect(MockCustomPlugin.supportsHealthMonitor == true) - #expect(MockCustomPlugin.systemSchemaNames.isEmpty) - } -} - -// NOTE: Per-plugin metadata tests (MySQL, PostgreSQL, etc.) cannot run in xcodebuild test -// because .tableplugin bundles are loaded at runtime by the main app, not the test runner. -// The protocol defaults and override mechanism are fully covered by the mock-based tests above. - struct RegistryAutoLimitStyleTests { private var defaults: [String: PluginMetadataSnapshot] { Dictionary( diff --git a/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift b/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift index 256b33645c..94520e4b6f 100644 --- a/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift +++ b/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift @@ -1,34 +1,12 @@ -import Foundation -import TableProPluginKit import Testing @testable import TablePro @Suite("PluginManager needsRestart State", .serialized) @MainActor struct NeedsRestartPersistenceTests { - private let defaults = UserDefaults.standard - private let needsRestartKey = "com.TablePro.needsRestart" - - @Test("needsRestart defaults to false") - func needsRestartDefaultsToFalse() { - #expect(PluginManager.shared.needsRestart == false) - } - - @Test("needsRestart value matches UserDefaults") - func needsRestartMatchesUserDefaults() { - let currentValue = PluginManager.shared.needsRestart - let defaultsValue = defaults.bool(forKey: needsRestartKey) - #expect(currentValue == defaultsValue) - } - @Test("loadPendingPlugins with no pending plugins does not set needsRestart") func loadPendingPluginsKeepsFalse() async { await PluginManager.shared.loadPendingPluginsAsync() #expect(PluginManager.shared.needsRestart == false) } - - @Test("UserDefaults key for needsRestart uses expected value") - func needsRestartKeyValue() { - #expect(needsRestartKey == "com.TablePro.needsRestart") - } } diff --git a/TableProTests/Core/Plugins/OracleCuratedParityTests.swift b/TableProTests/Core/Plugins/OracleCuratedParityTests.swift deleted file mode 100644 index d0b76bd71c..0000000000 --- a/TableProTests/Core/Plugins/OracleCuratedParityTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// OracleCuratedParityTests.swift -// TableProTests -// -// Oracle's type lists and paging are declared twice: by the plugin, and in the app's curated entry, which is what -// the Structure type picker and completion get until the registry plugin is installed. The two drifted before, the -// curated picker kept an empty Boolean group, so this holds them to one list. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -@MainActor -struct OracleCuratedParityTests { - private func curatedOracle() throws -> PluginMetadataSnapshot { - try #require( - PluginMetadataRegistry.shared.builtInDefaults().first { $0.typeId == DatabaseType.oracle.rawValue }?.snapshot - ) - } - - @Test("The curated type picker groups Oracle's types as the plugin does") - func columnTypesMatchThePlugin() throws { - #expect(try curatedOracle().editor.columnTypesByCategory == OracleTypeCatalog.columnTypesByCategory) - } - - @Test("The curated completion list names every type the plugin offers, once") - func dataTypesMatchThePlugin() throws { - let curated = try #require(try curatedOracle().editor.sqlDialect).dataTypes - #expect(curated == Set(OracleTypeCatalog.dataTypes)) - #expect(Set(OracleTypeCatalog.dataTypes).count == OracleTypeCatalog.dataTypes.count) - } - - @Test("Every type the picker offers is one completion offers") - func pickerTypesAreCompletable() throws { - let snapshot = try curatedOracle() - let completable = try #require(snapshot.editor.sqlDialect).dataTypes - let offered = Set(snapshot.editor.columnTypesByCategory.values.flatMap { $0 }) - #expect(offered.subtracting(completable).isEmpty) - #expect(offered.isSuperset(of: ["BOOLEAN", "JSON", "VECTOR"])) - } - - /// `ORDER BY 1` sorts by the first column, and a LOB first column fails the whole page with ORA-22848. The plugin - /// declares none, and the curated entry is what pages a table before the plugin loads. - @Test("The curated dialect pages without an ORDER BY, as the plugin does") - func pagingMatchesThePlugin() throws { - let curated = try #require(try curatedOracle().editor.sqlDialect).offsetFetchOrderBy - #expect(curated.isEmpty) - #expect(try Self.pluginOffsetFetchOrderBy() == [curated]) - } - - @Test("A keyless Oracle row match leaves out every column Oracle cannot compare with =") - func keylessMatchLeavesOutUncomparableColumns() throws { - let prefixes = try curatedOracle().schema.rowMatchExcludedTypePrefixes - let columns = [ - ColumnInfo(name: "ID", dataType: "NUMBER(10)", isNullable: false, isPrimaryKey: false), - ColumnInfo(name: "NAME", dataType: "NVARCHAR2(100)", isNullable: true, isPrimaryKey: false), - ColumnInfo(name: "CODE", dataType: "VARCHAR2(50 CHAR)", isNullable: true, isPrimaryKey: false), - ColumnInfo( - name: "CREATED", dataType: "DATE", isNullable: true, isPrimaryKey: false, - classificationTypeName: "TIMESTAMP(0)" - ), - ColumnInfo(name: "AT", dataType: "TIMESTAMP(6) WITH TIME ZONE", isNullable: true, isPrimaryKey: false), - ColumnInfo(name: "BYTES", dataType: "RAW(16)", isNullable: true, isPrimaryKey: false), - ColumnInfo(name: "NOTES", dataType: "CLOB", isNullable: true, isPrimaryKey: false), - ColumnInfo(name: "BODY", dataType: "NCLOB", isNullable: true, isPrimaryKey: false), - ColumnInfo(name: "PHOTO", dataType: "BLOB", isNullable: true, isPrimaryKey: false), - ColumnInfo(name: "LEGACY", dataType: "LONG", isNullable: true, isPrimaryKey: false), - ColumnInfo(name: "OLD_BYTES", dataType: "LONG RAW", isNullable: true, isPrimaryKey: false), - ColumnInfo(name: "SCAN", dataType: "BFILE", isNullable: true, isPrimaryKey: false), - ColumnInfo(name: "DOC", dataType: "XMLTYPE", isNullable: true, isPrimaryKey: false), - ColumnInfo(name: "DATA", dataType: "JSON", isNullable: true, isPrimaryKey: false), - ColumnInfo(name: "EMBEDDING", dataType: "VECTOR(3, FLOAT32)", isNullable: true, isPrimaryKey: false), - ColumnInfo( - name: "SHAPE", dataType: "\"MDSYS\".\"SDO_GEOMETRY\"", isNullable: true, isPrimaryKey: false, - ddlSpelling: "\"MDSYS\".\"SDO_GEOMETRY\"", classificationTypeName: "SDO_GEOMETRY" - ) - ] - - #expect( - QueryExecutor.columns(in: columns, typedAnyOf: prefixes) - == ["NOTES", "BODY", "PHOTO", "LEGACY", "OLD_BYTES", "SCAN", "DOC", "DATA", "EMBEDDING", "SHAPE"] - ) - } - - @Test("Oracle compares no column through a text conversion, which it has no CONCAT form for") - func noTextComparedColumns() throws { - #expect(try curatedOracle().schema.rowMatchTextTypePrefixes.isEmpty) - } - - /// Read from the plugin's source because its type list is shared with the tests but its dialect is not: the - /// dialect lives on the plugin class, which links the Oracle driver core. - private static func pluginOffsetFetchOrderBy(file: StaticString = #filePath) throws -> [String] { - let directory = try repositoryRoot(file: file).appendingPathComponent("Plugins/OracleDriverPlugin") - let sources = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "swift" } - let pattern = try NSRegularExpression(pattern: #"offsetFetchOrderBy:\s*"([^"]*)""#) - return try sources.flatMap { url -> [String] in - let text = try String(contentsOf: url, encoding: .utf8) - let range = NSRange(text.startIndex..., in: text) - return pattern.matches(in: text, range: range).compactMap { match in - Range(match.range(at: 1), in: text).map { String(text[$0]) } - } - } - } - - private static func repositoryRoot(file: StaticString) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - let candidate = directory.appendingPathComponent("Plugins/TableProPluginKit/DriverPlugin.swift") - if FileManager.default.fileExists(atPath: candidate.path) { return directory } - directory = directory.deletingLastPathComponent() - } - throw ParityError.sourceNotFound - } - - private enum ParityError: Error { - case sourceNotFound - } -} diff --git a/TableProTests/Core/Plugins/PingNeverReconnectsTests.swift b/TableProTests/Core/Plugins/PingNeverReconnectsTests.swift deleted file mode 100644 index 1fdfa8fe7b..0000000000 --- a/TableProTests/Core/Plugins/PingNeverReconnectsTests.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// PingNeverReconnectsTests.swift -// TableProTests -// -// A ping that heals its own connection reports success into a server session the app has just -// lost: the startup commands, the query timeout, the database and the schema all belong to -// `DatabaseManager.reconnectDriver`, and a private reconnect inside the driver re-runs none of -// them. Failing instead is what routes recovery through the manager (#2700). -// -// The driver sources are not in the test target, because they need their C bridges, so this is a -// source scan rather than a behavioural test. -// - -import Foundation -@testable import TablePro -import Testing - -struct PingNeverReconnectsTests { - /// Snowflake is the one deliberate exception. Its probe goes through `withReauthentication`, - /// and an expired token is ordinary rather than a fault, so failing the ping would rebuild the - /// driver every few hours instead of renewing the session it already has. - private static let allowed: Set = ["SnowflakeConnection.swift"] - - /// Calls that recover a dropped connection and replay, rather than reporting it. - private static let reconnectingCalls = ["executeWithReconnect(", "SyncRetrying(", "boundedQueryWithReconnect("] - - @Test("no driver's ping routes through a call that reconnects and replays") - func pingsDoNotHealThemselves() throws { - var offenders: [String] = [] - - for source in try Self.pluginSources() { - guard !Self.allowed.contains(source.url.lastPathComponent) else { continue } - /// A driver that has a reconnecting execute path must not reach its plain `execute` - /// from a ping either: that is the indirection PostgreSQL's ping used, where the - /// reconnect is a layer further down than the call site shows. - let heals = source.text.contains("func executeWithReconnect(") - for body in Self.pingBodies(in: source.text) { - for call in Self.reconnectingCalls where body.contains(call) { - offenders.append("\(source.url.lastPathComponent): ping() calls \(call)") - } - if heals, body.contains("execute(query:") { - offenders.append("\(source.url.lastPathComponent): ping() calls execute(query:), which reconnects") - } - } - } - - #expect( - offenders.isEmpty, - """ - A ping must report a dropped connection rather than repair it, or the health check \ - succeeds against a session that lost its startup commands, query timeout, database and \ - schema: \(offenders.sorted()) - """ - ) - } - - /// Everything from a `func ping(` line to the first line closing it at the declaration's own - /// indentation, which is enough to tell one function's body from its neighbours'. - private static func pingBodies(in text: String) -> [String] { - let lines = text.components(separatedBy: .newlines) - var bodies: [String] = [] - for (index, line) in lines.enumerated() where line.contains("func ping(") { - let indent = line.prefix { $0 == " " }.count - let closing = String(repeating: " ", count: indent) + "}" - guard let end = lines[(index + 1)...].firstIndex(of: closing) else { continue } - bodies.append(lines[index ... end].joined(separator: "\n")) - } - return bodies - } - - private struct PluginSource { - let url: URL - let text: String - } - - private static func pluginSources(file: StaticString = #filePath) throws -> [PluginSource] { - let root = try repositoryRoot(file: file).appendingPathComponent("Plugins") - guard let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) else { - throw ScanError.sourcesNotFound - } - var sources: [PluginSource] = [] - for case let url as URL in walker where url.pathExtension == "swift" { - guard let text = try? String(contentsOf: url, encoding: .utf8), text.contains("func ping(") else { continue } - sources.append(PluginSource(url: url, text: text)) - } - guard !sources.isEmpty else { throw ScanError.sourcesNotFound } - return sources - } - - private static func repositoryRoot(file: StaticString) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - let candidate = directory.appendingPathComponent("Plugins/TableProPluginKit/DriverPlugin.swift") - if FileManager.default.fileExists(atPath: candidate.path) { return directory } - directory = directory.deletingLastPathComponent() - } - throw ScanError.sourcesNotFound - } - - private enum ScanError: Error { - case sourcesNotFound - } -} diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift index 0f7648f885..ee8b89038a 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift @@ -238,16 +238,6 @@ struct PluginMetadataRegistryCuratedCapabilityTests { ) } - @Test("DuckDB keeps the file kinds a new database can be named with when its plugin registers") - func duckDBKeepsItsNewDatabaseFileExtensions() { - let built = PluginMetadataRegistry.shared.buildMetadataSnapshot(from: MockDuckDBPlugin.self) - - #expect( - built.capabilities.newDatabaseFileExtensions == DuckDBFileKinds.database, - "A missing Parquet or CSV path is refused, so New… must offer only DuckDB's own format" - ) - } - @Test("SQLite keeps the file kinds a new database can be named with when its plugin registers") func sqliteKeepsItsNewDatabaseFileExtensions() throws { let curated = try #require(PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: "SQLite")) diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift index 1dcb85852e..2296d772cf 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift @@ -14,117 +14,6 @@ struct PluginMetadataRegistrySchemaSwitchingTests { PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: typeId) } - // MARK: - SQL Server - - @Test("SQL Server supports schema switching") - func sqlServerSupportsSchemaSwitching() { - guard let snap = snapshot(forRegisteredTypeId: "SQL Server") else { - Issue.record("Registry default for SQL Server missing") - return - } - #expect(snap.capabilities.supportsSchemaSwitching == true) - } - - @Test("SQL Server post-connect actions restore last schema") - func sqlServerRestoresLastSchema() { - guard let snap = snapshot(forRegisteredTypeId: "SQL Server") else { - Issue.record("Registry default for SQL Server missing") - return - } - #expect(snap.postConnectActions.contains(.selectSchemaFromLastSession)) - } - - @Test("SQL Server post-connect actions still restore last database") - func sqlServerRestoresLastDatabase() { - guard let snap = snapshot(forRegisteredTypeId: "SQL Server") else { - Issue.record("Registry default for SQL Server missing") - return - } - #expect(snap.postConnectActions.contains(.selectDatabaseFromLastSession)) - } - - // MARK: - Oracle - - @Test("Oracle supports schema switching") - func oracleSupportsSchemaSwitching() { - guard let snap = snapshot(forRegisteredTypeId: "Oracle") else { - Issue.record("Registry default for Oracle missing") - return - } - #expect(snap.capabilities.supportsSchemaSwitching == true) - } - - @Test("Oracle post-connect actions restore last schema") - func oracleRestoresLastSchema() { - guard let snap = snapshot(forRegisteredTypeId: "Oracle") else { - Issue.record("Registry default for Oracle missing") - return - } - #expect(snap.postConnectActions.contains(.selectSchemaFromLastSession)) - } - - // MARK: - Dameng - - @Test("Dameng supports schema switching without TLS") - func damengSupportsSchemaSwitching() { - guard let snap = snapshot(forRegisteredTypeId: "Dameng") else { - Issue.record("Registry default for Dameng missing") - return - } - #expect(snap.capabilities.supportsSchemaSwitching == true) - #expect(snap.capabilities.supportsSSL == false) - #expect(snap.postConnectActions.contains(.selectSchemaFromLastSession)) - } - - @Test("Dameng publishes DM8 typing suggestions") - func damengPublishesTypingSuggestions() { - guard let snap = snapshot(forRegisteredTypeId: "Dameng") else { - Issue.record("Registry default for Dameng missing") - return - } - let labels = Set(snap.editor.statementCompletions.map(\.label)) - #expect(labels.isSuperset(of: ["SET SCHEMA", "CREATE SCHEMA", "EXPLAIN", "CONNECT BY"])) - #expect(snap.editor.sqlDialect?.functions.contains("NVL") == true) - #expect(snap.editor.sqlDialect?.dataTypes.contains("VARCHAR2") == true) - #expect(snap.editor.sqlDialect?.dataTypes.contains("XMLTYPE") == false) - #expect(snap.editor.sqlDialect?.functions.contains("DBMS_RANDOM.VALUE") == false) - } - - // MARK: - DuckDB - - @Test("DuckDB supports schema switching") - func duckDBSupportsSchemaSwitching() { - guard let snap = snapshot(forRegisteredTypeId: "DuckDB") else { - Issue.record("Registry default for DuckDB missing") - return - } - #expect(snap.capabilities.supportsSchemaSwitching == true) - } - - @Test("DuckDB post-connect actions restore last schema") - func duckDBRestoresLastSchema() { - guard let snap = snapshot(forRegisteredTypeId: "DuckDB") else { - Issue.record("Registry default for DuckDB missing") - return - } - #expect(snap.postConnectActions.contains(.selectSchemaFromLastSession)) - } - - /// DuckDB's default schema is `main`. Inheriting PostgreSQL's `public` made every - /// table in the default schema render as `main.name` and broke export preselection. - @Test("DuckDB's default schema is main") - func duckDBDefaultSchemaIsMain() { - #expect(snapshot(forRegisteredTypeId: "DuckDB")?.schema.defaultSchemaName == "main") - } - - /// `information_schema` and `pg_catalog` are schemas of DuckDB's `system` catalog, - /// not databases. The system databases are `system` and `temp`. - @Test("DuckDB's system databases are its built-in catalogs") - func duckDBSystemDatabases() { - let names = snapshot(forRegisteredTypeId: "DuckDB")?.schema.systemDatabaseNames ?? [] - #expect(Set(names) == ["system", "temp"]) - } - // MARK: - PostgreSQL (regression for the working reference) @Test("PostgreSQL supports schema switching") diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index 0700971f18..e549d93fce 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -22,10 +22,6 @@ import Testing /// `expectedTypeIds`, then, in TableProApp/docs, `snippets/driver-counts.mdx` and the `and N more` /// count in the `index.mdx` frontmatter, and the engine count on tablepro.app. The docs repository's /// `scripts/check-docs-against-source.py` reads this registry and holds the docs half. -/// -/// The count is taken from the built-in defaults rather than from `allRegisteredTypeIds()`. -/// Both answer 39 under XCTest, where no plugin bundle ever loads, but the registry is a -/// process-global singleton and suites that register a synthetic type run alongside this one. @MainActor struct PluginMetadataRegistryTypeCountTests { private static let expectedTypeIds: Set = [ @@ -42,13 +38,6 @@ struct PluginMetadataRegistryTypeCountTests { return Set(curated + registry) } - @Test("The app ships 39 database types before any plugin loads") - func builtInDefaultsCoverTwentyNineTypes() { - let ids = Self.builtInTypeIds() - #expect(ids.count == 39) - #expect(ids == Self.expectedTypeIds) - } - @Test("Every built-in type is offered by the registry") func registrySurfacesEveryBuiltInType() { let registered = Set(PluginMetadataRegistry.shared.allRegisteredTypeIds()) diff --git a/TableProTests/Core/Plugins/PluginSettingsTests.swift b/TableProTests/Core/Plugins/PluginSettingsTests.swift index 0e5c4e0d58..d80559bcf4 100644 --- a/TableProTests/Core/Plugins/PluginSettingsTests.swift +++ b/TableProTests/Core/Plugins/PluginSettingsTests.swift @@ -218,12 +218,6 @@ struct SettablePluginSnapshotTests { struct PluginCapabilityTests { - @Test("only has 3 cases: databaseDriver, exportFormat, importFormat") - func onlyThreeCases() { - let allCases: [PluginCapability] = [.databaseDriver, .exportFormat, .importFormat] - #expect(allCases.count == 3) - } - @Test("raw values are stable integers") func rawValuesStable() { #expect(PluginCapability.databaseDriver.rawValue == 0) @@ -231,14 +225,6 @@ struct PluginCapabilityTests { #expect(PluginCapability.importFormat.rawValue == 2) } - @Test("Codable round-trip preserves value") - func codableRoundTrip() throws { - let original = PluginCapability.exportFormat - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(PluginCapability.self, from: data) - #expect(decoded == original) - } - /// `PluginCapability` is a growing set, so a raw value that means nothing today can mean /// something tomorrow: this asserted that 3 was undecodable and 3 is now `documentInspector`. /// A value far outside the range keeps the property under test, which is that an unknown @@ -248,14 +234,4 @@ struct PluginCapabilityTests { let decoded = try? JSONDecoder().decode(PluginCapability.self, from: Data("9999".utf8)) #expect(decoded == nil) } - - @Test("every declared capability round-trips") - func declaredCapabilitiesRoundTrip() { - for capability in [ - PluginCapability.databaseDriver, .exportFormat, .importFormat, .documentInspector, - ] { - let data = try? JSONEncoder().encode(capability) - #expect(data.flatMap { try? JSONDecoder().decode(PluginCapability.self, from: $0) } == capability) - } - } } diff --git a/TableProTests/Core/Plugins/PluginSignatureGatePlacementTests.swift b/TableProTests/Core/Plugins/PluginSignatureGatePlacementTests.swift deleted file mode 100644 index a7b0ff9a4e..0000000000 --- a/TableProTests/Core/Plugins/PluginSignatureGatePlacementTests.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// PluginSignatureGatePlacementTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// Where the plugin signature check runs is a launch-time budget decision and a security decision -/// at once, and only one of the two is visible in a diff. -/// -/// `SecStaticCodeCheckValidity` hashes the whole bundle: measured at 13ms per user-installed plugin -/// on the launch thread, linear in how many are installed. Discovery and lazy registration load no -/// code, so a check there buys nothing but the Plugins pane's rejected list, which -/// `sweepPluginSignatures()` now fills off the main actor after the first frame. The two calls that -/// do load code must keep verifying, immediately before `PluginBundleLoader.load`. -struct PluginSignatureGatePlacementTests { - @Test("Discovery and lazy registration do not verify signatures") - func launchPathDoesNotVerifySignatures() throws { - for function in ["discoverPlugin", "registerLazyManifest"] { - let body = try Self.functionBody(named: function) - #expect( - !body.contains("verifyCodeSignature") && !body.contains("PluginCodeSignatureVerifier"), - """ - `\(function)` verifies a signature again. It loads no code, so the check costs the \ - launch 13ms per installed plugin and gates nothing. Leave it to \ - `sweepPluginSignatures()`. - """ - ) - } - } - - @Test("Every path that loads a plugin's executable goes through the gate first") - func loadPathsVerifySignatures() throws { - for function in ["activateLazyBundle", "validateAndLoadBundle"] { - let body = try Self.functionBody(named: function) - #expect( - body.contains("assertLoadable") || body.contains("PluginCodeSignatureVerifier"), - """ - `\(function)` reaches a plugin's executable without the load gate. Both `PluginBundleLoader.load` and `Bundle.principalClass` load it, and discovery now publishes an entry before its signature has been checked, so an unverified bundle is reachable from here. - """ - ) - } - } - - @Test("Nothing outside the gated paths touches principalClass") - func principalClassIsOnlyReachedFromGatedPaths() throws { - /// `validateDependencies` is allowed because it reads `principalClass` only for a bundle - /// that is already `isLoaded`, so the executable it would load is the one a gated path - /// already loaded. - let gated = ["registerBundle", "activateLazyBundle", "validateDependencies"] - var offenders: [String] = [] - for url in try Self.pluginSources() { - let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) - for (index, line) in lines.enumerated() where line.contains(".principalClass") { - let owner = Self.enclosingFunction(of: index, in: lines) - guard !gated.contains(where: { owner.contains($0) }) else { continue } - offenders.append("\(url.lastPathComponent):\(index + 1) in \(owner)") - } - } - #expect( - offenders.isEmpty, - """ - `Bundle.principalClass` loads the bundle's executable, so every use of it belongs in a function that has already called `assertLoadable`: \(offenders.sorted()) - """ - ) - } - - private static func enclosingFunction(of line: Int, in lines: [String]) -> String { - for index in stride(from: line, through: 0, by: -1) where lines[index].contains("func ") { - return lines[index].trimmingCharacters(in: .whitespaces) - } - return "" - } - - private static func pluginSources(file: StaticString = #filePath) throws -> [URL] { - let root = try pluginManagerSource(file: file).deletingLastPathComponent() - let contents = try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) - return contents.filter { $0.pathExtension == "swift" } - } - - /// Everything from the declaration line to the first line that closes it at the declaration's - /// own indentation, which is enough to tell one function's body from its neighbours'. - private static func functionBody(named name: String) throws -> String { - for url in try pluginSources() { - let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) - guard let start = lines.firstIndex(where: { $0.contains("func \(name)(") }) else { continue } - let indent = lines[start].prefix { $0 == " " }.count - let closing = String(repeating: " ", count: indent) + "}" - guard let end = lines[(start + 1)...].firstIndex(of: closing) else { continue } - return lines[start ... end].joined(separator: "\n") - } - throw PlacementError.functionNotFound(name) - } - - private static func pluginManagerSource(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - let candidate = directory.appendingPathComponent("TablePro/Core/Plugins/PluginManager.swift") - if FileManager.default.fileExists(atPath: candidate.path) { return candidate } - directory = directory.deletingLastPathComponent() - } - throw PlacementError.sourceNotFound - } - - private enum PlacementError: Error { - case functionNotFound(String) - case sourceNotFound - } -} diff --git a/TableProTests/Core/Plugins/PluginSignatureValidationFlagsTests.swift b/TableProTests/Core/Plugins/PluginSignatureValidationFlagsTests.swift index 20c3d066a1..3b43e6cbb4 100644 --- a/TableProTests/Core/Plugins/PluginSignatureValidationFlagsTests.swift +++ b/TableProTests/Core/Plugins/PluginSignatureValidationFlagsTests.swift @@ -3,7 +3,6 @@ // TableProTests // -import Foundation import Security @testable import TablePro import Testing @@ -28,43 +27,4 @@ struct PluginSignatureValidationFlagsTests { func flagsCheckAllArchitectures() { #expect(PluginCodeSignatureVerifier.validationFlags & kSecCSCheckAllArchitectures != 0) } - - @Test("No validity check is made with a weaker flag set") - func everyValidityCheckUsesTheSharedFlags() throws { - let source = try verifierSource() - let lines = try String(contentsOf: source, encoding: .utf8).components(separatedBy: .newlines) - - var checkedCalls = 0 - for (index, line) in lines.enumerated() where line.contains("SecStaticCodeCheckValidity(") { - checkedCalls += 1 - #expect( - line.contains("flags"), - """ - \(source.lastPathComponent):\(index + 1) checks validity with its own flag set. \ - Every call must pass the shared `flags`, or that load path silently drops the \ - nested-code and strict-validation checks. - """ - ) - } - #expect(checkedCalls > 0) - - let flagBindings = lines.filter { $0.contains("let flags = SecCSFlags(") } - #expect(flagBindings.count == 1) - #expect(flagBindings.first?.contains("Self.validationFlags") == true) - } - - private func verifierSource(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - let candidate = directory - .appendingPathComponent("TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift") - if FileManager.default.fileExists(atPath: candidate.path) { return candidate } - directory = directory.deletingLastPathComponent() - } - throw FlagsTestError.sourceNotFound - } - - private enum FlagsTestError: Error { - case sourceNotFound - } } diff --git a/TableProTests/Core/Plugins/PluginValidationTests.swift b/TableProTests/Core/Plugins/PluginValidationTests.swift index cae53f4912..86816082eb 100644 --- a/TableProTests/Core/Plugins/PluginValidationTests.swift +++ b/TableProTests/Core/Plugins/PluginValidationTests.swift @@ -155,59 +155,3 @@ struct PluginErrorInvalidDescriptorTests { #expect(description.contains("MySQL")) } } - -// MARK: - validateConnectionFields Tests - -struct ValidateConnectionFieldsTests { - - @Test("duplicate field IDs are detected") - @MainActor func duplicateFieldIds() { - let fields = [ - ConnectionField(id: "encoding", label: "Encoding"), - ConnectionField(id: "timeout", label: "Timeout"), - ConnectionField(id: "encoding", label: "Character Encoding") - ] - // Should not crash — warns via logger - PluginManager.shared.validateConnectionFields(fields, pluginId: "test") - } - - @Test("empty field ID is detected without crash") - @MainActor func emptyFieldId() { - let fields = [ConnectionField(id: "", label: "Something")] - PluginManager.shared.validateConnectionFields(fields, pluginId: "test") - } - - @Test("empty field label is detected without crash") - @MainActor func emptyFieldLabel() { - let fields = [ConnectionField(id: "test", label: "")] - PluginManager.shared.validateConnectionFields(fields, pluginId: "test") - } - - @Test("dropdown with empty options is detected without crash") - @MainActor func emptyDropdownOptions() { - let fields = [ - ConnectionField( - id: "encoding", - label: "Encoding", - fieldType: .dropdown(options: []) - ) - ] - PluginManager.shared.validateConnectionFields(fields, pluginId: "test") - } - - @Test("valid fields pass without issue") - @MainActor func validFields() { - let fields = [ - ConnectionField(id: "encoding", label: "Encoding"), - ConnectionField( - id: "mode", - label: "Mode", - fieldType: .dropdown(options: [ - ConnectionField.DropdownOption(value: "fast", label: "Fast"), - ConnectionField.DropdownOption(value: "safe", label: "Safe") - ]) - ) - ] - PluginManager.shared.validateConnectionFields(fields, pluginId: "test") - } -} diff --git a/TableProTests/Core/Plugins/SQLServerImportBatchTests.swift b/TableProTests/Core/Plugins/SQLServerImportBatchTests.swift deleted file mode 100644 index 2c1490c0a1..0000000000 --- a/TableProTests/Core/Plugins/SQLServerImportBatchTests.swift +++ /dev/null @@ -1,224 +0,0 @@ -// -// SQLServerImportBatchTests.swift -// TableProTests -// -// A SQL file imported into SQL Server reaches the server a batch at a time, the way sqlcmd sends it. The import used -// to split it at every `;` and send each `GO` line on to the server, which ran the statement after a `GO`, refused the -// `GO` itself with Msg 2812 or Msg 102, and lost every variable between the statement that declared it and the next. -// A file with no `GO` line still goes a statement at a time, each statement a batch of its own. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -private final class BatchImportDriver: PluginDatabaseDriver, @unchecked Sendable { - let declaresBatches: Bool - private(set) var sentBatches: [(query: String, rowCap: Int?)] = [] - private(set) var executedStatements: [String] = [] - - init(declaresBatches: Bool) { - self.declaresBatches = declaresBatches - } - - var capabilities: PluginCapabilities { - declaresBatches ? [.resultSetBatches] : [] - } - - func connect() async throws {} - func disconnect() {} - - func execute(query: String) async throws -> PluginQueryResult { - executedStatements.append(query) - return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) - } - - /// Answers the way SQL Server does for a batch naming a table that does not exist: the error comes back with the - /// batch's own line, counted from the batch's first line, and the rest of the batch still ran. - func executeBatch(query: String, rowCap: Int?, parameters: [PluginCellValue]?) async throws -> PluginBatchResult? { - guard declaresBatches else { return nil } - sentBatches.append((query, rowCap)) - let lines = query.components(separatedBy: "\n") - let errors = lines.enumerated() - .filter { $0.element.contains("missing") } - .map { index, _ in - PluginBatchError( - message: "Invalid object name 'missing'.", - code: 208, - line: index + 1, - procedure: nil, - precedingResultSetCount: 0 - ) - } - return PluginBatchResult( - resultSets: [], - rowsAffected: 1, - errors: errors, - discardedResultSetCount: 0, - executionTime: 0 - ) - } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } -} - -/// Serialized because `SQLImportPlugin.settings` persists through plugin storage. -@Suite("SQL Server import runs each GO batch whole", .serialized) -struct SQLServerImportBatchTests { - private func makeSink( - declaresBatches: Bool, - databaseType: DatabaseType = .mssql - ) -> (ImportDataSinkAdapter, BatchImportDriver) { - let driver = BatchImportDriver(declaresBatches: declaresBatches) - let adapter = PluginDriverAdapter( - connection: DatabaseConnection(name: "Test", type: databaseType), - pluginDriver: driver - ) - return (ImportDataSinkAdapter(driver: adapter, databaseType: databaseType), driver) - } - - private func runImport(_ script: String, sink: ImportDataSinkAdapter) async throws -> PluginImportResult { - let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".sql") - try script.write(to: url, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(at: url) } - - let plugin = SQLImportPlugin() - let original = plugin.settings - defer { plugin.settings = original } - plugin.settings.errorHandling = .stopAndRollback - plugin.settings.wrapInTransaction = false - plugin.settings.disableForeignKeyChecks = false - - return try await plugin.performImport( - source: SqlFileImportSource(url: url, encoding: .utf8, grammar: DatabaseType.mssql.lexicalGrammar), - sink: sink, - progress: PluginImportProgress(progress: Progress()) - ) - } - - @Test("A batch goes to the driver whole and once, keeping no rows") - func batchIsSentWhole() async throws { - let (sink, driver) = makeSink(declaresBatches: true) - let batch = "DECLARE @x INT = 1;\nINSERT INTO t (v) VALUES (@x);" - try await sink.execute(statement: batch, line: 4) - #expect(driver.sentBatches.map(\.query) == [batch]) - #expect(driver.sentBatches.map(\.rowCap) == [1]) - #expect(driver.executedStatements.isEmpty) - } - - @Test("An error inside a batch fails it on the file's own line") - func batchErrorNamesTheFileLine() async throws { - let (sink, _) = makeSink(declaresBatches: true) - do { - try await sink.execute(statement: "SELECT 1\nSELECT * FROM missing", line: 10) - Issue.record("A batch that raised an error was reported as run") - } catch { - #expect(error.localizedDescription == "Line 11: Invalid object name 'missing'.") - } - } - - @Test("A driver that cannot send a batch whole runs its statements one by one") - func batchlessDriverRunsStatements() async throws { - let (sink, driver) = makeSink(declaresBatches: false) - try await sink.execute(statement: "DECLARE @x INT = 1;\nINSERT INTO t (v) VALUES (@x);", line: 1) - #expect(driver.executedStatements == ["DECLARE @x INT = 1", "INSERT INTO t (v) VALUES (@x)"]) - } - - @Test("Another engine's statement is sent as it came") - func otherEnginesSendTheStatement() async throws { - let (sink, driver) = makeSink(declaresBatches: true, databaseType: .mysql) - try await sink.execute(statement: "INSERT INTO t VALUES (1)", line: 3) - #expect(driver.executedStatements == ["INSERT INTO t VALUES (1)"]) - #expect(driver.sentBatches.isEmpty) - } - - @Test("An imported Compare script sends each batch whole and never a GO line") - func compareScriptImports() async throws { - let (sink, driver) = makeSink(declaresBatches: true) - let script = """ - DROP PROCEDURE [dbo].[p]; - GO - CREATE PROCEDURE dbo.p AS SET NOCOUNT ON; SELECT 1; - GO - INSERT INTO t VALUES (1) - GO 2 - """ - let result = try await runImport(script, sink: sink) - #expect(driver.sentBatches.map(\.query) == [ - "DROP PROCEDURE [dbo].[p];", - "CREATE PROCEDURE dbo.p AS SET NOCOUNT ON; SELECT 1;", - "INSERT INTO t VALUES (1)", - "INSERT INTO t VALUES (1)", - ]) - #expect(driver.executedStatements.isEmpty) - #expect(result.executedStatements == 4) - } - - @Test("A failed batch stops the import on the batch's line, with the error's line in the file") - func failedBatchReportsBothLines() async throws { - let (sink, _) = makeSink(declaresBatches: true) - let script = "SELECT 1\nGO\n-- lead\nSELECT 2\nSELECT * FROM missing\nGO\nSELECT 3" - do { - _ = try await runImport(script, sink: sink) - Issue.record("An import whose batch raised an error completed") - } catch let PluginImportError.statementFailed(statement, line, underlying) { - #expect(statement == "-- lead\nSELECT 2\nSELECT * FROM missing") - #expect(line == 3) - #expect(underlying.localizedDescription == "Line 5: Invalid object name 'missing'.") - } - } - - @Test("A script with a GO line keeps a declared variable for the statement that reads it") - func declaredVariableReachesItsReader() async throws { - let (sink, driver) = makeSink(declaresBatches: true) - _ = try await runImport("DECLARE @x INT = 1;\nINSERT INTO t (v) VALUES (@x);\nGO\n", sink: sink) - #expect(driver.sentBatches.map(\.query) == ["DECLARE @x INT = 1;\nINSERT INTO t (v) VALUES (@x);"]) - } - - /// The shape of every SQL Server dump TablePro wrote before it wrote `GO` lines. Sent as one batch, SQL Server - /// refuses it with Msg 111 because the view is not the first statement of its batch, and runs none of it. - @Test("A dump with no GO line sends each statement as a batch of its own") - func dumpWithoutGoSendsEachStatement() async throws { - let (sink, driver) = makeSink(declaresBatches: true) - let dump = """ - CREATE TABLE [dbo].[t] ([a] int); - INSERT INTO [dbo].[t] ([a]) VALUES (1), (2); - -- View: v - CREATE VIEW [dbo].[v] AS SELECT a FROM dbo.t; - """ - let result = try await runImport(dump, sink: sink) - #expect(driver.sentBatches.map(\.query) == [ - "CREATE TABLE [dbo].[t] ([a] int)", - "INSERT INTO [dbo].[t] ([a]) VALUES (1), (2)", - "CREATE VIEW [dbo].[v] AS SELECT a FROM dbo.t", - ]) - #expect(result.executedStatements == 3) - } - - @Test("A failed statement in a file with no GO line names the file's own line, past a comment inside it") - func failedStatementReportsTheFileLine() async throws { - let (sink, _) = makeSink(declaresBatches: true) - let script = "SELECT 1;\n-- lead\nSELECT 2\n-- inner\nFROM missing;\nSELECT 3;" - do { - _ = try await runImport(script, sink: sink) - Issue.record("An import whose statement raised an error completed") - } catch let PluginImportError.statementFailed(statement, line, underlying) { - #expect(statement == "SELECT 2\n-- inner\nFROM missing") - #expect(line == 3) - #expect(underlying.localizedDescription == "Line 5: Invalid object name 'missing'.") - } - } -} diff --git a/TableProTests/Core/Redis/SidebarRedisCommandsTests.swift b/TableProTests/Core/Redis/SidebarRedisCommandsTests.swift deleted file mode 100644 index 3b132be19e..0000000000 --- a/TableProTests/Core/Redis/SidebarRedisCommandsTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// SidebarRedisCommandsTests.swift -// TableProTests -// -// Tests for Redis sidebar command generation. -// -// NOTE: All Redis sidebar helpers (generateSidebarRedisCommands, redisEscape, -// isSidebarSQLFunction, generateSidebarUpdateSQL) are private extension methods -// on MainContentCoordinator and cannot be unit tested without changing their -// access level. MainContentCoordinator also requires database connections and -// coordinator infrastructure that make it impractical to instantiate in tests. -// -// To enable testing, consider extracting these helpers into a standalone -// struct (e.g., SidebarRedisCommandBuilder) with internal access. -// - -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -// No testable public API available for sidebar Redis commands. -// All helper methods are private to MainContentCoordinator. diff --git a/TableProTests/Core/SSH/LibSSH2SFTPSessionIntegrationTests.swift b/TableProTests/Core/SSH/LibSSH2SFTPSessionIntegrationTests.swift deleted file mode 100644 index 8ee9ea9976..0000000000 --- a/TableProTests/Core/SSH/LibSSH2SFTPSessionIntegrationTests.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// LibSSH2SFTPSessionIntegrationTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -/// Exercises the SFTP transport against a real OpenSSH server. -/// -/// These do not have a unit-test equivalent. Both defects they guard are properties of the wire -/// protocol and of libssh2's own buffering, so a fake that answers the way the API reads would pass -/// every one of them while the shipped code corrupted a database. -/// -/// Start the server with `scripts/sftp-test-server.sh up`. Without it every case skips. -/// -/// Serialized because every case authenticates against the one server. Run in parallel they arrive -/// as a dozen simultaneous handshakes, sshd's `MaxStartups` drops the overflow, and whichever case -/// lost the race fails in a fraction of a second with a connection error that reads nothing like -/// its subject. -@Suite( - "SFTP transport against a real server", - .serialized, - .enabled(if: SFTPTestServer.isConfigured) -) -struct LibSSH2SFTPSessionIntegrationTests { - private func withSession( - _ label: String, - _ body: (LibSSH2SFTPSession) throws -> T - ) async throws -> T { - let session = try await SFTPTestServer.openSession(label: label) - defer { session.close() } - return try body(session) - } - - @Test("A download is byte-exact across a size that forces hundreds of short reads") - func downloadIsByteExact() async throws { - let remotePath = SFTPTestServer.scratchPath("download.db") - let downloadURL = FileManager.default.temporaryDirectory - .appendingPathComponent("sftp-down-\(UUID().uuidString).bin") - defer { try? FileManager.default.removeItem(at: downloadURL) } - - try await withSession("download") { session in - defer { session.remove(remotePath) } - let expected = try SFTPTestServer.seedRemoteFile( - session: session, at: remotePath, bytes: 4 * 1_024 * 1_024 - ) - - let downloaded = try session.download(remotePath: remotePath, to: downloadURL) - #expect(downloaded.bytes == 4 * 1_024 * 1_024) - #expect(downloaded.sha256 == expected) - } - } - - @Test("A path with a space and non-ASCII characters is read back correctly") - func awkwardPathsRead() async throws { - let remotePath = "\(SFTPTestServer.homeDirectory)/tablepro test \(UUID().uuidString) é 数据.db" - let downloadURL = FileManager.default.temporaryDirectory - .appendingPathComponent("sftp-odd-\(UUID().uuidString).bin") - defer { try? FileManager.default.removeItem(at: downloadURL) } - - try await withSession("awkward") { session in - defer { session.remove(remotePath) } - let expected = try SFTPTestServer.seedRemoteFile( - session: session, at: remotePath, bytes: 2_048 - ) - let downloaded = try session.download(remotePath: remotePath, to: downloadURL) - #expect(downloaded.sha256 == expected) - } - } - - @Test("A missing path reports that it is missing, not a generic failure") - func missingPathIsNamed() async throws { - try await withSession("missing") { session in - let path = SFTPTestServer.scratchPath("definitely-absent.db") - #expect(!session.exists(path)) - #expect(throws: SFTPError.noSuchFile(path: path)) { - try session.stat(path) - } - } - } - - @Test("A directory is refused rather than downloaded") - func directoryIsRefused() async throws { - let downloadURL = FileManager.default.temporaryDirectory - .appendingPathComponent("sftp-dir-\(UUID().uuidString).bin") - defer { try? FileManager.default.removeItem(at: downloadURL) } - - try await withSession("directory") { session in - #expect(throws: SFTPError.notAFile(path: SFTPTestServer.homeDirectory)) { - try session.download(remotePath: SFTPTestServer.homeDirectory, to: downloadURL) - } - } - } - - @Test("realPath resolves a relative path to the account's home, which SFTP will not expand") - func realPathResolvesHome() async throws { - try await withSession("realpath") { session in - let resolved = try session.realPath(".") - #expect(resolved == SFTPTestServer.homeDirectory) - } - } - - @Test("A tilde and a relative path resolve against the account's home; an absolute one is left alone") - func resolvedPathExpandsWhatSFTPWillNot() async throws { - try await withSession("resolve") { session in - let home = SFTPTestServer.homeDirectory - let tilde = try session.resolvedPath("~/app.db") - let relative = try session.resolvedPath("app.db") - let bareTilde = try session.resolvedPath("~") - let absolute = try session.resolvedPath("/srv/app.db") - - #expect(tilde == "\(home)/app.db") - #expect(relative == "\(home)/app.db") - #expect(bareTilde == home) - #expect(absolute == "/srv/app.db") - } - } - - @Test("Free space is reported, so a fetch can be refused before it starts") - func freeSpaceIsReported() async throws { - try await withSession("statvfs") { session in - let free = session.freeSpace(atPath: SFTPTestServer.homeDirectory) - #expect(free != nil) - #expect((free ?? 0) > 0) - } - } - - @Test("Listing a directory returns the entries it holds and omits dot entries") - func listingOmitsDotEntries() async throws { - let remotePath = SFTPTestServer.scratchPath("listed.db") - - try await withSession("listdir") { session in - defer { session.remove(remotePath) } - _ = try SFTPTestServer.seedRemoteFile(session: session, at: remotePath, bytes: 512) - - let entries = try session.listDirectory(SFTPTestServer.homeDirectory) - let names = entries.map(\.name) - #expect(!names.contains(".")) - #expect(!names.contains("..")) - #expect(names.contains(where: { remotePath.hasSuffix($0) })) - } - } - - @Test("A cancelled download stops and reports cancellation rather than a partial file") - func cancelledDownloadReportsCancellation() async throws { - let remotePath = SFTPTestServer.scratchPath("cancel.db") - let downloadURL = FileManager.default.temporaryDirectory - .appendingPathComponent("sftp-cancel-\(UUID().uuidString).bin") - defer { try? FileManager.default.removeItem(at: downloadURL) } - - try await withSession("cancel") { session in - defer { session.remove(remotePath) } - _ = try SFTPTestServer.seedRemoteFile( - session: session, at: remotePath, bytes: 2 * 1_024 * 1_024 - ) - - #expect(throws: SFTPError.cancelled) { - try session.download( - remotePath: remotePath, - to: downloadURL, - isCancelled: { true } - ) - } - } - } -} diff --git a/TableProTests/Core/SSH/SSHConfigurationTests.swift b/TableProTests/Core/SSH/SSHConfigurationTests.swift index 722dd62176..ffacfe6146 100644 --- a/TableProTests/Core/SSH/SSHConfigurationTests.swift +++ b/TableProTests/Core/SSH/SSHConfigurationTests.swift @@ -77,12 +77,6 @@ struct SSHConfigurationTests { #expect(config.isValid == true) } - @Test("Agent socket path defaults to empty string") - func testAgentSocketPathDefault() { - let config = SSHConfiguration() - #expect(config.agentSocketPath == "") - } - @Test("Empty socket path maps to SSH_AUTH_SOCK option") func testEmptySocketPathMapsToSystemDefault() { #expect(SSHAgentSocketOption(socketPath: "") == .systemDefault) diff --git a/TableProTests/Core/SSH/TestSupport/SFTPTestServer.swift b/TableProTests/Core/SSH/TestSupport/SFTPTestServer.swift deleted file mode 100644 index 06ceea999c..0000000000 --- a/TableProTests/Core/SSH/TestSupport/SFTPTestServer.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// SFTPTestServer.swift -// TableProTests -// - -import Foundation - -@testable import TablePro - -/// Locates the OpenSSH server that `scripts/sftp-test-server.sh up` starts. -/// -/// The gate is a reachability probe rather than an environment variable, for the reason -/// `KafkaTestBroker` records: `xcodebuild` does not pass the invoking shell's environment to the -/// test host, so exporting a variable before a test run arrives as nothing and the suite reports -/// zero executed cases while a server sits there answering. -enum SFTPTestServer { - static let host = "127.0.0.1" - static let port = 22_022 - static let username = "tp" - static let password = "tppass" - - /// The login directory of the container's account. Every test path hangs off this. - static let homeDirectory = "/config" - - static let isConfigured: Bool = canReach() - - static var configuration: SSHConfiguration { - var config = SSHConfiguration() - config.enabled = true - config.host = host - config.port = port - config.username = username - config.authMethod = .password - return config - } - - static var credentials: SSHTunnelCredentials { - SSHTunnelCredentials( - sshPassword: password, - keyPassphrase: nil, - totpSecret: nil, - keyboardInteractivePromptProvider: nil - ) - } - - static func openSession(label: String = "test") async throws -> LibSSH2SFTPSession { - try await LibSSH2SFTPSession.open( - config: configuration, - credentials: credentials, - label: label - ) - } - - /// Writes random bytes on the server and returns the SHA-256 **the server itself computed**. - /// - /// Seeding this way rather than by uploading keeps the check honest: the expected digest comes - /// from the far side, so a download that agrees with it agrees with something this code did not - /// produce. A test that uploaded its own bytes and then compared them to what came back would - /// pass even if both directions shared a defect. - static func seedRemoteFile( - session: LibSSH2SFTPSession, - at path: String, - bytes: Int - ) throws -> String { - let quoted = LibSSH2ExecChannel.shellQuoted(path) - let create = try session.runRemoteCommand( - "dd if=/dev/urandom of=\(quoted) bs=1 count=0 seek=0 2>/dev/null; " - + "head -c \(bytes) /dev/urandom > \(quoted)" - ) - guard create.succeeded else { - throw SFTPError.remoteCommandFailed( - command: "seed", status: create.exitStatus, output: create.standardError - ) - } - - let digest = try session.runRemoteCommand("sha256sum \(quoted) 2>/dev/null || shasum -a 256 \(quoted)") - guard digest.succeeded, let first = digest.trimmedOutput.split(separator: " ").first else { - throw SFTPError.remoteCommandFailed( - command: "sha256sum", status: digest.exitStatus, output: digest.standardError - ) - } - return String(first) - } - - /// A unique path under the account's home, so concurrent cases never collide. - static func scratchPath(_ name: String) -> String { - "\(homeDirectory)/tablepro-test-\(UUID().uuidString)-\(name)" - } - - private static func canReach() -> Bool { - let descriptor = socket(AF_INET, SOCK_STREAM, 0) - guard descriptor >= 0 else { return false } - defer { close(descriptor) } - - var timeout = timeval(tv_sec: 2, tv_usec: 0) - setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) - setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) - - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.size) - address.sin_family = sa_family_t(AF_INET) - address.sin_port = UInt16(port).bigEndian - guard inet_pton(AF_INET, host, &address.sin_addr) == 1 else { return false } - - let connected = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in - connect(descriptor, rebound, socklen_t(MemoryLayout.size)) == 0 - } - } - return connected - } -} diff --git a/TableProTests/Core/SchemaTracking/StructureChangeManagerClusteredIndexTests.swift b/TableProTests/Core/SchemaTracking/StructureChangeManagerClusteredIndexTests.swift deleted file mode 100644 index 64eeb1108e..0000000000 --- a/TableProTests/Core/SchemaTracking/StructureChangeManagerClusteredIndexTests.swift +++ /dev/null @@ -1,192 +0,0 @@ -// -// StructureChangeManagerClusteredIndexTests.swift -// TableProTests -// -// A copied SQL Server clustered index added to a table that already keeps its rows in one. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -@MainActor -struct StructureChangeManagerClusteredIndexTests { - private typealias IndexType = EditableIndexDefinition.IndexType - - private static func manager(indexes: [IndexInfo]) -> StructureChangeManager { - let manager = StructureChangeManager() - manager.loadSchema( - tableName: "orders", - columns: [ - ColumnInfo(name: "id", dataType: "int", isNullable: false, isPrimaryKey: true), - ColumnInfo(name: "placed_at", dataType: "int", isNullable: true, isPrimaryKey: false) - ], - indexes: indexes, - foreignKeys: [], - primaryKey: ["id"] - ) - return manager - } - - private static let clusteredPrimaryKey = IndexInfo( - name: "PK_orders", columns: ["id"], isUnique: true, isPrimary: true, type: "CLUSTERED" - ) - - private static func copied(_ name: String, type: IndexType) -> EditableIndexDefinition { - EditableIndexDefinition( - id: UUID(), name: name, columns: ["placed_at"], type: type, isUnique: false, isPrimary: false, - comment: nil - ) - } - - /// Duplicate stages the primary key's own row with `isPrimary` cleared, and a SQL Server primary - /// key is clustered by default. - @Test("A duplicated clustered primary key is added NONCLUSTERED and written that way") - func duplicateOfClusteredKeyIsNonclustered() throws { - let manager = Self.manager(indexes: [Self.clusteredPrimaryKey]) - var duplicate = try #require(manager.workingIndexes.first).withNewIdentity() - duplicate.isPrimary = false - duplicate.name = "ux_orders_id" - - manager.addIndex(duplicate) - - let added = try #require(manager.workingIndexes.last) - #expect(added.type == .nonclustered) - #expect( - MSSQLTableDefinitionSQL.indexDefinition(added.toPlugin(), qualifiedTable: "[dbo].[orders]") - == "CREATE UNIQUE NONCLUSTERED INDEX [ux_orders_id] ON [dbo].[orders] ([id])" - ) - } - - @Test("A clustered index pasted into a heap stays CLUSTERED, and a second one beside it does not") - func heapTakesOneClusteredIndex() throws { - let manager = Self.manager(indexes: []) - - manager.addIndex(Self.copied("ix_first", type: .clustered)) - manager.addIndex(Self.copied("ix_second", type: .clustered)) - - #expect(manager.workingIndexes.map(\.type) == [.clustered, .nonclustered]) - } - - @Test("A clustered columnstore index already on the table counts as the clustered one") - func clusteredColumnstoreCounts() throws { - let manager = Self.manager(indexes: [ - IndexInfo(name: "cci_orders", columns: ["id"], isUnique: false, isPrimary: false, type: "CLUSTERED COLUMNSTORE") - ]) - - manager.addIndex(Self.copied("ix_placed", type: .clustered)) - - #expect(manager.workingIndexes.last?.type == .nonclustered) - } - - /// The save drops every index before it adds one, so the replacement is the table's clustered index. - @Test("A clustered index added in place of one being deleted stays CLUSTERED") - func replacementForDeletedClusteredIndexStaysClustered() throws { - let manager = Self.manager(indexes: [Self.clusteredPrimaryKey]) - let primaryKey = try #require(manager.workingIndexes.first) - manager.deleteIndex(id: primaryKey.id) - - manager.addIndex(Self.copied("ix_placed", type: .clustered)) - - #expect(manager.workingIndexes.last?.type == .clustered) - } - - private static let clusteredIndex = IndexInfo( - name: "cx_orders_placed", columns: ["placed_at"], isUnique: false, isPrimary: false, type: "CLUSTERED" - ) - - /// Duplicate, edit the copy, then delete the original is how the Structure tab replaces an index. - /// Left `NONCLUSTERED`, the copy is created after the original is dropped, and the table ends the - /// save with no clustered index. - @Test("A duplicated clustered index takes the clustered place once the original is deleted") - func duplicateTakesThePlaceOfTheDeletedOriginal() throws { - let manager = Self.manager(indexes: [Self.clusteredIndex]) - let original = try #require(manager.workingIndexes.first) - manager.addIndex(original.withNewIdentity()) - var copy = try #require(manager.workingIndexes.last) - #expect(copy.type == .nonclustered) - copy.columns = ["placed_at", "id"] - manager.updateIndex(id: copy.id, with: copy) - - manager.deleteIndex(id: original.id) - - let replacement = try #require(manager.workingIndexes.last) - #expect(replacement.type == .clustered) - #expect(manager.canCommit) - #expect(manager.getChangesArray() == [.addIndex(replacement), .deleteIndex(original)]) - #expect( - MSSQLTableDefinitionSQL.indexDefinition(replacement.toPlugin(), qualifiedTable: "[dbo].[orders]") - == "CREATE CLUSTERED INDEX [cx_orders_placed] ON [dbo].[orders] ([placed_at], [id])" - ) - } - - @Test("Bringing the original back gives it the clustered place again") - func undoingTheDeletionGivesThePlaceBack() throws { - let manager = Self.manager(indexes: [Self.clusteredIndex]) - let original = try #require(manager.workingIndexes.first) - manager.addIndex(original.withNewIdentity()) - manager.deleteIndex(id: original.id) - #expect(manager.workingIndexes.last?.type == .clustered) - - manager.undo() - - let copy = try #require(manager.workingIndexes.last) - #expect(copy.type == .nonclustered) - #expect(manager.getChangesArray() == [.addIndex(copy)]) - } - - @Test("When the first of two clustered copies is removed, the second takes the place") - func removingTheFirstCopyHandsThePlaceOn() throws { - let manager = Self.manager(indexes: []) - let first = Self.copied("ix_first", type: .clustered) - manager.addIndex(first) - manager.addIndex(Self.copied("ix_second", type: .clustered)) - - manager.deleteIndex(id: first.id) - - #expect(manager.workingIndexes.map(\.name) == ["ix_second"]) - #expect(manager.workingIndexes.map(\.type) == [.clustered]) - } - - @Test("A copy whose type was changed keeps the type it was given") - func aTypeChosenForTheCopyIsKept() throws { - let manager = Self.manager(indexes: [Self.clusteredIndex]) - let original = try #require(manager.workingIndexes.first) - manager.addIndex(original.withNewIdentity()) - var copy = try #require(manager.workingIndexes.last) - copy.type = .hash - manager.updateIndex(id: copy.id, with: copy) - - manager.deleteIndex(id: original.id) - - #expect(manager.workingIndexes.last?.type == .hash) - } - - @Test("A clustered copy set to NONCLUSTERED stays that way while the clustered place is free") - func nonclusteredChosenForTheCopyIsKept() throws { - let manager = Self.manager(indexes: []) - manager.addIndex(Self.copied("ix_copy", type: .clustered)) - var copy = try #require(manager.workingIndexes.last) - #expect(copy.type == .clustered) - copy.type = .nonclustered - manager.updateIndex(id: copy.id, with: copy) - - manager.addIndex(Self.copied("ix_other", type: .hash)) - - #expect(manager.workingIndexes.first?.type == .nonclustered) - } - - @Test("Every other type is added as it was copied") - func otherTypesAreUntouched() throws { - let manager = Self.manager(indexes: [Self.clusteredPrimaryKey]) - - manager.addIndex(Self.copied("ix_nonclustered", type: .nonclustered)) - manager.addIndex(Self.copied("ix_hnsw", type: IndexType(rawValue: "hnsw"))) - manager.addIndex(Self.copied("ix_columnstore", type: IndexType(rawValue: "CLUSTERED COLUMNSTORE"))) - - #expect(manager.workingIndexes.dropFirst().map(\.type.rawValue) == [ - "NONCLUSTERED", "HNSW", "CLUSTERED COLUMNSTORE" - ]) - } -} diff --git a/TableProTests/Core/Scripting/ScriptingPolicyTests.swift b/TableProTests/Core/Scripting/ScriptingPolicyTests.swift index 5981d545fc..9d62b812cc 100644 --- a/TableProTests/Core/Scripting/ScriptingPolicyTests.swift +++ b/TableProTests/Core/Scripting/ScriptingPolicyTests.swift @@ -165,16 +165,4 @@ struct ScriptingPolicyTests { #expect(error.number == -10_000) #expect(error.errorDescription == String(localized: "The connection is not open. Connect it first.")) } - - // MARK: - Limits - - /// Deliberately independent of the MCP settings, so tuning the MCP server never changes what a - /// script gets back. - @Test("Row limit and timeout have their own defaults and ceilings") - func limitsAreScriptingsOwn() { - #expect(ScriptQueryRunner.defaultRowLimit == 500) - #expect(ScriptQueryRunner.maximumRowLimit == 10_000) - #expect(ScriptQueryRunner.defaultTimeoutSeconds == 30) - #expect(ScriptQueryRunner.maximumTimeoutSeconds == 600) - } } diff --git a/TableProTests/Core/Scripting/ScriptingVisibilityGuardTests.swift b/TableProTests/Core/Scripting/ScriptingVisibilityGuardTests.swift index 1ed4599496..61eb16f98b 100644 --- a/TableProTests/Core/Scripting/ScriptingVisibilityGuardTests.swift +++ b/TableProTests/Core/Scripting/ScriptingVisibilityGuardTests.swift @@ -2,101 +2,11 @@ // ScriptingVisibilityGuardTests.swift // TableProTests // -// A connection whose External Clients level is Blocked must be invisible to a script, and the only -// thing making it so is that every entry point checks. `connections()` filters, so anything a -// script reaches by resolving an element is safe by construction; `current tab` is not, because it -// starts from the front window. This scans the source rather than the behaviour, because the -// regression it guards is a missing call, and a behavioural test only fails once the call is -// missing from the one path the test happened to pick. -// -import Foundation @testable import TablePro import Testing struct ScriptingVisibilityGuardTests { - private static let snapshotSource: String = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 4 { - url.deleteLastPathComponent() - } - url.appendPathComponent("TablePro/Core/Scripting/ScriptingSnapshot.swift") - return (try? String(contentsOf: url, encoding: .utf8)) ?? "" - }() - - /// Every function that takes a `connectionId` and reads live state for it. `connections()` is - /// the filter itself and `isVisibleToScripts` is the check, so neither is listed. - private static let gatedFunctions = [ - "tabs(forConnection connectionId: UUID)", - "currentTab()", - "query(ofTab tabId: UUID, connectionId: UUID)", - "focus(tab tabId: UUID, connectionId: UUID)" - ] - - private func body(ofFunctionDeclaredAs signature: String) throws -> String { - let source = Self.snapshotSource - #expect(!source.isEmpty, "ScriptingSnapshot.swift was not readable") - let start = try #require(source.range(of: "func \(signature)"), "no function declared as '\(signature)'") - - var depth = 0 - var seenOpen = false - var body = "" - for character in source[start.upperBound...] { - if character == "{" { - depth += 1 - seenOpen = true - } - if seenOpen { body.append(character) } - if character == "}" { - depth -= 1 - if depth == 0 { break } - } - } - return body - } - - @Test("Every path that reads a connection's live state checks that a script may see it") - func everyEntryPointChecksVisibility() throws { - for signature in Self.gatedFunctions { - let body = try body(ofFunctionDeclaredAs: signature) - #expect( - body.contains("isVisibleToScripts"), - "'\(signature)' reads a connection without checking isVisibleToScripts" - ) - } - } - - /// The reader is shared with the JSON serializer, so the check has to sit in front of it here - /// rather than inside it. - @Test("Reading a tab's rows checks visibility before it reaches the result reader") - func resultReadingChecksVisibilityFirst() throws { - let body = try body(ofFunctionDeclaredAs: "result(") - let check = try #require(body.range(of: "isVisibleToScripts")) - let read = try #require(body.range(of: "DisplayedResultReader.read")) - #expect(check.lowerBound < read.lowerBound, "visibility is checked after the rows are read") - } - - /// A connect loads the connection's SQLite extensions, so the routes that can cause one have to - /// ask about any this Mac has not approved, the same as every route a person takes. - @Test("Every path that can cause a connect asks about unapproved extensions first") - func connectPathsAskAboutExtensions() throws { - var root = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 4 { - root.deleteLastPathComponent() - } - let paths = [ - "TablePro/Core/Scripting/ScriptQueryRunner.swift", - "TablePro/Core/Scripting/Commands/ScriptConnectionCommands.swift" - ] - for path in paths { - let source = try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) - #expect( - source.contains("ScriptConnectGate.authorizeConnect"), - "\(path) can reach ensureConnected without asking about unapproved extensions" - ) - } - } - @Test("Blocked is the only level that hides a connection from scripts") func blockedIsTheOnlyHiddenLevel() { let hidden = ExternalAccessLevel.allCases.filter { $0 == .blocked } diff --git a/TableProTests/Core/ServerDashboard/TypesenseDashboardProviderTests.swift b/TableProTests/Core/ServerDashboard/TypesenseDashboardProviderTests.swift deleted file mode 100644 index a0dc2ae51c..0000000000 --- a/TableProTests/Core/ServerDashboard/TypesenseDashboardProviderTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// TypesenseDashboardProviderTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct TypesenseDashboardProviderTests { - private struct UnexpectedCommand: Error { - let command: String - } - - private static let metricsPayload = """ - { - "system_memory_used_bytes": "536870912", - "system_memory_total_bytes": "1073741824", - "system_disk_used_bytes": "250000", - "system_disk_total_bytes": "1000000" - } - """ - - private func response(_ json: String) -> QueryResult { - QueryResult( - columns: ["response"], - columnTypes: [], - rows: [[PluginCellValue.text(json)]], - rowsAffected: 0, - executionTime: 0, - error: nil - ) - } - - private func usedShare(_ fraction: Double) -> String { - String(format: String(localized: "%@ used"), fraction.formatted(.percent.precision(.fractionLength(0)))) - } - - @Test("Memory and disk use read as a percentage through the string catalog") - func usedShareIsLocalized() async throws { - let metrics = try await TypesenseDashboardProvider().fetchMetrics { command in - switch command { - case "GET /metrics.json": - return response(Self.metricsPayload) - case "GET /stats.json": - return response("{}") - default: - throw UnexpectedCommand(command: command) - } - } - let memory = try #require(metrics.first { $0.id == "system_memory" }) - let disk = try #require(metrics.first { $0.id == "system_disk" }) - #expect(memory.unit == usedShare(0.5)) - #expect(disk.unit == usedShare(0.25)) - } - - @Test("The used-share unit is a key in the app's string catalog") - func usedShareKeyIsInTheCatalog() throws { - let catalog = try #require(StringCatalog.loadAll().first { $0.name == "TablePro" }) - #expect(catalog.containsKey("%@ used")) - } -} diff --git a/TableProTests/Core/Services/ColumnTypeTests.swift b/TableProTests/Core/Services/ColumnTypeTests.swift index ec047451bb..e15349b4bf 100644 --- a/TableProTests/Core/Services/ColumnTypeTests.swift +++ b/TableProTests/Core/Services/ColumnTypeTests.swift @@ -300,12 +300,6 @@ struct ColumnTypeTests { #expect(type.displayName == "Enum") } - @Test("enumType badgeLabel is enum") - func enumBadgeLabel() { - let type = ColumnType.enumType(rawType: nil, values: nil) - #expect(type.badgeLabel == "enum") - } - @Test("set displayName is Set") func setDisplayName() { let type = ColumnType.set(rawType: nil, values: nil) diff --git a/TableProTests/Core/Services/DateEditingServiceTests.swift b/TableProTests/Core/Services/DateEditingServiceTests.swift index d775fee9fc..f126e56c69 100644 --- a/TableProTests/Core/Services/DateEditingServiceTests.swift +++ b/TableProTests/Core/Services/DateEditingServiceTests.swift @@ -194,9 +194,4 @@ struct DateEditingServiceTests { timeZone: .gmt ) == "2024-03-14") } - - @Test("the picker opens in the user's own zone when the cell has no value to read one from") - func defaultZoneIsTheUsers() { - #expect(DateEditingService.defaultTimeZone == .current) - } } diff --git a/TableProTests/Core/Services/Formatting/CellValueContentDetectorTests.swift b/TableProTests/Core/Services/Formatting/CellValueContentDetectorTests.swift index c7f96e41fc..3230570976 100644 --- a/TableProTests/Core/Services/Formatting/CellValueContentDetectorTests.swift +++ b/TableProTests/Core/Services/Formatting/CellValueContentDetectorTests.swift @@ -138,16 +138,6 @@ struct CellValueContentDetectorImageTests { #expect(CellValueContentDetector.detect(PluginCellValue.null) == .plain) } - @Test("JSON still wins over image detection") - func jsonStillWins() { - #expect(CellValueContentDetector.detect(#"{"a":1}"#) == .json) - } - - @Test("PHP serialized still wins over image detection") - func phpStillWins() { - #expect(CellValueContentDetector.detect("a:0:{}") == .phpSerialized) - } - @Test("plain markup that is not an SVG document stays plain") func htmlStaysPlain() { #expect(CellValueContentDetector.detect("") == .plain) diff --git a/TableProTests/Core/Services/Formatting/MongoShellFormatterTests.swift b/TableProTests/Core/Services/Formatting/MongoShellFormatterTests.swift deleted file mode 100644 index b3c66d1d19..0000000000 --- a/TableProTests/Core/Services/Formatting/MongoShellFormatterTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// MongoShellFormatterTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -struct MongoShellFormatterTests { - private func formatted(_ text: String) throws -> String { - try MongoShellFormatter().format(text, cursorOffset: nil).text - } - - @Test("A regular expression is left exactly as written") - func regexIsAtomic() throws { - // `{2,3}` is a quantifier. Reflowing it to `{2, 3}` is a different pattern. - #expect(try formatted("db.c.find({x: /a{2,3}/})").contains("/a{2,3}/")) - #expect(try formatted("db.c.find({x: /[,{}]/i})").contains("/[,{}]/i")) - } - - @Test("Division is still formatted as an operator") - func divisionIsNotRegex() throws { - let output = try formatted("db.c.find({x: total / 2})") - #expect(output.contains("total / 2")) - } - - @Test("A line comment survives with its text intact") - func lineCommentIsAtomic() throws { - #expect(try formatted("db.c.find({}) // one, two").contains("// one, two")) - } - - @Test("A block comment survives with its text intact") - func blockCommentIsAtomic() throws { - #expect(try formatted("db.c.find(/* one, two */ {})").contains("/* one, two */")) - } - - @Test("A brace inside a string is not structure") - func stringsAreAtomic() throws { - #expect(try formatted("db.c.find({note: \"a, b\"})").contains("\"a, b\"")) - } - - @Test("An ordinary query still reflows") - func plainQueryFormats() throws { - let output = try formatted("db.c.find({a:1,b:2})") - #expect(output.contains("a")) - #expect(output.contains("b")) - } -} diff --git a/TableProTests/Core/Services/Formatting/ValueDisplayFormatTests.swift b/TableProTests/Core/Services/Formatting/ValueDisplayFormatTests.swift index 9f3d44c8e4..dd09da72d5 100644 --- a/TableProTests/Core/Services/Formatting/ValueDisplayFormatTests.swift +++ b/TableProTests/Core/Services/Formatting/ValueDisplayFormatTests.swift @@ -29,15 +29,6 @@ struct ValueDisplayFormatTests { #expect(!ValueDisplayFormat.json.rendersBinaryAsText) } - @Test("Codable round-trip preserves value") - func codableRoundTrip() throws { - for format in ValueDisplayFormat.allCases { - let encoded = try JSONEncoder().encode(format) - let decoded = try JSONDecoder().decode(ValueDisplayFormat.self, from: encoded) - #expect(decoded == format) - } - } - @Test("text column applicable formats include json and phpSerialized") func applicableForText() { let formats = ValueDisplayFormat.applicableFormats(for: .text(rawType: "TEXT")) diff --git a/TableProTests/Core/Services/ImportExecutionGateTests.swift b/TableProTests/Core/Services/ImportExecutionGateTests.swift index 7484449e68..99538610c3 100644 --- a/TableProTests/Core/Services/ImportExecutionGateTests.swift +++ b/TableProTests/Core/Services/ImportExecutionGateTests.swift @@ -211,23 +211,4 @@ struct ImportExecutionGateTests { #expect(requests.first?.kind == .importData) #expect(requests.first?.capabilities.contains(.confirmationPreCleared) == false) } - - /// The sink writes straight to the driver, so whatever builds one is where the gate has to be. - @Test("Only the two runs that authorize first build the import sink") - func onlyAuthorizedRunsBuildTheSink() throws { - var root = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 4 { - root.deleteLastPathComponent() - } - let appRoot = root.appendingPathComponent("TablePro") - let enumerator = try #require(FileManager.default.enumerator(at: appRoot, includingPropertiesForKeys: nil)) - var builders: [String] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - let source = try String(contentsOf: url, encoding: .utf8) - guard source.contains("ImportDataSinkAdapter(") else { continue } - builders.append(url.lastPathComponent) - #expect(source.contains("gate.authorize("), "\(url.lastPathComponent) builds the sink without the gate") - } - #expect(builders.sorted() == ["ImportService.swift", "TableTransferService.swift"]) - } } diff --git a/TableProTests/Core/Services/ImportStateTests.swift b/TableProTests/Core/Services/ImportStateTests.swift deleted file mode 100644 index 1167f8c494..0000000000 --- a/TableProTests/Core/Services/ImportStateTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// ImportStateTests.swift -// TableProTests -// -// Tests for ImportState consolidated struct. -// - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -struct ImportStateTests { - @Test("Default init has correct defaults") - func defaultInitHasCorrectDefaults() { - let state = ImportState() - #expect(state.isImporting == false) - #expect(state.progress == 0.0) - #expect(state.processedStatements == 0) - #expect(state.estimatedTotalStatements == 0) - #expect(state.statusMessage == "") - #expect(state.errorMessage == nil) - } - - @Test("Value semantics — copy is independent") - func valueSemanticsAreIndependent() { - var original = ImportState() - original.isImporting = true - original.progress = 0.5 - - var copy = original - copy.isImporting = false - copy.progress = 1.0 - - #expect(original.isImporting == true) - #expect(original.progress == 0.5) - #expect(copy.isImporting == false) - #expect(copy.progress == 1.0) - } - - @Test("Partial init with isImporting=true") - func partialInitWithImporting() { - var state = ImportState() - state.isImporting = true - - #expect(state.isImporting == true) - #expect(state.progress == 0.0) - #expect(state.processedStatements == 0) - #expect(state.errorMessage == nil) - } - - @Test("All fields are readable and writable") - func allFieldsAreReadableAndWritable() { - var state = ImportState() - - state.isImporting = true - #expect(state.isImporting == true) - - state.progress = 0.75 - #expect(state.progress == 0.75) - - state.processedStatements = 5 - #expect(state.processedStatements == 5) - - state.estimatedTotalStatements = 20 - #expect(state.estimatedTotalStatements == 20) - - state.statusMessage = "Importing..." - #expect(state.statusMessage == "Importing...") - - state.errorMessage = "Import error" - #expect(state.errorMessage == "Import error") - } -} diff --git a/TableProTests/Core/Services/Infrastructure/AppActivationPolicySourceTests.swift b/TableProTests/Core/Services/Infrastructure/AppActivationPolicySourceTests.swift deleted file mode 100644 index 9b3581cdcb..0000000000 --- a/TableProTests/Core/Services/Infrastructure/AppActivationPolicySourceTests.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// AppActivationPolicySourceTests.swift -// TableProTests -// -// Coming forward for a person and being an app a person can see are the same decision, and the app -// had 33 places making the first half of it on their own. A process serving MCP in the background -// that activates without a Dock icon hands the person a window with no menu bar: no Cmd+W, no -// Cmd+Q, no Edit menu. -// - -import Foundation -import Testing - -struct AppActivationPolicySourceTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 5 { - url.deleteLastPathComponent() - } - return url - }() - - private static let owner = "AppActivationPolicyController.swift" - - private func swiftFiles(under directory: URL) -> [URL] { - guard let walker = FileManager.default.enumerator( - at: directory, - includingPropertiesForKeys: nil - ) else { return [] } - return walker - .compactMap { $0 as? URL } - .filter { $0.pathExtension == "swift" && $0.lastPathComponent != Self.owner } - } - - private func offenders(matching needle: String) throws -> [String] { - let sources = swiftFiles(under: Self.repositoryRoot.appendingPathComponent("TablePro")) - return try sources - .filter { try String(contentsOf: $0, encoding: .utf8).contains(needle) } - .map(\.lastPathComponent) - .sorted() - } - - @Test("Only the policy controller activates the app") - func activationGoesThroughTheController() throws { - let found = try offenders(matching: "NSApp.activate(") - #expect( - found.isEmpty, - "Call AppActivationPolicyController.shared.activate() instead, so the app is one a person can see before it takes focus: \(found)" - ) - } - - @Test("Prompts and confirmations ask for attention instead of taking focus") - func promptsDoNotTakeFocus() throws { - let prompts = ["Core/Utilities/UI/AlertHelper.swift", "Core/Services/Execution/Providers/OperationConfirming.swift"] - let found = try prompts.filter { path in - let url = Self.repositoryRoot.appendingPathComponent("TablePro").appendingPathComponent(path) - return try String(contentsOf: url, encoding: .utf8).contains(".activate(") - } - #expect( - found.isEmpty, - "A prompt that takes focus can be answered by a keystroke meant for another app; use requestAttention(): \(found)" - ) - } - - @Test("Only the policy controller sets the activation policy") - func policyChangesGoThroughTheController() throws { - let found = try offenders(matching: "setActivationPolicy(") - #expect( - found.isEmpty, - "The activation policy has one owner, which is the only thing that can keep the Dock icon and the session's origin in step: \(found)" - ) - } -} diff --git a/TableProTests/Core/Services/Infrastructure/ToolbarSymbolConventionTests.swift b/TableProTests/Core/Services/Infrastructure/ToolbarSymbolConventionTests.swift deleted file mode 100644 index 4e286def91..0000000000 --- a/TableProTests/Core/Services/Infrastructure/ToolbarSymbolConventionTests.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// ToolbarSymbolConventionTests.swift -// TableProTests -// -// A window toolbar draws outline glyphs. A fill is the HIG's variant for selection, so a filled -// glyph on a command reads as state that is not there, and from macOS 26 a circled one draws a -// border inside the border its item already has. Four shipped that way: Save was the filled, -// circled check the rest of the app uses for "succeeded", Actions and the data file's Filters -// were circled, and the License pane was the one filled glyph of twelve in Settings. -// -// A glyph whose form depends on the release comes from `ToolbarSymbols`, which is why a literal -// with a `circle` or `fill` component has no business in these files at all. The SwiftUI toolbars -// (welcome, Integrations) take theirs from the same owner but are not scanned: their files also -// draw content, where a circled or filled status glyph is the right one. -// - -import Foundation -import Testing - -struct ToolbarSymbolConventionTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 5 { - url.deleteLastPathComponent() - } - return url - }() - - /// The Settings panes are toolbar items too, built from the tab view rather than by hand, so - /// the file never names `NSToolbarItem` and has to be listed. - private static let additionalFiles = ["TablePro/Views/Settings/SettingsView.swift"] - - private static let bannedComponents: Set = ["circle", "fill"] - - /// Comments are dropped before the scan, because a comment may name what was removed. - private static func code(of source: String) -> String { - source - .split(separator: "\n", omittingEmptySubsequences: false) - .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } - .joined(separator: "\n") - } - - /// Every string literal shaped like a symbol name: lowercase words and digits joined by dots. - private static func symbolLiterals(in source: String) -> [String] { - guard let pattern = try? NSRegularExpression(pattern: "\"([a-z0-9]+(?:\\.[a-z0-9]+)+)\"") else { return [] } - let text = code(of: source) as NSString - return pattern - .matches(in: text as String, range: NSRange(location: 0, length: text.length)) - .map { text.substring(with: $0.range(at: 1)) } - } - - /// By component, not by substring: `arrow.triangle.2.circlepath` and `rectangle.inset.filled` - /// are outline glyphs whose names happen to contain the letters. - private static func isEnclosedOrFilled(_ name: String) -> Bool { - name.split(separator: ".").contains { bannedComponents.contains($0) } - } - - private static func buildsToolbarItems(_ source: String) -> Bool { - source.contains("NSToolbarItem") || source.contains("NSMenuToolbarItem") - } - - @Test("No file that builds toolbar items names a circled or filled symbol") - func toolbarFilesNameNoEnclosedOrFilledSymbol() throws { - let appRoot = Self.repositoryRoot.appendingPathComponent("TablePro") - let enumerator = try #require(FileManager.default.enumerator(at: appRoot, includingPropertiesForKeys: nil)) - - var sources: [(path: String, source: String)] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - let source = try String(contentsOf: url, encoding: .utf8) - guard Self.buildsToolbarItems(source) else { continue } - sources.append((url.path.replacingOccurrences(of: Self.repositoryRoot.path + "/", with: ""), source)) - } - for path in Self.additionalFiles { - let url = Self.repositoryRoot.appendingPathComponent(path) - sources.append((path, try String(contentsOf: url, encoding: .utf8))) - } - - var inspected = 0 - var offenders: [String] = [] - for (path, source) in sources { - let literals = Self.symbolLiterals(in: source) - inspected += literals.count - offenders += literals.filter(Self.isEnclosedOrFilled).map { "\(path): \($0)" } - } - - /// Guards the scan itself: a root that stopped resolving reads as a clean run. - #expect(sources.count > 8, "Expected to find the toolbar sources, found \(sources.count)") - #expect(inspected > 15, "Expected to find symbol names to check, found \(inspected)") - #expect( - offenders.isEmpty, - "A toolbar glyph is outline. Take one whose form follows the release from ToolbarSymbols: \(offenders.sorted())" - ) - } - - /// A scan that stops matching anything passes forever. This pins both halves. - @Test("The scan catches a circled or filled name and leaves an outline one alone") - func scanTellsAVariantFromALookalike() { - let flagged = [ - " symbol: \"checkmark.circle.fill\",", - " item.image = NSImage(systemSymbolName: \"ellipsis.circle\", accessibilityDescription: label)", - " case .account: \"key.fill\"", - " symbolProvider: { \"line.3.horizontal.decrease.circle\" }", - ] - for line in flagged { - #expect(Self.symbolLiterals(in: line).contains(where: Self.isEnclosedOrFilled), "\(line)") - } - - let allowed = [ - " symbol: \"clock.arrow.circlepath\",", - " symbol: \"arrow.triangle.2.circlepath\",", - " ? \"rectangle.inset.filled\"", - " symbol: \"square.and.arrow.down.on.square\",", - " /// It used to draw \"checkmark.circle.fill\".", - ] - for line in allowed { - #expect(Self.symbolLiterals(in: line).contains(where: Self.isEnclosedOrFilled) == false, "\(line)") - } - } -} diff --git a/TableProTests/Core/Services/Infrastructure/TrailingPaneCommandTitleTests.swift b/TableProTests/Core/Services/Infrastructure/TrailingPaneCommandTitleTests.swift index 1d14c4951e..dc08a7aa9f 100644 --- a/TableProTests/Core/Services/Infrastructure/TrailingPaneCommandTitleTests.swift +++ b/TableProTests/Core/Services/Infrastructure/TrailingPaneCommandTitleTests.swift @@ -235,26 +235,8 @@ struct TrailingPaneCommandTitleTests { #expect(TrailingPaneCommandResolver.assistantToggle(assistantClosed) == nil) } - /// A dead fourth name for the pane toggle, beside Inspector in the menu, trailing pane in the - /// proxy and right panel in a legacy defaults key. Nothing called it, and a command surface that - /// keeps a name nobody uses is where the next caller picks the wrong one. - @Test("The command surface has no second name for the pane toggle") - func toggleRightSidebarIsGone() throws { - let url = Self.repositoryRoot.appendingPathComponent("TablePro/Views/Main/MainContentCommandActions.swift") - let source = try String(contentsOf: url, encoding: .utf8) - #expect(!source.contains("toggleRightSidebar")) - } - // MARK: - Helpers - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 5 { - url.deleteLastPathComponent() - } - return url - }() - private static func context( mode: ConnectionWorkspaceContentMode, stored: TrailingPaneSurface, diff --git a/TableProTests/Core/Services/Query/DamengPlanParserTests.swift b/TableProTests/Core/Services/Query/DamengPlanParserTests.swift deleted file mode 100644 index eb3b3b2f17..0000000000 --- a/TableProTests/Core/Services/Query/DamengPlanParserTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// DamengPlanParserTests.swift -// TableProTests -// - -@testable import TablePro -import TableProPluginKit -import Testing - -struct DamengPlanParserTests { - private let parser = DamengPlanParser() - - @Test("Parses numeric prefixes and indentation as a tree") - func parsesHierarchy() throws { - let text = [ - "1 #NSET2: [1, 1, 108]", - "2 #PRJT2: [1, 1, 108]; exp_num(3)", - "3 #CSCN2: [1, 1, 108]; TABLEPRO_TEST", - ].joined(separator: "\n") - - let plan = try #require(parser.parse(rawText: text)) - - #expect(plan.rootNode.operation == "NSET2") - #expect(plan.rootNode.properties["Details"] == "[1, 1, 108]") - #expect(plan.rootNode.children.first?.operation == "PRJT2") - #expect(plan.rootNode.children.first?.children.first?.operation == "CSCN2") - } - - @Test("A widening line number keeps its depth when DM8 aligns the marker") - func alignsMarkerColumnPastNinetyNine() throws { - var lines = [ - "1 #NSET2: [1, 1, 108]", - "2 #PRJT2: [1, 1, 108]", - "3 #SLCT2: [1, 1, 108]", - ] - for number in 4...100 { - let gap = String(repeating: " ", count: 10 - "\(number)".count) - lines.append("\(number)\(gap)#CSCN2: [1, 1, 108]; T\(number)") - } - - let plan = try #require(parser.parse(rawText: lines.joined(separator: "\n"))) - let projection = try #require(plan.rootNode.children.first) - let select = try #require(projection.children.first) - - #expect(projection.children.count == 1) - #expect(select.children.count == 97) - } - - @Test("Ignores malformed non-plan lines") - func ignoresMalformedLines() { - #expect(parser.parse(rawText: "not a DM8 plan") == nil) - #expect(parser.parse(rawText: "1 missing marker") == nil) - } - - @Test("Registry returns the Dameng parser") - func registryReturnsDamengParser() { - #expect(ExplainPlanParserRegistry.parser(for: .damengText) is DamengPlanParser) - #expect(ExplainFormatResolver.resolve(declared: .damengText, databaseType: .dameng) == .damengText) - } -} diff --git a/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift b/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift index 5a66455c8a..1f34947861 100644 --- a/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift +++ b/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift @@ -95,12 +95,6 @@ struct TabSessionRegistryTableRowsTests { #expect(evicted?.columns == ["c"]) } - @Test("evict(for:) is no-op for unknown tab") - func evictUnknownTabIsNoOp() { - let store = TabSessionRegistry() - store.evict(for: UUID()) - } - @Test("setTableRows clears evicted flag") func setClearsEvicted() { let store = TabSessionRegistry() diff --git a/TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift b/TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift index c2c8a8bf95..92bc7a1511 100644 --- a/TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift +++ b/TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift @@ -46,13 +46,6 @@ struct SchemaMetadataGeneratedColumnTests { #expect(QueryExecutor.parseSchemaMetadata(schema).generatedColumns.isEmpty) } - @Test("ColumnInfo defaults to not generated") - func columnInfoDefaultsToNotGenerated() { - let column = ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true) - #expect(!column.isGenerated) - #expect(column.identityKind == nil) - } - /// PostgreSQL reports an identity column with a null `column_default`, so the kind is the only /// thing that says the server fills the column in. @Test("Both identity kinds are carried into the parsed metadata") diff --git a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift deleted file mode 100644 index 055d63de81..0000000000 --- a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// TableQueryBuilderMSSQLTests.swift -// TableProTests -// -// Tests for TableQueryBuilder with databaseType: .mssql -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -@MainActor -struct TableQueryBuilderMSSQLTests { - private let builder: TableQueryBuilder - - init() { - FakeMSSQLPluginRegistration.registerIfNeeded() - let dialect = PluginManager.shared.sqlDialect(for: .mssql) - let dialectQuote = dialect.map(quoteIdentifierFromDialect) - self.builder = TableQueryBuilder( - databaseType: .mssql, - pluginDriver: PluginManager.shared.queryBuildingDriver(for: .mssql), - dialect: dialect, - pagination: .offset, - dialectQuote: dialectQuote - ) - } - - // MARK: - Base Query Tests - - @Test("Base query with no sort uses ORDER BY SELECT NULL and OFFSET FETCH NEXT syntax") - func baseQueryNoSort() { - let query = builder.buildBaseQuery(tableName: "users") - #expect(query.contains("ORDER BY (SELECT NULL)")) - #expect(query.contains("OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY")) - } - - @Test("Base query uses bracket-quoted table name") - func baseQueryBracketQuotedTable() { - let query = builder.buildBaseQuery(tableName: "users") - #expect(query.contains("SELECT * FROM [users]")) - } - - @Test("Base query with offset applies correct OFFSET value") - func baseQueryWithOffset() { - let query = builder.buildBaseQuery(tableName: "users", offset: 200) - #expect(query.contains("OFFSET 200 ROWS FETCH NEXT 200 ROWS ONLY")) - } - - @Test("Base query with custom limit applies correct FETCH NEXT value") - func baseQueryWithCustomLimit() { - let query = builder.buildBaseQuery(tableName: "users", limit: 50) - #expect(query.contains("FETCH NEXT 50 ROWS ONLY")) - } - - @Test("Base query does not use MySQL-style LIMIT OFFSET syntax") - func baseQueryNoMySQLLimitSyntax() { - let query = builder.buildBaseQuery(tableName: "users") - let normalized = query.uppercased() - #expect(!normalized.contains(" LIMIT ")) - } - - @Test("Base query with table name containing bracket escapes it") - func baseQueryBracketInTableName() { - let query = builder.buildBaseQuery(tableName: "user]s") - #expect(query.contains("[user]]s]")) - } - - // MARK: - Filtered Query Tests - - @Test("Filtered query without filters uses ORDER BY SELECT NULL") - func filteredQueryNoFilters() throws { - let query = try #require(builder.buildFilteredQuery(tableName: "users", filters: [])) - #expect(query.contains("ORDER BY (SELECT NULL)")) - #expect(query.contains("OFFSET")) - #expect(query.contains("FETCH NEXT")) - } - - @Test("Filtered query with filters contains WHERE clause and OFFSET FETCH NEXT") - func filteredQueryWithFilters() throws { - let filters = [ - TestFixtures.makeTableFilter(column: "name", op: .equal, value: "Alice") - ] - let query = try #require(builder.buildFilteredQuery(tableName: "users", filters: filters)) - #expect(query.contains("WHERE")) - #expect(query.contains("[name]")) - #expect(query.contains("OFFSET")) - #expect(query.contains("FETCH NEXT")) - } - - @Test("Filtered query does not use MySQL-style LIMIT OFFSET syntax") - func filteredQueryNoMySQLSyntax() throws { - let query = try #require(builder.buildFilteredQuery(tableName: "users", filters: [])) - let normalized = query.uppercased() - #expect(!normalized.contains(" LIMIT ")) - } - - // MARK: - OFFSET FETCH Fallback (no plugin browse query) - - @Test("Base query without a plugin driver still emits ORDER BY (SELECT NULL) before OFFSET FETCH") - func baseQueryFallbackEmitsOrderBy() { - let dialect = PluginManager.shared.sqlDialect(for: .mssql) - let fallback = TableQueryBuilder( - databaseType: .mssql, - pluginDriver: nil, - dialect: dialect, - pagination: .offset, - dialectQuote: dialect.map(quoteIdentifierFromDialect) - ) - let query = fallback.buildBaseQuery(tableName: "users") - #expect(query == "SELECT * FROM [users] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY") - } - - @Test("The filtered row count spells a non-ASCII value the way the browse query does") - func filteredCountUsesNationalLiterals() { - let filters = [TestFixtures.makeTableFilter(column: "name", op: .equal, value: "日本語メール")] - let count = builder.buildFilteredCountQuery(tableName: "users", filters: filters, columns: ["name"]) - #expect(count?.contains("[name] = N'日本語メール'") == true) - #expect(count?.contains("= '日本語メール'") == false) - } -} diff --git a/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift b/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift index 3aa4f9bd5f..b32c42a1ea 100644 --- a/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift +++ b/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift @@ -74,11 +74,6 @@ struct WindowLifecycleMonitorTests { #expect(monitor.windows(for: connectionId).isEmpty) } - @Test("unregisterWindow for unknown windowId — does not crash") - func unregisterUnknownWindowId() { - monitor.unregisterWindow(for: UUID()) - } - // MARK: - Multiple connections @Test("Multiple connections — windows are independent") diff --git a/TableProTests/Core/SiteWordingTests.swift b/TableProTests/Core/SiteWordingTests.swift deleted file mode 100644 index 04a31acb92..0000000000 --- a/TableProTests/Core/SiteWordingTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// SiteWordingTests.swift -// TableProTests -// -// tablepro.app sells Starter and Team, billed Monthly, Yearly or One-time, and its privacy policy -// says the server stores each usage report with its IP address and can link it to a license. The -// app asked for a "Pro license", a plan the pricing page does not have, and called the report -// anonymous. -// - -import Foundation -import Testing - -struct SiteWordingTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - - /// Comments are dropped before the scan, because a comment may name what was removed. - private static func code(of source: String) -> String { - source - .split(separator: "\n", omittingEmptySubsequences: false) - .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } - .joined(separator: "\n") - } - - private static func appCode(matching pattern: String) throws -> (files: Int, hits: [String]) { - let expression = try NSRegularExpression(pattern: pattern) - let appRoot = repositoryRoot.appendingPathComponent("TablePro") - let enumerator = try #require(FileManager.default.enumerator(at: appRoot, includingPropertiesForKeys: nil)) - - var files = 0 - var hits: [String] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - files += 1 - let text = Self.code(of: try String(contentsOf: url, encoding: .utf8)) as NSString - hits += expression - .matches(in: text as String, range: NSRange(location: 0, length: text.length)) - .map { "\(url.lastPathComponent): \(text.substring(with: $0.range))" } - } - return (files, hits) - } - - @Test("No string names a plan or a billing cycle the pricing page does not sell") - func plansAreStarterAndTeam() throws { - let scan = try Self.appCode(matching: #"(? 1_000, "Expected to scan the app sources, found \(scan.files) files") - #expect(scan.hits.isEmpty, "Name the plan a feature needs, Starter or Team: \(scan.hits)") - } - - @Test("No string calls the usage report anonymous") - func usageReportIsNotCalledAnonymous() throws { - let scan = try Self.appCode(matching: "(?i)anonymous (usage|statistics|analytics|report|heartbeat)") - - #expect(scan.files > 1_000, "Expected to scan the app sources, found \(scan.files) files") - #expect(scan.hits.isEmpty, "The report carries a hashed machine ID: \(scan.hits)") - } -} diff --git a/TableProTests/Core/Storage/AIChatStorageTests.swift b/TableProTests/Core/Storage/AIChatStorageTests.swift deleted file mode 100644 index 186d510ac0..0000000000 --- a/TableProTests/Core/Storage/AIChatStorageTests.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// AIChatStorageTests.swift -// TableProTests -// -// Tests for AIChatStorage static encoder/decoder and round-trip persistence. -// - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -// TODO: Convert to async tests — AIChatStorage is an actor, methods require await -#if false -struct AIChatStorageTests { - private let storage = AIChatStorage.shared - - private func makeConversation( - id: UUID = UUID(), - title: String = "Test Chat", - messages: [AIChatMessage] = [], - createdAt: Date = Date(), - updatedAt: Date = Date(), - connectionName: String? = "test-db" - ) -> AIConversation { - AIConversation( - id: id, - title: title, - messages: messages, - createdAt: createdAt, - updatedAt: updatedAt, - connectionName: connectionName - ) - } - - private func makeMessage( - role: AIChatRole = .user, - content: String = "Hello" - ) -> AIChatMessage { - AIChatMessage( - role: role, - content: content, - timestamp: Date() - ) - } - - private func cleanupConversation(_ id: UUID) { - storage.delete(id) - } - - @Test("Save and load round-trip preserves all fields") - func saveAndLoadRoundTrip() { - let id = UUID() - let message = makeMessage(role: .user, content: "Test message") - let conversation = makeConversation( - id: id, - title: "Round Trip Test", - messages: [message], - connectionName: "mydb" - ) - - storage.save(conversation) - - let loaded = storage.loadAll() - let found = loaded.first { $0.id == id } - - #expect(found != nil) - #expect(found?.title == "Round Trip Test") - #expect(found?.messages.count == 1) - #expect(found?.messages.first?.content == "Test message") - #expect(found?.messages.first?.role == .user) - #expect(found?.connectionName == "mydb") - - cleanupConversation(id) - } - - @Test("ISO 8601 date encoding preserves to-second accuracy") - func iso8601DatePreservesAccuracy() { - let id = UUID() - let now = Date() - let conversation = makeConversation(id: id, createdAt: now, updatedAt: now) - - storage.save(conversation) - - let loaded = storage.loadAll() - let found = loaded.first { $0.id == id } - - #expect(found != nil) - let diff = abs(found!.createdAt.timeIntervalSince(now)) - #expect(diff < 1.0) - - cleanupConversation(id) - } - - @Test("Delete removes specific conversation") - func deleteRemovesSpecificConversation() { - let id1 = UUID() - let id2 = UUID() - - storage.save(makeConversation(id: id1, title: "Keep")) - storage.save(makeConversation(id: id2, title: "Delete")) - - storage.delete(id2) - - let loaded = storage.loadAll() - #expect(loaded.contains { $0.id == id1 }) - #expect(!loaded.contains { $0.id == id2 }) - - cleanupConversation(id1) - } - - @Test("loadAll returns conversations sorted by date descending") - func loadAllReturnsSortedByDate() { - let id1 = UUID() - let id2 = UUID() - let id3 = UUID() - - let older = makeConversation(id: id1, title: "Older", updatedAt: Date().addingTimeInterval(-200)) - let middle = makeConversation(id: id2, title: "Middle", updatedAt: Date().addingTimeInterval(-100)) - let newer = makeConversation(id: id3, title: "Newer", updatedAt: Date()) - - storage.save(older) - storage.save(middle) - storage.save(newer) - - let loaded = storage.loadAll() - - let ourConversations = loaded.filter { [id1, id2, id3].contains($0.id) } - #expect(ourConversations.count == 3) - - if ourConversations.count == 3 { - #expect(ourConversations[0].id == id3) - #expect(ourConversations[1].id == id2) - #expect(ourConversations[2].id == id1) - } - - cleanupConversation(id1) - cleanupConversation(id2) - cleanupConversation(id3) - } -} -#endif diff --git a/TableProTests/Core/Storage/AppStorageEnvironmentTests.swift b/TableProTests/Core/Storage/AppStorageEnvironmentTests.swift index 1299a52fb1..d5bbaf3b0d 100644 --- a/TableProTests/Core/Storage/AppStorageEnvironmentTests.swift +++ b/TableProTests/Core/Storage/AppStorageEnvironmentTests.swift @@ -1,5 +1,6 @@ import Foundation @testable import TablePro +import TableProPluginKit import Testing struct AppStorageEnvironmentTests { @@ -18,8 +19,7 @@ struct AppStorageEnvironmentTests { #expect(decision == .isolated(path: "/tmp/somewhere")) } - /// The failure this whole type exists to prevent: a UI test that opts in but names no sandbox - /// would otherwise drive the developer's real connections, keychain and preferences. + /// The failure this type exists to prevent: a UI test that names no sandbox would drive the real store. @Test("A UI test with no sandbox refuses to launch rather than falling back") func refusesWhenUiTestingHasNoSandbox() { #expect(AppStorageEnvironment.decision(for: [uiTesting: "1"]) == .refuseToLaunch) @@ -46,35 +46,45 @@ struct AppStorageEnvironmentTests { #expect(AppStorageEnvironment.decision(for: [sandbox: " /tmp/y "]) == .isolated(path: "/tmp/y")) } - /// The runtime guard only fires for a test that sets the opt-in flag. A test that sets neither - /// variable is caught here instead: `UITestCase` is the only supported way to launch the app, - /// and it always names a sandbox. - @Test("Every UI test launches through UITestCase") - func everyUiTestGoesThroughTheBaseClass() throws { - let root = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - let suiteDirectory = root.appendingPathComponent("TableProUITests") - /// Walked rather than listed. XcodeGen globs the target recursively, so a suite added under - /// TableProUITests/Editor/ would compile and run while a top-level listing never saw it. - let enumerator = FileManager.default.enumerator(atPath: suiteDirectory.path) - let baseClass = "Support/UITestCase.swift" - let paths = (enumerator?.allObjects as? [String] ?? []) - .filter { $0.hasSuffix(".swift") && $0 != baseClass } - #expect(!paths.isEmpty, "The UI test directory should not be empty at \(suiteDirectory.path)") + // Fails when TestPlans/Unit.xctestplan stops handing the host its sandbox variable. + @Test("The unit test host runs inside the sandbox the Unit plan names") + func unitTestHostIsSandboxed() { + let environment = AppStorageEnvironment.shared + #expect(environment.isIsolated) + #expect(environment.applicationSupportRoot.lastPathComponent == "UnitTestStorage") + #expect( + environment.defaultsSuiteName + == PluginHostStorage.suiteName(forSandboxAt: environment.applicationSupportRoot.path) + ) + } + + // The literal pins a digest that every process computes alike; `Hasher` would pass the other checks. + @Test("Each sandbox path gets its own suite under the uitest prefix") + func suiteNameFollowsTheSandboxPath() { + let suite = PluginHostStorage.suiteName(forSandboxAt: "/tmp/TableProSandbox") + #expect(suite == "com.TablePro.uitest.0b4547851efcce3e") + #expect(PluginHostStorage.suiteName(forSandboxAt: "/tmp/TableProSandbox/") == suite) + #expect(PluginHostStorage.suiteName(forSandboxAt: "/tmp/OtherSandbox") != suite) + } - for path in paths { - let source = try String(contentsOf: suiteDirectory.appendingPathComponent(path), encoding: .utf8) - #expect( - !source.contains("XCUIApplication()"), - "\(path) constructs an application directly; use UITestCase.launchApp() so it gets a sandbox" - ) - #expect( - !source.contains(": XCTestCase"), - "\(path) subclasses XCTestCase; UI tests must subclass UITestCase" - ) + @Test("A new sandbox directory starts with an empty suite, and a relaunch into it keeps the suite") + func newSandboxEmptiesTheSuite() throws { + let suiteName = "com.TablePro.tests.AppStorageEnvironmentTests" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("AppStorageEnvironmentTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + defaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: directory) } + + defaults.set("left by an earlier sandbox", forKey: "probe") + AppStorageEnvironment.emptyDefaultsIfNewSandbox(defaults, suiteName: suiteName, supportDirectory: directory) + #expect(defaults.string(forKey: "probe") == nil) + + defaults.set("written by this sandbox", forKey: "probe") + AppStorageEnvironment.emptyDefaultsIfNewSandbox(defaults, suiteName: suiteName, supportDirectory: directory) + #expect(defaults.string(forKey: "probe") == "written by this sandbox") } } diff --git a/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift b/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift index 7cc45c1493..cf32e7df2e 100644 --- a/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift +++ b/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift @@ -9,8 +9,7 @@ import TableProPluginKit import TableProSyncTransport import Testing -/// The stores a deleted connection leaves behind that can only be reached with `await`, and the -/// drift guard over the three sites that delete one. +/// The stores a deleted connection leaves behind that can only be reached with `await`. struct ConnectionLocalStatePurgeTests { private func makeHistory() -> (QueryHistoryManager, QueryHistoryStorage) { let storage = QueryHistoryStorage( @@ -257,69 +256,4 @@ struct ConnectionLocalStatePurgeTests { defaults.string(forKey: RowInspectorState.viewModeKey(kept)) == InspectorViewMode.json.rawValue ) } - - /// `ConnectionLocalState` exists because this list used to be written out at each delete site, - /// and they drifted: the query history clear reached the two local sites and never the remote - /// one. Anything reaching these stores itself is that drift starting again. - /// - /// Scans the whole app rather than the two files that regressed, because a delete site added in - /// a third one is exactly what a two-file list would miss. Comments are stripped first: this - /// codebase explains its routing decisions in `///` blocks, and a block naming the store it - /// deliberately does not call would otherwise fail the suite with no defect present. A file - /// that declares the method is exempt, so a type forwarding to its own storage is not a site. - @Test("Only ConnectionLocalState clears a deleted connection's async stores") - func onlyConnectionLocalStateClearsAsyncStores() throws { - let root = try Self.repoRoot().appendingPathComponent("TablePro", isDirectory: true) - let calls = [ - "removeFavoritesAndFolders(for:", - "removeFavoritesAndFoldersWithoutSync(for:", - "deleteEverything(forConnection:", - ] - - var offenders: [String] = [] - for url in try Self.swiftSources(under: root) - where url.lastPathComponent != "ConnectionLocalState.swift" { - let code = Self.strippingComments(try String(contentsOf: url, encoding: .utf8)) - for call in calls where code.contains(call) { - let name = call.prefix { $0 != "(" } - guard !code.contains("func \(name)(") else { continue } - offenders.append("\(url.lastPathComponent) calls \(call)") - } - } - - #expect( - offenders.isEmpty, - "A deleted connection's async stores are cleared through ConnectionLocalState.purge: \(offenders.sorted())" - ) - } - - private static func swiftSources(under directory: URL) throws -> [URL] { - guard let walker = FileManager.default.enumerator( - at: directory, includingPropertiesForKeys: nil - ) else { return [] } - return walker.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" } - } - - /// Line comments only. A `/* */` block holding one of these names would slip through, which is - /// a trade for not hand-rolling a Swift lexer in a guard test. - private static func strippingComments(_ text: String) -> String { - text - .split(separator: "\n", omittingEmptySubsequences: false) - .map { line -> Substring in - guard let marker = line.range(of: "//") else { return line } - return line[line.startIndex.. URL { - var url = URL(fileURLWithPath: #filePath) - while url.pathComponents.count > 1 { - url.deleteLastPathComponent() - if FileManager.default.fileExists(atPath: url.appendingPathComponent("project.yml").path) { - return url - } - } - throw CocoaError(.fileNoSuchFile) - } } diff --git a/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift b/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift index 33b48e2ce8..587a662a1c 100644 --- a/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift +++ b/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift @@ -3,7 +3,6 @@ // TableProTests // -import Foundation @testable import TablePro import Testing @@ -30,111 +29,4 @@ struct PreferenceKeysGuardTests { #expect(PreferenceKeys.registeredKeyNames.contains(key), "\(key) is not registered") } } - - @Test("No off-namespace forKey: literals outside the frozen baseline") - func noNewRawForKeyLiterals() throws { - let offenders = try Self.scan(pattern: #"forKey:\s*"([^"\\]+)""#, ignoringCalls: Self.nonPreferenceCalls) - .filter { !$0.hasPrefix("com.TablePro") && Self.grandfatheredForKey[$0] == nil } - #expect(offenders.isEmpty, "Route new UserDefaults keys through PreferenceKeys: \(offenders.sorted())") - } - - @Test("No off-namespace @AppStorage literals outside the frozen baseline") - func noNewRawAppStorageLiterals() throws { - let offenders = try Self.scan(pattern: #"@AppStorage\(\s*"([^"\\]+)""#) - .filter { !$0.hasPrefix("com.TablePro") && Self.grandfatheredAppStorage[$0] == nil } - #expect(offenders.isEmpty, "Route new @AppStorage keys through the preferences layer: \(offenders.sorted())") - } - - /// `forKey:` is not UserDefaults' label alone: `Dictionary.removeValue(forKey:)` and - /// `CALayer.add(_:forKey:)` spell it the same way, and a text scan cannot tell them apart. The - /// baseline grew one entry per dictionary key instead, three of its five, and the fourth arrived - /// as `removeValue(forKey: "LC_ALL")` in the dump environment (#2747), which failed this suite on - /// main for a value no preference has ever read. Naming the calls that are not preferences keeps - /// the baseline for the keys that genuinely are. - private static let nonPreferenceCalls: Set = [ - "removeValue", "updateValue", "add", "animation", "removeAnimation", "values", - ] - - private static let grandfatheredForKey: [String: String] = [ - "AppleLanguages": "Apple system default written when switching app language", - "NSTableViewDefaultSizeMode": "Apple system default read by the workspace rail for Sidebar icon size, never written", - ] - - private static let grandfatheredAppStorage: [String: String] = [ - "hideExportSuccessDialog": "legacy export flag, migrates to PreferenceKeys in a later phase", - "skipSchemaPreview": "legacy schema-preview flag, migrates to PreferenceKeys in a later phase", - "structureCodeFontSize": "legacy structure font size, migrates to PreferenceKeys in a later phase", - ] - - private static func scan(pattern: String, ignoringCalls ignored: Set = []) throws -> Set { - let sourceRoot = try repoRoot().appendingPathComponent("TablePro") - let regex = try NSRegularExpression(pattern: pattern) - guard let enumerator = FileManager.default.enumerator( - at: sourceRoot, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - - var matches: Set = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - let text = try String(contentsOf: url, encoding: .utf8) - let range = NSRange(text.startIndex..., in: text) - for match in regex.matches(in: text, range: range) where match.numberOfRanges > 1 { - guard let captured = Range(match.range(at: 1), in: text), - let start = Range(match.range, in: text)?.lowerBound - else { continue } - if let call = enclosingCall(in: text, at: start), ignored.contains(call) { continue } - matches.insert(String(text[captured])) - } - } - return matches - } - - /// The function whose argument list the match sits in, found by walking back to the innermost - /// unmatched `(`. Nested parentheses in an earlier argument are skipped, so - /// `layer.add(makeBlinkAnimation(), forKey: "blink")` reports `add` rather than - /// `makeBlinkAnimation`. - private static func enclosingCall(in text: String, at index: String.Index) -> String? { - var depth = 0 - var cursor = index - while cursor > text.startIndex { - cursor = text.index(before: cursor) - let character = text[cursor] - if character == ")" { - depth += 1 - } else if character == "(" { - if depth == 0 { return identifier(in: text, endingBefore: cursor) } - depth -= 1 - } else if character == "\n", depth == 0 { - continue - } - } - return nil - } - - private static func identifier(in text: String, endingBefore index: String.Index) -> String? { - var end = index - while end > text.startIndex { - let previous = text.index(before: end) - let character = text[previous] - guard character.isLetter || character.isNumber || character == "_" else { break } - end = previous - } - guard end < index else { return nil } - return String(text[end ..< index]) - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("TablePro.xcodeproj").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw GuardError.repoRootNotFound - } - - private enum GuardError: Error { - case repoRootNotFound - } } diff --git a/TableProTests/Core/Storage/SafeModeMigrationTests.swift b/TableProTests/Core/Storage/SafeModeMigrationTests.swift index aac59dcc31..8d6d5abf4f 100644 --- a/TableProTests/Core/Storage/SafeModeMigrationTests.swift +++ b/TableProTests/Core/Storage/SafeModeMigrationTests.swift @@ -473,12 +473,4 @@ struct SafeModeMigrationTests { #expect(manager.session(for: id)?.safeModeLevel == .silent) } - - // MARK: - Default Level - - @Test("New connection defaults to silent safe mode level") - func defaultLevel() { - let connection = TestFixtures.makeConnection() - #expect(connection.safeModeLevel == .silent) - } } diff --git a/TableProTests/Core/Storage/TableScopedSettingsRegistryTests.swift b/TableProTests/Core/Storage/TableScopedSettingsRegistryTests.swift index 5f420b446e..e03518e992 100644 --- a/TableProTests/Core/Storage/TableScopedSettingsRegistryTests.swift +++ b/TableProTests/Core/Storage/TableScopedSettingsRegistryTests.swift @@ -107,166 +107,6 @@ struct TableScopedSettingsRegistryTests { #expect(store.purgedConnectionIds.isEmpty) } - /// Every type that persists state keyed by a connection and a database must either conform to - /// `TableScopedSettingsStore`, so the rename, drop and purge hooks reach it, or be named here - /// with a reason. - /// - /// The list is the point. The previous version looked for three sentinel type names inside one - /// directory, so `FavoriteTablesStorage`, whose key is its own `FavoriteEntry` struct, was - /// invisible to it, and a dropped table went on leaving its star behind with nothing failing. - /// A store is found by the shape of its key now, so one that invents a fourth key type is still - /// seen, and an exemption is a line in a diff someone has to justify. - private static let exemptStoreTypes: [String: String] = [ - "FavoriteTablesStorage": """ - Its lifecycle turns on the local-or-remote origin that TableScopedSettingsStore does not carry, so ConnectionLocalState and CatalogEditAdoption drive it by name. - """, - "FavoriteDatabasesStorage": """ - Keyed by connection and database with no table, and origin-sensitive like its sibling, so it is driven by name from the same two places. - """, - ] - - @Test("Every store keyed by a connection and a database conforms, or is a named exception") - func everyConnectionScopedStoreIsRegisteredOrExempt() throws { - let app = try Self.repoRoot().appendingPathComponent("TablePro", isDirectory: true) - let sources = try Self.swiftSources(under: app) - let registry = try String( - contentsOf: app.appendingPathComponent("Core/Storage/TableScopedSettingsStore.swift"), - encoding: .utf8 - ) - - let keyTypes = Set(sources.flatMap(Self.connectionScopedKeyTypes(in:))) - #expect(keyTypes.isSuperset(of: ["TableScope", "FavoriteEntry", "FavoriteDatabaseEntry"])) - - let storeTypes = Set(sources.flatMap { Self.storesSpeaking(keyTypes, in: $0) }) - .subtracting(Self.exemptStoreTypes.keys) - #expect(storeTypes.isSuperset(of: [ - "FilterSettingsStorage", - "FileColumnLayoutPersister", - "HighlightRuleStorage", - "ValueDisplayFormatStorage", - "ForeignKeyLabelColumnStore" - ])) - - let conformance = try NSRegularExpression( - pattern: #"\b(?:class|extension)\s+([A-Z]\w*)\s*:[^{]*\bTableScopedSettingsStore\b"# - ) - let conforming = Set(sources.flatMap { Self.captures(conformance, in: $0) }) - - let unconformed = storeTypes.subtracting(conforming) - #expect( - unconformed.isEmpty, - """ - A store keyed by a connection and a database conforms to TableScopedSettingsStore, or is listed in exemptStoreTypes with a reason: \(unconformed.sorted()) - """ - ) - let unregistered = storeTypes.filter { !registry.contains("\($0).shared") } - #expect( - unregistered.isEmpty, - "A table-scoped store must be listed in TableScopedSettingsRegistry.stores: \(unregistered.sorted())" - ) - } - - /// An exemption naming a type that no longer exists reads as a decision someone made about code - /// that has since moved, which is worse than no exemption at all. - @Test("Every exemption still names a store the scan finds") - func everyExemptionNamesAStoreTheScanFinds() throws { - let app = try Self.repoRoot().appendingPathComponent("TablePro", isDirectory: true) - let sources = try Self.swiftSources(under: app) - let keyTypes = Set(sources.flatMap(Self.connectionScopedKeyTypes(in:))) - let detected = Set(sources.flatMap { Self.storesSpeaking(keyTypes, in: $0) }) - - let stale = Self.exemptStoreTypes.keys.filter { !detected.contains($0) } - #expect(stale.isEmpty, "An exemption names a type the scan no longer finds: \(stale.sorted())") - } - - /// A key carrying both a connection and a database is what makes its owner's data outlive a - /// rename or a drop of either. The name of the type is deliberately not consulted; the cap on - /// the property count is what keeps a whole model out of the result. - private static func connectionScopedKeyTypes(in text: String) -> [String] { - let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - var found: [String] = [] - for (index, line) in lines.enumerated() { - guard line.contains("{"), let name = Self.declaredStructName(in: line) else { continue } - var depth = line.filter { $0 == "{" }.count - line.filter { $0 == "}" }.count - var properties: [String] = [] - var cursor = index + 1 - while cursor < lines.count, depth > 0 { - let body = lines[cursor] - if depth == 1, let property = Self.declaredPropertyName(in: body) { - properties.append(property) - } - depth += body.filter { $0 == "{" }.count - body.filter { $0 == "}" }.count - cursor += 1 - } - guard properties.count <= 6, - properties.contains("connectionId"), - properties.contains("database") || properties.contains("databaseName") else { continue } - found.append(name) - } - return found - } - - /// A type that takes one of those keys in a function signature and writes something somewhere. - /// - /// A write rather than a mention of a persistence type, because a view model that reads - /// `FileManager` to decide what to draw is not a store, and one of them takes a display target - /// shaped exactly like a storage key. - private static func storesSpeaking(_ keyTypes: Set, in text: String) -> [String] { - let writes = [ - ".set(", "setDataValue(", ".write(to:", "removeObject(forKey:", - "removeValues(withPrefix:", "sqlite3_step", - ] - guard writes.contains(where: text.contains) else { return [] } - let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - let speaks = lines.contains { line in - guard line.contains("func ") else { return false } - return !Self.identifiers(in: line).isDisjoint(with: keyTypes) - } - guard speaks else { return [] } - return lines.compactMap(Self.declaredClassName(in:)) - } - - /// Whole identifiers only. Matching a key type as a substring made the nested `Key` of one - /// cache match every `forKey:` parameter in the app, and the scan reported twenty-two stores - /// that persist nothing table-scoped at all. - private static func identifiers(in line: String) -> Set { - Set( - line.split(whereSeparator: { !($0.isLetter || $0.isNumber || $0 == "_") }) - .map(String.init) - ) - } - - private static func declaredStructName(in line: String) -> String? { - Self.name(after: "struct ", in: line) - } - - private static func declaredClassName(in line: String) -> String? { - guard !line.hasPrefix(" "), !line.hasPrefix("\t") else { return nil } - return Self.name(after: "class ", in: line) - } - - private static func name(after keyword: String, in line: String) -> String? { - guard let range = line.range(of: keyword) else { return nil } - let name = String(line[range.upperBound...].prefix { $0.isLetter || $0.isNumber || $0 == "_" }) - guard let first = name.first, first.isUppercase else { return nil } - return name - } - - private static func declaredPropertyName(in line: String) -> String? { - let trimmed = line.trimmingCharacters(in: .whitespaces) - for keyword in ["let ", "var "] { - guard let range = trimmed.range(of: keyword) else { continue } - let prefix = trimmed[trimmed.startIndex.. Bool { - regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil - } - - private static func captures(_ regex: NSRegularExpression, in text: String) -> Set { - let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) - return Set(matches.compactMap { match in - Range(match.range(at: 1), in: text).map { String(text[$0]) } - }) - } - - private static func swiftSources(under directory: URL) throws -> [String] { - guard let enumerator = FileManager.default.enumerator( - at: directory, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - - var sources: [String] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - sources.append(try String(contentsOf: url, encoding: .utf8)) - } - return sources - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("TablePro.xcodeproj").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw GuardError.repoRootNotFound - } - - private enum GuardError: Error { - case repoRootNotFound - } } diff --git a/TableProTests/Core/Sync/EntitlementsEnvironmentParityTests.swift b/TableProTests/Core/Sync/EntitlementsEnvironmentParityTests.swift deleted file mode 100644 index 3b24493c36..0000000000 --- a/TableProTests/Core/Sync/EntitlementsEnvironmentParityTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// EntitlementsEnvironmentParityTests.swift -// TableProTests -// -// Guards the CloudKit environment pin. The Mac and iOS apps must both target -// the Production environment, or a development iOS build talks to the -// Development database while the Mac talks to Production and sync silently -// moves nothing across devices. -// - -import Foundation -import Testing - -struct EntitlementsEnvironmentParityTests { - private static let environmentKey = "com.apple.developer.icloud-container-environment" - private static let macEntitlements = "TablePro/TablePro.entitlements" - private static let iosEntitlements = "TableProMobile/TableProMobile/TableProMobileRelease.entitlements" - - @Test("Mac app pins the Production CloudKit environment") - func macTargetsProduction() throws { - try #expect(environment(in: Self.macEntitlements) == "Production") - } - - @Test("iOS app pins the Production CloudKit environment") - func iosTargetsProduction() throws { - try #expect(environment(in: Self.iosEntitlements) == "Production") - } - - private func environment(in relativePath: String) throws -> String? { - let url = try repoRoot().appendingPathComponent(relativePath) - let data = try Data(contentsOf: url) - let plist = try PropertyListSerialization.propertyList(from: data, format: nil) - let entitlements = try #require(plist as? [String: Any], "Entitlements at \(relativePath) is not a dictionary") - return entitlements[Self.environmentKey] as? String - } - - private func repoRoot(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - let marker = directory.appendingPathComponent("TablePro.xcodeproj") - if FileManager.default.fileExists(atPath: marker.path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw EntitlementsParityError.repoRootNotFound - } - - private enum EntitlementsParityError: Error { - case repoRootNotFound - } -} diff --git a/TableProTests/Core/Sync/SyncPushBatchPlannerTests.swift b/TableProTests/Core/Sync/SyncPushBatchPlannerTests.swift index ee1dc1d878..a4bd0f2ff6 100644 --- a/TableProTests/Core/Sync/SyncPushBatchPlannerTests.swift +++ b/TableProTests/Core/Sync/SyncPushBatchPlannerTests.swift @@ -8,11 +8,6 @@ import Testing import TableProSyncTransport struct SyncPushBatchPlannerTests { - @Test("The default limit is the server's documented 250 items per request") - func defaultLimitMatchesServer() { - #expect(SyncPushBatchPlanner.serverItemLimit == 250) - } - @Test("251 saves are sent as two requests") func savesAboveTheLimitSplit() { let plans = SyncPushBatchPlanner.plans(saveCount: 251, deletionCount: 0) diff --git a/TableProTests/Core/ThirdPartyLicenseInventoryTests.swift b/TableProTests/Core/ThirdPartyLicenseInventoryTests.swift index c9a4eb4a6f..78bb9fdd01 100644 --- a/TableProTests/Core/ThirdPartyLicenseInventoryTests.swift +++ b/TableProTests/Core/ThirdPartyLicenseInventoryTests.swift @@ -8,93 +8,12 @@ import Testing @testable import TablePro -/// The drift gate behind the acknowledgements list. -/// -/// Versions move every time a library is bumped, and a licence can change with them: OpenSSL -/// relicensed at 3.0, Redis at 7.4 and again at 8.0. So the shipped version is checked against -/// the pin in the build scripts, and a mismatch fails here rather than being auto-corrected, -/// because the right response to a bump is for a person to re-read the upstream licence. struct ThirdPartyLicenseInventoryTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - - private static var inventoryRoot: URL { - repositoryRoot - .appendingPathComponent("TablePro/Resources") - .appendingPathComponent(ThirdPartyLicenseInventory.resourceDirectoryName) - } - private func loadInventory() throws -> ThirdPartyLicenseInventory { - try ThirdPartyLicenseInventory(rootURL: Self.inventoryRoot) - } - - /// Every `NAME_VERSION="value"` literal the build scripts pin, which is what the shipped - /// static libraries are actually built from, plus the default of a `"${1:-value}"` pin. - private static func shellVersionPins() throws -> [String: Set] { - let scripts = repositoryRoot.appendingPathComponent("scripts") - /// `scripts/lib` as well as the build scripts. A pin shared by several builds lives in the - /// library rather than in any one script, and naming a single file here meant the test - /// stopped seeing `OPENSSL_VERSION` the moment it moved there. - let buildScripts = try FileManager.default.contentsOfDirectory(atPath: scripts.path) - .filter { $0.hasPrefix("build-") && $0.hasSuffix(".sh") } - .map { scripts.appendingPathComponent($0) } - let libraryScripts = shellScripts(in: scripts.appendingPathComponent("lib")) - let iosScripts = shellScripts(in: scripts.appendingPathComponent("ios")) - - let pattern = try NSRegularExpression( - pattern: #"^([A-Z0-9_]+_VERSION)="(?:\$\{[^:}]+:-)*(v?[0-9][^"$}]*)\}*""#, - options: [.anchorsMatchLines] + let root = try #require( + Bundle.main.url(forResource: ThirdPartyLicenseInventory.resourceDirectoryName, withExtension: nil) ) - var pins: [String: Set] = [:] - for path in buildScripts + libraryScripts + iosScripts { - guard let source = try? String(contentsOf: path, encoding: .utf8) else { continue } - let range = NSRange(source.startIndex ..< source.endIndex, in: source) - for match in pattern.matches(in: source, range: range) { - guard let key = Range(match.range(at: 1), in: source), - let value = Range(match.range(at: 2), in: source) else { continue } - pins[String(source[key]), default: []].insert(String(source[value])) - } - } - return pins - } - - private static func shellScripts(in directory: URL) -> [URL] { - ((try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []) - .filter { $0.hasSuffix(".sh") } - .map { directory.appendingPathComponent($0) } - } - - private static var iosAcknowledgementsURL: URL { - repositoryRoot.appendingPathComponent("TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json") - } - - private static func swiftPackagePins() throws -> [String: Set] { - let files = [ - "TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", - "Packages/TableProOracle/Package.resolved", - "TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" - ] - - var pins: [String: Set] = [:] - for file in files { - let url = repositoryRoot.appendingPathComponent(file) - guard let data = try? Data(contentsOf: url), - let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let entries = root["pins"] as? [[String: Any]] else { continue } - for entry in entries { - guard let identity = entry["identity"] as? String, - let state = entry["state"] as? [String: Any] else { continue } - let version = (state["version"] as? String) ?? (state["revision"] as? String).map { "rev:\($0)" } - guard let version else { continue } - pins[identity, default: []].insert(version) - } - } - return pins + return try ThirdPartyLicenseInventory(rootURL: root) } @Test("The inventory parses and is not empty") @@ -117,148 +36,6 @@ struct ThirdPartyLicenseInventoryTests { } } - @Test("No two components share an id") - func idsAreUnique() throws { - let inventory = try loadInventory() - let ids = inventory.components.map(\.id) - #expect(ids.count == Set(ids).count) - } - - @Test("Every referenced license text exists and carries a body") - func licenseTextsExist() throws { - let inventory = try loadInventory() - for file in inventory.distinctTextFiles() { - let url = Self.inventoryRoot.appendingPathComponent(file) - let text = try? String(contentsOf: url, encoding: .utf8) - #expect(text != nil, "missing license text: \(file)") - #expect((text?.count ?? 0) > 300, "license text looks truncated: \(file)") - } - } - - @Test("Every shipped license text is referenced by a component") - func noOrphanedLicenseTexts() throws { - let inventory = try loadInventory() - let referenced = Set(inventory.distinctTextFiles()) - let textsDirectory = Self.inventoryRoot.appendingPathComponent(ThirdPartyLicenseInventory.textsDirectoryName) - let onDisk = (try? FileManager.default.contentsOfDirectory(atPath: textsDirectory.path)) ?? [] - let orphans = onDisk - .filter { $0.hasSuffix(".txt") } - .map { "\(ThirdPartyLicenseInventory.textsDirectoryName)/\($0)" } - .filter { !referenced.contains($0) } - - #expect(orphans.isEmpty, "license texts nothing references: \(orphans.sorted().joined(separator: ", "))") - } - - @Test("A component's recorded version matches the version its build script pins") - func shellPinsMatch() throws { - let inventory = try loadInventory() - let pins = try Self.shellVersionPins() - - for component in inventory.components { - guard case .shellVariable(let variable) = component.versionSource else { continue } - let pinned = pins[variable] - #expect(pinned != nil, "\(component.id) points at \(variable), which no build script defines") - guard let pinned else { continue } - let pinnedVersions = Set(pinned.map(\.normalizedVersion)) - let recorded = Set(component.version.components(separatedBy: ", ").map(\.normalizedVersion)) - #expect( - recorded == pinnedVersions, - """ - \(component.id) is recorded as \(component.version) but \(variable) pins \ - \(pinned.sorted().joined(separator: ", ")) across the macOS and iOS build scripts. - Re-read the upstream license at the new version before updating this entry: - a bump can change the license, as OpenSSL did at 3.0 and Redis at 7.4. - """ - ) - } - } - - @Test("A component's recorded version matches the version SwiftPM resolved") - func swiftPackagePinsMatch() throws { - let inventory = try loadInventory() - let pins = try Self.swiftPackagePins() - - for component in inventory.components { - guard case .swiftPackage(let identity) = component.versionSource else { continue } - let resolved = pins[identity] - #expect(resolved != nil, "\(component.id) points at SwiftPM package \(identity), which nothing resolves") - guard let resolved else { continue } - - /// A package can resolve to more than one version across the app, the Oracle - /// package and the iOS app, and every one of them ships, so the entry has to - /// name all of them rather than whichever was read first. - let recorded = Set(component.version.components(separatedBy: ", ")) - #expect( - recorded == resolved, - """ - \(component.id) is recorded as \(recorded.sorted().joined(separator: ", ")) \ - but SwiftPM resolves \(resolved.sorted().joined(separator: ", ")). - """ - ) - } - } - - @Test("Every build-script pin is attributed by a component") - func everyShellPinIsAttributed() throws { - let inventory = try loadInventory() - let attributedVariables = Set(inventory.components.compactMap { component -> String? in - guard case .shellVariable(let variable) = component.versionSource else { return nil } - return variable - }) - - let unattributed = try Self.shellVersionPins().keys.filter { !attributedVariables.contains($0) } - - #expect( - unattributed.isEmpty, - """ - These libraries are built and shipped with no license entry: \ - \(unattributed.sorted().joined(separator: ", ")). - Add each to TablePro/Resources/ThirdPartyLicenses/licenses.yml. - """ - ) - } - - @Test("The patched flag agrees with the patches on disk") - func patchedFlagMatchesPatchDirectories() throws { - let inventory = try loadInventory() - let patchesRoot = Self.repositoryRoot.appendingPathComponent("scripts/patches") - let patchedOnDisk = Set((try? FileManager.default.contentsOfDirectory(atPath: patchesRoot.path)) ?? []) - let patchedInInventory = Set(inventory.components.filter(\.patched).map(\.id)) - - for id in patchedOnDisk where !id.hasPrefix(".") { - #expect( - patchedInInventory.contains(id), - "scripts/patches/\(id) exists but \(id) is not marked patched in the license inventory" - ) - } - for id in patchedInInventory { - #expect( - patchedOnDisk.contains(id), - "\(id) is marked patched but scripts/patches/\(id) does not exist" - ) - } - } - - @Test("The iOS acknowledgements are the projection of the inventory's iOS entries") - func iosAcknowledgementsMatchInventory() throws { - let inventory = try loadInventory() - let expected = inventory.components - .filter { $0.ships(on: .ios) } - .map(IOSAcknowledgement.init(component:)) - .sorted { ($0.name.lowercased(), $0.id) < ($1.name.lowercased(), $1.id) } - - let data = try Data(contentsOf: Self.iosAcknowledgementsURL) - let committed = try JSONDecoder().decode([IOSAcknowledgement].self, from: data) - - #expect( - committed == expected, - """ - TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json is out of date with licenses.yml. - Run scripts/generate-ios-acknowledgements.py and commit the result. - """ - ) - } - @Test("A component with no platforms ships on macOS only") func platformsDefaultToMacOS() throws { let root = FileManager.default.temporaryDirectory @@ -302,34 +79,3 @@ struct ThirdPartyLicenseInventoryTests { #expect(inventory.attributed.map(\.id) == ["mac-only"]) } } - -private struct IOSAcknowledgement: Codable, Equatable { - let id: String - let name: String - let version: String - let spdx: String - let copyrights: [String] - let homepageURL: String - let textFile: String? -} - -private extension IOSAcknowledgement { - init(component: ThirdPartyComponent) { - self.init( - id: component.id, - name: component.name, - version: component.version, - spdx: component.spdx, - copyrights: component.copyrights, - homepageURL: component.homepageURL, - textFile: component.textFile - ) - } -} - -private extension String { - /// Build scripts pin some tags with a leading `v`; the upstream release is the same one. - var normalizedVersion: String { - hasPrefix("v") ? String(dropFirst()) : self - } -} diff --git a/TableProTests/Core/Utilities/ConnectionURLParserMSSQLTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserMSSQLTests.swift deleted file mode 100644 index 1140fa7169..0000000000 --- a/TableProTests/Core/Utilities/ConnectionURLParserMSSQLTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// ConnectionURLParserMSSQLTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -struct ConnectionURLParserMSSQLTests { - - @Test("Full MSSQL URL with default port") - func testFullMSSQLURLDefaultPort() { - let result = ConnectionURLParser.parse("mssql://user:pass@host:1433/mydb") - guard case .success(let parsed) = result else { - Issue.record("Expected success"); return - } - #expect(parsed.type == .mssql) - #expect(parsed.host == "host") - #expect(parsed.port == nil) - #expect(parsed.database == "mydb") - #expect(parsed.username == "user") - #expect(parsed.password == "pass") - } - - @Test("sqlserver scheme alias parses as MSSQL") - func testSqlServerSchemeAlias() { - let result = ConnectionURLParser.parse("sqlserver://user:pass@host/db") - guard case .success(let parsed) = result else { - Issue.record("Expected success"); return - } - #expect(parsed.type == .mssql) - #expect(parsed.host == "host") - #expect(parsed.database == "db") - #expect(parsed.username == "user") - #expect(parsed.password == "pass") - } - - @Test("Case-insensitive MSSQL scheme") - func testCaseInsensitiveMSSQLScheme() { - let result = ConnectionURLParser.parse("MSSQL://user@host/db") - guard case .success(let parsed) = result else { - Issue.record("Expected success"); return - } - #expect(parsed.type == .mssql) - #expect(parsed.host == "host") - #expect(parsed.username == "user") - } - - @Test("MSSQL URL without credentials") - func testMSSQLWithoutCredentials() { - let result = ConnectionURLParser.parse("mssql://host/db") - guard case .success(let parsed) = result else { - Issue.record("Expected success"); return - } - #expect(parsed.type == .mssql) - #expect(parsed.host == "host") - #expect(parsed.database == "db") - #expect(parsed.username == "") - #expect(parsed.password == "") - } - - @Test("MSSQL non-default port preserved") - func testMSSQLNonDefaultPortPreserved() { - let result = ConnectionURLParser.parse("mssql://user:pass@host:1434/db") - guard case .success(let parsed) = result else { - Issue.record("Expected success"); return - } - #expect(parsed.type == .mssql) - #expect(parsed.port == 1434) - #expect(parsed.host == "host") - #expect(parsed.database == "db") - } - - @Test("MongoDB+SRV scheme parses as MongoDB") - func testMongoDBSrvParsesAsMongoDBType() { - let result = ConnectionURLParser.parse("mongodb+srv://user:pass@cluster.net/db") - guard case .success(let parsed) = result else { - Issue.record("Expected success"); return - } - #expect(parsed.type == .mongodb) - #expect(parsed.host == "cluster.net") - #expect(parsed.database == "db") - } -} diff --git a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift index 25796b3b1d..96af9181c8 100644 --- a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift @@ -676,17 +676,6 @@ struct ConnectionURLParserTests { #expect(parsed.redisDatabase == nil) } - @Test("Redis URL with database index") - func testRedisURLWithDatabaseIndex() { - let result = ConnectionURLParser.parse("redis://localhost:6379/3") - guard case .success(let parsed) = result else { - Issue.record("Expected success"); return - } - #expect(parsed.type == .redis) - #expect(parsed.redisDatabase == 3) - #expect(parsed.database == "") - } - @Test("Redis URL without port") func testRedisURLWithoutPort() { let result = ConnectionURLParser.parse("redis://localhost") diff --git a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift index 5171f97ae6..688463d033 100644 --- a/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift +++ b/TableProTests/Core/Utilities/DatabaseURLSchemeTests.swift @@ -319,15 +319,6 @@ struct DatabaseURLSchemeTests { #expect(parsed.type == .mysql) } - @Test("MongoDB+srv scheme preserved (not stripped)") - func mongodbSrvPreserved() { - let result = ConnectionURLParser.parse("mongodb+srv://user:pass@cluster.example.com/mydb") - guard case .success(let parsed) = result else { - Issue.record("Expected success"); return - } - #expect(parsed.type == .mongodb) - } - @Test("PostgreSQL+ssh still enables SSH mode") func postgresqlSshStillWorks() { let result = ConnectionURLParser.parse("postgresql+ssh://user:pass@localhost/mydb") diff --git a/TableProTests/Core/Utilities/FixedFormatDateFormatterLocaleTests.swift b/TableProTests/Core/Utilities/FixedFormatDateFormatterLocaleTests.swift index 4fb6f68f46..2a7b17ffdb 100644 --- a/TableProTests/Core/Utilities/FixedFormatDateFormatterLocaleTests.swift +++ b/TableProTests/Core/Utilities/FixedFormatDateFormatterLocaleTests.swift @@ -29,71 +29,4 @@ struct FixedFormatDateFormatterLocaleTests { #expect(unpinned.string(from: date) != "2026-09-18") #expect(pinned.string(from: date) == "2026-09-18") } - - @Test("Every fixed-format date formatter in the codebase states its locale") - func everyFixedFormatFormatterStatesItsLocale() throws { - let root = try repositoryRoot() - var offenders: [String] = [] - - for directory in ["TablePro", "Plugins", "Packages", "TableProMobile"] { - let base = root.appendingPathComponent(directory) - guard FileManager.default.fileExists(atPath: base.path) else { continue } - offenders.append(contentsOf: try offendingSites(under: base, root: root)) - } - - #expect( - offenders.isEmpty, - """ - These formatters assign a fixed `dateFormat` without stating a `locale`, so their year \ - follows the user's calendar: \(offenders.sorted()) - """ - ) - } - - private func offendingSites(under directory: URL, root: URL) throws -> [String] { - let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) - var offenders: [String] = [] - - while let url = enumerator?.nextObject() as? URL { - guard url.pathExtension == "swift" else { continue } - guard !url.path.contains("/.build/"), !url.path.contains("/checkouts/") else { continue } - - let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) - for (index, line) in lines.enumerated() where line.contains(".dateFormat = \"") { - guard !statesALocale(at: index, in: lines) else { continue } - let relative = url.path.replacingOccurrences(of: root.path + "/", with: "") - offenders.append("\(relative):\(index + 1)") - } - } - - return offenders - } - - /// A formatter built right there is judged from where it is built to eight lines past the - /// format. One handed over by a factory (`makeFormatter { $0.dateFormat = ... }`) has its locale - /// set out of sight, so the file as a whole has to state one. - private func statesALocale(at index: Int, in lines: [String]) -> Bool { - let searchStart = max(0, index - 20) - let construction = lines[searchStart...index].lastIndex { $0.contains("DateFormatter(") } - - guard let construction else { - return lines.contains { $0.contains(".locale = ") } - } - return lines[construction...min(lines.count - 1, index + 8)].contains { $0.contains(".locale = ") } - } - - private func repositoryRoot(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw LocaleScanError.repositoryRootNotFound - } - - private enum LocaleScanError: Error { - case repositoryRootNotFound - } } diff --git a/TableProTests/Core/Validation/SettingsValidationTests.swift b/TableProTests/Core/Validation/SettingsValidationTests.swift index f832dc270a..ab4fb01064 100644 --- a/TableProTests/Core/Validation/SettingsValidationTests.swift +++ b/TableProTests/Core/Validation/SettingsValidationTests.swift @@ -259,13 +259,6 @@ struct SettingsValidationTests { #expect(SettingsValidationRules.nullDisplayMaxLength == 20) } - @Test("Validation rules default page size range is correct") - func validationRulesDefaultPageSizeRange() { - let range = SettingsValidationRules.defaultPageSizeRange - #expect(range.lowerBound == 10) - #expect(range.upperBound == 100_000) - } - @Test("Decoding clamps an out-of-range stored page size to the valid range") func decodingClampsDefaultPageSize() throws { let range = SettingsValidationRules.defaultPageSizeRange diff --git a/TableProTests/Core/Vim/VimEngineSearchTests.swift b/TableProTests/Core/Vim/VimEngineSearchTests.swift index cbaf213c6f..a3c838c6d4 100644 --- a/TableProTests/Core/Vim/VimEngineSearchTests.swift +++ b/TableProTests/Core/Vim/VimEngineSearchTests.swift @@ -165,16 +165,6 @@ final class VimEngineSearchTests: XCTestCase { XCTAssertEqual(pos, 20, "# should find the previous occurrence of the word under cursor") } - // MARK: - Search with Operator - - func testDeleteToForwardSearchMatch() throws { - // Search-as-motion (d/pattern) is not yet wired into the operator + motion - // machinery — the search command runs but does not feed its target back as - // a motion endpoint. Leaving this test as a documented TODO so the contract - // is recorded for the next implementation pass. - throw XCTSkip("Operator + search motion (d/pattern) not yet implemented") - } - // MARK: - Search Highlighting func testSearchStateRetainedAfterMatch() { diff --git a/TableProTests/Database/MongoToolsConnectionStringTests.swift b/TableProTests/Database/MongoToolsConnectionStringTests.swift deleted file mode 100644 index 12f79f5cb7..0000000000 --- a/TableProTests/Database/MongoToolsConnectionStringTests.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// MongoToolsConnectionStringTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct MongoToolsConnectionStringTests { - private func connection( - host: String = "db.example.com", - port: Int = 27_017, - database: String = "shop", - username: String = "alice", - sslMode: SSLMode = .disabled - ) -> DatabaseConnection { - DatabaseConnection( - name: "Test", - host: host, - port: port, - database: database, - username: username, - type: .mongodb, - sshConfig: SSHConfiguration(), - sslConfig: SSLConfiguration(mode: sslMode) - ) - } - - private func uri(_ connection: DatabaseConnection) -> String { - MongoToolsConnectionString.make(for: connection, host: connection.host) - } - - @Test("The auth database follows the driver's rule for every combination") - func authenticationDatabaseMatchesTheDriver() { - for explicit in [nil, "", "accounts", "$external"] { - for database in ["", "shop"] { - for useSrv in [false, true] { - var subject = connection(database: database) - subject.mongoAuthSource = explicit - subject.mongoUseSrv = useSrv - let driver = MongoDBAuthSourceResolver.resolve( - explicitAuthSource: explicit, - configuredDatabase: database, - useSrv: useSrv - ) - #expect( - MongoToolsConnectionString.authenticationDatabase(for: subject) == driver, - "explicit \(explicit ?? "nil"), database \(database), srv \(useSrv)" - ) - } - } - } - } - - @Test("An Atlas host is SRV without the toggle, as the driver treats it") - func atlasHostImpliesSrv() { - let atlas = uri(connection(host: "cluster0.abcde.mongodb.net")) - #expect(atlas.hasPrefix("mongodb+srv://alice@cluster0.abcde.mongodb.net/?authSource=admin")) - #expect(atlas.contains("tls=true")) - } - - @Test("An SRV name loses the port the Hosts list gave it") - func srvDropsThePort() { - var subject = connection(host: "cluster0.example.com") - subject.mongoUseSrv = true - subject.additionalFields["mongoHosts"] = "cluster0.example.com:27017" - #expect(uri(subject).hasPrefix("mongodb+srv://alice@cluster0.example.com/?")) - } - - @Test("Hosts without a port take the connection's port, and an IPv6 literal keeps its brackets") - func hostListPorts() { - var subject = connection(port: 27_018) - subject.additionalFields["mongoHosts"] = "a.example.com, [::1], b.example.com:27019, [fe80::1]:27020" - #expect(uri(subject).hasPrefix( - "mongodb://alice@a.example.com:27018,[::1]:27018,b.example.com:27019,[fe80::1]:27020/?" - )) - } - - @Test("A connection without a username sends no auth database, which the tools would try to use") - func anonymousSendsNoAuthDatabase() { - #expect(uri(connection(username: "")) == "mongodb://db.example.com:27017/") - } - - @Test("Reserved characters in the username are percent-encoded") - func usernameIsEncoded() { - #expect(uri(connection(username: "pl+us@x:y/z%")).hasPrefix("mongodb://pl+us%40x%3Ay%2Fz%25@db.example.com")) - } - - @Test("The connection's mechanism, replica set, read preference and write concern reach the tool") - func connectionOptionsReachTheTool() { - var subject = connection() - subject.mongoAuthMechanism = "SCRAM-SHA-256" - subject.mongoReplicaSet = "rs0" - subject.mongoReadPreference = "secondaryPreferred" - subject.mongoWriteConcern = "majority" - #expect(uri(subject) == """ - mongodb://alice@db.example.com:27017/?authSource=shop&authMechanism=SCRAM-SHA-256\ - &replicaSet=rs0&readPreference=secondaryPreferred&w=majority - """) - } - - @Test("Extra URI parameters pass through unless a connection field owns them") - func extraParametersPassThrough() { - var subject = connection() - subject.additionalFields["mongoParam_directConnection"] = "true" - subject.additionalFields["mongoParam_authSource"] = "elsewhere" - subject.additionalFields["mongoParam_readPreference"] = "nearest" - subject.additionalFields["mongoParam_appName"] = "a&b=c+d" - let plain = uri(subject) - #expect(plain.contains("directConnection=true")) - #expect(plain.contains("readPreference=nearest")) - #expect(plain.contains("appName=a%26b%3Dc%2Bd")) - #expect(!plain.contains("authSource=elsewhere")) - - subject.mongoReadPreference = "secondary" - let owned = uri(subject) - #expect(owned.contains("readPreference=secondary")) - #expect(!owned.contains("readPreference=nearest")) - } - - @Test("SSL off sends no TLS options and no certificate the form still holds") - func tlsDisabled() { - var subject = connection() - subject.sslConfig.caCertificatePath = "/certs/ca.pem" - subject.sslConfig.clientCertificatePath = "/certs/client.pem" - #expect(MongoToolsConnectionString.tlsParameters(for: subject).isEmpty) - } - - @Test("Preferred and Required skip verification with the one option the tools honor") - func tlsWithoutVerification() { - for mode in [SSLMode.preferred, .required] { - var subject = connection(sslMode: mode) - subject.sslConfig.caCertificatePath = "/certs/ca.pem" - #expect( - MongoToolsConnectionString.tlsParameters(for: subject) == ["tls=true", "tlsInsecure=true"], - "\(mode.rawValue)" - ) - } - } - - @Test("The verifying modes send their CA and never switch verification off") - func tlsWithVerification() { - for mode in [SSLMode.verifyCa, .verifyIdentity] { - var subject = connection(sslMode: mode) - subject.sslConfig.caCertificatePath = "/certs/my ca.pem" - subject.sslConfig.clientCertificatePath = "/certs/client.pem" - #expect( - MongoToolsConnectionString.tlsParameters(for: subject) == [ - "tls=true", "tlsCAFile=/certs/my%20ca.pem", "tlsCertificateKeyFile=/certs/client.pem" - ], - "\(mode.rawValue)" - ) - } - } - - @Test("SRV turns TLS on with full verification when SSL is left off") - func srvForcesTls() { - var subject = connection() - subject.mongoUseSrv = true - subject.sslConfig.clientCertificatePath = "/certs/client.pem" - #expect(MongoToolsConnectionString.tlsParameters(for: subject) == ["tls=true"]) - } -} diff --git a/TableProTests/Database/SQLServerDumpTests.swift b/TableProTests/Database/SQLServerDumpTests.swift deleted file mode 100644 index a22674e6a5..0000000000 --- a/TableProTests/Database/SQLServerDumpTests.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// SQLServerDumpTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct SQLServerDumpTests { - private func command(kind: NativeDumpKind, username: String = "sa") throws -> NativeDumpCommand { - var sslConfig = SSLConfiguration() - sslConfig.mode = .disabled - let connection = DatabaseConnection( - name: "Test", host: "db.example.com", port: 1_433, database: "sales", - username: username, type: .mssql, - sshConfig: SSHConfiguration(), sslConfig: sslConfig - ) - let tool = try #require(NativeDumpRegistry.descriptor(for: .mssql)?.commandLineTool) - return try NativeDumpService.buildCommand( - kind: kind, - tool: tool, - resolved: NativeDumpResolvedTool(name: "sqlpackage", path: "/usr/local/bin/sqlpackage"), - request: NativeDumpDescriptor.Request( - connection: connection, - database: "sales", - fileURL: URL(fileURLWithPath: "/tmp/sales.bacpac"), - password: "s3cret" - ) - ) - } - - @Test("SQL Server exports and imports a bacpac through sqlpackage") - func bacpacActions() throws { - let backup = try command(kind: .backup) - #expect(backup.arguments.contains("/Action:Export")) - #expect(backup.arguments.contains("/TargetFile:/tmp/sales.bacpac")) - - let restore = try command(kind: .restore) - #expect(restore.arguments.contains("/Action:Import")) - #expect(restore.arguments.contains("/SourceFile:/tmp/sales.bacpac")) - } - - @Test("The archive is a bacpac and the tool writes it itself") - func archiveShape() throws { - let descriptor = try #require(NativeDumpRegistry.descriptor(for: .mssql)) - #expect(descriptor.archiveFormat.fileExtension == "bacpac") - #expect(descriptor.commandLineTool?.backupDelivery == .toolWritesFile) - #expect(try command(kind: .backup).redirectedFileURL == nil) - } - - /// An empty username means Windows or Entra authentication, which carries no password at all. - @Test("No username asks for integrated security instead of a blank password") - func integratedSecurity() throws { - let anonymous = try command(kind: .backup, username: "") - let connectionString = try #require( - anonymous.arguments.first { $0.hasPrefix("/SourceConnectionString:") }) - #expect(connectionString.contains("Integrated Security=true")) - #expect(!connectionString.contains("Password=")) - } - - /// Recorded rather than asserted away: `sqlpackage` takes no password flag and no password - /// environment variable, so the connection string is its only channel and the password is - /// visible in `ps`. Every other engine keeps it out of `argv`. - @Test("SQL Server is the one engine whose password reaches the argument list") - func passwordIsInArgvForSQLServerOnly() throws { - let backup = try command(kind: .backup) - #expect(backup.arguments.contains { $0.contains("s3cret") }) - #expect(backup.environment["SQLCMDPASSWORD"] == nil) - } -} diff --git a/TableProTests/Helpers/SQLTestHelpers.swift b/TableProTests/Helpers/SQLTestHelpers.swift deleted file mode 100644 index 817c49c211..0000000000 --- a/TableProTests/Helpers/SQLTestHelpers.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// SQLTestHelpers.swift -// TableProTests -// -// SQL assertion utilities for test suite -// - -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -// MARK: - SQL Normalization - -func normalizeSQL(_ sql: String) -> String { - let normalized = sql - .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - .trimmingCharacters(in: .whitespaces) - return normalized -} - -// MARK: - SQL Assertions - -func expectSQLContains( - _ sql: String, - _ substring: String, - sourceLocation: SourceLocation = #_sourceLocation -) { - let normalizedSQL = normalizeSQL(sql).lowercased() - let normalizedSubstring = normalizeSQL(substring).lowercased() - - #expect( - normalizedSQL.contains(normalizedSubstring), - "Expected SQL to contain '\(substring)'\nActual SQL: \(sql)", - sourceLocation: sourceLocation - ) -} - -func expectSQLEquals( - _ actual: String, - _ expected: String, - sourceLocation: SourceLocation = #_sourceLocation -) { - let normalizedActual = normalizeSQL(actual) - let normalizedExpected = normalizeSQL(expected) - - #expect( - normalizedActual == normalizedExpected, - "SQL mismatch\nExpected: \(normalizedExpected)\nActual: \(normalizedActual)", - sourceLocation: sourceLocation - ) -} diff --git a/TableProTests/Helpers/StringCatalog.swift b/TableProTests/Helpers/StringCatalog.swift new file mode 100644 index 0000000000..cf0dc8a0c0 --- /dev/null +++ b/TableProTests/Helpers/StringCatalog.swift @@ -0,0 +1,89 @@ +// +// StringCatalog.swift +// TableProTests +// + +import Foundation + +// The build compiles the catalogs to .strings, so translations are read from the checked-in JSON. +struct StringCatalog { + struct Unit { + let catalog: String + let language: String + let key: String + let value: String + + var description: String { "\(catalog):\(language): \"\(key)\" -> \"\(value)\"" } + } + + let name: String + private let strings: [String: Entry] + private let sourceLanguage: String + + var translatedUnits: [Unit] { + strings.sorted { $0.key < $1.key }.flatMap { key, entry -> [Unit] in + (entry.localizations ?? [:]) + .sorted { $0.key < $1.key } + .filter { $0.key != sourceLanguage } + .flatMap { language, localization -> [Unit] in + let plain = localization.stringUnit.map { [$0] } ?? [] + let varied = (localization.variations ?? [:]).values + .flatMap(\.values) + .compactMap(\.stringUnit) + return (plain + varied) + .filter { $0.state == "translated" } + .map { Unit(catalog: name, language: language, key: key, value: $0.value) } + } + } + } + + static func loadAll() throws -> [StringCatalog] { + let root = try repoRoot() + return try [ + ("TablePro", "TablePro/Resources/Localizable.xcstrings"), + ("TableProMobile", "TableProMobile/TableProMobile/Localizable.xcstrings"), + ].map { name, path in + let data = try Data(contentsOf: root.appendingPathComponent(path)) + let decoded = try JSONDecoder().decode(Payload.self, from: data) + return StringCatalog(name: name, strings: decoded.strings, sourceLanguage: decoded.sourceLanguage) + } + } + + private static func repoRoot() throws -> URL { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0 ..< 12 { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + throw CatalogError.repoRootNotFound + } + + private struct Payload: Decodable { + let sourceLanguage: String + let strings: [String: Entry] + } + + private struct Entry: Decodable { + let localizations: [String: Localization]? + } + + private struct Localization: Decodable { + let stringUnit: StringUnit? + let variations: [String: [String: Variation]]? + } + + private struct Variation: Decodable { + let stringUnit: StringUnit? + } + + private struct StringUnit: Decodable { + let state: String + let value: String + } + + private enum CatalogError: Error { + case repoRootNotFound + } +} diff --git a/TableProTests/Helpers/UserFacingSourceScan.swift b/TableProTests/Helpers/UserFacingSourceScan.swift deleted file mode 100644 index 5c8f4fd264..0000000000 --- a/TableProTests/Helpers/UserFacingSourceScan.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// UserFacingSourceScan.swift -// TableProTests -// - -import Foundation - -enum UserFacingSourceScan { - static func scanAppTarget( - offends: (_ line: String, _ previous: String) -> Bool - ) throws -> (scanned: Int, offenders: [String]) { - let root = try repoRoot().appendingPathComponent("TablePro", isDirectory: true) - guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) else { - throw ScanError.sourceTreeNotFound - } - - var scanned = 0 - var found: [String] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - scanned += 1 - let text = try String(contentsOf: url, encoding: .utf8) - var previous = "" - for (index, line) in text.components(separatedBy: .newlines).enumerated() { - defer { if !line.trimmingCharacters(in: .whitespaces).isEmpty { previous = line } } - guard offends(line, previous) else { continue } - found.append("\(url.lastPathComponent):\(index + 1): \(line.trimmingCharacters(in: .whitespaces))") - } - } - return (scanned, found.sorted()) - } - - static func userFacingCode(line: String, previous: String) -> String? { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.hasPrefix("//") else { return nil } - guard !isLogMessage(trimmed, previous: previous.trimmingCharacters(in: .whitespaces)) else { return nil } - return withoutTrailingComment(trimmed) - } - - private static let levels = "(debug|info|notice|warning|error|fault|critical|trace|log)" - private static let logCall = #"(\blogger|Logger|Log)\s*\.\s*"# + levels + #"\s*\(|Logger\([^)]*\)\s*\.\s*"# - + levels + #"\s*\(|\bos_log\s*\("# - private static let chainedLevel = #"^\.\s*"# + levels + #"\s*\("# - private static let logReceiverAtEnd = #"(\blogger|Logger|Log|Logger\([^)]*\))\s*$"# - - /// The level name alone is not a log call: `.info(for:)` and a banner's `.notice(_:)` are user-facing. - private static func isLogMessage(_ line: String, previous: String) -> Bool { - if matches(line, logCall) { return true } - let previousCode = previous.hasPrefix("//") ? "" : withoutTrailingComment(previous) - if matches(line, chainedLevel), matches(previousCode, logReceiverAtEnd) { return true } - return matches(previousCode, logCall) && leavesParenthesisOpen(previousCode) - } - - private static func matches(_ text: String, _ pattern: String) -> Bool { - text.range(of: pattern, options: .regularExpression) != nil - } - - private static func leavesParenthesisOpen(_ line: String) -> Bool { - var depth = 0 - var insideQuotes = false - var escaped = false - for character in line { - if escaped { - escaped = false - continue - } - switch character { - case "\\": escaped = true - case "\"": insideQuotes.toggle() - case "(" where !insideQuotes: depth += 1 - case ")" where !insideQuotes: depth -= 1 - default: break - } - } - return depth > 0 - } - - private static func withoutTrailingComment(_ line: String) -> String { - let characters = Array(line) - var insideQuotes = false - var escaped = false - for index in characters.indices { - let character = characters[index] - if escaped { - escaped = false - continue - } - if character == "\\" { - escaped = true - continue - } - if character == "\"" { - insideQuotes.toggle() - continue - } - if !insideQuotes, character == "/", index + 1 < characters.count, characters[index + 1] == "/" { - return String(characters[.. URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("TablePro.xcodeproj").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw ScanError.repoRootNotFound - } - - private enum ScanError: Error { - case repoRootNotFound - case sourceTreeNotFound - } -} diff --git a/TableProTests/Localization/CountedNounAgreementTests.swift b/TableProTests/Localization/CountedNounAgreementTests.swift index 24387b2814..546e951806 100644 --- a/TableProTests/Localization/CountedNounAgreementTests.swift +++ b/TableProTests/Localization/CountedNounAgreementTests.swift @@ -13,14 +13,6 @@ import Testing struct CountedNounAgreementTests { private static let english = Locale(identifier: "en_US") - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - /// `Text` takes the attributed path, so this is what a SwiftUI label renders. private static func rendered(_ value: String.LocalizationValue) -> String { String(AttributedString(localized: value, locale: english).characters) @@ -50,31 +42,4 @@ struct CountedNounAgreementTests { #expect(Self.rendered("\(1) of ^[\(1) row](inflect: true) selected") == "1 of 1 row selected") #expect(Self.rendered("Executed ^[\(1) statement](inflect: true)") == "Executed 1 statement") } - - @Test("No view writes a count in front of an inflected span") - func noCountSitsOutsideItsSpan() throws { - let pattern = try NSRegularExpression(pattern: #"\\\([^()]*\)\s*\^\["#) - let roots = ["TablePro", "TableProMobile", "Plugins"] - var offenders: [String] = [] - for root in roots { - let directory = Self.repositoryRoot.appendingPathComponent(root) - guard let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else { - continue - } - for case let url as URL in enumerator where url.pathExtension == "swift" { - let source = try String(contentsOf: url, encoding: .utf8) - for (index, line) in source.components(separatedBy: "\n").enumerated() { - let range = NSRange(line.startIndex..., in: line) - if pattern.firstMatch(in: line, range: range) != nil { - offenders.append("\(url.lastPathComponent):\(index + 1)") - } - } - } - } - - #expect( - offenders.isEmpty, - "Move the count inside the span, ^[\\(count) noun](inflect: true), so the noun agrees: \(offenders)" - ) - } } diff --git a/TableProTests/Localization/EditorMenuAndStatementCountTests.swift b/TableProTests/Localization/EditorMenuAndStatementCountTests.swift index 92aca608da..3f80756be2 100644 --- a/TableProTests/Localization/EditorMenuAndStatementCountTests.swift +++ b/TableProTests/Localization/EditorMenuAndStatementCountTests.swift @@ -3,15 +3,9 @@ // TableProTests // -import AppKit import Foundation -@testable import TablePro import Testing -/// Two strings the user reads constantly: the editor's own context menu, and the statement count on -/// a review sheet. One was English in every language because the menu was built from literals; the -/// other chose between two whole keys in Swift, which is the shape that cannot survive a language -/// with more than two plural categories. struct EditorMenuAndStatementCountTests { /// Measured on this toolchain: `String(localized:)` hands back the key verbatim, /// `(^[1 statement](inflect: true))`, so a counted noun that has to end up in a `String` still @@ -32,61 +26,4 @@ struct EditorMenuAndStatementCountTests { #expect(viaLocalized == "(^[1 statement](inflect: true))", "measured as \(viaLocalized)") } - - @Test("The statement count is translated into every language the app ships") - func statementCountIsTranslatedEverywhere() throws { - let catalog = try catalogEntries() - let entry = try #require(catalog["(^[%lld statement](inflect: true))"] as? [String: Any]) - let localizations = try #require(entry["localizations"] as? [String: Any]) - - for language in Self.shippedLanguages { - #expect(localizations[language] != nil, "\(language) has no translation for the statement count") - } - } - - @Test("The editor's context menu titles come from the catalog, not from literals") - func editorContextMenuIsLocalized() throws { - let source = try String(contentsOf: editorMenuSource(), encoding: .utf8) - - for title in ["Cut", "Copy", "Paste"] { - #expect( - !source.contains("NSMenuItem(title: \"\(title)\""), - """ - The editor's context menu builds \(title) from a literal, so it stays English while \ - the Edit menu shows the translation the catalog already carries. - """ - ) - #expect(source.contains("String(localized: \"\(title)\")")) - } - } - - private static let shippedLanguages = ["fr", "ko", "tr", "vi", "zh-Hans", "zh-Hant"] - - private func catalogEntries() throws -> [String: Any] { - let url = try repositoryRoot().appendingPathComponent("TablePro/Resources/Localizable.xcstrings") - let json = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) - let root = try #require(json as? [String: Any]) - return try #require(root["strings"] as? [String: Any]) - } - - private func editorMenuSource() throws -> URL { - try repositoryRoot().appendingPathComponent( - "Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+Menu.swift" - ) - } - - private func repositoryRoot(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CatalogTestError.repositoryRootNotFound - } - - private enum CatalogTestError: Error { - case repositoryRootNotFound - } } diff --git a/TableProTests/Localization/StringCatalogIntegrityTests.swift b/TableProTests/Localization/StringCatalogIntegrityTests.swift deleted file mode 100644 index c33448776b..0000000000 --- a/TableProTests/Localization/StringCatalogIntegrityTests.swift +++ /dev/null @@ -1,553 +0,0 @@ -// -// StringCatalogIntegrityTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// A translation is data, so the compiler never reads it. A Turkish string that spells `%@` where the -/// source passes `%lld` still builds, ships, and then formats garbage or traps at runtime, and nobody -/// on the team reads Turkish closely enough to catch it in review. The catalogs carry more than 15,000 -/// translated units across four languages, and both grow every release. -/// -/// These are the rules the shipped catalogs already keep, measured across every unit in both of them -/// before this guard was written, so a failure here is a new defect rather than a pre-existing one. -/// Style choices that legitimately differ by language are deliberately not asserted: Chinese renders -/// terminal punctuation and ellipses full width, and 232 shipped translations pass two or more -/// arguments in plain `%@` order rather than positionally. -struct StringCatalogIntegrityTests { - @Test("Every translation consumes the arguments its source passes") - func argumentsMatchSource() throws { - let offenders = try StringCatalog.loadAll().flatMap { catalog in - catalog.translatedUnits.compactMap { unit -> String? in - let expected = FormatSpecifier.arguments(in: unit.source, substitutions: unit.sourceSubstitutions) - let found = FormatSpecifier.parse(unit.value) - let foundArguments = FormatSpecifier.arguments(in: unit.value, substitutions: unit.valueSubstitutions) - guard !expected.isEmpty || !foundArguments.isEmpty else { return nil } - if let complaint = Self.mismatch(expected: expected, found: found, foundArguments: foundArguments) { - return "\(unit.description): \(complaint)" - } - return nil - } - } - - #expect( - offenders.isEmpty, - """ - A translation does not consume the same arguments as its source string. String(format:) \ - reads whatever the specifier names, so this formats the wrong value or crashes. - \(offenders.joined(separator: "\n")) - """ - ) - } - - @Test("No translation carries Apple's English-only inflection markup") - func inflectionMarkupStaysInTheSource() throws { - let offenders = try StringCatalog.loadAll().flatMap { catalog in - catalog.translatedUnits - .filter { $0.value.contains("^[") } - .map(\.description) - } - - #expect( - offenders.isEmpty, - """ - `^[noun](inflect: true)` is English grammar Foundation applies for the source language \ - only. Translations write the plain noun their own language needs. - \(offenders.joined(separator: "\n")) - """ - ) - } - - @Test("Shortcuts parameter tokens survive translation") - func interpolationTokensSurvive() throws { - let offenders = try StringCatalog.loadAll().flatMap { catalog in - catalog.translatedUnits.compactMap { unit -> String? in - let wanted = Self.interpolationTokens(in: unit.key) - let found = Self.interpolationTokens(in: unit.value) - guard wanted != found else { return nil } - return "\(unit.description): has \(found.joined(separator: " ")), source has \(wanted.joined(separator: " "))" - } - } - - #expect( - offenders.isEmpty, - """ - `${name}` in an App Intents parameter summary is a parameter reference, not a word. \ - Shortcuts drops the summary when the token no longer resolves. - \(offenders.joined(separator: "\n")) - """ - ) - } - - @Test("A command that ends in an ellipsis keeps one in every language") - func ellipsisSurvives() throws { - let offenders = try StringCatalog.loadAll().flatMap { catalog in - catalog.translatedUnits.compactMap { unit -> String? in - let sourceEnds = Self.endsWithEllipsis(unit.key) - guard sourceEnds != Self.endsWithEllipsis(unit.value) else { return nil } - return "\(unit.description): source \(sourceEnds ? "has" : "has no") trailing ellipsis" - } - } - - #expect( - offenders.isEmpty, - """ - The trailing ellipsis is the platform's promise that a command opens something before it \ - acts. Either spelling is fine, and Chinese uses the single character, but it has to be there. - \(offenders.joined(separator: "\n")) - """ - ) - } - - @Test("A source that ends in a sentence mark keeps one in every language") - func terminalPunctuationSurvives() throws { - let offenders = try StringCatalog.loadAll().flatMap { catalog in - catalog.translatedUnits.compactMap { unit -> String? in - guard !Self.endsWithEllipsis(unit.key) else { return nil } - guard let mark = Self.terminalMarks.first(where: { unit.key.hasSuffix($0.key) }) else { return nil } - guard !mark.value.contains(where: { unit.value.hasSuffix($0) }) else { return nil } - return "\(unit.description): source ends in \(mark.key)" - } - } - - #expect( - offenders.isEmpty, - """ - A label that drops the source's sentence mark reads as a different kind of string. \ - The full-width forms Chinese uses count. - \(offenders.joined(separator: "\n")) - """ - ) - } - - @Test("No translation introduces an em dash its source does not have") - func translationsAddNoEmDash() throws { - let offenders = try StringCatalog.loadAll().flatMap { catalog in - catalog.translatedUnits - .filter { $0.value.contains(Self.emDash) && !$0.key.contains(Self.emDash) } - .map(\.description) - } - - #expect( - offenders.isEmpty, - """ - CLAUDE.md bans the em dash from anything a user reads. Nine source strings still carry one \ - and their translations may mirror it; nothing else may introduce one. - \(offenders.joined(separator: "\n")) - """ - ) - } - - static func mismatch( - expected: [Int: String], - found: [FormatSpecifier], - foundArguments: [Int: String] - ) -> String? { - let indices = found.map(\.argumentIndex) - if indices.contains(where: { $0 == nil }), indices.contains(where: { $0 != nil }) { - return "mixes positional and plain specifiers" - } - guard Set(foundArguments.keys) == Set(expected.keys) else { - let want = expected.isEmpty - ? "no arguments" - : expected.keys.sorted().map(String.init).joined(separator: ",") - let got = foundArguments.isEmpty - ? "none" - : foundArguments.keys.sorted().map(String.init).joined(separator: ",") - return "consumes \(got) but the source passes \(want)" - } - for index in expected.keys.sorted() { - guard let want = expected[index], let got = foundArguments[index], want != got else { continue } - return "argument \(index) is %\(got) but the source passes %\(want)" - } - return nil - } - - static func interpolationTokens(in text: String) -> [String] { - var tokens: [String] = [] - var remainder = Substring(text) - while let start = remainder.range(of: "${") { - guard let end = remainder[start.upperBound...].firstIndex(of: "}") else { break } - tokens.append(String(remainder[start.lowerBound ... end])) - remainder = remainder[remainder.index(after: end)...] - } - return tokens.sorted() - } - - static func endsWithEllipsis(_ text: String) -> Bool { - text.hasSuffix("...") || text.hasSuffix("\u{2026}") - } - - static let terminalMarks: [String: [String]] = [ - ":": [":", "\u{FF1A}"], - "?": ["?", "\u{FF1F}"], - ".": [".", "\u{3002}"], - ] - - static let emDash = "\u{2014}" -} - -/// Foundation has no public parser for printf specifiers, so the guard carries its own. It reads the -/// grammar `String(format:)` accepts: an optional `n$` argument index, flags, width, precision, a -/// length modifier, and the conversion character. `%%` is a literal percent and consumes no argument. -struct FormatSpecifier: Equatable, Sendable { - let argumentIndex: Int? - let type: String - - static func parse(_ text: String) -> [FormatSpecifier] { - var specifiers: [FormatSpecifier] = [] - var characters = Array(text) - var cursor = 0 - while cursor < characters.count { - guard characters[cursor] == "%" else { - cursor += 1 - continue - } - cursor += 1 - guard cursor < characters.count else { break } - - /// `%#@name@` is a substitution placeholder, not a printf specifier. Read as printf it - /// looks like `%@` with a `#` flag, which is what made a plural translation report as - /// mixing positional and plain specifiers. - if characters[cursor] == "#", cursor + 1 < characters.count, characters[cursor + 1] == "@" { - var scan = cursor + 2 - while scan < characters.count, characters[scan] != "@" { - scan += 1 - } - guard scan < characters.count else { break } - cursor = scan + 1 - continue - } - - var index: Int? - var digits = "" - var lookahead = cursor - while lookahead < characters.count, characters[lookahead].isNumber { - digits.append(characters[lookahead]) - lookahead += 1 - } - if !digits.isEmpty, lookahead < characters.count, characters[lookahead] == "$" { - index = Int(digits) - cursor = lookahead + 1 - } - - while cursor < characters.count, "-+ #0".contains(characters[cursor]) { - cursor += 1 - } - while cursor < characters.count, characters[cursor].isNumber { - cursor += 1 - } - if cursor < characters.count, characters[cursor] == "." { - cursor += 1 - while cursor < characters.count, characters[cursor].isNumber { - cursor += 1 - } - } - - var length = "" - for candidate in ["ll", "hh", "l", "h", "z", "q"] where length.isEmpty { - let end = cursor + candidate.count - if end <= characters.count, String(characters[cursor ..< end]) == candidate { - length = candidate - cursor = end - } - } - - guard cursor < characters.count else { break } - let conversion = characters[cursor] - cursor += 1 - guard conversion != "%" else { continue } - guard "@diuUfFeEgGxXoscpaA".contains(conversion) else { continue } - specifiers.append(FormatSpecifier(argumentIndex: index, type: length + String(conversion))) - } - return specifiers - } - - /// The argument types a source string passes, in argument order. - static func argumentTypes(in source: String) -> [String] { - let specifiers = parse(source) - guard !specifiers.isEmpty else { return [] } - if specifiers.allSatisfy({ $0.argumentIndex != nil }) { - var byIndex: [Int: String] = [:] - for specifier in specifiers { - byIndex[specifier.argumentIndex ?? 0] = specifier.type - } - return byIndex.keys.sorted().compactMap { byIndex[$0] } - } - return specifiers.map(\.type) - } - - /// Which argument each side consumes, and as what. - /// - /// A `%#@name@` substitution is not a printf specifier and consumes no argument of its own; the - /// `substitutions` block beside it says which argument it stands for and how that argument is - /// spelled. Reading only the printf specifiers therefore under-counts a plural source by one - /// argument, and reports every translation that inlines the plural as consuming an argument the - /// source never passed. - static func arguments(in text: String, substitutions: [Int: String]) -> [Int: String] { - var arguments: [Int: String] = [:] - for (position, specifier) in parse(text).enumerated() { - arguments[specifier.argumentIndex ?? position + 1] = specifier.type - } - for (index, specifier) in substitutions { - arguments[index] = specifier - } - return arguments - } -} - -struct StringCatalog { - struct Unit { - let catalog: String - let language: String - let key: String - let source: String - let value: String - - /// `argNum` to format specifier, for the `%#@name@` substitutions each side declares. A - /// plural substitution consumes a real argument that no printf specifier spells out, so a - /// side that inlines `%4$d` and a side that writes `%#@points@` pass the same arguments. - let sourceSubstitutions: [Int: String] - let valueSubstitutions: [Int: String] - - var description: String { "\(catalog):\(language): \"\(key)\" -> \"\(value)\"" } - } - - let name: String - private let strings: [String: Entry] - private let sourceLanguage: String - - func containsKey(_ key: String) -> Bool { - strings[key] != nil - } - - var translatedUnits: [Unit] { - strings.sorted { $0.key < $1.key }.flatMap { key, entry -> [Unit] in - let sourceLocalization = entry.localizations?[sourceLanguage] - let source = sourceLocalization?.stringUnit?.value ?? key - let sourceSubstitutions = sourceLocalization?.argumentSubstitutions ?? [:] - return (entry.localizations ?? [:]) - .sorted { $0.key < $1.key } - .filter { $0.key != sourceLanguage } - .flatMap { language, localization -> [Unit] in - let plain = localization.stringUnit.map { [$0] } ?? [] - let varied = (localization.variations ?? [:]).values - .flatMap(\.values) - .compactMap(\.stringUnit) - return (plain + varied) - .filter { $0.state == "translated" } - .map { - Unit( - catalog: name, - language: language, - key: key, - source: source, - value: $0.value, - sourceSubstitutions: sourceSubstitutions, - valueSubstitutions: localization.argumentSubstitutions - ) - } - } - } - } - - static func loadAll() throws -> [StringCatalog] { - let root = try repoRoot() - return try [ - ("TablePro", "TablePro/Resources/Localizable.xcstrings"), - ("TableProMobile", "TableProMobile/TableProMobile/Localizable.xcstrings"), - ].map { name, path in - let data = try Data(contentsOf: root.appendingPathComponent(path)) - let decoded = try JSONDecoder().decode(Payload.self, from: data) - return StringCatalog(name: name, strings: decoded.strings, sourceLanguage: decoded.sourceLanguage) - } - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CatalogError.repoRootNotFound - } - - private struct Payload: Decodable { - let sourceLanguage: String - let strings: [String: Entry] - } - - struct Entry: Decodable { - let localizations: [String: Localization]? - } - - struct Localization: Decodable { - let stringUnit: StringUnit? - let variations: [String: [String: Variation]]? - let substitutions: [String: Substitution]? - - var argumentSubstitutions: [Int: String] { - (substitutions ?? [:]).values.reduce(into: [:]) { result, substitution in - result[substitution.argNum] = substitution.formatSpecifier - } - } - } - - struct Substitution: Decodable { - let argNum: Int - let formatSpecifier: String - } - - struct Variation: Decodable { - let stringUnit: StringUnit? - } - - struct StringUnit: Decodable { - let state: String - let value: String - } - - enum CatalogError: Error { - case repoRootNotFound - } -} - -struct FormatSpecifierTests { - @Test("A plain specifier carries no argument index") - func plainSpecifier() { - #expect(FormatSpecifier.parse("%@ rows") == [FormatSpecifier(argumentIndex: nil, type: "@")]) - } - - @Test("A length modifier is part of the type") - func lengthModifier() { - #expect(FormatSpecifier.parse("%lld of %d").map(\.type) == ["lld", "d"]) - } - - @Test("Width and precision are not the type") - func widthAndPrecision() { - #expect(FormatSpecifier.parse("%.3fms") == [FormatSpecifier(argumentIndex: nil, type: "f")]) - #expect(FormatSpecifier.parse("%-8.2f") == [FormatSpecifier(argumentIndex: nil, type: "f")]) - } - - @Test("A literal percent consumes no argument") - func literalPercent() { - #expect(FormatSpecifier.parse("100%% done").isEmpty) - #expect(FormatSpecifier.parse("%1$lld%% slower").map(\.argumentIndex) == [1]) - } - - @Test("Positional specifiers report their index") - func positionalSpecifiers() { - let parsed = FormatSpecifier.parse("%2$@ on %1$@") - #expect(parsed.map(\.argumentIndex) == [2, 1]) - } - - @Test("An argument used twice is one argument") - func repeatedArgument() { - #expect(FormatSpecifier.argumentTypes(in: "%1$@ owns %2$d items in %1$@") == ["@", "d"]) - } - - @Test("Argument types follow argument order, not written order") - func typesFollowArgumentOrder() { - #expect(FormatSpecifier.argumentTypes(in: "%2$lld of %1$@") == ["@", "lld"]) - } -} - -struct StringCatalogRuleTests { - private static func complaint( - source: String, - sourceSubstitutions: [Int: String] = [:], - value: String, - valueSubstitutions: [Int: String] = [:] - ) -> String? { - StringCatalogIntegrityTests.mismatch( - expected: FormatSpecifier.arguments(in: source, substitutions: sourceSubstitutions), - found: FormatSpecifier.parse(value), - foundArguments: FormatSpecifier.arguments(in: value, substitutions: valueSubstitutions) - ) - } - - @Test("A reordered translation is fine when it stays positional") - func reorderedPositionalPasses() { - #expect(Self.complaint(source: "%1$@ on %2$@", value: "%2$@ üzerinde %1$@") == nil) - } - - @Test("A dropped argument is caught") - func droppedArgumentFails() { - #expect(Self.complaint(source: "%1$@ on %2$@", value: "%1$@") != nil) - } - - @Test("A retyped argument is caught") - func retypedArgumentFails() { - #expect(Self.complaint(source: "%lld rows", value: "%@ satır") != nil) - } - - @Test("Mixing positional and plain specifiers is caught") - func mixedStyleFails() { - #expect(Self.complaint(source: "%1$@ on %2$@", value: "%1$@ üzerinde %@") != nil) - } - - /// A plural source spells its count as `%#@points@` and declares the real argument beside it. - /// A translation that inlines `%4$d` passes the same four arguments, and one that keeps its own - /// substitution does too. Reading only the printf specifiers reported both as defects, which is - /// six false alarms on the shipped catalog. - @Test("A plural substitution counts as the argument it stands for") - func pluralSubstitutionCountsAsItsArgument() { - let source = "%1$@ chart of %2$@ by %3$@ with %#@points@" - let substitutions = [4: "d"] - - #expect(Self.complaint( - source: source, - sourceSubstitutions: substitutions, - value: "%3$@ ölçütüne göre %2$@ için %4$d noktalı %1$@ grafiği" - ) == nil) - #expect(Self.complaint( - source: source, - sourceSubstitutions: substitutions, - value: "%3$@ 기준 %2$@의 %1$@ 차트, %#@points@", - valueSubstitutions: substitutions - ) == nil) - } - - @Test("A translation that drops the plural argument is still caught") - func droppedPluralArgumentFails() { - #expect(Self.complaint( - source: "%1$@ with %#@points@", - sourceSubstitutions: [2: "d"], - value: "%1$@" - ) != nil) - } - - @Test("A translation that retypes the plural argument is still caught") - func retypedPluralArgumentFails() { - #expect(Self.complaint( - source: "%1$@ with %#@points@", - sourceSubstitutions: [2: "d"], - value: "%1$@ với %2$@" - ) != nil) - } - - @Test("Full-width punctuation counts as the sentence mark") - func fullWidthPunctuationCounts() { - let marks = StringCatalogIntegrityTests.terminalMarks - #expect(marks["."]?.contains(where: { "已导入。".hasSuffix($0) }) == true) - #expect(marks["?"]?.contains(where: { "確定嗎?".hasSuffix($0) }) == true) - } - - @Test("Either ellipsis spelling satisfies the ellipsis rule") - func eitherEllipsisSpellingPasses() { - #expect(StringCatalogIntegrityTests.endsWithEllipsis("Open Quickly...")) - #expect(StringCatalogIntegrityTests.endsWithEllipsis("快速打開…")) - #expect(!StringCatalogIntegrityTests.endsWithEllipsis("Open Quickly")) - } - - @Test("Interpolation tokens are compared as a set") - func interpolationTokensAreCompared() { - #expect(StringCatalogIntegrityTests.interpolationTokens(in: "Add a row to ${table}") == ["${table}"]) - #expect(StringCatalogIntegrityTests.interpolationTokens(in: "Thêm hàng vào ${table}") == ["${table}"]) - #expect(StringCatalogIntegrityTests.interpolationTokens(in: "no tokens").isEmpty) - } -} diff --git a/TableProTests/Models/AISettingsTests.swift b/TableProTests/Models/AISettingsTests.swift index b037160fc7..114130ac1b 100644 --- a/TableProTests/Models/AISettingsTests.swift +++ b/TableProTests/Models/AISettingsTests.swift @@ -9,11 +9,6 @@ import TableProPluginKit import Testing struct AISettingsTests { - @Test("default has enabled true") - func defaultEnabledIsTrue() { - #expect(AISettings.default.enabled == true) - } - @Test("decoding without enabled key defaults to true") func decodingWithoutEnabledDefaultsToTrue() throws { let json = "{}" diff --git a/TableProTests/Models/ColumnLayoutStateTests.swift b/TableProTests/Models/ColumnLayoutStateTests.swift index de18f071ca..9c836ef8e3 100644 --- a/TableProTests/Models/ColumnLayoutStateTests.swift +++ b/TableProTests/Models/ColumnLayoutStateTests.swift @@ -11,99 +11,6 @@ import TableProPluginKit import Testing struct ColumnLayoutStateTests { - @Test("Default has empty widths") - func defaultEmptyWidths() { - let state = ColumnLayoutState() - #expect(state.columnWidths.isEmpty) - #expect(state.columnContentWidths == nil) - } - - @Test("Default has nil column order") - func defaultNilOrder() { - let state = ColumnLayoutState() - #expect(state.columnOrder == nil) - } - - @Test("Stores column widths") - func storesWidths() { - let state = ColumnLayoutState(columnWidths: ["name": 120.0, "email": 200.0]) - #expect(state.columnWidths["name"] == 120.0) - #expect(state.columnWidths["email"] == 200.0) - } - - @Test("Stores column order") - func storesOrder() { - let state = ColumnLayoutState(columnOrder: ["id", "name", "email"]) - #expect(state.columnOrder == ["id", "name", "email"]) - } - - @Test("Equal states are equal") - func equalStates() { - let a = ColumnLayoutState(columnWidths: ["id": 50.0], columnOrder: ["id"]) - let b = ColumnLayoutState(columnWidths: ["id": 50.0], columnOrder: ["id"]) - #expect(a == b) - } - - @Test("Different widths produces unequal states") - func differentWidths() { - let a = ColumnLayoutState(columnWidths: ["id": 50.0]) - let b = ColumnLayoutState(columnWidths: ["id": 100.0]) - #expect(a != b) - } - - @Test("Different order produces unequal states") - func differentOrder() { - let a = ColumnLayoutState(columnOrder: ["id", "name"]) - let b = ColumnLayoutState(columnOrder: ["name", "id"]) - #expect(a != b) - } - - @Test("nil vs empty order are not equal") - func nilVsEmptyOrder() { - let a = ColumnLayoutState(columnOrder: nil) - let b = ColumnLayoutState(columnOrder: []) - #expect(a != b) - } - - // MARK: - hiddenColumns - - @Test("Default hiddenColumns is empty") - func defaultHiddenColumnsEmpty() { - let state = ColumnLayoutState() - #expect(state.hiddenColumns.isEmpty) - } - - @Test("Same hiddenColumns produces equal states") - func sameHiddenColumnsEqual() { - let a = ColumnLayoutState(hiddenColumns: ["name", "email"]) - let b = ColumnLayoutState(hiddenColumns: ["name", "email"]) - #expect(a == b) - } - - @Test("Different hiddenColumns produces unequal states") - func differentHiddenColumnsNotEqual() { - let a = ColumnLayoutState(hiddenColumns: ["name"]) - let b = ColumnLayoutState(hiddenColumns: ["email"]) - #expect(a != b) - } - - @Test("Same widths with different hiddenColumns are not equal") - func sameWidthsDifferentHiddenColumnsNotEqual() { - let a = ColumnLayoutState(columnWidths: ["id": 50.0], hiddenColumns: ["name"]) - let b = ColumnLayoutState(columnWidths: ["id": 50.0], hiddenColumns: ["email"]) - #expect(a != b) - } - - @Test("Setting hiddenColumns stores and retrieves values") - func setAndReadHiddenColumns() { - var state = ColumnLayoutState() - state.hiddenColumns = ["id", "created_at"] - #expect(state.hiddenColumns == ["id", "created_at"]) - #expect(state.hiddenColumns.contains("id")) - #expect(state.hiddenColumns.contains("created_at")) - #expect(!state.hiddenColumns.contains("name")) - } - // MARK: - applyGeometry @Test("Applying geometry updates widths and order") diff --git a/TableProTests/Models/Connection/ConnectionFormRequestTests.swift b/TableProTests/Models/Connection/ConnectionFormRequestTests.swift index 166281702c..8341c27d4f 100644 --- a/TableProTests/Models/Connection/ConnectionFormRequestTests.swift +++ b/TableProTests/Models/Connection/ConnectionFormRequestTests.swift @@ -51,18 +51,4 @@ struct ConnectionFormRequestTests { #expect(ConnectionFormRequest.create(draftId: draftId).draftId == draftId) #expect(ConnectionFormRequest.edit(connectionId: UUID()).draftId == nil) } - - @Test("A request round trips through Codable") - func requestRoundTripsThroughCodable() throws { - let requests: [ConnectionFormRequest] = [ - .edit(connectionId: UUID()), - .create(draftId: UUID()) - ] - - for request in requests { - let data = try JSONEncoder().encode(request) - let decoded = try JSONDecoder().decode(ConnectionFormRequest.self, from: data) - #expect(decoded == request) - } - } } diff --git a/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift b/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift index b36e2767c6..cfb685d6ec 100644 --- a/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift +++ b/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift @@ -39,43 +39,22 @@ struct SQLDDLFallbackPolicyTests { #expect(!SQLDDLFallbackPolicy.allowsGeneratedDDL(for: .dynamodb)) } - /// Every engine `DatabaseType` declares must be named in one of the two lists, so an engine - /// added without a decision fails here rather than inheriting one. - /// - /// The declared constants are read from the type's own source rather than from - /// `DatabaseType.allKnownTypes`, which reads the live `PluginMetadataRegistry`: other suites - /// register synthetic snapshots and never remove them, so under a different test order those - /// ids would arrive here and fail a test about shipping engines. - @Test("Every declared engine is classified deliberately") - func everyDeclaredTypeIsClassified() throws { - let declared = try declaredDatabaseTypes() - #expect(declared.count > 30, "Parsed only \(declared.count) types; the scan is not reading the file") + /// Every engine in the curated table must be named in one of the two lists, so an engine added + /// without a decision fails here. The live registry also holds synthetic engines other suites add. + @Test("Every shipping engine is classified deliberately") + func everyShippingEngineIsClassified() { + let shipping = Set(PluginMetadataRegistry.shared.builtInDefaults().map(\.typeId)) + #expect(shipping.count > 30, "The curated table lists only \(shipping.count) engines") - let named = Self.fabricatingTypes.union(SQLDDLFallbackPolicy.enginesWithoutSQLDDL) - let unclassified = declared.subtracting(named.map(\.rawValue)) + let named = Set(Self.fabricatingTypes.union(SQLDDLFallbackPolicy.enginesWithoutSQLDDL).map(\.rawValue)) + let unclassified = shipping.subtracting(named) #expect( unclassified.isEmpty, "Classify these in SQLDDLFallbackPolicyTests or the policy: \(unclassified.sorted())" ) - let unknown = Set(named.map(\.rawValue)).subtracting(declared) - #expect(unknown.isEmpty, "Named but not declared by DatabaseType: \(unknown.sorted())") - } - - private func declaredDatabaseTypes() throws -> Set { - let root = try #require(repositoryRoot) - let source = try String( - contentsOf: root.appendingPathComponent("TablePro/Models/Connection/DatabaseType.swift"), - encoding: .utf8 - ) - var found: Set = [] - for line in source.components(separatedBy: .newlines) { - guard line.contains("static let"), let range = line.range(of: "DatabaseType(rawValue: \"") else { continue } - let rest = line[range.upperBound...] - guard let end = rest.firstIndex(of: "\"") else { continue } - found.insert(String(rest[.. = [ @@ -85,45 +64,14 @@ struct SQLDDLFallbackPolicyTests { .libsql, .turso, .teradata, .trino, .sapHana, ] - /// The policy lives in the app and the statement lives in the plugin, so nothing but this - /// forces them to agree. An engine whose plugin declares a non-SQL editor language has no SQL - /// DDL by construction and must never reach the fabricating branch. - @Test("No plugin declaring a non-SQL editor language is left fabricating") - func nonSQLPluginsAreAllExcluded() throws { - let pluginsDirectory = try #require(repositoryRoot?.appendingPathComponent("Plugins")) - let files = FileManager.default.enumerator(at: pluginsDirectory, includingPropertiesForKeys: nil) - var offenders: [String] = [] - - while let url = files?.nextObject() as? URL { - guard url.pathExtension == "swift", - let source = try? String(contentsOf: url, encoding: .utf8), - source.contains("editorLanguage: EditorLanguage = ."), - !source.contains("editorLanguage: EditorLanguage = .sql"), - let typeId = declaredDatabaseTypeId(in: source) - else { continue } - if SQLDDLFallbackPolicy.allowsGeneratedDDL(for: DatabaseType(rawValue: typeId)) { - offenders.append(typeId) - } - } - + /// The policy lives in the app and the statement lives in the plugin. An engine with a non-SQL + /// editor language has no SQL DDL by construction and must never reach the fabricating branch. + @Test("No engine with a non-SQL editor language is left fabricating") + func nonSQLEnginesAreAllExcluded() { + let offenders = PluginMetadataRegistry.shared.builtInDefaults() + .filter { $0.snapshot.editorLanguage != .sql } + .map(\.typeId) + .filter { SQLDDLFallbackPolicy.allowsGeneratedDDL(for: DatabaseType(rawValue: $0)) } #expect(offenders.isEmpty, "Add to SQLDDLFallbackPolicy.enginesWithoutSQLDDL: \(offenders.sorted())") } - - private func declaredDatabaseTypeId(in source: String) -> String? { - guard let range = source.range(of: "databaseTypeId = \"") else { return nil } - let rest = source[range.upperBound...] - guard let end = rest.firstIndex(of: "\"") else { return nil } - return String(rest[.. 1 { - url.deleteLastPathComponent() - if FileManager.default.fileExists(atPath: url.appendingPathComponent("project.yml").path) { - return url - } - } - return nil - } } diff --git a/TableProTests/Models/DatabaseConnectionAIRulesTests.swift b/TableProTests/Models/DatabaseConnectionAIRulesTests.swift index d4781e2c92..e839b2517e 100644 --- a/TableProTests/Models/DatabaseConnectionAIRulesTests.swift +++ b/TableProTests/Models/DatabaseConnectionAIRulesTests.swift @@ -10,29 +10,6 @@ import Testing @testable import TablePro struct DatabaseConnectionAIRulesTests { - @Test("aiRules defaults to nil") - func defaultsToNil() { - let conn = TestFixtures.makeConnection() - #expect(conn.aiRules == nil) - } - - @Test("init populates aiRules") - func initPopulatesAIRules() { - let conn = DatabaseConnection( - name: "Test", - type: .mysql, - aiRules: "Always filter by tenant_id." - ) - #expect(conn.aiRules == "Always filter by tenant_id.") - } - - @Test("aiRules is mutable on var") - func aiRulesMutable() { - var conn = TestFixtures.makeConnection() - conn.aiRules = "Avoid users.ssn" - #expect(conn.aiRules == "Avoid users.ssn") - } - @Test("Codable round-trip preserves aiRules") func codableRoundTripWithRules() throws { let original = DatabaseConnection( diff --git a/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift b/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift index a6018ce67a..c14b4ddb5b 100644 --- a/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift +++ b/TableProTests/Models/DatabaseConnectionAdditionalFieldsTests.swift @@ -10,81 +10,8 @@ import Testing @testable import TablePro struct DatabaseConnectionAdditionalFieldsTests { - // MARK: - Defaults - - @Test("mongoAuthSource defaults to nil") - func mongoAuthSourceDefaultsToNil() { - let conn = TestFixtures.makeConnection(type: .mongodb) - #expect(conn.mongoAuthSource == nil) - } - - @Test("mongoReadPreference defaults to nil") - func mongoReadPreferenceDefaultsToNil() { - let conn = TestFixtures.makeConnection(type: .mongodb) - #expect(conn.mongoReadPreference == nil) - } - - @Test("mongoWriteConcern defaults to nil") - func mongoWriteConcernDefaultsToNil() { - let conn = TestFixtures.makeConnection(type: .mongodb) - #expect(conn.mongoWriteConcern == nil) - } - - @Test("mssqlSchema defaults to nil") - func mssqlSchemaDefaultsToNil() { - let conn = TestFixtures.makeConnection(type: .mssql) - #expect(conn.mssqlSchema == nil) - } - - @Test("oracleServiceName defaults to nil") - func oracleServiceNameDefaultsToNil() { - let conn = TestFixtures.makeConnection(type: .oracle) - #expect(conn.oracleServiceName == nil) - } - - @Test("redisDatabase defaults to nil") - func redisDatabaseDefaultsToNil() { - let conn = TestFixtures.makeConnection(type: .redis) - #expect(conn.redisDatabase == nil) - } - // MARK: - Read/Write via Computed Aliases - @Test("mongoAuthSource is readable and writable") - func mongoAuthSourceReadWrite() { - var conn = TestFixtures.makeConnection(type: .mongodb) - conn.mongoAuthSource = "admin" - #expect(conn.mongoAuthSource == "admin") - } - - @Test("mongoReadPreference is readable and writable") - func mongoReadPreferenceReadWrite() { - var conn = TestFixtures.makeConnection(type: .mongodb) - conn.mongoReadPreference = "secondary" - #expect(conn.mongoReadPreference == "secondary") - } - - @Test("mongoWriteConcern is readable and writable") - func mongoWriteConcernReadWrite() { - var conn = TestFixtures.makeConnection(type: .mongodb) - conn.mongoWriteConcern = "majority" - #expect(conn.mongoWriteConcern == "majority") - } - - @Test("mssqlSchema is readable and writable") - func mssqlSchemaReadWrite() { - var conn = TestFixtures.makeConnection(type: .mssql) - conn.mssqlSchema = "dbo" - #expect(conn.mssqlSchema == "dbo") - } - - @Test("oracleServiceName is readable and writable") - func oracleServiceNameReadWrite() { - var conn = TestFixtures.makeConnection(type: .oracle) - conn.oracleServiceName = "ORCL" - #expect(conn.oracleServiceName == "ORCL") - } - @Test("redisDatabase is readable and writable") func redisDatabaseReadWrite() { var conn = TestFixtures.makeConnection(type: .redis) @@ -94,13 +21,6 @@ struct DatabaseConnectionAdditionalFieldsTests { // MARK: - additionalFields Dict - @Test("Setting mongoAuthSource writes to additionalFields dict") - func mongoAuthSourceWritesToDict() { - var conn = TestFixtures.makeConnection(type: .mongodb) - conn.mongoAuthSource = "admin" - #expect(conn.additionalFields["mongoAuthSource"] == "admin") - } - @Test("Init with additionalFields dict populates computed aliases") func initWithDictPopulatesAliases() { let conn = DatabaseConnection( @@ -137,39 +57,6 @@ struct DatabaseConnectionAdditionalFieldsTests { // MARK: - Init with Named Params - @Test("init populates mongoAuthSource into additionalFields") - func initPopulatesMongoAuthSource() { - let conn = DatabaseConnection( - name: "Test", - type: .mongodb, - mongoAuthSource: "admin" - ) - #expect(conn.mongoAuthSource == "admin") - #expect(conn.additionalFields["mongoAuthSource"] == "admin") - } - - @Test("init populates mssqlSchema") - func initPopulatesMssqlSchema() { - let conn = DatabaseConnection( - name: "Test", - type: .mssql, - mssqlSchema: "dbo" - ) - #expect(conn.mssqlSchema == "dbo") - #expect(conn.additionalFields["mssqlSchema"] == "dbo") - } - - @Test("init populates oracleServiceName") - func initPopulatesOracleServiceName() { - let conn = DatabaseConnection( - name: "Test", - type: .oracle, - oracleServiceName: "ORCL" - ) - #expect(conn.oracleServiceName == "ORCL") - #expect(conn.additionalFields["oracleServiceName"] == "ORCL") - } - @Test("init with additionalFields dict overrides named params") func initDictOverridesNamedParams() { let conn = DatabaseConnection( @@ -221,46 +108,6 @@ struct DatabaseConnectionAdditionalFieldsTests { // MARK: - Codable Round-Trip - @Test("Codable round-trip preserves mongo additional fields") - func codableRoundTripMongo() throws { - let original = DatabaseConnection( - name: "Mongo", - type: .mongodb, - mongoAuthSource: "admin", - mongoReadPreference: "secondary", - mongoWriteConcern: "majority" - ) - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) - #expect(decoded.mongoAuthSource == "admin") - #expect(decoded.mongoReadPreference == "secondary") - #expect(decoded.mongoWriteConcern == "majority") - } - - @Test("Codable round-trip preserves mssqlSchema") - func codableRoundTripMssql() throws { - let original = DatabaseConnection( - name: "MSSQL", - type: .mssql, - mssqlSchema: "dbo" - ) - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) - #expect(decoded.mssqlSchema == "dbo") - } - - @Test("Codable round-trip preserves oracleServiceName") - func codableRoundTripOracle() throws { - let original = DatabaseConnection( - name: "Oracle", - type: .oracle, - oracleServiceName: "ORCL" - ) - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) - #expect(decoded.oracleServiceName == "ORCL") - } - @Test("Codable round-trip preserves nil additional fields") func codableRoundTripNils() throws { let original = TestFixtures.makeConnection(type: .mongodb) diff --git a/TableProTests/Models/DatabaseObjectRefCodingTests.swift b/TableProTests/Models/DatabaseObjectRefCodingTests.swift index 8cc58e0bda..65ee4448c4 100644 --- a/TableProTests/Models/DatabaseObjectRefCodingTests.swift +++ b/TableProTests/Models/DatabaseObjectRefCodingTests.swift @@ -11,18 +11,6 @@ import Testing @testable import TablePro struct DatabaseObjectRefCodingTests { - @Test("A type ref round-trips with its kind") - func userTypeRoundTrip() throws { - let ref = DatabaseObjectRef( - userType: UserDefinedTypeInfo(name: "mood", kind: .domain, schema: "app", identity: "16387"), - database: "shop" - ) - let data = try JSONEncoder().encode(ref) - let decoded = try JSONDecoder().decode(DatabaseObjectRef.self, from: data) - #expect(decoded == ref) - #expect(decoded.typeKind == .domain) - } - /// A tab persisted before types existed carries no `typeKind` at all. @Test("A routine ref written without typeKind still decodes") func legacyRefDecodes() throws { diff --git a/TableProTests/Models/DatabaseTypeCassandraTests.swift b/TableProTests/Models/DatabaseTypeCassandraTests.swift deleted file mode 100644 index 9364fa74a1..0000000000 --- a/TableProTests/Models/DatabaseTypeCassandraTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import TableProPluginKit -import Testing -@testable import TablePro - -struct DatabaseTypeCassandraTests { - @Test("Cassandra raw value is Cassandra") - func cassandraRawValue() { - #expect(DatabaseType.cassandra.rawValue == "Cassandra") - } - - @Test("ScyllaDB raw value is ScyllaDB") - func scylladbRawValue() { - #expect(DatabaseType.scylladb.rawValue == "ScyllaDB") - } - - @Test("Cassandra pluginTypeId is Cassandra") - func cassandraPluginTypeId() { - #expect(DatabaseType.cassandra.pluginTypeId == "Cassandra") - } - - @Test("ScyllaDB pluginTypeId is Cassandra") - func scylladbPluginTypeId() { - #expect(DatabaseType.scylladb.pluginTypeId == "Cassandra") - } - - @Test("Cassandra default port is 9042") - func cassandraDefaultPort() { - #expect(DatabaseType.cassandra.defaultPort == 9_042) - } - - @Test("ScyllaDB default port is 9042") - func scylladbDefaultPort() { - #expect(DatabaseType.scylladb.defaultPort == 9_042) - } - - @Test("Cassandra does not require authentication") - func cassandraRequiresAuthentication() { - #expect(DatabaseType.cassandra.requiresAuthentication == false) - } - - @Test("ScyllaDB does not require authentication") - func scylladbRequiresAuthentication() { - #expect(DatabaseType.scylladb.requiresAuthentication == false) - } - - @Test("Cassandra does not support foreign keys") - func cassandraSupportsForeignKeys() { - #expect(DatabaseType.cassandra.supportsForeignKeys == false) - } - - @Test("ScyllaDB does not support foreign keys") - func scylladbSupportsForeignKeys() { - #expect(DatabaseType.scylladb.supportsForeignKeys == false) - } - - @Test("Cassandra supports schema editing") - func cassandraSupportsSchemaEditing() { - #expect(DatabaseType.cassandra.supportsSchemaEditing == true) - } - - @Test("ScyllaDB supports schema editing") - func scylladbSupportsSchemaEditing() { - #expect(DatabaseType.scylladb.supportsSchemaEditing == true) - } - - @Test("Cassandra icon name is cassandra-icon") - func cassandraIconName() { - #expect(DatabaseType.cassandra.iconName == "cassandra-icon") - } - - @Test("ScyllaDB icon name is cassandra-icon") - /// ScyllaDB has its own registry entry and its own asset, so it stopped borrowing Cassandra's - /// icon. The shared "Cassandra / ScyllaDB" entry still uses `cassandra-icon`; this one does not. - func scylladbIconName() { - #expect(DatabaseType.scylladb.iconName == "scylladb-icon") - } - - @Test("Cassandra is a downloadable plugin") - func cassandraIsDownloadablePlugin() { - #expect(DatabaseType.cassandra.isDownloadablePlugin == true) - } - - @Test("ScyllaDB is a downloadable plugin") - func scylladbIsDownloadablePlugin() { - #expect(DatabaseType.scylladb.isDownloadablePlugin == true) - } - - @Test("Cassandra included in allCases") - func cassandraIncludedInAllCases() { - #expect(DatabaseType.allKnownTypes.contains(.cassandra)) - } - - @Test("ScyllaDB included in allCases") - func scylladbIncludedInAllCases() { - #expect(DatabaseType.allKnownTypes.contains(.scylladb)) - } -} diff --git a/TableProTests/Models/DatabaseTypeCockroachDBTests.swift b/TableProTests/Models/DatabaseTypeCockroachDBTests.swift index 55504376dd..8b8b183621 100644 --- a/TableProTests/Models/DatabaseTypeCockroachDBTests.swift +++ b/TableProTests/Models/DatabaseTypeCockroachDBTests.swift @@ -2,8 +2,6 @@ // DatabaseTypeCockroachDBTests.swift // TableProTests // -// Tests for .cockroachdb properties and plugin resolution. -// import Foundation @testable import TablePro @@ -11,62 +9,10 @@ import TableProPluginKit import Testing struct DatabaseTypeCockroachDBTests { - @Test("rawValue is CockroachDB") - func rawValue() { - #expect(DatabaseType.cockroachdb.rawValue == "CockroachDB") - } - - @Test("defaultPort is 26257") - func defaultPort() { - #expect(DatabaseType.cockroachdb.defaultPort == 26_257) - } - - @Test("requiresAuthentication is true") - func requiresAuthentication() { - #expect(DatabaseType.cockroachdb.requiresAuthentication == true) - } - - @Test("supportsForeignKeys is true") - func supportsForeignKeys() { - #expect(DatabaseType.cockroachdb.supportsForeignKeys == true) - } - - @Test("supportsSchemaEditing is false (no driver-side DDL generators)") - func supportsSchemaEditing() { - #expect(DatabaseType.cockroachdb.supportsSchemaEditing == false) - } - - @Test("iconName is cockroachdb-icon") - func iconName() { - #expect(DatabaseType.cockroachdb.iconName == "cockroachdb-icon") - } - - @Test("pluginTypeId resolves to PostgreSQL") - func pluginTypeIdResolvesToPostgres() { - #expect(DatabaseType.cockroachdb.pluginTypeId == "PostgreSQL") - } - @Test("EXPLAIN variants use plain text, not FORMAT JSON") func explainVariantsAreText() { let variants = DatabaseType.cockroachdb.explainVariants #expect(!variants.isEmpty) #expect(variants.allSatisfy { !$0.sqlPrefix.uppercased().contains("JSON") }) } - - @Test("Codable round-trips through rawValue") - func codableRoundTrip() throws { - let encoded = try JSONEncoder().encode(DatabaseType.cockroachdb) - let decoded = try JSONDecoder().decode(DatabaseType.self, from: encoded) - #expect(decoded == DatabaseType.cockroachdb) - } - - @Test("allKnownTypes contains cockroachdb") - func allKnownTypesContainsCockroachDB() { - #expect(DatabaseType.allKnownTypes.contains(.cockroachdb)) - } - - @Test("allCases shim contains cockroachdb") - func allCasesContainsCockroachDB() { - #expect(DatabaseType.allKnownTypes.contains(.cockroachdb)) - } } diff --git a/TableProTests/Models/DatabaseTypeDatabendTests.swift b/TableProTests/Models/DatabaseTypeDatabendTests.swift deleted file mode 100644 index b4b03e5942..0000000000 --- a/TableProTests/Models/DatabaseTypeDatabendTests.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct DatabaseTypeDatabendTests { - @Test("rawValue is Databend") - func rawValue() { - #expect(DatabaseType.databend.rawValue == "Databend") - } - - @Test("defaultPort is 3307") - func defaultPort() { - #expect(DatabaseType.databend.defaultPort == 3_307) - } - - @Test("iconName is databend-icon") - func iconName() { - #expect(DatabaseType.databend.iconName == "databend-icon") - } - - @Test("pluginTypeId resolves to MySQL") - func pluginTypeIdResolvesToMySQL() { - #expect(DatabaseType.databend.pluginTypeId == "MySQL") - } - - @Test("warehouse flags") - func warehouseFlags() { - #expect(DatabaseType.databend.supportsForeignKeys == false) - #expect(DatabaseType.databend.supportsTriggers == false) - #expect(DatabaseType.databend.supportsRoutines == false) - } - - @Test("allKnownTypes contains databend") - func allKnownTypesContainsDatabend() { - #expect(DatabaseType.allKnownTypes.contains(.databend)) - } - - @Test("Codable round-trips through rawValue") - func codableRoundTrip() throws { - let encoded = try JSONEncoder().encode(DatabaseType.databend) - let decoded = try JSONDecoder().decode(DatabaseType.self, from: encoded) - #expect(decoded == DatabaseType.databend) - } -} diff --git a/TableProTests/Models/DatabaseTypeMSSQLTests.swift b/TableProTests/Models/DatabaseTypeMSSQLTests.swift deleted file mode 100644 index 87d24d2432..0000000000 --- a/TableProTests/Models/DatabaseTypeMSSQLTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// DatabaseTypeMSSQLTests.swift -// TableProTests -// -// Tests for .mssql properties and methods. -// - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -struct DatabaseTypeMSSQLTests { - // MARK: - Basic Properties - - @Test("defaultPort is 1433") - func defaultPort() { - #expect(DatabaseType.mssql.defaultPort == 1_433) - } - - @Test("rawValue is SQL Server") - func rawValue() { - #expect(DatabaseType.mssql.rawValue == "SQL Server") - } - - @Test("requiresAuthentication is true") - func requiresAuthentication() { - #expect(DatabaseType.mssql.requiresAuthentication == true) - } - - @Test("supportsForeignKeys is true") - func supportsForeignKeys() { - #expect(DatabaseType.mssql.supportsForeignKeys == true) - } - - @Test("supportsSchemaEditing is true") - func supportsSchemaEditing() { - #expect(DatabaseType.mssql.supportsSchemaEditing == true) - } - - @Test("iconName is mssql-icon") - func iconName() { - #expect(DatabaseType.mssql.iconName == "mssql-icon") - } - - // MARK: - allKnownTypes Tests - - @Test("allKnownTypes contains mssql") - func allKnownTypesContainsMSSql() { - #expect(DatabaseType.allKnownTypes.contains(.mssql)) - } - - @Test("allCases shim contains mssql") - func allCasesContainsMSSql() { - #expect(DatabaseType.allKnownTypes.contains(.mssql)) - } -} diff --git a/TableProTests/Models/DatabaseTypeOceanBaseTests.swift b/TableProTests/Models/DatabaseTypeOceanBaseTests.swift deleted file mode 100644 index cfa583dcb5..0000000000 --- a/TableProTests/Models/DatabaseTypeOceanBaseTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct DatabaseTypeOceanBaseTests { - @Test("rawValue is OceanBase") - func rawValue() { - #expect(DatabaseType.oceanbase.rawValue == "OceanBase") - } - - @Test("defaultPort is 2881") - func defaultPort() { - #expect(DatabaseType.oceanbase.defaultPort == 2_881) - } - - @Test("iconName is oceanbase-icon") - func iconName() { - #expect(DatabaseType.oceanbase.iconName == "oceanbase-icon") - } - - @Test("pluginTypeId resolves to MySQL") - func pluginTypeIdResolvesToMySQL() { - #expect(DatabaseType.oceanbase.pluginTypeId == "MySQL") - } - - @Test("triggers browse without trigger editing") - func triggerFlags() { - #expect(DatabaseType.oceanbase.supportsTriggers == true) - #expect(DatabaseType.oceanbase.supportsTriggerEditing == false) - } - - @Test("routines are offered") - func routines() { - #expect(DatabaseType.oceanbase.supportsRoutines == true) - } - - @Test("allKnownTypes contains oceanbase") - func allKnownTypesContainsOceanBase() { - #expect(DatabaseType.allKnownTypes.contains(.oceanbase)) - } - - @Test("Codable round-trips through rawValue") - func codableRoundTrip() throws { - let encoded = try JSONEncoder().encode(DatabaseType.oceanbase) - let decoded = try JSONDecoder().decode(DatabaseType.self, from: encoded) - #expect(decoded == DatabaseType.oceanbase) - } -} diff --git a/TableProTests/Models/DatabaseTypePGliteTests.swift b/TableProTests/Models/DatabaseTypePGliteTests.swift index eb5fac4ad7..a6bbf32fdc 100644 --- a/TableProTests/Models/DatabaseTypePGliteTests.swift +++ b/TableProTests/Models/DatabaseTypePGliteTests.swift @@ -2,8 +2,6 @@ // DatabaseTypePGliteTests.swift // TableProTests // -// Tests for .pglite properties and plugin resolution. -// import Foundation @testable import TablePro @@ -11,55 +9,8 @@ import TableProPluginKit import Testing struct DatabaseTypePGliteTests { - @Test("rawValue is PGlite") - func rawValue() { - #expect(DatabaseType.pglite.rawValue == "PGlite") - } - - @Test("defaultPort is 5432") - func defaultPort() { - #expect(DatabaseType.pglite.defaultPort == 5_432) - } - - @Test("defaultHost is loopback IPv4") - func defaultHost() { - #expect(DatabaseType.pglite.defaultHost == "127.0.0.1") - } - - @Test("SSL is disabled by default (socket server has no TLS)") - func defaultSSLModeDisabled() { - #expect(DatabaseType.pglite.defaultSSLMode == .disabled) - } - @Test("connection pooling is off (single connection)") func doesNotPool() { #expect(DatabaseType.pglite.supportsConnectionPooling == false) } - - @Test("PostgreSQL still pools (default unchanged)") - func postgresStillPools() { - #expect(DatabaseType.postgresql.supportsConnectionPooling == true) - } - - @Test("iconName reuses the PostgreSQL icon") - func iconName() { - #expect(DatabaseType.pglite.iconName == "postgresql-icon") - } - - @Test("pluginTypeId resolves to PostgreSQL") - func pluginTypeIdResolvesToPostgres() { - #expect(DatabaseType.pglite.pluginTypeId == "PostgreSQL") - } - - @Test("Codable round-trips through rawValue") - func codableRoundTrip() throws { - let encoded = try JSONEncoder().encode(DatabaseType.pglite) - let decoded = try JSONDecoder().decode(DatabaseType.self, from: encoded) - #expect(decoded == DatabaseType.pglite) - } - - @Test("allKnownTypes contains pglite") - func allKnownTypesContainsPGlite() { - #expect(DatabaseType.allKnownTypes.contains(.pglite)) - } } diff --git a/TableProTests/Models/DatabaseTypeRedisTests.swift b/TableProTests/Models/DatabaseTypeRedisTests.swift deleted file mode 100644 index c3d6b92dc7..0000000000 --- a/TableProTests/Models/DatabaseTypeRedisTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import TableProPluginKit -import Testing -@testable import TablePro - -struct DatabaseTypeRedisTests { - @Test("Default port is 6379") - func defaultPort() { - #expect(DatabaseType.redis.defaultPort == 6_379) - } - - @Test("Icon name is redis-icon") - func iconName() { - #expect(DatabaseType.redis.iconName == "redis-icon") - } - - @Test("Does not require authentication") - func requiresAuthentication() { - #expect(DatabaseType.redis.requiresAuthentication == false) - } - - @Test("Does not support foreign keys") - func supportsForeignKeys() { - #expect(DatabaseType.redis.supportsForeignKeys == false) - } - - @Test("Does not support schema editing") - func supportsSchemaEditing() { - #expect(DatabaseType.redis.supportsSchemaEditing == false) - } - - @Test("Raw value is Redis") - func rawValue() { - #expect(DatabaseType.redis.rawValue == "Redis") - } - - @Test("Theme color is derived from plugin brand color") - @MainActor func themeColor() { - #expect(DatabaseType.redis.themeColor == PluginManager.shared.brandColor(for: .redis)) - } - - @Test("Included in allKnownTypes") - func includedInAllKnownTypes() { - #expect(DatabaseType.allKnownTypes.contains(.redis)) - } - - @Test("Included in allCases shim") - func includedInAllCases() { - #expect(DatabaseType.allKnownTypes.contains(.redis)) - } -} diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index 34a153576e..c560e82974 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -12,135 +12,13 @@ import Testing struct DatabaseTypeTests { - @Test("MySQL default port is 3306") - func testMySQLDefaultPort() { - #expect(DatabaseType.mysql.defaultPort == 3306) - } - - @Test("MariaDB default port is 3306") - func testMariaDBDefaultPort() { - #expect(DatabaseType.mariadb.defaultPort == 3306) - } - - @Test("PostgreSQL default port is 5432") - func testPostgreSQLDefaultPort() { - #expect(DatabaseType.postgresql.defaultPort == 5432) - } - - @Test("SQLite default port is 0") - func testSQLiteDefaultPort() { - #expect(DatabaseType.sqlite.defaultPort == 0) - } - - @Test("Spanner default port is 0") - func testSpannerDefaultPort() { - #expect(DatabaseType.spanner.defaultPort == 0) - } - - @Test("MongoDB default port is 27017") - func testMongoDBDefaultPort() { - #expect(DatabaseType.mongodb.defaultPort == 27_017) - } - - @Test("allKnownTypes contains all built-in types") - func testAllKnownTypesContainsBuiltIns() { - let knownTypes = DatabaseType.allKnownTypes - #expect(knownTypes.contains(.mysql)) - #expect(knownTypes.contains(.postgresql)) - #expect(knownTypes.contains(.sqlite)) - #expect(knownTypes.count >= 5) - } - - @Test("Raw value matches display name", arguments: [ - (DatabaseType.mysql, "MySQL"), - (DatabaseType.mariadb, "MariaDB"), - (DatabaseType.tidb, "TiDB"), - (DatabaseType.databend, "Databend"), - (DatabaseType.oceanbase, "OceanBase"), - (DatabaseType.postgresql, "PostgreSQL"), - (DatabaseType.sqlite, "SQLite"), - (DatabaseType.mongodb, "MongoDB"), - (DatabaseType.redis, "Redis"), - (DatabaseType.redshift, "Redshift"), - (DatabaseType.mssql, "SQL Server"), - (DatabaseType.oracle, "Oracle"), - (DatabaseType.dameng, "Dameng"), - (DatabaseType.clickhouse, "ClickHouse"), - (DatabaseType.duckdb, "DuckDB"), - (DatabaseType.cassandra, "Cassandra"), - (DatabaseType.scylladb, "ScyllaDB"), - (DatabaseType.beancount, "Beancount") - ]) - func testRawValueMatchesDisplayName(dbType: DatabaseType, expectedRawValue: String) { - #expect(dbType.rawValue == expectedRawValue) - } - - // MARK: - ClickHouse Tests - - @Test("ClickHouse default port is 8123") - func testClickHouseDefaultPort() { - #expect(DatabaseType.clickhouse.defaultPort == 8_123) - } - - @Test("ClickHouse requires authentication") - func testClickHouseRequiresAuth() { - #expect(DatabaseType.clickhouse.requiresAuthentication == true) - } - - @Test("ClickHouse does not support foreign keys") - func testClickHouseSupportsForeignKeys() { - #expect(DatabaseType.clickhouse.supportsForeignKeys == false) - } - - @Test("ClickHouse supports schema editing") - func testClickHouseSupportsSchemaEditing() { - #expect(DatabaseType.clickhouse.supportsSchemaEditing == true) - } - - @Test("ClickHouse icon name is clickhouse-icon") - func testClickHouseIconName() { - #expect(DatabaseType.clickhouse.iconName == "clickhouse-icon") - } - // MARK: - Plugin Type ID Alias Tests - @Test("MariaDB pluginTypeId maps to MySQL plugin") - func testMariaDBPluginTypeId() { - #expect(DatabaseType.mariadb.pluginTypeId == "MySQL") - } - - @Test("TiDB pluginTypeId maps to MySQL plugin") - func testTiDBPluginTypeId() { - #expect(DatabaseType.tidb.pluginTypeId == "MySQL") - } - - @Test("Databend pluginTypeId maps to MySQL plugin") - func testDatabendPluginTypeId() { - #expect(DatabaseType.databend.pluginTypeId == "MySQL") - } - - @Test("OceanBase pluginTypeId maps to MySQL plugin") - func testOceanBasePluginTypeId() { - #expect(DatabaseType.oceanbase.pluginTypeId == "MySQL") - } - - @Test("Redshift pluginTypeId maps to PostgreSQL plugin") - func testRedshiftPluginTypeId() { - #expect(DatabaseType.redshift.pluginTypeId == "PostgreSQL") - } - @Test("Unknown type pluginTypeId falls back to rawValue") func testUnknownPluginTypeIdFallback() { #expect(DatabaseType(rawValue: "FutureDB").pluginTypeId == "FutureDB") } - // MARK: - Struct Behavior Tests - - @Test("Struct equality via rawValue") - func testStructEquality() { - #expect(DatabaseType(rawValue: "MySQL") == .mysql) - } - // MARK: - Default SSL Mode Tests @Test("libpq-family engines default SSL mode to preferred", arguments: [ @@ -296,14 +174,6 @@ struct DatabaseTypeTests { #expect(DatabaseType(validating: "MySQL") == .mysql) } - @Test("Codable round-trip for known type") - func testCodableRoundTrip() throws { - let original = DatabaseType.postgresql - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(DatabaseType.self, from: data) - #expect(decoded == original) - } - @Test("Codable round-trip for unknown type") func testCodableUnknownRoundTrip() throws { let original = DatabaseType(rawValue: "FutureDB") @@ -312,12 +182,4 @@ struct DatabaseTypeTests { #expect(decoded == original) #expect(decoded.rawValue == "FutureDB") } - - @Test("Hashable set membership works") - func testHashableSetMembership() { - let types: Set = [.mysql, .postgresql, .sqlite] - #expect(types.contains(.mysql)) - #expect(types.contains(.postgresql)) - #expect(!types.contains(.redis)) - } } diff --git a/TableProTests/Models/DatabaseTypeTiDBTests.swift b/TableProTests/Models/DatabaseTypeTiDBTests.swift deleted file mode 100644 index f439da9b4c..0000000000 --- a/TableProTests/Models/DatabaseTypeTiDBTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct DatabaseTypeTiDBTests { - @Test("rawValue is TiDB") - func rawValue() { - #expect(DatabaseType.tidb.rawValue == "TiDB") - } - - @Test("defaultPort is 4000") - func defaultPort() { - #expect(DatabaseType.tidb.defaultPort == 4_000) - } - - @Test("iconName is tidb-icon") - func iconName() { - #expect(DatabaseType.tidb.iconName == "tidb-icon") - } - - @Test("pluginTypeId resolves to MySQL") - func pluginTypeIdResolvesToMySQL() { - #expect(DatabaseType.tidb.pluginTypeId == "MySQL") - } - - @Test("does not offer triggers or routines") - func triggerAndRoutineFlags() { - #expect(DatabaseType.tidb.supportsTriggers == false) - #expect(DatabaseType.tidb.supportsRoutines == false) - } - - @Test("allKnownTypes contains tidb") - func allKnownTypesContainsTiDB() { - #expect(DatabaseType.allKnownTypes.contains(.tidb)) - } - - @Test("Codable round-trips through rawValue") - func codableRoundTrip() throws { - let encoded = try JSONEncoder().encode(DatabaseType.tidb) - let decoded = try JSONDecoder().decode(DatabaseType.self, from: encoded) - #expect(decoded == DatabaseType.tidb) - } -} diff --git a/TableProTests/Models/ExportRowScopeTests.swift b/TableProTests/Models/ExportRowScopeTests.swift index e43d1e33e7..cefe79d518 100644 --- a/TableProTests/Models/ExportRowScopeTests.swift +++ b/TableProTests/Models/ExportRowScopeTests.swift @@ -59,14 +59,6 @@ struct ExportRowScopeTests { #expect(PluginExportRowScope.unrestricted.summary.isEmpty) } - @Test("A scope survives a round trip through Codable") - func scopeRoundTrips() throws { - let scope = PluginExportRowScope(filter: "id > 5", rowLimit: 42, columns: ["a", "b"]) - let decoded = try JSONDecoder().decode( - PluginExportRowScope.self, from: JSONEncoder().encode(scope)) - #expect(decoded == scope) - } - @Test("An export item carries an unrestricted scope unless one is given") func exportTableDefaultsToUnrestricted() { let table = PluginExportTable( diff --git a/TableProTests/Models/KeyboardShortcutTests.swift b/TableProTests/Models/KeyboardShortcutTests.swift index 50e25891ab..f569003152 100644 --- a/TableProTests/Models/KeyboardShortcutTests.swift +++ b/TableProTests/Models/KeyboardShortcutTests.swift @@ -12,48 +12,6 @@ import Foundation @testable import TablePro import Testing -struct ShortcutActionDefaultsTests { - @Test("Execute Query default is Cmd+Return") - func executeQueryDefault() { - #expect(KeyboardSettings.defaultShortcuts[.executeQuery] == .special(.return, command: true)) - } - - @Test("Execute All Statements default is Cmd+Shift+Return") - func executeAllStatementsDefault() { - #expect(KeyboardSettings.defaultShortcuts[.executeAllStatements] == .special(.return, command: true, shift: true)) - } - - @Test("Cancel Query default is Cmd+.") - func cancelQueryDefault() { - #expect(KeyboardSettings.defaultShortcuts[.cancelQuery] == .character(".", command: true)) - } - - @Test("New Connection default is Cmd+N") - func newConnectionDefault() { - #expect(KeyboardSettings.defaultShortcuts[.newConnection] == .character("n", command: true)) - } - - @Test("First and Last Page have defaults") - func paginationEdgeDefaults() { - #expect(KeyboardSettings.defaultShortcuts[.firstPage] != nil) - #expect(KeyboardSettings.defaultShortcuts[.lastPage] != nil) - } - - @Test("Find Next and Find Previous have defaults") - func findDefaults() { - #expect(KeyboardSettings.defaultShortcuts[.findNext] == .character("g", command: true)) - #expect(KeyboardSettings.defaultShortcuts[.findPrevious] == .character("g", command: true, shift: true)) - } - - @Test("Jump to Column default is Cmd+Shift+J and belongs to the data grid") - func jumpToColumnDefault() { - #expect(KeyboardSettings.defaultShortcuts[.jumpToColumn] == .character("j", command: true, shift: true)) - #expect(ShortcutAction.jumpToColumn.context == .dataGrid) - #expect(ShortcutAction.jumpToColumn.category == .dataGrid) - #expect(ShortcutAction.reservedConflict(for: .character("j", command: true, shift: true), context: .dataGrid) == nil) - } -} - struct EditorBuiltInNameTests { @Test("Command-[ is Outdent and Command-] is Indent") func bracketsAreNamedAsTheEditorMapsThem() { diff --git a/TableProTests/Models/PaginationStateTests.swift b/TableProTests/Models/PaginationStateTests.swift index 325d4d2b5d..0235101ebe 100644 --- a/TableProTests/Models/PaginationStateTests.swift +++ b/TableProTests/Models/PaginationStateTests.swift @@ -12,11 +12,6 @@ import Testing @testable import TablePro struct PaginationStateTests { - @Test("Default page size is 1000") - func defaultPageSize() { - #expect(PaginationState.defaultPageSize == 1_000) - } - @Test("Total pages with nil total returns 1") func totalPagesWithNilTotal() { let state = PaginationState(totalRowCount: nil, pageSize: 100) diff --git a/TableProTests/Models/PreviewTabTests.swift b/TableProTests/Models/PreviewTabTests.swift index c13d34db61..9b8505882f 100644 --- a/TableProTests/Models/PreviewTabTests.swift +++ b/TableProTests/Models/PreviewTabTests.swift @@ -12,12 +12,6 @@ import Testing @testable import TablePro struct PreviewTabTests { - @Test("QueryTab isPreview defaults to false") - func queryTabIsPreviewDefaultsFalse() { - let tab = QueryTab(title: "Test", tabType: .query) - #expect(tab.isPreview == false) - } - @Test("QueryTab from persisted tab is not preview") func queryTabFromPersistedIsNotPreview() { let persisted = PersistedTab( @@ -31,12 +25,6 @@ struct PreviewTabTests { #expect(tab.isPreview == false) } - @Test("TabSettings enablePreviewTabs defaults to true") - func tabSettingsDefaultsToTrue() { - let settings = TabSettings.default - #expect(settings.enablePreviewTabs == true) - } - @Test("A table tab added with isPreview is a preview tab") @MainActor func addPreviewTab() throws { diff --git a/TableProTests/Models/Query/ExplainRequestTests.swift b/TableProTests/Models/Query/ExplainRequestTests.swift index 3dacaba460..9690bfb884 100644 --- a/TableProTests/Models/Query/ExplainRequestTests.swift +++ b/TableProTests/Models/Query/ExplainRequestTests.swift @@ -2,8 +2,6 @@ // ExplainRequestTests.swift // TableProTests // -// Tests for choosing which EXPLAIN variant runs and what format its output is read as. -// import Foundation @testable import TablePro @@ -216,17 +214,4 @@ struct ExplainRequestTests { } private let connectionId = UUID() - - private func repositoryRoot() -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - while directory.path != "/" { - if FileManager.default.fileExists( - atPath: directory.appendingPathComponent("TablePro.xcodeproj").path - ) { - return directory - } - directory.deleteLastPathComponent() - } - return directory - } } diff --git a/TableProTests/Models/Query/ResultSetOwnershipTests.swift b/TableProTests/Models/Query/ResultSetOwnershipTests.swift deleted file mode 100644 index 2dc3a5053a..0000000000 --- a/TableProTests/Models/Query/ResultSetOwnershipTests.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// ResultSetOwnershipTests.swift -// TableProTests -// -// A `ResultSet` is replaced on every load, page turn, sort and re-execute, so view state kept on -// one dies at the first page turn. `QueryTab` owns that state. The boundary is easy to cross by -// accident because it is invisible in a diff: the chart configuration was nearly put on -// `ResultSet`, and the unread copies of the tab's own state it would have sat next to are what -// made that look right (#2243). -// - -import Foundation -@testable import TablePro -import Testing - -struct ResultSetOwnershipTests { - @Test("ResultSet declares no stored property that QueryTab already owns") - func resultSetDoesNotMirrorTheTab() throws { - let shared = try Self.storedProperties(of: "ResultSet", in: "TablePro/Models/Query/ResultSet.swift") - .intersection(Self.storedProperties(of: "QueryTab", in: "TablePro/Models/Query/QueryTab.swift")) - - #expect( - shared == ["id"], - """ - A ResultSet is rebuilt on every load, page turn, sort and re-execute, so a copy of \ - tab state kept here is read by nothing and discarded at the first page turn. Keep it \ - on QueryTab: \(shared.subtracting(["id"]).sorted()) - """ - ) - } - - /// Stored properties are the `var`/`let` declarations at type scope, which the four-space house - /// indent pins exactly. A brace with no `=` ahead of it opens an accessor block, so the - /// declaration computes rather than stores; a brace after one is a default value or an - /// observer on something that does store. - /// - /// This compares names, so it catches a copy that keeps the name it had on `QueryTab` and - /// nothing else. State moved back under a new name still needs a reader to notice it. - private static func storedProperties(of type: String, in path: String) throws -> Set { - let source = try String(contentsOf: repoRoot().appendingPathComponent(path), encoding: .utf8) - let lines = source.components(separatedBy: .newlines) - guard let start = lines.firstIndex(where: { - $0.contains("class \(type):") || $0.contains("struct \(type):") - || $0.contains("class \(type) {") || $0.contains("struct \(type) {") - }) else { throw CocoaError(.fileNoSuchFile) } - - var names: Set = [] - for line in lines[(start + 1)...] { - if line == "}" { break } - guard line.hasPrefix(" "), !line.hasPrefix(" "), Self.stores(line) else { continue } - let words = line.split(separator: " ").map(String.init) - guard let keyword = words.firstIndex(where: { $0 == "var" || $0 == "let" }), - keyword + 1 < words.count - else { continue } - let name = words[keyword + 1].prefix { $0 != ":" && $0 != "=" } - if !name.isEmpty { names.insert(String(name)) } - } - return names - } - - private static func stores(_ line: String) -> Bool { - guard let brace = line.firstIndex(of: "{") else { return true } - return line[.. URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } -} diff --git a/TableProTests/Models/Query/TabSessionRegistryTests.swift b/TableProTests/Models/Query/TabSessionRegistryTests.swift index f43ebbe62e..a3acc545b7 100644 --- a/TableProTests/Models/Query/TabSessionRegistryTests.swift +++ b/TableProTests/Models/Query/TabSessionRegistryTests.swift @@ -51,12 +51,6 @@ struct TabSessionRegistryTests { #expect(registry.session(for: session.id) == nil) } - @Test("unregister of an unknown id is a no-op") - func unregisterUnknownIdIsNoOp() { - let registry = TabSessionRegistry() - registry.unregister(id: UUID()) - } - @Test("removeAll clears every registered session") func removeAllClearsAll() { let registry = TabSessionRegistry() diff --git a/TableProTests/Models/SQLFileDeduplicationTests.swift b/TableProTests/Models/SQLFileDeduplicationTests.swift index f7c9c13da2..5e26b1f103 100644 --- a/TableProTests/Models/SQLFileDeduplicationTests.swift +++ b/TableProTests/Models/SQLFileDeduplicationTests.swift @@ -12,26 +12,6 @@ import Foundation import TableProPluginKit import Testing -// MARK: - QueryTab sourceFileURL Property Tests - -struct QueryTabSourceFileURLTests { - @Test("QueryTab stores sourceFileURL when set") - func storesSourceFileURL() { - var tab = QueryTab(title: "Test", tabType: .query) - let url = URL(fileURLWithPath: "/tmp/test.sql") - tab.content.sourceFileURL = url - - #expect(tab.content.sourceFileURL == url) - } - - @Test("QueryTab sourceFileURL defaults to nil") - func defaultsToNil() { - let tab = QueryTab(title: "Test", tabType: .query) - - #expect(tab.content.sourceFileURL == nil) - } -} - // MARK: - QueryTabManager Deduplication Tests struct QueryTabManagerDeduplicationTests { diff --git a/TableProTests/Models/SafeModeLevelTests.swift b/TableProTests/Models/SafeModeLevelTests.swift index fcff3a76bb..563dd32bea 100644 --- a/TableProTests/Models/SafeModeLevelTests.swift +++ b/TableProTests/Models/SafeModeLevelTests.swift @@ -4,73 +4,12 @@ // import AppKit -import SwiftUI import TableProPluginKit import Testing @testable import TablePro struct SafeModeLevelTests { - // MARK: - Raw Values - - @Test("Raw values match expected strings") - func rawValues() { - #expect(SafeModeLevel.silent.rawValue == "silent") - #expect(SafeModeLevel.alert.rawValue == "alert") - #expect(SafeModeLevel.alertFull.rawValue == "alertFull") - #expect(SafeModeLevel.safeMode.rawValue == "safeMode") - #expect(SafeModeLevel.safeModeFull.rawValue == "safeModeFull") - #expect(SafeModeLevel.readOnly.rawValue == "readOnly") - } - - // MARK: - Identifiable - - @Test("id returns rawValue for all cases") - func idMatchesRawValue() { - for level in SafeModeLevel.allCases { - #expect(level.id == level.rawValue) - } - } - - // MARK: - CaseIterable - - @Test("allCases contains exactly 6 cases") - func allCasesCount() { - #expect(SafeModeLevel.allCases.count == 6) - } - - // MARK: - displayName - - @Test("silent displayName") - func displayNameSilent() { - #expect(SafeModeLevel.silent.displayName == String(localized: "Silent")) - } - - @Test("alert displayName") - func displayNameAlert() { - #expect(SafeModeLevel.alert.displayName == String(localized: "Alert")) - } - - @Test("alertFull displayName") - func displayNameAlertFull() { - #expect(SafeModeLevel.alertFull.displayName == String(localized: "Alert (Full)")) - } - - @Test("safeMode displayName") - func displayNameSafeMode() { - #expect(SafeModeLevel.safeMode.displayName == String(localized: "Safe Mode")) - } - - @Test("safeModeFull displayName") - func displayNameSafeModeFull() { - #expect(SafeModeLevel.safeModeFull.displayName == String(localized: "Safe Mode (Full)")) - } - - @Test("readOnly displayName") - func displayNameReadOnly() { - #expect(SafeModeLevel.readOnly.displayName == String(localized: "Read-Only")) - } - // MARK: - blocksAllWrites @Test("only readOnly blocks all writes") @@ -121,16 +60,6 @@ struct SafeModeLevelTests { // MARK: - iconName - @Test("each case has the correct SF Symbol icon name") - func iconNames() { - #expect(SafeModeLevel.silent.iconName == "lock.open") - #expect(SafeModeLevel.alert.iconName == "exclamationmark.triangle") - #expect(SafeModeLevel.alertFull.iconName == "exclamationmark.triangle.fill") - #expect(SafeModeLevel.safeMode.iconName == "lock.shield") - #expect(SafeModeLevel.safeModeFull.iconName == "lock.shield.fill") - #expect(SafeModeLevel.readOnly.iconName == "lock") - } - /// The fill used to mark two of the four levels that gate reads and both of the two that do /// not, so it said nothing. The weakest level drew the heaviest glyph in the toolbar. @Test("An icon is filled exactly when its level applies to all queries", arguments: SafeModeLevel.allCases) @@ -152,39 +81,8 @@ struct SafeModeLevelTests { } } - // MARK: - badgeColor - - @Test("silent badge color is secondary") - func badgeColorSilent() { - #expect(SafeModeLevel.silent.badgeColor == .secondary) - } - - @Test("alert and alertFull badge color is orange") - func badgeColorAlert() { - #expect(SafeModeLevel.alert.badgeColor == .orange) - #expect(SafeModeLevel.alertFull.badgeColor == .orange) - } - - @Test("safeMode, safeModeFull, and readOnly badge color is red") - func badgeColorSafeAndReadOnly() { - #expect(SafeModeLevel.safeMode.badgeColor == .red) - #expect(SafeModeLevel.safeModeFull.badgeColor == .red) - #expect(SafeModeLevel.readOnly.badgeColor == .red) - } - // MARK: - Codable - @Test("round-trips through JSON encoding and decoding") - func codableRoundTrip() throws { - let encoder = JSONEncoder() - let decoder = JSONDecoder() - for level in SafeModeLevel.allCases { - let data = try encoder.encode(level) - let decoded = try decoder.decode(SafeModeLevel.self, from: data) - #expect(decoded == level) - } - } - @Test("A macOS wire value decodes to its own level", arguments: SafeModeLevel.allCases) func decodesOwnWireValues(_ level: SafeModeLevel) { #expect(SafeModeLevel(wireValue: level.rawValue, isReadOnly: false) == level) diff --git a/TableProTests/Models/Schema/ColumnClassificationHintTests.swift b/TableProTests/Models/Schema/ColumnClassificationHintTests.swift index de0675e221..89964b8d43 100644 --- a/TableProTests/Models/Schema/ColumnClassificationHintTests.swift +++ b/TableProTests/Models/Schema/ColumnClassificationHintTests.swift @@ -113,47 +113,3 @@ struct ColumnClassificationHintTests { #expect(result.snapshot.columns.map(\.dataType) == ["INT", "VARCHAR(50)", "DATETIME(3)"]) } } - -struct ClassifierInputScanTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 4 { - url.deleteLastPathComponent() - } - return url - }() - - /// Reading a column's kind from the declared spelling is the defect this guards: a PostgreSQL - /// enum reads as text, which takes its value picker away, and a domain over an integer reads as - /// text, which sorts its keys as strings. Every one of these calls answers "what does the column - /// hold", so none of them may be handed `dataType`. - private static let classifyingCalls = [ - "classify(rawTypeName:", - "SQLTypeParser.parse(", - "EnumValueParser.parseMySQLEnumOrSet(", - "sourceType:", - "targetType:" - ] - - private static let scannedFolders = ["TablePro", "Plugins/ParquetExportPlugin"] - - @Test("No classifier, parser or comparison key is handed the declared spelling") - func classifiersReadTheClassificationName() throws { - var scanned = 0 - for folder in Self.scannedFolders { - let root = Self.repositoryRoot.appendingPathComponent(folder) - let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) - while let url = files?.nextObject() as? URL { - guard url.pathExtension == "swift" else { continue } - scanned += 1 - let source = try String(contentsOf: url, encoding: .utf8) - for line in source.split(separator: "\n") where line.contains(".dataType") { - guard let call = Self.classifyingCalls.first(where: { line.contains($0) }) else { continue } - let text = line.trimmingCharacters(in: .whitespaces) - Issue.record("\(url.lastPathComponent) hands a declared spelling to \(call): \(text)") - } - } - } - #expect(scanned > 100) - } -} diff --git a/TableProTests/Models/SharedSidebarStateTests.swift b/TableProTests/Models/SharedSidebarStateTests.swift index 53ffb02967..7178b32770 100644 --- a/TableProTests/Models/SharedSidebarStateTests.swift +++ b/TableProTests/Models/SharedSidebarStateTests.swift @@ -48,12 +48,6 @@ struct SharedSidebarStateTests { SharedSidebarState.removeConnection(id) } - @Test("removeConnection for unknown ID does not crash") - @MainActor - func removeUnknownIdNoCrash() { - SharedSidebarState.removeConnection(UUID()) - } - // MARK: - Sidebar Tab Persistence @Test("selectedSidebarTab persists across registry lookups for same connection") diff --git a/TableProTests/Models/SortStateTests.swift b/TableProTests/Models/SortStateTests.swift index a1ea18e84a..83a22dd1a8 100644 --- a/TableProTests/Models/SortStateTests.swift +++ b/TableProTests/Models/SortStateTests.swift @@ -11,21 +11,6 @@ import TableProPluginKit import Testing struct SortDirectionTests { - @Test("Ascending equals ascending") - func ascendingEquality() { - #expect(SortDirection.ascending == SortDirection.ascending) - } - - @Test("Descending equals descending") - func descendingEquality() { - #expect(SortDirection.descending == SortDirection.descending) - } - - @Test("Ascending not equal to descending") - func ascendingNotDescending() { - #expect(SortDirection.ascending != SortDirection.descending) - } - @Test("Toggle ascending becomes descending") func toggleAscending() { var dir = SortDirection.ascending @@ -50,50 +35,7 @@ struct SortDirectionTests { } -struct SortColumnTests { - @Test("Stores columnIndex and direction") - func storesProperties() { - let col = SortColumn(columnIndex: 2, direction: .descending) - #expect(col.columnIndex == 2) - #expect(col.direction == .descending) - } - - @Test("Equal columns are equal") - func equalColumns() { - let a = SortColumn(columnIndex: 1, direction: .ascending) - let b = SortColumn(columnIndex: 1, direction: .ascending) - #expect(a == b) - } - - @Test("Different index produces unequal columns") - func differentIndex() { - let a = SortColumn(columnIndex: 1, direction: .ascending) - let b = SortColumn(columnIndex: 2, direction: .ascending) - #expect(a != b) - } - - @Test("Different direction produces unequal columns") - func differentDirection() { - let a = SortColumn(columnIndex: 1, direction: .ascending) - let b = SortColumn(columnIndex: 1, direction: .descending) - #expect(a != b) - } - - @Test("Direction is mutable") - func directionMutable() { - var col = SortColumn(columnIndex: 0, direction: .ascending) - col.direction = .descending - #expect(col.direction == .descending) - } -} - struct SortStateTests { - @Test("Empty init has no columns") - func emptyInit() { - let state = SortState() - #expect(state.columns.isEmpty) - } - @Test("Empty state is not sorting") func emptyNotSorting() { let state = SortState() @@ -106,12 +48,6 @@ struct SortStateTests { #expect(state.columnIndex == nil) } - @Test("Empty state direction defaults to ascending") - func emptyDirectionDefault() { - let state = SortState() - #expect(state.direction == .ascending) - } - @Test("Single column makes isSorting true") func singleColumnSorting() { var state = SortState() @@ -153,15 +89,6 @@ struct SortStateTests { #expect(state.direction == .ascending) } - @Test("Equal states are equal") - func equalStates() { - var a = SortState() - a.columns = [SortColumn(columnIndex: 1, direction: .ascending)] - var b = SortState() - b.columns = [SortColumn(columnIndex: 1, direction: .ascending)] - #expect(a == b) - } - @Test("isSorting flips true after adding a column") func isSortingFlipsTrue() { var state = SortState() diff --git a/TableProTests/Models/SplitViewAutosaveNameTests.swift b/TableProTests/Models/SplitViewAutosaveNameTests.swift index a1bbf4de58..f8c67f6c94 100644 --- a/TableProTests/Models/SplitViewAutosaveNameTests.swift +++ b/TableProTests/Models/SplitViewAutosaveNameTests.swift @@ -8,101 +8,24 @@ import Foundation import Testing struct SplitViewAutosaveNameTests { - /// A real user's saved widths and collapse states hang off this exact string. Versioning it - /// discards all of them, so production must keep the bare name whatever the sandbox does. - @Test("Production keeps the bare name") - func productionIsUnnamespaced() { - #expect(SplitViewAutosaveName.resolved(isIsolated: false, sandboxIdentifier: "ABC") == "com.TablePro.mainSplit") - #expect(SplitViewAutosaveName.resolved(isIsolated: false, sandboxIdentifier: nil) == "com.TablePro.mainSplit") - } - - /// AppKit files the autosave record in the standard defaults domain, which the UI test sandbox - /// does not redirect, so without this every case inherits the pane geometry of whichever case - /// ran before it in its shard. - @Test("A sandboxed run gets its own record") - func isolationNamespacesTheRecord() { - let name = SplitViewAutosaveName.resolved(isIsolated: true, sandboxIdentifier: "ABC-123") - - #expect(name == "com.TablePro.mainSplit.ABC-123") - #expect(name != SplitViewAutosaveName.base) - } - - /// The whole point is that two cases cannot collide. The sandbox root's last path component is - /// a fresh UUID per case, which is what supplies the difference. - @Test("Two sandboxes never share a record") - func twoSandboxesDoNotCollide() { - let first = SplitViewAutosaveName.resolved(isIsolated: true, sandboxIdentifier: "case-one") - let second = SplitViewAutosaveName.resolved(isIsolated: true, sandboxIdentifier: "case-two") - - #expect(first != second) - } - - /// An empty identifier would namespace to a trailing dot shared by every case, which is the - /// collision this exists to prevent wearing a different name. - @Test("An empty identifier falls back rather than sharing a suffix") - func emptyIdentifierFallsBack() { - #expect(SplitViewAutosaveName.resolved(isIsolated: true, sandboxIdentifier: "") == SplitViewAutosaveName.base) - #expect(SplitViewAutosaveName.resolved(isIsolated: true, sandboxIdentifier: nil) == SplitViewAutosaveName.base) - } - - /// Every autosave name in the app goes through the same helper, because they all land in the - /// same unisolated defaults: the window frames, the tab-content split dividers and the query - /// history drawer's own list/detail divider, which is the one that pushed a drawer's action bar - /// past the window's bottom edge. - @Test("The general form namespaces any name") - func anyNameIsNamespaced() { - let drawer = "com.TablePro.queryHistory.listDetail" + @Test("Production keeps the bare name a user's saved widths hang off") + func productionKeepsTheBareName() { #expect( - SplitViewAutosaveName.resolved(drawer, isIsolated: true, sandboxIdentifier: "s1") - == "com.TablePro.queryHistory.listDetail.s1" - ) - #expect( - SplitViewAutosaveName.resolved(drawer, isIsolated: false, sandboxIdentifier: "s1") == drawer - ) - #expect( - SplitViewAutosaveName.resolved("TextViewerWindow", isIsolated: true, sandboxIdentifier: "s1") - != SplitViewAutosaveName.resolved("JSONViewerWindow", isIsolated: true, sandboxIdentifier: "s1") + SplitViewAutosaveName.autosaveName(SplitViewAutosaveName.base, isUnitTestHost: false, isIsolated: false) + == "com.TablePro.mainSplit" ) } + @Test("A sandboxed run names no record, so nothing reaches the real defaults domain") + func sandboxNamesNoRecord() { + #expect(SplitViewAutosaveName.autosaveName("MainEditorWindow", isUnitTestHost: false, isIsolated: true) == nil) + #expect(SplitViewAutosaveName.autosaveName("MainEditorWindow", isUnitTestHost: true, isIsolated: true) == nil) + } + @Test("The unit test host names no autosave record, so no window it builds writes one") @MainActor func unitTestHostNamesNoRecord() { #expect(SplitViewAutosaveName.current(SplitViewAutosaveName.base) == nil) #expect(SplitViewAutosaveName.current(SplitViewAutosaveName.historyDrawer(connectionId: UUID())) == nil) } - - /// The rule is only useful if the call sites actually use it. A new autosave name assigned - /// directly is a new leak, and this is what catches one. - @Test("Every autosave assignment goes through the helper") - func everyCallSiteIsNamespaced() throws { - let root = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - let sites = [ - "TablePro/Views/Components/AutosavingSplitView.swift", - "TablePro/Views/Components/VerticalCollapsibleSplitView.swift", - "TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift", - "TablePro/Views/QueryPlan/QueryPlanOutlineView.swift", - "TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift", - "TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift", - "TablePro/Views/Integrations/IntegrationsActivityWindowController.swift", - "TablePro/Core/Services/Infrastructure/TabWindowController.swift", - "TablePro/Extensions/NSWindow+FrameAutosave.swift", - "TablePro/Core/Services/Infrastructure/MainSplitViewController.swift", - "TablePro/Views/DataFiles/DataFileSplitViewController.swift", - "TablePro/Views/DataFiles/DataFileWindowController.swift", - ] - - var offenders: [String] = [] - for site in sites { - let source = try String(contentsOf: root.appendingPathComponent(site), encoding: .utf8) - if !source.contains("SplitViewAutosaveName") { - offenders.append(site) - } - } - - #expect(offenders.isEmpty, "These assign an autosave name without namespacing it: \(offenders)") - } } diff --git a/TableProTests/Models/TableOperationPromptTests.swift b/TableProTests/Models/TableOperationPromptTests.swift index e668b796ff..66c1eaf143 100644 --- a/TableProTests/Models/TableOperationPromptTests.swift +++ b/TableProTests/Models/TableOperationPromptTests.swift @@ -161,22 +161,6 @@ struct TableOperationPromptTests { #expect(options.cascade == false) } - @Test("TableOperationOptions is Equatable") - func optionsEquatable() { - let first = TableOperationOptions(ignoreForeignKeys: true, cascade: false) - let second = TableOperationOptions(ignoreForeignKeys: true, cascade: false) - let third = TableOperationOptions(ignoreForeignKeys: false, cascade: true) - #expect(first == second) - #expect(first != third) - } - - @Test("TableOperationOptions survives a Codable roundtrip") - func optionsCodableRoundtrip() throws { - let original = TableOperationOptions(ignoreForeignKeys: true, cascade: true) - let data = try JSONEncoder().encode(original) - #expect(try JSONDecoder().decode(TableOperationOptions.self, from: data) == original) - } - // MARK: - TableOperationType @Test("TableOperationType raw values are stable") @@ -184,12 +168,4 @@ struct TableOperationPromptTests { #expect(TableOperationType.truncate.rawValue == "truncate") #expect(TableOperationType.drop.rawValue == "drop") } - - @Test("TableOperationType survives a Codable roundtrip") - func operationTypeCodableRoundtrip() throws { - for operationType in [TableOperationType.truncate, TableOperationType.drop] { - let data = try JSONEncoder().encode(operationType) - #expect(try JSONDecoder().decode(TableOperationType.self, from: data) == operationType) - } - } } diff --git a/TableProTests/Plugins/BeancountConnectTimeoutTests.swift b/TableProTests/Plugins/BeancountConnectTimeoutTests.swift deleted file mode 100644 index be1d3a0218..0000000000 --- a/TableProTests/Plugins/BeancountConnectTimeoutTests.swift +++ /dev/null @@ -1,188 +0,0 @@ -// -// BeancountConnectTimeoutTests.swift -// TableProTests -// - -import Darwin -import Foundation -import TableProPluginKit -import Testing - -private enum BeancountConnectOutcome: Equatable, Sendable { - case connected - case cancelled - case failed(String) -} - -extension BeancountPluginDriverTests { - @Test("connect timeout stops and reaps a cancellation-deaf backend") - func connectTimeoutStopsHangingBackend() async throws { - try await Self.withHangingPythonBackend { ledger, processIdentifierFile in - let driver = BeancountPluginDriver(config: Self.timeoutConfig( - ledger, - additionalFields: ["connectTimeoutMilliseconds": "1000"] - )) - defer { driver.disconnect() } - - let start = ContinuousClock.now - let connectTask = Task { await Self.connectOutcome(driver) } - defer { connectTask.cancel() } - let processIdentifier = try #require(await Self.waitForProcessIdentifier(at: processIdentifierFile)) - let outcome = await BoundedCall.result( - onDeadline: { Darwin.kill(processIdentifier, SIGKILL) } - ) { - await connectTask.value - } - let elapsed = start.duration(to: .now) - - let resolvedOutcome = try #require(outcome) - guard case .failed(let message) = resolvedOutcome else { - Issue.record("Expected connect timeout, got \(resolvedOutcome)") - return - } - #expect(message.contains("Connection timed out")) - #expect(elapsed < .seconds(2)) - - #expect(await Self.waitForProcessExit(processIdentifier)) - } - } - - @Test("task cancellation stops and reaps a cancellation-deaf backend") - func cancellationStopsHangingBackend() async throws { - try await Self.withHangingPythonBackend { ledger, processIdentifierFile in - let driver = BeancountPluginDriver(config: Self.timeoutConfig( - ledger, - additionalFields: ["connectTimeoutSeconds": "30"] - )) - defer { driver.disconnect() } - - let connectTask = Task { await Self.connectOutcome(driver) } - defer { connectTask.cancel() } - let processIdentifier = try #require(await Self.waitForProcessIdentifier(at: processIdentifierFile)) - defer { Darwin.kill(processIdentifier, SIGKILL) } - - let start = ContinuousClock.now - connectTask.cancel() - let outcome = await BoundedCall.result( - onDeadline: { Darwin.kill(processIdentifier, SIGKILL) } - ) { - await connectTask.value - } - let elapsed = start.duration(to: .now) - - #expect(try #require(outcome) == .cancelled) - #expect(elapsed < .seconds(2)) - #expect(await Self.waitForProcessExit(processIdentifier)) - } - } - - private static func connectOutcome(_ driver: BeancountPluginDriver) async -> BeancountConnectOutcome { - do { - try await driver.connect() - return .connected - } catch is CancellationError { - return .cancelled - } catch { - return .failed(error.localizedDescription) - } - } - - private static func withHangingPythonBackend( - _ body: (URL, URL) async throws -> Void - ) async throws { - let directory = try makeTimeoutTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let executable = directory.appendingPathComponent("python3") - try """ - #!/bin/sh - trap '' TERM - printf '%s' "$$" > "$TABLEPRO_BEANCOUNT_TEST_PID_FILE" - while :; do :; done - """.write(to: executable, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) - - let ledger = directory.appendingPathComponent("main.beancount") - try "".write(to: ledger, atomically: true, encoding: .utf8) - let processIdentifierFile = directory.appendingPathComponent("backend.pid") - - try await withTimeoutEnvironment([ - "TABLEPRO_BEANCOUNT_BACKEND": "python", - "TABLEPRO_BEANCOUNT_PYTHON": executable.path, - "TABLEPRO_BEANCOUNT_TEST_PID_FILE": processIdentifierFile.path - ]) { - try await body(ledger, processIdentifierFile) - } - } - - private static func processIdentifier(at fileURL: URL) async -> pid_t? { - guard let value = try? String(contentsOf: fileURL, encoding: .utf8), - let processIdentifier = pid_t(value.trimmingCharacters(in: .whitespacesAndNewlines)) else { - return nil - } - return processIdentifier - } - - private static func waitForProcessIdentifier(at fileURL: URL) async -> pid_t? { - let deadline = ContinuousClock.now.advanced(by: .seconds(2)) - while ContinuousClock.now < deadline { - if let processIdentifier = await processIdentifier(at: fileURL) { - return processIdentifier - } - try? await Task.sleep(for: .milliseconds(10)) - } - return nil - } - - private static func waitForProcessExit(_ processIdentifier: pid_t) async -> Bool { - let deadline = ContinuousClock.now.advanced(by: .seconds(2)) - while ContinuousClock.now < deadline { - if Darwin.kill(processIdentifier, 0) == -1, errno == ESRCH { - return true - } - try? await Task.sleep(for: .milliseconds(10)) - } - return Darwin.kill(processIdentifier, 0) == -1 && errno == ESRCH - } - - private static func timeoutConfig( - _ ledger: URL, - additionalFields: [String: String] - ) -> DriverConnectionConfig { - DriverConnectionConfig( - host: "", - port: 0, - username: "", - password: "", - database: ledger.path, - additionalFields: additionalFields - ) - } - - private static func withTimeoutEnvironment( - _ values: [String: String], - _ body: () async throws -> T - ) async throws -> T { - let previous = values.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } - for (name, value) in values { - setenv(name, value, 1) - } - defer { - for (name, previousValue) in previous { - if let previousValue { - setenv(name, previousValue, 1) - } else { - unsetenv(name) - } - } - } - return try await body() - } - - private static func makeTimeoutTempDirectory() throws -> URL { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("beancount-timeout-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return directory - } -} diff --git a/TableProTests/Plugins/BeancountDriverMetadataTests.swift b/TableProTests/Plugins/BeancountDriverMetadataTests.swift deleted file mode 100644 index c00019ce36..0000000000 --- a/TableProTests/Plugins/BeancountDriverMetadataTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// BeancountDriverMetadataTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -@MainActor -struct BeancountDriverMetadataTests { - @Test("registry exposes Beancount as a downloadable file-based driver") - func registryMetadata() throws { - let snapshot = try #require(PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: "Beancount")) - #expect(snapshot.displayName == "Beancount") - #expect(snapshot.isDownloadable == true) - #expect(snapshot.connectionMode == .fileBased) - #expect(snapshot.schema.fileExtensions == ["beancount"]) - #expect(snapshot.pathFieldRole == .filePath) - #expect(snapshot.supportsSchemaEditing == false) - #expect(snapshot.supportsDatabaseSwitching == false) - #expect(snapshot.supportsHealthMonitor == false) - } - - @Test("URLClassifier resolves .beancount files to the Beancount database type") - func urlClassifierResolvesBeancountFiles() { - #expect(PluginManager.shared.allRegisteredFileExtensions["beancount"] == DatabaseType(rawValue: "Beancount")) - } - - @Test("app bundle claims .beancount files as the owner viewer") - func appBundleClaimsBeancountFilesAsOwnerViewer() throws { - let plistURL = Bundle(for: AppDelegate.self) - .bundleURL - .appendingPathComponent("Contents/Info.plist") - let data = try Data(contentsOf: plistURL) - let plistObject = try PropertyListSerialization.propertyList(from: data, format: nil) - let plist = try #require(plistObject as? [String: Any]) - let documentTypes = try #require(plist["CFBundleDocumentTypes"] as? [[String: Any]]) - let beancountDocumentType = try #require(documentTypes.first { documentType in - let contentTypes = documentType["LSItemContentTypes"] as? [String] - return contentTypes?.contains("com.tablepro.beancount") == true - }) - - #expect(beancountDocumentType["CFBundleTypeRole"] as? String == "Viewer") - #expect(beancountDocumentType["LSHandlerRank"] as? String == "Owner") - #expect(beancountDocumentType["CFBundleTypeExtensions"] as? [String] == ["beancount"]) - - let exportedTypes = try #require(plist["UTExportedTypeDeclarations"] as? [[String: Any]]) - let beancountType = try #require(exportedTypes.first { - $0["UTTypeIdentifier"] as? String == "com.tablepro.beancount" - }) - let tags = try #require(beancountType["UTTypeTagSpecification"] as? [String: Any]) - #expect(tags["public.filename-extension"] as? [String] == ["beancount"]) - } -} diff --git a/TableProTests/Plugins/BeancountIncludeResolverTests.swift b/TableProTests/Plugins/BeancountIncludeResolverTests.swift deleted file mode 100644 index 69f29277a1..0000000000 --- a/TableProTests/Plugins/BeancountIncludeResolverTests.swift +++ /dev/null @@ -1,214 +0,0 @@ -// -// BeancountIncludeResolverTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct BeancountIncludeResolverTests { - @Test("collects the main ledger and every included file") - func resolvesIncludes() throws { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - try "2024-01-02 price USD 1.35 CAD\n" - .write(to: directory.appendingPathComponent("prices.beancount"), atomically: true, encoding: .utf8) - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - include "prices.beancount" - - 2024-01-01 open Assets:Bank:Checking USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let graph = try BeancountIncludeResolver().resolve(fileURL: ledger) - - #expect(graph.sourceFiles.map(\.lastPathComponent).sorted() == ["main.beancount", "prices.beancount"]) - } - - @Test("tracks missing documents relative to their declaring source") - func resolvesIncludedDocumentDependencies() throws { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let parts = directory.appendingPathComponent("parts", isDirectory: true) - try FileManager.default.createDirectory(at: parts, withIntermediateDirectories: true) - - let absoluteDocument = directory.appendingPathComponent("absolute.pdf").standardizedFileURL - let linkedDocument = parts.appendingPathComponent("linked.pdf").standardizedFileURL - let linkedTarget = directory.appendingPathComponent("external.pdf").standardizedFileURL - try FileManager.default.createSymbolicLink( - atPath: linkedDocument.path, - withDestinationPath: linkedTarget.path - ) - let included = parts.appendingPathComponent("entries.beancount") - try """ - 2024/1/2 document Assets:Cash "receipt.pdf" - 2024-01-03 document Assets:Cash "\(absoluteDocument.path)" - 2024-01-04 document Assets:Cash "tab\\treceipt.pdf" - 2024-01-05 document Assets:Cash "linked.pdf" - """.write(to: included, atomically: true, encoding: .utf8) - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - include\t"parts/entries.beancount" - - 2024-01-01 open Assets:Cash USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let graph = try BeancountIncludeResolver().resolve(fileURL: ledger) - let relativeDocument = parts.appendingPathComponent("receipt.pdf").standardizedFileURL - let escapedDocument = parts.appendingPathComponent("tab\treceipt.pdf").standardizedFileURL - - #expect(graph.reloadDependencies.contains(relativeDocument)) - #expect(graph.reloadDependencies.contains(absoluteDocument)) - #expect(graph.reloadDependencies.contains(escapedDocument)) - #expect(graph.reloadDependencies.contains(linkedDocument)) - #expect(graph.reloadDependencies.contains(linkedTarget)) - #expect(!graph.sourceFiles.contains(relativeDocument)) - #expect(!graph.sourceFiles.contains(absoluteDocument)) - } - - @Test("bounds document symlink dependency resolution") - func boundsDocumentSymlinkDependencies() throws { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - for index in 0..<40 { - try FileManager.default.createSymbolicLink( - atPath: directory.appendingPathComponent("link-\(index).pdf").path, - withDestinationPath: "link-\(index + 1).pdf" - ) - } - - let ledger = directory.appendingPathComponent("main.beancount") - try "2024-01-01 document Assets:Cash \"link-0.pdf\"\n" - .write(to: ledger, atomically: true, encoding: .utf8) - - let graph = try BeancountIncludeResolver().resolve(fileURL: ledger) - let linkedDependencies = graph.reloadDependencies.filter { - $0.deletingLastPathComponent() == directory.standardizedFileURL - && $0.lastPathComponent.hasPrefix("link-") - } - - #expect(linkedDependencies.count <= 34) - #expect(graph.reloadDependencies.contains(directory.appendingPathComponent("link-32.pdf"))) - #expect(!graph.reloadDependencies.contains(directory.appendingPathComponent("link-33.pdf"))) - } - - @Test("applies document roots after traversing every include") - func resolvesDocumentRootDependencies() throws { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let parts = directory.appendingPathComponent("parts", isDirectory: true) - let relativeRoot = directory.appendingPathComponent("archive", isDirectory: true) - let nestedRoot = relativeRoot.appendingPathComponent("account/nested", isDirectory: true) - let absoluteRoot = directory.appendingPathComponent("absolute-archive", isDirectory: true) - try FileManager.default.createDirectory(at: parts, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: nestedRoot, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: absoluteRoot, withIntermediateDirectories: true) - - try "2024-01-02 document Assets:Cash \"receipt.pdf\"\n" - .write(to: parts.appendingPathComponent("entries.beancount"), atomically: true, encoding: .utf8) - try """ - option\t"documents"\t"archive" - option "documents" "\(absoluteRoot.path)" - """.write( - to: parts.appendingPathComponent("options.beancount"), - atomically: true, - encoding: .utf8 - ) - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - include "parts/entries.beancount" - include "parts/options.beancount" - """.write(to: ledger, atomically: true, encoding: .utf8) - - let graph = try BeancountIncludeResolver().resolve(fileURL: ledger) - let candidates = [ - relativeRoot.appendingPathComponent("receipt.pdf").standardizedFileURL, - absoluteRoot.appendingPathComponent("receipt.pdf").standardizedFileURL - ] - - for candidate in candidates { - #expect(graph.reloadDependencies.contains(candidate)) - #expect(!graph.sourceFiles.contains(candidate)) - } - #expect(graph.reloadDependencies.contains(parts.appendingPathComponent("receipt.pdf").standardizedFileURL)) - #expect(!graph.reloadDependencies.contains(relativeRoot.standardizedFileURL)) - #expect(!graph.reloadDependencies.contains(nestedRoot.standardizedFileURL)) - } - - @Test("expands glob includes and watches their directories") - func resolvesGlobIncludes() throws { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let imports = directory.appendingPathComponent("imports", isDirectory: true) - let nested = imports.appendingPathComponent("nested", isDirectory: true) - try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) - - try "2024-01-01 open Assets:Bank:Checking USD\n" - .write(to: imports.appendingPathComponent("accounts.beancount"), atomically: true, encoding: .utf8) - try "2024-01-01 open Expenses:Food USD\n" - .write(to: nested.appendingPathComponent("expenses.beancount"), atomically: true, encoding: .utf8) - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - include "imports/*.beancount" - include "imports/**/*.beancount" - """.write(to: ledger, atomically: true, encoding: .utf8) - - let graph = try BeancountIncludeResolver().resolve(fileURL: ledger) - - #expect(graph.sourceFiles.map(\.lastPathComponent).sorted() == [ - "accounts.beancount", - "expenses.beancount", - "main.beancount" - ]) - #expect(graph.watchedDirectories.map(\.lastPathComponent).contains("imports")) - #expect(graph.watchedDirectories.map(\.lastPathComponent).contains("nested")) - } - - @Test("detects include cycles instead of looping") - func detectsIncludeCycle() throws { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let first = directory.appendingPathComponent("first.beancount") - let second = directory.appendingPathComponent("second.beancount") - try "include \"second.beancount\"\n".write(to: first, atomically: true, encoding: .utf8) - try "include \"first.beancount\"\n".write(to: second, atomically: true, encoding: .utf8) - - #expect(throws: BeancountResolverError.self) { - _ = try BeancountIncludeResolver().resolve(fileURL: first) - } - } - - @Test("ignores filesystem-root glob includes") - func ignoresRootGlobIncludes() throws { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - include "/*.beancount" - - 2024-01-01 open Assets:Bank:Checking USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let graph = try BeancountIncludeResolver().resolve(fileURL: ledger) - - #expect(graph.sourceFiles.map(\.lastPathComponent) == ["main.beancount"]) - } - - private static func makeTempDirectory() throws -> URL { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("beancount-resolver-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return directory - } -} diff --git a/TableProTests/Plugins/BeancountPluginDriverTests.swift b/TableProTests/Plugins/BeancountPluginDriverTests.swift deleted file mode 100644 index e7ed8dbdc5..0000000000 --- a/TableProTests/Plugins/BeancountPluginDriverTests.swift +++ /dev/null @@ -1,1183 +0,0 @@ -// -// BeancountPluginDriverTests.swift -// TableProTests -// - -import Darwin -import Foundation -import TableProPluginKit -import Testing - -enum RustledgerLocator { - static let path: String? = resolve() - - static func resolve() -> String? { - var candidates: [String] = [] - if let env = ProcessInfo.processInfo.environment["TABLEPRO_RUSTLEDGER_BINARY"] { - candidates.append(env) - } - - let pathCandidates = (ProcessInfo.processInfo.environment["PATH"] ?? "") - .split(separator: ":") - .map { URL(fileURLWithPath: String($0)).appendingPathComponent("rledger").path } - candidates.append(contentsOf: pathCandidates) - - candidates.append(contentsOf: ["/opt/homebrew/bin/rledger", "/usr/local/bin/rledger"]) - return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } - } -} - -private enum PythonBeancountLocator { - static let path: String? = resolve() - - static func resolve() -> String? { - var candidates: [String] = [] - if let env = ProcessInfo.processInfo.environment["TABLEPRO_BEANCOUNT_PYTHON"] { - candidates.append(env) - } - - let pathCandidates = (ProcessInfo.processInfo.environment["PATH"] ?? "") - .split(separator: ":") - .map { URL(fileURLWithPath: String($0)).appendingPathComponent("python3").path } - candidates.append(contentsOf: pathCandidates) - - candidates.append(contentsOf: ["/opt/homebrew/bin/python3", "/usr/local/bin/python3", "/usr/bin/python3"]) - return candidates.first { FileManager.default.isExecutableFile(atPath: $0) && canImportBeancount($0) } - } - - private static func canImportBeancount(_ executablePath: String) -> Bool { - let process = Process() - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = ["-c", "import beancount"] - process.standardOutput = Pipe() - process.standardError = Pipe() - do { - try process.run() - process.waitUntilExit() - return process.terminationStatus == 0 - } catch { - return false - } - } -} - -@Suite( - "Beancount plugin driver", - .serialized -) -struct BeancountPluginDriverTests { - @Test( - "reloads the SQL projection when an included ledger file changes", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func reloadsWhenIncludedFileChanges() async throws { - try await Self.withRustledger { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let included = directory.appendingPathComponent("accounts.beancount") - try "2024-01-01 open Assets:Bank:Checking USD\n" - .write(to: included, atomically: true, encoding: .utf8) - - let ledger = directory.appendingPathComponent("main.beancount") - try "include \"accounts.beancount\"\n".write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - var result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") - #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking"]) - - try """ - 2024-01-01 open Assets:Bank:Checking USD - 2024-01-02 open Expenses:Food USD - """.write(to: included, atomically: true, encoding: .utf8) - - result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") - #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking", "Expenses:Food"]) - } - } - - @Test( - "reloads the SQL projection when a glob include matches a new file", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func reloadsWhenGlobIncludeMatchesNewFile() async throws { - try await Self.withRustledger { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let imports = directory.appendingPathComponent("imports", isDirectory: true) - try FileManager.default.createDirectory(at: imports, withIntermediateDirectories: true) - try "2024-01-01 open Assets:Bank:Checking USD\n" - .write(to: imports.appendingPathComponent("accounts.beancount"), atomically: true, encoding: .utf8) - - let ledger = directory.appendingPathComponent("main.beancount") - try "include \"imports/*.beancount\"\n".write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - var result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") - #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking"]) - - try "2024-01-02 open Expenses:Food USD\n" - .write(to: imports.appendingPathComponent("expenses.beancount"), atomically: true, encoding: .utf8) - - result = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") - #expect(result.rows.map { $0[0].asText } == ["Assets:Bank:Checking", "Expenses:Food"]) - } - } - - @Test( - "rejects write queries", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func rejectsWriteQueries() async throws { - try await Self.withRustledger { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let ledger = directory.appendingPathComponent("main.beancount") - try "2024-01-01 open Assets:Bank:Checking USD\n".write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - await #expect(throws: BeancountDriverError.self) { - _ = try await driver.execute(query: "DELETE FROM accounts") - } - } - } - - @Test( - "projects posting semantics through rledger", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func projectsPostingSemanticsThroughRustledger() async throws { - try await Self.withRustledger { - try await Self.withPostingSemanticsLedger(Self.expectPostingSemantics) - } - } - - @Test( - "projects posting semantics through Python Beancount", - .enabled(if: PythonBeancountLocator.path != nil, "Python Beancount unavailable") - ) - func projectsPostingSemanticsThroughPythonBeancount() async throws { - let python = try #require(PythonBeancountLocator.path) - try await Self.withEnvironment([ - "TABLEPRO_BEANCOUNT_BACKEND": "python", - "TABLEPRO_BEANCOUNT_PYTHON": python - ]) { - try await Self.withPostingSemanticsLedger(Self.expectPostingSemantics) - } - } - - @Test( - "keeps posting source locations and metadata when the backend lacks the semantic columns", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func keepsPostingSourceDetailWhenSemanticColumnsAreUnsupported() async throws { - let rledger = try #require(RustledgerLocator.path) - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let wrapper = directory.appendingPathComponent("rledger") - try """ - #!/bin/sh - for argument in "$@"; do - case "$argument" in - *posting_flag*) - echo "evaluation error: column 'posting_flag' not found in subquery result" >&2 - exit 1 - ;; - esac - done - exec "\(rledger)" "$@" - """.write(to: wrapper, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path) - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 open Assets:Cash USD - 2024-01-01 open Expenses:Food USD - - 2024-01-05 * "Cafe" "Coffee" - Expenses:Food 3.00 USD - method: "card" - Assets:Cash - """.write(to: ledger, atomically: true, encoding: .utf8) - - try await Self.withEnvironment([ - "TABLEPRO_BEANCOUNT_BACKEND": "rledger", - "TABLEPRO_RUSTLEDGER_BINARY": wrapper.path - ]) { - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - let postings = try await driver.execute(query: """ - SELECT account, flag, price_number, line FROM postings ORDER BY account - """) - #expect(postings.rows.map { $0.map(\.asText) } == [ - ["Assets:Cash", nil, nil, "7"], - ["Expenses:Food", nil, nil, "5"] - ]) - - let metadata = try await driver.execute(query: """ - SELECT key, value FROM posting_metadata ORDER BY key - """) - #expect(metadata.rows.map { $0.map(\.asText) } == [["method", "card"]]) - } - } - - @Test( - "projects computed balances from postings", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func projectsComputedBalancesFromPostings() async throws { - try await Self.withRustledger { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 open Assets:Cash USD - 2024-01-01 open Expenses:Food USD - 2024-01-01 open Income:Salary USD - - 2024-01-05 * "Employer" "Pay" - Assets:Cash 10.00 USD - Income:Salary - - 2024-01-06 * "Cafe" "Coffee" - Expenses:Food 3.00 USD - Assets:Cash - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - let result = try await driver.execute(query: """ - SELECT account, amount, commodity - FROM balances ORDER BY account, commodity - """) - #expect(result.rows.map { $0.map(\.asText) } == [ - ["Assets:Cash", "7.00", "USD"], - ["Expenses:Food", "3.00", "USD"], - ["Income:Salary", "-10.00", "USD"] - ]) - } - } - - @Test( - "projects balance assertions separately", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func projectsBalanceAssertionsSeparately() async throws { - try await Self.withRustledger { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 open Assets:Cash USD - 2024-01-01 open Income:Salary USD - - 2024-01-05 * "Employer" "Pay" - Assets:Cash 10.00 USD - Income:Salary - - 2024-01-31 balance Assets:Cash 10.00 USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - let result = try await driver.execute(query: """ - SELECT date, account, amount, commodity - FROM balance_assertions - """) - #expect(result.rows.map { $0.map(\.asText) } == [ - ["2024-01-31", "Assets:Cash", "10.00", "USD"] - ]) - } - } - - @Test( - "links postings to a single transaction row", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func linksPostingsToTransaction() async throws { - try await Self.withRustledger { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 open Assets:Cash USD - 2024-01-01 open Expenses:Food USD - - 2024-01-05 * "Cafe" "Coffee" - Expenses:Food 4.00 USD - Assets:Cash -4.00 USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - let transactions = try await driver.execute(query: "SELECT id, payee, narration FROM transactions") - #expect(transactions.rows.count == 1) - #expect(transactions.rows.first?[1].asText == "Cafe") - - let transactionId = try #require(transactions.rows.first?[0].asText) - let postings = try await driver.execute(query: "SELECT transaction_id FROM postings") - #expect(postings.rows.count == 2) - #expect(postings.rows.allSatisfy { $0[0].asText == transactionId }) - } - } - - @Test( - "opens a ledger through the Python Beancount backend", - .enabled(if: PythonBeancountLocator.path != nil, "Python Beancount unavailable") - ) - func opensLedgerThroughPythonBeancountBackend() async throws { - let python = try #require(PythonBeancountLocator.path) - try await Self.withEnvironment([ - "TABLEPRO_BEANCOUNT_BACKEND": "python", - "TABLEPRO_BEANCOUNT_PYTHON": python - ]) { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 open Assets:Cash USD - 2024-01-01 open Expenses:Food USD - 2024-01-01 open Income:Salary USD - - 2024-01-05 * "Employer" "Pay" - Assets:Cash 10.00 USD - Income:Salary - - 2024-01-06 * "Cafe" "Coffee" - Expenses:Food 3.00 USD - Assets:Cash - - 2024-01-31 balance Assets:Cash 7.00 USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - let balances = try await driver.execute(query: """ - SELECT account, amount, commodity - FROM balances ORDER BY account, commodity - """) - #expect(balances.rows.map { $0.map(\.asText) } == [ - ["Assets:Cash", "7.00", "USD"], - ["Expenses:Food", "3.00", "USD"], - ["Income:Salary", "-10.00", "USD"] - ]) - - let assertions = try await driver.execute(query: """ - SELECT date, account, amount, commodity - FROM balance_assertions - """) - #expect(assertions.rows.map { $0.map(\.asText) } == [ - ["2024-01-31", "Assets:Cash", "7.00", "USD"] - ]) - } - } - - @Test("reads the ledger plugin trust flag from the connection, not the environment") - func readsLedgerPluginTrustFromConnection() { - #expect(BeancountPluginDriver.allowsLedgerPlugins([:]) == false) - #expect(BeancountPluginDriver.allowsLedgerPlugins(["beancountRunLedgerPlugins": "false"]) == false) - #expect(BeancountPluginDriver.allowsLedgerPlugins(["beancountRunLedgerPlugins": "true"])) - } - - @Test( - "does not run ledger-declared Python plugins for an untrusted connection", - .enabled(if: PythonBeancountLocator.path != nil, "Python Beancount unavailable") - ) - func doesNotRunLedgerDeclaredPythonPluginsWhenUntrusted() async throws { - try await Self.withPythonBeancount { directory in - let marker = directory.appendingPathComponent("plugin-executed") - let ledger = try Self.writeMarkerPluginLedger(in: directory, marker: marker) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - #expect(!FileManager.default.fileExists(atPath: marker.path)) - let diagnostics = try await driver.execute(query: """ - SELECT phase, severity, message FROM diagnostics WHERE phase = 'security' - """) - #expect(diagnostics.rows.count == 1) - #expect(diagnostics.rows.first?[1].asText == "warning") - #expect(diagnostics.rows.first?[2].asText?.contains("tablepro_marker_plugin") == true) - } - } - - @Test( - "runs ledger-declared Python plugins for a trusted connection", - .enabled(if: PythonBeancountLocator.path != nil, "Python Beancount unavailable") - ) - func runsLedgerDeclaredPythonPluginsWhenTrusted() async throws { - try await Self.withPythonBeancount { directory in - let marker = directory.appendingPathComponent("plugin-executed") - let ledger = try Self.writeMarkerPluginLedger(in: directory, marker: marker) - - let driver = BeancountPluginDriver( - config: Self.config(ledger, additionalFields: ["beancountRunLedgerPlugins": "true"]) - ) - try await driver.connect() - defer { driver.disconnect() } - - #expect(FileManager.default.fileExists(atPath: marker.path)) - } - } - - @Test( - "keeps running the plugins that ship with Beancount", - .enabled(if: PythonBeancountLocator.path != nil, "Python Beancount unavailable") - ) - func keepsRunningPluginsThatShipWithBeancount() async throws { - try await Self.withPythonBeancount { directory in - let ledger = directory.appendingPathComponent("main.beancount") - try """ - plugin "beancount.plugins.auto_accounts" - - 2024-01-02 * "Cafe" "Coffee" - Expenses:Food 3.00 USD - Assets:Cash - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - let accounts = try await driver.execute(query: "SELECT name FROM accounts ORDER BY name") - #expect(accounts.rows.map { $0[0].asText } == ["Assets:Cash", "Expenses:Food"]) - - let diagnostics = try await driver.execute(query: """ - SELECT message FROM diagnostics WHERE phase = 'security' - """) - #expect(diagnostics.rows.isEmpty) - } - } - - @Test("reports the active Python Beancount backend and version") - func reportsActivePythonBeancountBackendAndVersion() async throws { - try await Self.withFakePythonBackend(reportedVersion: "3.2.3") { _, ledger in - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - #expect(driver.serverVersion == "Python Beancount 3.2.3") - } - } - - @Test("names the backend without a version when the executable reports none") - func namesBackendWithoutVersionWhenExecutableReportsNone() async throws { - try await Self.withFakePythonBackend(reportedVersion: "") { _, ledger in - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - #expect(driver.serverVersion == "Python Beancount") - } - } - - @Test("measures a backend version once per executable, not once per projection") - func measuresBackendVersionOncePerExecutable() async throws { - try await Self.withFakePythonBackend(reportedVersion: "3.2.3") { directory, ledger in - for _ in 0..<3 { - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - driver.disconnect() - } - - let calls = try String( - contentsOf: directory.appendingPathComponent("version-calls"), - encoding: .utf8 - ) - #expect(calls.split(separator: "\n").count == 1) - } - } - - @Test( - "executes BQL queries through the rledger executable", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func executesBQLQueriesThroughRustledgerExecutable() async throws { - try await Self.withRustledger { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 open Assets:Bank:Checking USD - 2024-01-01 open Expenses:Food USD - 2024-01-01 open Income:Salary USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - let result = try await driver.execute(query: "BQL: SELECT account FROM accounts ORDER BY account") - #expect(result.columns == ["account"]) - #expect(result.rows.map { $0.first?.asText } == [ - "Assets:Bank:Checking", - "Expenses:Food", - "Income:Salary" - ]) - - let count = try await driver.fetchRowCount(query: "BQL: SELECT account FROM accounts ORDER BY account") - #expect(count == 3) - - let page = try await driver.fetchRows( - query: "BQL: SELECT account FROM accounts ORDER BY account", - offset: 1, - limit: 1 - ) - #expect(page.rows.map { $0.first?.asText } == ["Expenses:Food"]) - } - } - - @Test("fails clearly when TABLEPRO_RUSTLEDGER_BINARY points at a missing executable") - func failsClearlyWhenConfiguredRustledgerIsMissing() async throws { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let ledger = directory.appendingPathComponent("main.beancount") - try "2024-01-01 open Assets:Bank:Checking USD\n" - .write(to: ledger, atomically: true, encoding: .utf8) - - let missing = directory.appendingPathComponent("missing-rledger").path - try await Self.withRustledgerEnvironment(missing) { - let driver = BeancountPluginDriver(config: Self.config(ledger)) - do { - try await driver.connect() - Issue.record("Expected missing rledger configuration to fail") - } catch let error as BeancountDriverError { - let message = error.errorDescription ?? "" - #expect(message.contains("TABLEPRO_RUSTLEDGER_BINARY")) - #expect(message.contains(missing)) - } catch { - Issue.record("Expected BeancountDriverError, got \(error)") - } - } - } - - // MARK: - Helpers - - @Test( - "projects rich directives and posting-free transactions through rledger", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func projectsRichDirectivesThroughRustledger() async throws { - try await Self.withRustledger { - try await Self.withRichDirectiveLedger { driver, ledger in - try await Self.expectRichDirectives(driver, ledger: ledger) - } - } - } - - @Test( - "projects rich directives and posting-free transactions through Python Beancount", - .enabled(if: PythonBeancountLocator.path != nil, "Python Beancount unavailable") - ) - func projectsRichDirectivesThroughPythonBeancount() async throws { - let python = try #require(PythonBeancountLocator.path) - try await Self.withEnvironment([ - "TABLEPRO_BEANCOUNT_BACKEND": "python", - "TABLEPRO_BEANCOUNT_PYTHON": python - ]) { - try await Self.withRichDirectiveLedger { driver, ledger in - try await Self.expectRichDirectives(driver, ledger: ledger) - } - } - } - - @Test( - "projects the same core relations through both Beancount backends", - .enabled( - if: RustledgerLocator.path != nil && PythonBeancountLocator.path != nil, - "rledger and Python Beancount are both required" - ) - ) - func projectsSameCoreRelationsThroughBothBackends() async throws { - let rledger = try #require(RustledgerLocator.path) - let python = try #require(PythonBeancountLocator.path) - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 commodity USD - 2024-01-01 open Assets:Cash USD - 2024-01-01 open Expenses:Food USD - - 2024-01-02 * "Cafe" "Coffee" - Expenses:Food 3.00 USD - Assets:Cash - - 2024-01-03 price USD 0.92 EUR - 2024-01-04 balance Assets:Cash -3.00 USD - 2024-01-05 event "location" "Taipei" - 2024-01-06 note Assets:Cash "checked" - 2024-06-30 close Expenses:Food - """.write(to: ledger, atomically: true, encoding: .utf8) - - let rustledgerRows = try await Self.withEnvironment([ - "TABLEPRO_BEANCOUNT_BACKEND": "rledger", - "TABLEPRO_RUSTLEDGER_BINARY": rledger - ]) { - try await Self.coreProjectionSnapshot(ledger: ledger) - } - let pythonRows = try await Self.withEnvironment([ - "TABLEPRO_BEANCOUNT_BACKEND": "python", - "TABLEPRO_BEANCOUNT_PYTHON": python - ]) { - try await Self.coreProjectionSnapshot(ledger: ledger) - } - - #expect(rustledgerRows == pythonRows) - } - - @Test( - "refreshes document diagnostics when an included document target changes", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func refreshesDocumentDiagnostics() async throws { - try await Self.withRustledger { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let subdirectory = directory.appendingPathComponent("sub", isDirectory: true) - try FileManager.default.createDirectory(at: subdirectory, withIntermediateDirectories: true) - let included = subdirectory.appendingPathComponent("entries.beancount") - try """ - 2024-01-01 open Assets:Cash USD - - 2024-01-04 document Assets:Cash "receipt.pdf" - """.write(to: included, atomically: true, encoding: .utf8) - - let ledger = directory.appendingPathComponent("main.beancount") - try "include \"sub/entries.beancount\"\n" - .write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - let accounts = try await driver.execute(query: "SELECT name FROM accounts") - #expect(accounts.rows.map { $0[0].asText } == ["Assets:Cash"]) - - var diagnostics = try await driver.execute(query: """ - SELECT source_file, line, source_location FROM diagnostics - WHERE code = 'E8001' ORDER BY id - """) - #expect(diagnostics.rows.count == 1) - let diagnostic = try #require(diagnostics.rows.first) - let sourceFile = try #require(diagnostic[0].asText) - #expect(Self.canonicalPath(URL(fileURLWithPath: sourceFile)) == Self.canonicalPath(included)) - #expect(diagnostic[1].asText == "3") - #expect(diagnostic[2].asText == "\(sourceFile):3") - - let document = subdirectory.appendingPathComponent("receipt.pdf") - try Data("pdf".utf8).write(to: document) - - diagnostics = try await driver.execute(query: """ - SELECT source_file, line, source_location FROM diagnostics - WHERE code = 'E8001' ORDER BY id - """) - #expect(diagnostics.rows.isEmpty) - - try FileManager.default.removeItem(at: document) - - diagnostics = try await driver.execute(query: """ - SELECT source_file, line, source_location FROM diagnostics - WHERE code = 'E8001' ORDER BY id - """) - #expect(diagnostics.rows.count == 1) - let returned = try #require(diagnostics.rows.first) - let returnedSourceFile = try #require(returned[0].asText) - #expect(Self.canonicalPath(URL(fileURLWithPath: returnedSourceFile)) == Self.canonicalPath(included)) - #expect(returned[1].asText == "3") - #expect(returned[2].asText == "\(returnedSourceFile):3") - } - } - - private static func withRichDirectiveLedger( - _ body: (BeancountPluginDriver, URL) async throws -> Void - ) async throws { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - try Data("pdf".utf8).write(to: directory.appendingPathComponent("receipt.pdf")) - - let padsLedger = directory.appendingPathComponent("pads.beancount") - let crlfPadsLedger = [ - "2024-01-01 open Equity:Opening-Balances USD", - "", - "2024/06/28 pad Assets:Cash Equity:Opening-Balances;opening", - "2024-06-29 balance Assets:Cash 0 ~ 0.02 USD", - " note: \"crlf metadata\"", - "2024-06-29 note Assets:Cash \"crlf note\" #crlf ^crlf-1" - ].joined(separator: "\r\n") - try crlfPadsLedger.write(to: padsLedger, atomically: true, encoding: .utf8) - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 commodity USD - name: "US Dollar" - - 2024-01-01 open Assets:Cash USD "STRICT" - 2024-01-01 open Expenses:Food USD - - 2024-01-02 event "location" "Taipei" - - 2024-01-03 note Assets:Cash "called the bank" #urgent ^case-1 - - 2024-01-04 document Assets:Cash "receipt.pdf" - - 2024-01-05 * "Cafe" "Coffee" #coffee #daily ^receipt-123 - invoice: "INV-9" - verified: TRUE - Expenses:Food 3.00 USD - method: "card" - Assets:Cash - - 2024-01-06 balance Assets:Cash -3.00 ~ 0.01 USD - - 2024-01-07 * "Archive" "No postings" #empty ^standalone - reason: "record only" - - 2024-01-07 query "cash" "SELECT account FROM accounts" - - 2024-01-08 custom "mixed" "text" 2024-12-31 TRUE 12.50 USD Assets:Cash 7 - - include "pads.beancount" - - 2024-06-30 close Expenses:Food - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - try await body(driver, ledger) - } - - private static func withPostingSemanticsLedger( - _ body: (BeancountPluginDriver) async throws -> Void - ) async throws { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - 2023-12-31 open Assets:Brokerage HOOL - 2023-12-31 open Assets:Cash USD - - 2024-01-02 * "Broker" "Buy" - ! Assets:Brokerage 2 HOOL {100 USD, 2023-12-31, "opening-lot"} @ 110 USD - Assets:Cash -200 USD - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - try await body(driver) - } - - private static func expectPostingSemantics(_ driver: BeancountPluginDriver) async throws { - let result = try await driver.execute(query: """ - SELECT account, amount, commodity, flag, - cost_number, cost_currency, cost_date, cost_label, - price_number, price_currency - FROM postings ORDER BY account - """) - #expect(result.rows.map { $0.map(\.asText) } == [ - ["Assets:Brokerage", "2", "HOOL", "!", "100", "USD", "2023-12-31", "opening-lot", "110", "USD"], - ["Assets:Cash", "-200", "USD", nil, nil, nil, nil, nil, nil, nil] - ]) - } - - private static func expectRichDirectives(_ driver: BeancountPluginDriver, ledger: URL) async throws { - let booking = try await driver.execute(query: "SELECT booking FROM accounts WHERE name = 'Assets:Cash'") - #expect(booking.rows.first?.first?.asText == "STRICT") - - let queries = try await driver.execute(query: "SELECT date, name, query FROM queries") - #expect(queries.rows.map { $0.map(\.asText) } == [[ - "2024-01-07", "cash", "SELECT account FROM accounts" - ]]) - - let custom = try await driver.execute(query: "SELECT date, type FROM custom") - #expect(custom.rows.map { $0.map(\.asText) } == [["2024-01-08", "mixed"]]) - - let customValues = try await driver.execute(query: """ - SELECT position, value_type, value, number, currency - FROM custom_values ORDER BY position - """) - #expect(customValues.rows.map { $0.map(\.asText) } == [ - ["0", "string", "text", nil, nil], - ["1", "date", "2024-12-31", nil, nil], - ["2", "boolean", "TRUE", nil, nil], - ["3", "amount", "12.50 USD", "12.50", "USD"], - ["4", "account", "Assets:Cash", nil, nil], - ["5", "number", "7", "7", nil] - ]) - - let commodities = try await driver.execute(query: "SELECT date, commodity FROM commodities") - #expect(commodities.rows.map { $0.map(\.asText) } == [["2024-01-01", "USD"]]) - - let events = try await driver.execute(query: "SELECT date, type, description FROM events") - #expect(events.rows.map { $0.map(\.asText) } == [["2024-01-02", "location", "Taipei"]]) - - let notes = try await driver.execute(query: """ - SELECT date, account, comment, tags, links FROM notes ORDER BY date - """) - #expect(notes.rows.map { $0.map(\.asText) } == [ - ["2024-01-03", "Assets:Cash", "called the bank", "urgent", "case-1"], - ["2024-06-29", "Assets:Cash", "crlf note", "crlf", "crlf-1"] - ]) - - let assertion = try await driver.execute(query: """ - SELECT tolerance, difference_amount, difference_currency FROM balance_assertions - WHERE date = '2024-01-06' - """) - #expect(assertion.rows.map { $0.map(\.asText) } == [["0.01", "0", "USD"]]) - - let crlfAssertion = try await driver.execute(query: """ - SELECT tolerance FROM balance_assertions WHERE date = '2024-06-29' - """) - #expect(crlfAssertion.rows.map { $0.map(\.asText) } == [["0.02"]]) - - let crlfMetadata = try await driver.execute(query: """ - SELECT d.type, d.line, m.key, m.value FROM directive_metadata m - JOIN directives d ON d.id = m.directive_id - WHERE m.key = 'note' - """) - #expect(crlfMetadata.rows.map { $0.map(\.asText) } == [["balance", "4", "note", "crlf metadata"]]) - - let directiveMetadata = try await driver.execute(query: """ - SELECT d.type, m.key, m.value, d.line FROM directive_metadata m - JOIN directives d ON d.id = m.directive_id - WHERE d.type = 'commodity' ORDER BY m.key - """) - #expect(directiveMetadata.rows.map { $0.map(\.asText) } == [["commodity", "name", "US Dollar", "1"]]) - - let closes = try await driver.execute(query: "SELECT date, account FROM closes") - #expect(closes.rows.map { $0.map(\.asText) } == [["2024-06-30", "Expenses:Food"]]) - - let pads = try await driver.execute(query: """ - SELECT date, account, source_account, source_file, line, source_location FROM pads - """) - #expect(pads.rows.count == 1) - let pad = try #require(pads.rows.first) - #expect(pad[0].asText == "2024-06-28") - #expect(pad[1].asText == "Assets:Cash") - #expect(pad[2].asText == "Equity:Opening-Balances") - #expect(pad[3].asText?.hasSuffix("pads.beancount") == true) - #expect(pad[4].asText == "3") - #expect(pad[5].asText?.hasSuffix("pads.beancount:3") == true) - - let documents = try await driver.execute(query: "SELECT date, account, path FROM documents") - #expect(documents.rows.count == 1) - let document = try #require(documents.rows.first) - #expect(document[0].asText == "2024-01-04") - #expect(document[1].asText == "Assets:Cash") - #expect(document[2].asText?.hasSuffix("receipt.pdf") == true) - - let metadata = try await driver.execute(query: """ - SELECT metadata.key, metadata.value - FROM transaction_metadata AS metadata - JOIN transactions ON transactions.id = metadata.transaction_id - WHERE transactions.narration = 'Coffee' - ORDER BY metadata.key - """) - #expect(metadata.rows.map { $0.map(\.asText) } == [["invoice", "INV-9"], ["verified", "TRUE"]]) - - let postingMetadata = try await driver.execute(query: """ - SELECT metadata.key, metadata.value - FROM posting_metadata AS metadata - JOIN postings ON postings.id = metadata.posting_id - JOIN transactions ON transactions.id = postings.transaction_id - WHERE transactions.narration = 'Coffee' - """) - #expect(postingMetadata.rows.map { $0.map(\.asText) } == [["method", "card"]]) - - let tags = try await driver.execute(query: """ - SELECT tags.tag - FROM transaction_tags AS tags - JOIN transactions ON transactions.id = tags.transaction_id - WHERE transactions.narration = 'Coffee' - ORDER BY tags.tag - """) - #expect(tags.rows.map { $0[0].asText } == ["coffee", "daily"]) - - let links = try await driver.execute(query: """ - SELECT links.link - FROM transaction_links AS links - JOIN transactions ON transactions.id = links.transaction_id - WHERE transactions.narration = 'Coffee' - """) - #expect(links.rows.map { $0[0].asText } == ["receipt-123"]) - - let transactions = try await driver.execute(query: """ - SELECT source_file, line, source_location FROM transactions WHERE narration = 'Coffee' - """) - #expect(transactions.rows.count == 1) - let transaction = try #require(transactions.rows.first) - #expect(transaction[0].asText?.hasSuffix(ledger.lastPathComponent) == true) - #expect(transaction[1].asText == "13") - #expect(transaction[2].asText?.hasSuffix("\(ledger.lastPathComponent):13") == true) - - let postings = try await driver.execute(query: """ - SELECT postings.line - FROM postings - JOIN transactions ON transactions.id = postings.transaction_id - WHERE transactions.narration = 'Coffee' - ORDER BY postings.id - """) - #expect(postings.rows.map { $0[0].asText } == ["16", "18"]) - - let postingFree = try await driver.execute(query: """ - SELECT id, date, flag, payee, narration, source_file, line, source_location - FROM transactions WHERE narration = 'No postings' - """) - #expect(postingFree.rows.count == 1) - let transactionWithoutPostings = try #require(postingFree.rows.first) - #expect(transactionWithoutPostings[0].asText != nil) - #expect(transactionWithoutPostings.dropFirst().prefix(4).map(\.asText) == [ - "2024-01-07", - "*", - "Archive", - "No postings" - ]) - let sourceFile = try #require(transactionWithoutPostings[5].asText) - #expect(Self.canonicalPath(URL(fileURLWithPath: sourceFile)) == Self.canonicalPath(ledger)) - #expect(transactionWithoutPostings[6].asText == "22") - #expect(transactionWithoutPostings[7].asText == "\(sourceFile):22") - - let postingFreeMetadata = try await driver.execute(query: """ - SELECT metadata.key, metadata.value - FROM transaction_metadata AS metadata - JOIN transactions ON transactions.id = metadata.transaction_id - WHERE transactions.narration = 'No postings' - ORDER BY metadata.key - """) - #expect(postingFreeMetadata.rows.map { $0.map(\.asText) } == [["reason", "record only"]]) - - let postingFreeTags = try await driver.execute(query: """ - SELECT tags.tag - FROM transaction_tags AS tags - JOIN transactions ON transactions.id = tags.transaction_id - WHERE transactions.narration = 'No postings' - ORDER BY tags.tag - """) - #expect(postingFreeTags.rows.map { $0[0].asText } == ["empty"]) - - let postingFreeLinks = try await driver.execute(query: """ - SELECT links.link - FROM transaction_links AS links - JOIN transactions ON transactions.id = links.transaction_id - WHERE transactions.narration = 'No postings' - ORDER BY links.link - """) - #expect(postingFreeLinks.rows.map { $0[0].asText } == ["standalone"]) - - let postingFreeCount = try await driver.execute(query: """ - SELECT COUNT(postings.id) - FROM transactions - LEFT JOIN postings ON postings.transaction_id = transactions.id - WHERE transactions.narration = 'No postings' - GROUP BY transactions.id - """) - #expect(postingFreeCount.rows.first?.first?.asText == "0") - } - - static func withRustledger(_ body: () async throws -> Void) async throws { - let rledger = try #require(RustledgerLocator.path) - try await withRustledgerEnvironment(rledger, body) - } - - static func withRustledgerEnvironment(_ path: String, _ body: () async throws -> Void) async throws { - try await withEnvironment([ - "TABLEPRO_BEANCOUNT_BACKEND": "rledger", - "TABLEPRO_RUSTLEDGER_BINARY": path - ], body) - } - - private static func withEnvironment( - _ values: [String: String], - _ body: () async throws -> T - ) async throws -> T { - let previous = values.keys.map { ($0, ProcessInfo.processInfo.environment[$0]) } - for (name, value) in values { - setenv(name, value, 1) - } - defer { - for (name, previousValue) in previous { - if let previousValue { - setenv(name, previousValue, 1) - } else { - unsetenv(name) - } - } - } - return try await body() - } - - private static func coreProjectionSnapshot(ledger: URL) async throws -> [String: [[String?]]] { - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - // Row ids are per-backend: rledger numbers every entry in the ledger, the Python script - // numbers only the transactions it walks. Comparing them would never match, so the - // snapshot compares the values and orders by them. - let queries = [ - "accounts": "SELECT name, open_date, currencies FROM accounts ORDER BY name", - "balance_assertions": """ - SELECT date, account, amount, commodity FROM balance_assertions - ORDER BY date, account, commodity - """, - "balances": "SELECT account, amount, commodity FROM balances ORDER BY account, commodity", - "closes": "SELECT date, account FROM closes ORDER BY date, account", - "commodities": "SELECT date, commodity FROM commodities ORDER BY date, commodity", - "events": "SELECT date, type, description FROM events ORDER BY date, type", - "notes": "SELECT date, account, comment FROM notes ORDER BY date, account", - "postings": """ - SELECT date, account, amount, commodity FROM postings - ORDER BY date, account, commodity - """, - "prices": "SELECT date, commodity, amount, currency FROM prices ORDER BY date, commodity", - "transactions": """ - SELECT date, flag, payee, narration FROM transactions - ORDER BY date, payee, narration - """ - ] - - var snapshot: [String: [[String?]]] = [:] - for (table, query) in queries { - let result = try await driver.execute(query: query) - snapshot[table] = result.rows.map { $0.map(\.asText) } - } - return snapshot - } - - private static func withFakePythonBackend( - reportedVersion: String, - _ body: (URL, URL) async throws -> Void - ) async throws { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let python = directory.appendingPathComponent("python3") - try """ - #!/bin/sh - case "$2" in - *importlib.metadata*) - echo called >> "$(dirname "$0")/version-calls" - printf '%s' '\(reportedVersion)' - ;; - "import beancount") exit 0 ;; - *) printf '{"transactions":[]}' ;; - esac - """.write(to: python, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: python.path) - - let ledger = directory.appendingPathComponent("main.beancount") - try "".write(to: ledger, atomically: true, encoding: .utf8) - - try await Self.withEnvironment([ - "TABLEPRO_BEANCOUNT_BACKEND": "python", - "TABLEPRO_BEANCOUNT_PYTHON": python.path - ]) { - try await body(directory, ledger) - } - } - - static func config( - _ ledger: URL, - additionalFields: [String: String] = [:] - ) -> DriverConnectionConfig { - DriverConnectionConfig( - host: "", - port: 0, - username: "", - password: "", - database: ledger.path, - additionalFields: additionalFields - ) - } - - private static func canonicalPath(_ url: URL) -> String { - url.withUnsafeFileSystemRepresentation { fileSystemPath in - guard let fileSystemPath, let resolvedPath = realpath(fileSystemPath, nil) else { - return url.standardizedFileURL.path - } - defer { free(resolvedPath) } - return String(cString: resolvedPath) - } - } - - private static func withPythonBeancount( - _ body: (URL) async throws -> Void - ) async throws { - let python = try #require(PythonBeancountLocator.path) - try await Self.withEnvironment([ - "TABLEPRO_BEANCOUNT_BACKEND": "python", - "TABLEPRO_BEANCOUNT_PYTHON": python - ]) { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - try await body(directory) - } - } - - private static func writeMarkerPluginLedger(in directory: URL, marker: URL) throws -> URL { - try """ - from pathlib import Path - - __plugins__ = ("write_marker",) - - def write_marker(entries, options_map, marker_path): - Path(marker_path).write_text("executed", encoding="utf-8") - return entries, [] - """.write( - to: directory.appendingPathComponent("tablepro_marker_plugin.py"), - atomically: true, - encoding: .utf8 - ) - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - option "insert_pythonpath" "TRUE" - plugin "tablepro_marker_plugin" "\(marker.path)" - """.write(to: ledger, atomically: true, encoding: .utf8) - return ledger - } - - static func makeTempDirectory() throws -> URL { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("beancount-driver-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return directory - } -} diff --git a/TableProTests/Plugins/BeancountProjectionTests.swift b/TableProTests/Plugins/BeancountProjectionTests.swift deleted file mode 100644 index dcdfe156c2..0000000000 --- a/TableProTests/Plugins/BeancountProjectionTests.swift +++ /dev/null @@ -1,824 +0,0 @@ -// -// BeancountProjectionTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct BeancountProjectionTests { - @Test("projects transactions and full posting semantics") - func projectsTransactionsAndPostings() async throws { - let driver = try Self.makeDriver() - defer { driver.disconnect() } - - let transactions = try await driver.execute(query: "SELECT id, payee, narration FROM transactions ORDER BY id") - #expect(transactions.rows.map { $0.map(\.asText) } == [ - ["1", "Cafe", "Coffee"], - ["2", "Broker", "Buy stock"], - ["3", "Archive", "No postings"] - ]) - - let postings = try await driver.execute(query: """ - SELECT transaction_id, account, amount, commodity, flag, - cost_number, cost_currency, cost_date, cost_label, - price_number, price_currency - FROM postings ORDER BY id - """) - #expect(postings.rows.map { $0.map(\.asText) } == [ - ["1", "Expenses:Food", "4.00", "USD", nil, nil, nil, nil, nil, nil, nil], - ["1", "Assets:Cash", "-4.00", "USD", nil, nil, nil, nil, nil, nil, nil], - [ - "2", "Assets:Stock", "10", "HOOL", "!", "100.00", "USD", "2023-12-31", "opening-lot", - "110", "USD" - ], - ["2", "Assets:Cash", "-1000.00", "USD", nil, nil, nil, nil, nil, nil, nil] - ]) - - let postingFreeCount = try await driver.execute(query: """ - SELECT COUNT(*) FROM postings WHERE transaction_id = 3 - """) - #expect(postingFreeCount.rows.first?.first?.asText == "0") - } - - @Test("projects computed balances, assertions, accounts, and prices") - func projectsBalancesAssertionsAccountsPrices() async throws { - let driver = try Self.makeDriver() - defer { driver.disconnect() } - - let balances = try await driver.execute(query: "SELECT account, amount, commodity FROM balances ORDER BY account") - #expect(balances.rows.map { $0.map(\.asText) } == [ - ["Assets:Cash", "-1004.00", "USD"], - ["Assets:Stock", "10", "HOOL"], - ["Expenses:Food", "4.00", "USD"] - ]) - - let assertions = try await driver.execute(query: """ - SELECT date, account, amount, commodity, tolerance, difference_amount, difference_currency - FROM balance_assertions - """) - #expect(assertions.rows.map { $0.map(\.asText) } == [[ - "2024-01-31", "Assets:Cash", "-1004.00", "USD", "0.01", "0.00", "USD" - ]]) - - let accounts = try await driver.execute(query: "SELECT name, currencies, booking FROM accounts ORDER BY name") - #expect(accounts.rows.map { $0.map(\.asText) } == [ - ["Assets:Cash", "USD", "STRICT"], - ["Assets:Stock", "HOOL", nil], - ["Expenses:Food", "USD", nil] - ]) - - let prices = try await driver.execute(query: "SELECT date, commodity, amount, currency FROM prices") - #expect(prices.rows.map { $0.map(\.asText) } == [["2024-01-02", "USD", "1.35", "CAD"]]) - } - - @Test("records parsed source files and rejects writes") - func recordsSourceFilesAndRejectsWrites() async throws { - let driver = try Self.makeDriver() - defer { driver.disconnect() } - - let sources = try await driver.execute(query: "SELECT path FROM source_files") - #expect(sources.rows.map { $0.first?.asText } == [Self.ledgerURL.path]) - - await #expect(throws: BeancountDriverError.self) { - _ = try await driver.execute(query: "DELETE FROM postings") - } - } - - @Test("projects commodity, document, note, event, and close directives") - func projectsRichDirectives() async throws { - let driver = try Self.makeDriver() - defer { driver.disconnect() } - - let commodities = try await driver.execute(query: "SELECT date, commodity FROM commodities ORDER BY commodity") - #expect(commodities.rows.map { $0.map(\.asText) } == [ - ["2024-01-01", "HOOL"], - ["2024-01-01", "USD"] - ]) - - let documents = try await driver.execute(query: "SELECT date, account, path, tags, links FROM documents") - #expect(documents.rows.map { $0.map(\.asText) } == [ - ["2024-01-03", "Assets:Cash", "/receipts/jan.pdf", "scanned", "batch-1"] - ]) - - let notes = try await driver.execute(query: "SELECT date, account, comment, tags, links FROM notes") - #expect(notes.rows.map { $0.map(\.asText) } == [[ - "2024-01-04", "Assets:Cash", "called the bank", "urgent", "case-1" - ]]) - - let events = try await driver.execute(query: "SELECT date, type, description FROM events") - #expect(events.rows.map { $0.map(\.asText) } == [["2024-01-01", "location", "Taipei"]]) - - let closes = try await driver.execute(query: "SELECT date, account FROM closes") - #expect(closes.rows.map { $0.map(\.asText) } == [["2024-06-30", "Expenses:Food"]]) - } - - @Test("projects named queries and typed custom values") - func projectsQueriesAndCustomValues() async throws { - let driver = try Self.makeDriver() - defer { driver.disconnect() } - - let queries = try await driver.execute(query: """ - SELECT date, name, query, source_file, line FROM queries - """) - #expect(queries.rows.map { $0.map(\.asText) } == [[ - "2024-01-06", "cash", "SELECT account FROM accounts", "/ledger/main.beancount", "50" - ]]) - - let custom = try await driver.execute(query: "SELECT date, type FROM custom") - #expect(custom.rows.map { $0.map(\.asText) } == [["2024-01-07", "mixed"]]) - - let values = try await driver.execute(query: """ - SELECT position, value_type, value, number, currency - FROM custom_values ORDER BY position - """) - #expect(values.rows.map { $0.map(\.asText) } == [ - ["0", "string", "text", nil, nil], - ["1", "date", "2024-12-31", nil, nil], - ["2", "boolean", "TRUE", nil, nil], - ["3", "amount", "12.50 USD", "12.50", "USD"], - ["4", "account", "Assets:Cash", nil, nil], - ["5", "number", "7", "7", nil] - ]) - } - - @Test("reads multiline queries and all custom value types from source") - func readsQueryAndCustomDirectivesFromSource() throws { - let sourceURL = URL(fileURLWithPath: "/ledger/included.beancount") - let projection = BeancountDirectiveProjectionReader.read( - contents: """ - 2024/1/6 query "cash" " - SELECT account - FROM accounts" ; visible only in source - 2024-01-07 custom "mixed" "quoted \\"text\\"" 2024-12-31 TRUE 12.50 USD Assets:Cash 7 - note: "metadata is not a custom value" - """, - sourceURL: sourceURL - ) - - #expect(projection.queries.count == 1) - #expect(projection.queries[0]["date"] as? String == "2024-01-06") - #expect(projection.queries[0]["name"] as? String == "cash") - #expect(projection.queries[0]["query"] as? String == "\n SELECT account\n FROM accounts") - #expect(projection.queries[0]["lineno"] as? Int == 1) - - let custom = try #require(projection.custom.first) - #expect(custom["lineno"] as? Int == 4) - let values = try #require(custom["values"] as? [[String: Any]]) - #expect(values.map { $0["value_type"] as? String } == [ - "string", "date", "boolean", "amount", "account", "number" - ]) - #expect(values.first?["value"] as? String == "quoted \"text\"") - } - - @Test("reads query and custom directives out of a CRLF ledger") - func readsDirectivesFromCarriageReturnLineFeedSource() throws { - let sourceURL = URL(fileURLWithPath: "/ledger/crlf.beancount") - let projection = BeancountDirectiveProjectionReader.read( - contents: "2024-01-01 open Assets:Cash USD\r\n" - + "\r\n" - + "2024-01-04 query \"cash\" \"\r\n" - + " SELECT account\r\n" - + " FROM accounts\"\r\n" - + "\r\n" - + "2024-01-05 custom \"mixed\" \"text\" 12.50 USD\r\n", - sourceURL: sourceURL - ) - - #expect(projection.queries.count == 1) - #expect(projection.queries[0]["lineno"] as? Int == 3) - #expect(projection.queries[0]["query"] as? String == "\n SELECT account\n FROM accounts") - - let custom = try #require(projection.custom.first) - #expect(custom["lineno"] as? Int == 7) - #expect(custom["location"] as? String == "/ledger/crlf.beancount:7") - let values = try #require(custom["values"] as? [[String: Any]]) - #expect(values.map { $0["value"] as? String } == ["text", "12.50 USD"]) - } - - @Test("joins directive metadata to the directive that declared it") - func projectsDirectiveMetadata() async throws { - let driver = try Self.makeDriver() - defer { driver.disconnect() } - - let metadata = try await driver.execute(query: """ - SELECT d.type, d.date, m.key, m.value, d.source_file, d.line - FROM directive_metadata m JOIN directives d ON d.id = m.directive_id - ORDER BY m.id - """) - #expect(metadata.rows.map { $0.map(\.asText) } == [ - ["commodity", "2024-01-01", "name", "US Dollar", "/ledger/main.beancount", "2"], - ["commodity", "2024-01-05", "name", "Hooli", "/ledger/main.beancount", "9"] - ]) - } - - @Test("keeps two same-day directives of one type apart") - func keepsSameDayDirectivesApart() async throws { - let driver = try Self.makeDriver() - defer { driver.disconnect() } - - let directives = try await driver.execute(query: """ - SELECT id, type, date, line FROM directives WHERE type = 'commodity' ORDER BY id - """) - #expect(directives.rows.map { $0.map(\.asText) } == [ - ["1", "commodity", "2024-01-01", "2"], - ["2", "commodity", "2024-01-05", "9"] - ]) - } - - @Test("reads directive details and computes assertion differences") - func readsAndCombinesDirectiveDetails() throws { - let sourceURL = URL(fileURLWithPath: "/ledger/details.beancount") - let details = BeancountDirectiveDetailsReader.read( - contents: """ - 2024-01-01 commodity USD - name: "US Dollar" - 2024-01-02 note Assets:Cash "called bank" #urgent ^case-1 - 2024/1/3 balance Assets:Cash 10.00 ~ 0.05 USD - """, - sourceURL: sourceURL - ) - #expect(details.notes.count == 1) - #expect(details.balances.count == 1) - #expect(details.notes.first?["tags"] as? [String] == ["urgent"]) - #expect(details.notes.first?["links"] as? [String] == ["case-1"]) - #expect(details.balances.first?["tolerance"] as? String == "0.05") - - let rows = BeancountPluginDriver.balanceRowsByAddingDetails( - [[ - "date": "2024-01-03", "account": "Assets:Cash", - "amount": ["number": "10.00", "currency": "USD"] - ]], - details: details.balances, - postings: [[ - "date": "2024-01-02", "account": "Assets:Cash", "number": "7.50", "currency": "USD" - ]] - ) - #expect(rows.first?["difference_amount"] as? String == "-2.5") - #expect(rows.first?["difference_currency"] as? String == "USD") - } - - @Test("reads a note whose comment spans lines, in a CRLF ledger") - func readsMultilineNoteFromCarriageReturnLineFeedSource() throws { - let sourceURL = URL(fileURLWithPath: "/ledger/crlf.beancount") - let details = BeancountDirectiveDetailsReader.read( - contents: "2024-01-02 note Assets:Cash \"called the\r\n" - + " bank\" #urgent ^case-1\r\n" - + "2024-01-03 balance Assets:Cash 10.00 ~ 0.05 USD\r\n", - sourceURL: sourceURL - ) - - #expect(details.notes.count == 1) - #expect(details.notes.first?["comment"] as? String == "called the\n bank") - #expect(details.notes.first?["tags"] as? [String] == ["urgent"]) - #expect(details.notes.first?["links"] as? [String] == ["case-1"]) - #expect(details.balances.first?["tolerance"] as? String == "0.05") - } - - @Test("projects pad directives with source locations") - func projectsPads() async throws { - let driver = try Self.makeDriver() - defer { driver.disconnect() } - - let pads = try await driver.execute(query: """ - SELECT date, account, source_account, source_file, line, source_location FROM pads - """) - #expect(pads.rows.map { $0.map(\.asText) } == [[ - "2024-01-30", - "Assets:Cash", - "Equity:Opening-Balances", - Self.ledgerURL.path, - "40", - "\(Self.ledgerURL.path):40" - ]]) - } - - @Test("projects a generated pad with no source position rather than dropping it") - func projectsGeneratedPadWithoutSourcePosition() async throws { - var rows = Self.cannedRows - rows.pads = [[ - "date": "2024-02-15", - "account": "Assets:Cash", - "source_account": "Equity:Opening-Balances", - "filename": "", - "lineno": 0 - ]] - let handle = try BeancountPluginDriver.loadProjection(rows: rows, sourceFiles: [Self.ledgerURL]) - let driver = BeancountPluginDriver( - config: DriverConnectionConfig( - host: "", port: 0, username: "", password: "", database: Self.ledgerURL.path - ) - ) - driver.installProjection(handle, ledgerURL: Self.ledgerURL) - defer { driver.disconnect() } - - let pads = try await driver.execute(query: """ - SELECT date, account, source_account, source_file, line, source_location FROM pads - """) - #expect(pads.rows.map { $0.map(\.asText) } == [[ - "2024-02-15", - "Assets:Cash", - "Equity:Opening-Balances", - "", - nil, - nil - ]]) - } - - @Test("reads pad accounts from a rendered directive") - func readsPadAccountsFromRenderedDirective() throws { - let pad = try #require(BeancountPluginDriver.padDirective( - rendering: "2024-01-30 pad Assets:Cash Equity:Opening-Balances\n" - )) - #expect(pad.date == "2024-01-30") - #expect(pad.account == "Assets:Cash") - #expect(pad.sourceAccount == "Equity:Opening-Balances") - - let nonBreakingSpace = "Assets:Ca\u{00A0}sh" - #expect(BeancountPluginDriver.padDirective( - rendering: "2024-01-30 pad \(nonBreakingSpace) Equity:Opening-Balances\n" - )?.account == nonBreakingSpace) - - #expect(BeancountPluginDriver.padDirective(rendering: "2024-01-30 open Assets:Cash USD\n") == nil) - #expect(BeancountPluginDriver.padDirective(rendering: "2024-01-30 balance Assets:Cash 0 USD\n") == nil) - #expect(BeancountPluginDriver.padDirective(rendering: "2024-01-30 pad Assets:Cash\n") == nil) - #expect(BeancountPluginDriver.padDirective(rendering: "") == nil) - } - - @Test("correlates pad entries with rendered directives in order") - func correlatesPadsWithRenderedDirectives() throws { - let projection = BeancountPluginDriver.padProjection( - entries: [ - Self.padEntry(id: 4, date: "2024-02-01", line: 6), - Self.padEntry(id: 5, date: "2024-05-01", line: 1, file: "/ledger/included.beancount") - ], - directives: [ - "2024-01-01 open Assets:Cash USD\n", - "2024-02-01 pad Assets:Cash Equity:Opening-Balances\n", - "2024-05-01 pad Assets:Cash Income:Salary\n", - "2024-12-31 balance Assets:Cash 5.00 USD\n" - ] - ) - #expect(projection.diagnostics.isEmpty) - #expect(projection.rows.count == 2) - #expect(projection.rows.map { $0["account"] as? String } == ["Assets:Cash", "Assets:Cash"]) - #expect( - projection.rows.map { $0["source_account"] as? String } - == ["Equity:Opening-Balances", "Income:Salary"] - ) - } - - @Test("correlates two pads sharing one date by position") - func correlatesPadsSharingOneDate() throws { - let projection = BeancountPluginDriver.padProjection( - entries: [ - Self.padEntry(id: 4, date: "2024-05-05", line: 6), - Self.padEntry(id: 5, date: "2024-05-05", line: 7) - ], - directives: [ - "2024-05-05 pad Assets:Cash Equity:Charlie\n", - "2024-05-05 pad Assets:Cash Equity:Alpha\n" - ] - ) - #expect(projection.diagnostics.isEmpty) - #expect( - projection.rows.map { $0["source_account"] as? String } == ["Equity:Charlie", "Equity:Alpha"] - ) - } - - @Test("keeps an unparseable pad rendering in place rather than shifting later pads onto it") - func keepsUnparseablePadRenderingInPlace() throws { - let projection = BeancountPluginDriver.padProjection( - entries: [ - Self.padEntry(id: 4, date: "2024-05-05", line: 6), - Self.padEntry(id: 5, date: "2024-05-05", line: 7) - ], - directives: [ - "2024-05-05 pad Assets:Cash\n", - "2024-05-05 pad Assets:Cash Equity:Bravo\n" - ] - ) - #expect(projection.rows.count == 1) - #expect(projection.rows.first?["source_account"] as? String == "Equity:Bravo") - #expect(projection.rows.first?["lineno"] as? Int == 7) - #expect(projection.diagnostics.count == 1) - #expect(projection.diagnostics.first?["line"] as? Int == 6) - } - - @Test("reports an uncorrelated pad in diagnostics instead of dropping it silently") - func reportsUncorrelatedPadsInDiagnostics() throws { - let dateMismatch = BeancountPluginDriver.padProjection( - entries: [Self.padEntry(id: 4, date: "2024-02-01", line: 6)], - directives: ["2024-09-09 pad Assets:Cash Equity:Opening-Balances\n"] - ) - #expect(dateMismatch.rows.isEmpty) - #expect(dateMismatch.diagnostics.count == 1) - #expect(dateMismatch.diagnostics.first?["phase"] as? String == "projection") - #expect(dateMismatch.diagnostics.first?["severity"] as? String == "warning") - #expect(dateMismatch.diagnostics.first?["line"] as? Int == 6) - #expect(dateMismatch.diagnostics.first?["file"] as? String == "/ledger/main.beancount") - let dateMismatchMessage = try #require(dateMismatch.diagnostics.first?["message"] as? String) - #expect(!dateMismatchMessage.isEmpty) - - let noDirectives = BeancountPluginDriver.padProjection( - entries: [Self.padEntry(id: 4, date: "2024-02-01", line: 6)], - directives: [] - ) - #expect(noDirectives.rows.isEmpty) - #expect(noDirectives.diagnostics.count == 1) - - let surplusDirectives = BeancountPluginDriver.padProjection( - entries: [Self.padEntry(id: 4, date: "2024-02-01", line: 6)], - directives: [ - "2024-02-01 pad Assets:Cash Equity:Opening-Balances\n", - "2024-03-01 pad Assets:Cash Income:Salary\n" - ] - ) - #expect(surplusDirectives.rows.count == 1) - #expect(surplusDirectives.diagnostics.isEmpty) - - let undatedEntry = BeancountPluginDriver.padProjection( - entries: [["id": 4, "filename": "/ledger/main.beancount", "lineno": 6]], - directives: ["2024-02-01 pad Assets:Cash Equity:Opening-Balances\n"] - ) - #expect(undatedEntry.rows.isEmpty) - #expect(undatedEntry.diagnostics.count == 1) - } - - private static func padEntry( - id: Int, - date: String, - line: Int, - file: String = "/ledger/main.beancount" - ) -> [String: Any] { - ["id": id, "date": date, "filename": file, "lineno": line] - } - - @Test("projects transaction metadata, posting metadata, tags, and links") - func projectsMetadataTagsAndLinks() async throws { - let driver = try Self.makeDriver() - defer { driver.disconnect() } - - let metadata = try await driver.execute(query: """ - SELECT transaction_id, key, value FROM transaction_metadata ORDER BY transaction_id, key - """) - #expect(metadata.rows.map { $0.map(\.asText) } == [ - ["1", "invoice", "INV-9"], - ["1", "reviewed", "TRUE"], - ["3", "reason", "record only"] - ]) - - let postingMetadata = try await driver.execute(query: """ - SELECT posting_id, key, value FROM posting_metadata ORDER BY id - """) - #expect(postingMetadata.rows.map { $0.map(\.asText) } == [["1", "method", "card"]]) - - let tags = try await driver.execute(query: """ - SELECT transaction_id, tag FROM transaction_tags ORDER BY transaction_id, tag - """) - #expect(tags.rows.map { $0.map(\.asText) } == [ - ["1", "coffee"], ["1", "daily"], ["3", "empty"] - ]) - - let links = try await driver.execute(query: """ - SELECT transaction_id, link FROM transaction_links ORDER BY transaction_id, link - """) - #expect(links.rows.map { $0.map(\.asText) } == [["1", "receipt-123"], ["3", "standalone"]]) - } - - @Test("projects source locations for transactions and postings") - func projectsSourceLocations() async throws { - let driver = try Self.makeDriver() - defer { driver.disconnect() } - - let transactions = try await driver.execute(query: """ - SELECT id, source_file, line, source_location FROM transactions ORDER BY id - """) - #expect(transactions.rows.map { $0.map(\.asText) } == [ - ["1", "/ledger/main.beancount", "10", "/ledger/main.beancount:10"], - ["2", nil, nil, nil], - ["3", "/ledger/main.beancount", "30", "/ledger/main.beancount:30"] - ]) - - let postings = try await driver.execute(query: """ - SELECT id, source_location FROM postings ORDER BY id LIMIT 2 - """) - #expect(postings.rows.map { $0.map(\.asText) } == [ - ["1", "/ledger/main.beancount:11"], - ["2", "/ledger/main.beancount:12"] - ]) - } - - @Test("projects validation diagnostics and stays empty without a validator") - func projectsDiagnostics() async throws { - let driver = try Self.makeDriver() - defer { driver.disconnect() } - - let diagnostics = try await driver.execute(query: """ - SELECT source_file, line, source_location, column_number, end_line, end_column, - severity, phase, code, message - FROM diagnostics ORDER BY id - """) - #expect(diagnostics.rows.map { $0.map(\.asText) } == [ - [ - "/ledger/main.beancount", "20", "/ledger/main.beancount:20", "1", "22", "1", - "error", "validate", "E8001", "Document file not found: /receipts/jan.pdf" - ] - ]) - - let handle = try BeancountPluginDriver.loadProjection( - rows: BeancountProjectionRows(), - sourceFiles: [Self.ledgerURL] - ) - let empty = BeancountPluginDriver( - config: DriverConnectionConfig(host: "", port: 0, username: "", password: "", database: Self.ledgerURL.path) - ) - empty.installProjection(handle, ledgerURL: Self.ledgerURL) - defer { empty.disconnect() } - - let none = try await empty.execute(query: "SELECT COUNT(*) FROM diagnostics") - #expect(none.rows.first?.first?.asText == "0") - } - - @Test("rejects postings whose transaction is missing") - func rejectsOrphanPostings() { - let rows = BeancountProjectionRows( - postings: [ - Self.postingRow( - transactionID: 99, - account: "Assets:Cash", - number: "1.00", - currency: "USD" - ) - ] - ) - - #expect(throws: BeancountDriverError.self) { - _ = try BeancountPluginDriver.loadProjection(rows: rows, sourceFiles: []) - } - } - - @Test("backfills legacy transaction details without using posting locations") - func backfillsLegacyTransactionDetails() { - let transactions: [[String: Any]] = [ - [ - "id": 1, - "tags": ["entry"], - "links": ["entry-link"], - "_entry_meta": ["source": "entry"] - ], - ["id": 2] - ] - let postings: [[String: Any]] = [ - [ - "transaction_id": 1, - "tags": ["posting"], - "links": ["posting-link"], - "_entry_meta": ["source": "posting"] - ], - [ - "transaction_id": 2, - "tags": ["legacy"], - "links": ["legacy-link"], - "_entry_meta": ["source": "legacy"], - "filename": "/ledger/posting.beancount", - "lineno": 22 - ] - ] - - let enriched = BeancountPluginDriver.transactionRowsByAddingPostingDetails( - transactions, - postings: postings - ) - - #expect(enriched[0]["tags"] as? [String] == ["entry"]) - #expect(enriched[0]["links"] as? [String] == ["entry-link"]) - #expect((enriched[0]["_entry_meta"] as? [String: String])?["source"] == "entry") - #expect(enriched[1]["tags"] as? [String] == ["legacy"]) - #expect(enriched[1]["links"] as? [String] == ["legacy-link"]) - #expect((enriched[1]["_entry_meta"] as? [String: String])?["source"] == "legacy") - #expect(enriched[1]["filename"] == nil) - #expect(enriched[1]["lineno"] == nil) - } - - // MARK: - Fixtures - - private static let ledgerURL = URL(fileURLWithPath: "/tmp/tablepro-beancount-fixture/main.beancount") - - private static func makeDriver() throws -> BeancountPluginDriver { - let handle = try BeancountPluginDriver.loadProjection(rows: cannedRows, sourceFiles: [ledgerURL]) - let driver = BeancountPluginDriver( - config: DriverConnectionConfig(host: "", port: 0, username: "", password: "", database: ledgerURL.path) - ) - driver.installProjection(handle, ledgerURL: ledgerURL) - return driver - } - - private static let cannedRows = BeancountProjectionRows( - transactions: [ - transactionRow( - id: 1, - payee: "Cafe", - narration: "Coffee", - line: 10, - metadata: ["invoice": "INV-9", "reviewed": true], - tags: ["coffee", "daily"], - links: ["receipt-123"] - ), - transactionRow(id: 2, payee: "Broker", narration: "Buy stock"), - transactionRow( - id: 3, - payee: "Archive", - narration: "No postings", - line: 30, - metadata: ["reason": "record only"], - tags: ["empty"], - links: ["standalone"] - ) - ], - postings: [ - postingRow( - transactionID: 1, - account: "Expenses:Food", - number: "4.00", - currency: "USD", - line: 11, - metadata: ["method": "card"] - ), - postingRow(transactionID: 1, account: "Assets:Cash", number: "-4.00", currency: "USD", line: 12), - postingRow( - transactionID: 2, - account: "Assets:Stock", - number: "10", - currency: "HOOL", - costNumber: "100.00", - costCurrency: "USD", - costDate: "2023-12-31", - costLabel: "opening-lot", - priceNumber: "110", - priceCurrency: "USD", - flag: "!" - ), - postingRow(transactionID: 2, account: "Assets:Cash", number: "-1000.00", currency: "USD") - ], - accounts: [ - ["account": "Assets:Cash", "open": "2024-01-01", "currencies": ["USD"], "booking": "STRICT"], - ["account": "Assets:Stock", "open": "2024-01-01", "currencies": ["HOOL"]], - ["account": "Expenses:Food", "open": "2024-01-01", "currencies": ["USD"]] - ], - prices: [ - ["date": "2024-01-02", "currency": "USD", "amount": ["number": "1.35", "currency": "CAD"]] - ], - balances: [ - position(account: "Assets:Cash", number: "-1004.00", currency: "USD"), - position(account: "Assets:Stock", number: "10", currency: "HOOL"), - position(account: "Expenses:Food", number: "4.00", currency: "USD") - ], - balanceAssertions: [ - [ - "date": "2024-01-31", "account": "Assets:Cash", - "amount": ["number": "-1004.00", "currency": "USD"], - "tolerance": "0.01", "difference_amount": "0.00", "difference_currency": "USD" - ] - ], - commodities: [ - ["date": "2024-01-01", "name": "USD"], - ["date": "2024-01-01", "name": "HOOL"] - ], - documents: [ - [ - "date": "2024-01-03", "account": "Assets:Cash", "filename": "/receipts/jan.pdf", - "tags": ["scanned"], "links": ["batch-1"] - ] - ], - notes: [ - [ - "date": "2024-01-04", "account": "Assets:Cash", "comment": "called the bank", - "tags": ["urgent"], "links": ["case-1"] - ] - ], - events: [ - ["date": "2024-01-01", "type": "location", "description": "Taipei"] - ], - pads: [ - [ - "date": "2024-01-30", - "account": "Assets:Cash", - "source_account": "Equity:Opening-Balances", - "filename": ledgerURL.path, - "lineno": 40, - "location": "\(ledgerURL.path):40" - ] - ], - closes: [ - ["account": "Expenses:Food", "close": "2024-06-30"] - ], - queries: [[ - "date": "2024-01-06", "name": "cash", "query": "SELECT account FROM accounts", - "filename": "/ledger/main.beancount", "lineno": 50 - ]], - custom: [[ - "id": 1, - "date": "2024-01-07", - "type": "mixed", - "values": [ - ["value_type": "string", "value": "text"], - ["value_type": "date", "value": "2024-12-31"], - ["value_type": "boolean", "value": "TRUE"], - ["value_type": "amount", "value": "12.50 USD", "number": "12.50", "currency": "USD"], - ["value_type": "account", "value": "Assets:Cash"], - ["value_type": "number", "value": "7", "number": "7"] - ] - ]], - directives: [ - [ - "id": 1, "type": "commodity", "date": "2024-01-01", - "filename": "/ledger/main.beancount", "lineno": 2, - "_entry_meta": ["name": "US Dollar"] - ], - [ - "id": 2, "type": "commodity", "date": "2024-01-05", - "filename": "/ledger/main.beancount", "lineno": 9, - "_entry_meta": ["name": "Hooli"] - ] - ], - diagnostics: [ - [ - "file": "/ledger/main.beancount", "line": 20, "column": 1, "end_line": 22, "end_column": 1, - "severity": "error", "phase": "validate", "code": "E8001", - "message": "Document file not found: /receipts/jan.pdf" - ] - ] - ) - - private static func transactionRow( - id: Int, - payee: String, - narration: String, - line: Int? = nil, - metadata: [String: Any]? = nil, - tags: [String]? = nil, - links: [String]? = nil - ) -> [String: Any] { - var row: [String: Any] = [ - "id": id, - "date": "2024-01-05", - "flag": "*", - "payee": payee, - "narration": narration - ] - if let line { - row["filename"] = "/ledger/main.beancount" - row["lineno"] = line - row["location"] = "/ledger/main.beancount:\(line)" - } - row["_entry_meta"] = metadata - row["tags"] = tags - row["links"] = links - return row - } - - private static func postingRow( - transactionID: Int, - account: String, - number: String, - currency: String, - costNumber: String? = nil, - costCurrency: String? = nil, - costDate: String? = nil, - costLabel: String? = nil, - priceNumber: String? = nil, - priceCurrency: String? = nil, - flag: String? = nil, - line: Int? = nil, - metadata: [String: Any]? = nil - ) -> [String: Any] { - var row: [String: Any] = [ - "transaction_id": transactionID, - "date": "2024-01-05", - "account": account, - "number": number, - "currency": currency - ] - row["cost_number"] = costNumber - row["cost_currency"] = costCurrency - row["cost_date"] = costDate - row["cost_label"] = costLabel - if let priceNumber, let priceCurrency { - row["price"] = ["number": priceNumber, "currency": priceCurrency] - } - row["posting_flag"] = flag - if let line { - row["filename"] = "/ledger/main.beancount" - row["lineno"] = line - row["location"] = "/ledger/main.beancount:\(line)" - } - row["_posting_meta"] = metadata - return row - } - - private static func position(account: String, number: String, currency: String) -> [String: Any] { - ["account": account, "balance": ["positions": [["number": number, "currency": currency]]]] - } -} diff --git a/TableProTests/Plugins/BeancountRledgerOutputTests.swift b/TableProTests/Plugins/BeancountRledgerOutputTests.swift deleted file mode 100644 index 541eff81f5..0000000000 --- a/TableProTests/Plugins/BeancountRledgerOutputTests.swift +++ /dev/null @@ -1,188 +0,0 @@ -// -// BeancountRledgerOutputTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -extension BeancountPluginDriverTests { - @Test("reads rledger rows written as objects and as positional arrays") - func readsBothRledgerRowShapes() throws { - let position = #"{"cost": null, "units": {"currency": "USD", "number": "19.43"}}"# - let keyed = Data(#"{"columns": ["account", "position"], "rows": [{"account": "Expenses:Food", "position": \#(position)}]}"#.utf8) - let positional = Data(#"{"columns": ["account", "position"], "rows": [["Expenses:Food", \#(position)]]}"#.utf8) - - for data in [keyed, positional] { - let result = try BeancountPluginDriver.decodeRustledgerQueryOutput(data, executionTime: 0) - #expect(result.columns == ["account", "position"]) - #expect(result.rows.map { $0.map(\.asText) } == [["Expenses:Food", "19.43 USD"]]) - - let rows = try BeancountPluginDriver.decodeRledgerRows(data) - #expect(rows.map { $0["account"] as? String } == ["Expenses:Food"]) - } - } - - @Test("keeps two BQL columns that share a name apart") - func keepsBQLColumnsSharingANameApart() throws { - let data = Data(#"{"columns": ["a", "a"], "rows": [["2024-01-05", "Assets:Cash"]]}"#.utf8) - let result = try BeancountPluginDriver.decodeRustledgerQueryOutput(data, executionTime: 0) - #expect(result.rows.map { $0.map(\.asText) } == [["2024-01-05", "Assets:Cash"]]) - } - - @Test("fails on rledger rows of an unknown shape instead of reading them as no rows") - func rejectsUnknownRledgerRowShape() { - let data = Data(#"{"columns": ["account"], "rows": ["Assets:Cash"]}"#.utf8) - #expect(throws: BeancountDriverError.self) { - _ = try BeancountPluginDriver.decodeRustledgerQueryOutput(data, executionTime: 0) - } - #expect(throws: BeancountDriverError.self) { - _ = try BeancountPluginDriver.decodeRledgerRows(data) - } - } - - @Test("renders rledger values the way rledger prints them") - func rendersRledgerValues() throws { - let cases: [(json: String, text: String)] = [ - (#"{"cost": null, "units": {"currency": "USD", "number": "19.43"}}"#, "19.43 USD"), - ( - #"{"cost": {"currency": "USD", "number": "150.00"}, "units": {"currency": "AAPL", "number": "2"}}"#, - "2 AAPL {150.00 USD}" - ), - (#"{"positions": [{"currency": "AAPL", "number": "3"}, {"currency": "USD", "number": "-451.00"}]}"#, "3 AAPL, -451.00 USD"), - (#"{"positions": []}"#, ""), - (#"["trip", "food"]"#, "trip, food"), - (#"{"count": 1, "unit": "month"}"#, "1 month"), - (#"{"count": 3, "unit": "day"}"#, "3 days"), - (#"{"count": -1, "unit": "week"}"#, "-1 week"), - (#"{"count": -9223372036854775808, "unit": "day"}"#, "-9223372036854775808 days"), - ("true", "TRUE"), - ("false", "FALSE"), - ("12", "12"), - (#"{"key": "value"}"#, #"{"key":"value"}"#) - ] - for testCase in cases { - let value = try JSONSerialization.jsonObject(with: Data(testCase.json.utf8), options: .fragmentsAllowed) - #expect(BeancountPluginDriver.rustledgerCellValue(value) == testCase.text, "\(testCase.json)") - } - } - - @Test("keeps a directive's own metadata out of the meta column newer rledger releases return") - func keepsDirectiveOwnMetadataFromMetaColumn() throws { - let meta: [String: Any] = [ - "filename": "/ledger/main.beancount", - "lineno": 3, - "__tolerances__": ["USD": "0.005"], - "name": "US Dollar" - ] - let own = try #require(BeancountPluginDriver.ownMetadata(meta) as? [String: Any]) - #expect(own.keys.sorted() == ["name"]) - #expect(own["name"] as? String == "US Dollar") - #expect(BeancountPluginDriver.ownMetadata(["filename": "/ledger/main.beancount", "lineno": 3]) is NSNull) - #expect(BeancountPluginDriver.ownMetadata(nil) is NSNull) - } - - @Test( - "shows BQL positions as units and cost, and one balance per commodity held in lots", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func showsPositionsAndLotBalancesThroughRustledger() async throws { - try await Self.withRustledger { - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 open Assets:Broker AAPL - 2024-01-01 open Assets:Cash USD - 2024-01-01 open Expenses:Food USD - - 2024-01-03 * "Cafe" "Lunch" #trip - Expenses:Food 19.43 USD - Assets:Cash - - 2024-01-04 * "Broker" "Buy" - Assets:Broker 2 AAPL {150.00 USD} - Assets:Cash - - 2024-01-05 * "Broker" "Buy" - Assets:Broker 1 AAPL {151.00 USD} - Assets:Cash - """.write(to: ledger, atomically: true, encoding: .utf8) - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - let positions = try await driver.execute(query: """ - BQL: SELECT date, account, position, tags WHERE account != 'Assets:Cash' ORDER BY date - """) - #expect(positions.rows.map { $0.map(\.asText) } == [ - ["2024-01-03", "Expenses:Food", "19.43 USD", "trip"], - ["2024-01-04", "Assets:Broker", "2 AAPL {150.00 USD}", ""], - ["2024-01-05", "Assets:Broker", "1 AAPL {151.00 USD}", ""] - ]) - - let balances = try await driver.execute(query: """ - SELECT account, amount, commodity FROM balances ORDER BY account, commodity - """) - #expect(balances.rows.map { $0.map(\.asText) } == [ - ["Assets:Broker", "3", "AAPL"], - ["Assets:Cash", "-470.43", "USD"], - ["Expenses:Food", "19.43", "USD"] - ]) - } - } - - @Test( - "keeps directive metadata when the first ledger opened has no transactions", - .enabled(if: RustledgerLocator.path != nil, "rledger executable unavailable") - ) - func keepsDirectiveMetadataAfterLedgerWithoutTransactions() async throws { - let rledger = try #require(RustledgerLocator.path) - let directory = try Self.makeTempDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - - // A fresh executable path, so no earlier test has already chosen the metadata column for it. - let wrapper = directory.appendingPathComponent("rledger") - try "#!/bin/sh\nexec \"\(rledger)\" \"$@\"\n".write(to: wrapper, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path) - - let accountsOnly = directory.appendingPathComponent("accounts.beancount") - try "2024-01-01 open Assets:Cash USD\n".write(to: accountsOnly, atomically: true, encoding: .utf8) - - let ledger = directory.appendingPathComponent("main.beancount") - try """ - 2024-01-01 commodity USD - name: "US Dollar" - - 2024-01-07 * "Archive" "No postings" #empty ^standalone - reason: "record only" - """.write(to: ledger, atomically: true, encoding: .utf8) - - try await Self.withRustledgerEnvironment(wrapper.path) { - let first = BeancountPluginDriver(config: Self.config(accountsOnly)) - try await first.connect() - first.disconnect() - - let driver = BeancountPluginDriver(config: Self.config(ledger)) - try await driver.connect() - defer { driver.disconnect() } - - let transactionMetadata = try await driver.execute(query: """ - SELECT key, value FROM transaction_metadata ORDER BY key - """) - #expect(transactionMetadata.rows.map { $0.map(\.asText) } == [["reason", "record only"]]) - - let tags = try await driver.execute(query: "SELECT tag FROM transaction_tags") - #expect(tags.rows.map { $0[0].asText } == ["empty"]) - - let directiveMetadata = try await driver.execute(query: """ - SELECT d.type, m.key, m.value FROM directive_metadata m - JOIN directives d ON d.id = m.directive_id - """) - #expect(directiveMetadata.rows.map { $0.map(\.asText) } == [["commodity", "name", "US Dollar"]]) - } - } -} diff --git a/TableProTests/Plugins/BigQueryCredentialFactoryTests.swift b/TableProTests/Plugins/BigQueryCredentialFactoryTests.swift deleted file mode 100644 index cd044370aa..0000000000 --- a/TableProTests/Plugins/BigQueryCredentialFactoryTests.swift +++ /dev/null @@ -1,300 +0,0 @@ -import Foundation -import TableProGoogleCloud -import TableProPluginKit -import Testing - -private final class BigQueryRecordingHTTPClient: GoogleHTTPClient, @unchecked Sendable { - private let lock = NSLock() - private var recordedRequest: URLRequest? - - var request: URLRequest? { - lock.withLock { recordedRequest } - } - - func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { - lock.withLock { recordedRequest = request } - guard let response = HTTPURLResponse( - url: request.url ?? URL(string: "https://oauth2.googleapis.com/token")!, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: nil - ) else { - throw URLError(.badServerResponse) - } - let body = #"{"access_token":"access-token","expires_in":3600}"# - return (Data(body.utf8), response) - } -} - -private enum BigQueryTokenRequestStop: Error { - case recorded -} - -private final class BigQueryStoppingHTTPClient: GoogleHTTPClient, @unchecked Sendable { - private let lock = NSLock() - private var recordedRequest: URLRequest? - - var request: URLRequest? { - lock.withLock { recordedRequest } - } - - func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { - lock.withLock { recordedRequest = request } - throw BigQueryTokenRequestStop.recorded - } -} - -struct BigQueryCredentialFactoryTests { - private static let serviceAccountJSON = """ - {"type":"service_account","client_email":"reader@key-project.iam.gserviceaccount.com",\ - "private_key":"-----BEGIN PRIVATE KEY-----\\nAAAA\\n-----END PRIVATE KEY-----\\n",\ - "project_id":"key-project"} - """ - - private static let authorizedUserJSON = """ - {"type":"authorized_user","client_id":"id","client_secret":"secret",\ - "refresh_token":"refresh","quota_project_id":"quota-project"} - """ - - private func credentials( - _ fields: [String: String], - password: String = "", - files: [String: String] = [:], - environment: [String: String] = [:] - ) throws -> BigQueryCredentials { - try BigQueryCredentialFactory.credentials( - fields: fields, - password: password, - readFile: { path in files[path].map { Data($0.utf8) } }, - environment: environment, - http: URLSessionGoogleHTTPClient(), - refreshTokenStore: GoogleInMemoryRefreshTokenStore() - ) - } - - @Test("A service account key supplies the project when none is configured") - func serviceAccountProjectFromKey() throws { - let resolved = try credentials(["bqServiceAccountJson": Self.serviceAccountJSON]) - #expect(resolved.projectId == "key-project") - } - - @Test("A configured project wins over the key's project") - func configuredProjectWins() throws { - let resolved = try credentials([ - "bqServiceAccountJson": Self.serviceAccountJSON, - "bqProjectId": " billing-project " - ]) - #expect(resolved.projectId == "billing-project") - } - - @Test("The legacy password field still holds the key") - func keyFromPassword() throws { - let resolved = try credentials([:], password: Self.serviceAccountJSON) - #expect(resolved.projectId == "key-project") - } - - @Test("A missing key names the field") - func missingKey() { - #expect(throws: BigQueryConfigurationError.missingField("bqServiceAccountJson")) { - _ = try credentials(["bqAuthMethod": "serviceAccount"]) - } - } - - @Test("Application default credentials supply the quota project as a hint") - func applicationDefaultProjectHint() throws { - let resolved = try credentials( - ["bqAuthMethod": "adc"], - files: ["/tmp/adc.json": Self.authorizedUserJSON], - environment: ["GOOGLE_APPLICATION_CREDENTIALS": "/tmp/adc.json"] - ) - #expect(resolved.projectId == "quota-project") - } - - @Test("OAuth needs a configured project") - func oauthRequiresProject() { - #expect(throws: BigQueryConfigurationError.missingField("bqProjectId")) { - _ = try credentials([ - "bqAuthMethod": "oauth", - "bqOAuthClientId": "client", - "bqOAuthClientSecret": "secret" - ]) - } - } - - @Test("OAuth needs a client ID") - func oauthRequiresClientId() { - #expect(throws: BigQueryConfigurationError.missingField("bqOAuthClientId")) { - _ = try credentials(["bqAuthMethod": "oauth", "bqProjectId": "p"]) - } - } - - @Test("OAuth builds a provider without opening a browser") - func oauthBuildsProvider() throws { - let resolved = try credentials([ - "bqAuthMethod": "oauth", - "bqOAuthClientId": "client", - "bqOAuthClientSecret": "secret", - "bqProjectId": "p" - ]) - #expect(resolved.projectId == "p") - } - - @Test("OAuth with no saved or pasted sign-in asks the user to sign in") - func oauthWithoutTokenRequiresSignIn() async throws { - let resolved = try credentials([ - "bqAuthMethod": "oauth", - "bqOAuthClientId": "client", - "bqOAuthClientSecret": "secret", - "bqProjectId": "p" - ]) - await #expect(throws: GoogleAuthError.signInRequired) { - _ = try await resolved.tokenProvider.accessToken() - } - } - - @Test("An unknown auth method is refused") - func unknownAuthMethod() { - #expect(throws: BigQueryConfigurationError.unknownAuthMethod) { - _ = try credentials(["bqAuthMethod": "kerberos"]) - } - } - - @Test("Google authentication requests share the remaining connect deadline") - func googleAuthenticationUsesConnectDeadline() async throws { - let base = BigQueryRecordingHTTPClient() - let phase = PluginConnectTimeoutPhase( - deadline: PluginConnectDeadline(milliseconds: 45_000) - ) - let client = GoogleDeadlineHTTPClient(base: base) { phase.remainingSeconds(or: $0) } - let request = URLRequest( - url: URL(string: "https://oauth2.googleapis.com/token")!, - timeoutInterval: 30 - ) - - _ = try await client.send(request) - - let timeout = try #require(base.request?.timeoutInterval) - #expect(timeout > 44) - #expect(timeout <= 45) - - phase.finish() - _ = try await client.send(request) - #expect(base.request?.timeoutInterval == 30) - } - - @Test("OAuth token exchange receives the credential factory's connect deadline", .timeLimit(.minutes(1))) - func oauthTokenExchangeUsesConnectDeadline() async throws { - let budgetMilliseconds = 2_500 - let http = BigQueryRecordingHTTPClient() - let phase = PluginConnectTimeoutPhase( - deadline: PluginConnectDeadline(milliseconds: budgetMilliseconds) - ) - let resolved = try BigQueryCredentialFactory.credentials( - fields: [ - "bqAuthMethod": "oauth", - "bqOAuthClientId": "client", - "bqOAuthClientSecret": "secret", - "bqProjectId": "project" - ], - password: "", - readFile: { _ in nil }, - environment: [:], - http: http, - refreshTokenStore: GoogleInMemoryRefreshTokenStore(["client": "refresh"]), - connectTimeoutPhase: phase - ) - - #expect(try await resolved.tokenProvider.accessToken() == "access-token") - - let timeout = try #require(http.request?.timeoutInterval) - #expect(timeout > 0) - #expect(timeout <= TimeInterval(budgetMilliseconds) / 1_000) - } - - @Test("Driver connect forwards its config deadline to OAuth token exchange", .timeLimit(.minutes(1))) - func driverConnectForwardsDeadlineToOAuth() async throws { - let budgetMilliseconds = 2_500 - let http = BigQueryStoppingHTTPClient() - let driver = BigQueryPluginDriver( - config: DriverConnectionConfig( - host: "", - port: 0, - username: "", - password: "", - database: "", - additionalFields: [ - BigQueryConnectionFields.authMethod: BigQueryAuthMethod.oauth.rawValue, - BigQueryConnectionFields.oauthClientId: "client", - BigQueryConnectionFields.oauthClientSecret: "secret", - BigQueryConnectionFields.projectId: "project", - "connectTimeoutMilliseconds": String(budgetMilliseconds) - ] - ), - credentialHTTPClient: http, - refreshTokenStore: GoogleInMemoryRefreshTokenStore(["client": "refresh"]) - ) - - await #expect(throws: BigQueryTokenRequestStop.self) { - try await driver.connect() - } - - let request = try #require(http.request) - #expect(request.url == GoogleOAuthClient.tokenEndpoint) - #expect(request.timeoutInterval > 0) - #expect(request.timeoutInterval <= TimeInterval(budgetMilliseconds) / 1_000) - } -} - -struct BigQueryErrorTests { - @Test("A sign-in failure carries SQLSTATE 28000") - func signInRequiredIsInvalidAuthorization() { - let error = BigQueryError.authentication(.signInRequired) - #expect(error.pluginSqlState == "28000") - } - - @Test("A revoked refresh token carries SQLSTATE 28000") - func invalidGrantIsInvalidAuthorization() { - let error = BigQueryError.authentication(.tokenRequestRejected(status: 400, oauthError: "invalid_grant")) - #expect(error.pluginSqlState == "28000") - } - - @Test("An unreadable key file is not an authorization failure") - func unreadableKeyIsNotInvalidAuthorization() { - #expect(BigQueryError.authentication(.credentialFileUnreadable).pluginSqlState == nil) - } - - @Test("HTTP 401 from BigQuery carries SQLSTATE 28000 and the status code") - func unauthorizedResponse() { - let error = BQErrorResponse.apiError( - status: 401, - data: Data(#"{"error":{"code":401,"message":"Request had invalid credentials."}}"#.utf8) - ) - #expect(error.pluginSqlState == "28000") - #expect(error.pluginErrorCode == 401) - #expect(error.pluginErrorMessage == "Request had invalid credentials.") - } - - @Test("An invalid query reason is recognised for the parameter memo") - func invalidQueryReason() { - let error = BQErrorResponse.apiError( - status: 400, - data: Data(#"{"error":{"code":400,"message":"Syntax error","errors":[{"reason":"invalidQuery"}]}}"#.utf8) - ) - #expect(error.isInvalidQuery) - #expect(error.pluginSqlState == nil) - } - - @Test("A missing partition filter adds a hint") - func partitionHint() { - let message = "Cannot query over table 't' without a filter over column(s) 'd' " - + "that can be used for partition elimination" - let error = BigQueryError.jobFailed(message: message, reason: "invalidQuery") - #expect(error.pluginErrorDetail != nil) - } - - @Test("Google auth errors wrap into driver errors") - func wrapsGoogleAuthErrors() { - let wrapped = BigQueryError.wrap(GoogleAuthError.signInRequired) - #expect((wrapped as? BigQueryError)?.pluginSqlState == "28000") - } -} diff --git a/TableProTests/Plugins/BigQueryQueryBuilderTests.swift b/TableProTests/Plugins/BigQueryQueryBuilderTests.swift deleted file mode 100644 index afa9559178..0000000000 --- a/TableProTests/Plugins/BigQueryQueryBuilderTests.swift +++ /dev/null @@ -1,493 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct BigQueryQueryBuilderBrowseTests { - @Test("Browse query returns tagged string") - func browseReturnsTag() { - let query = BigQueryQueryBuilder.encodeBrowseQuery( - table: "users", dataset: "analytics", sortColumns: [], limit: 100, offset: 0 - ) - #expect(query.hasPrefix(BigQueryQueryBuilder.browseTag)) - } - - @Test("Browse query round-trips through decode") - func browseRoundTrip() { - let query = BigQueryQueryBuilder.encodeBrowseQuery( - table: "orders", dataset: "sales", sortColumns: [(columnIndex: 0, ascending: true)], - limit: 50, offset: 10 - ) - let params = BigQueryQueryBuilder.decode(query) - #expect(params != nil) - #expect(params?.table == "orders") - #expect(params?.dataset == "sales") - #expect(params?.limit == 50) - #expect(params?.offset == 10) - #expect(params?.sortColumns?.count == 1) - #expect(params?.sortColumns?.first?.ascending == true) - } -} - -struct BigQueryQueryBuilderFilteredTests { - @Test("Filtered query returns filter tag") - func filteredReturnsTag() { - let query = BigQueryQueryBuilder.encodeFilteredQuery( - table: "users", dataset: "main", - filters: [PluginQueryFilter(column: "name", op: "=", value: "Alice")], - logicMode: "AND", sortColumns: [], limit: 100, offset: 0 - ) - #expect(query.hasPrefix(BigQueryQueryBuilder.filterTag)) - } - - @Test("Filtered query preserves filters and logic mode") - func filteredPreservesFilters() { - let query = BigQueryQueryBuilder.encodeFilteredQuery( - table: "events", dataset: "analytics", - filters: [ - PluginQueryFilter(column: "type", op: "=", value: "click"), - PluginQueryFilter(column: "count", op: ">", value: "10") - ], - logicMode: "OR", sortColumns: [], limit: 200, offset: 0 - ) - let params = BigQueryQueryBuilder.decode(query) - #expect(params?.filters?.count == 2) - #expect(params?.logicMode == "OR") - #expect(params?.filters?[0].column == "type") - #expect(params?.filters?[0].op == "=") - #expect(params?.filters?[0].value == "click") - #expect(params?.filters?[1].column == "count") - #expect(params?.filters?[1].op == ">") - } -} - -struct BigQueryQueryBuilderSearchTests { - @Test("Search query returns search tag") - func searchReturnsTag() { - let query = BigQueryQueryBuilder.encodeSearchQuery( - table: "users", dataset: "main", searchText: "hello", - searchColumns: ["name", "email"], sortColumns: [], limit: 100, offset: 0 - ) - #expect(query.hasPrefix(BigQueryQueryBuilder.searchTag)) - } - - @Test("Search query preserves search text and columns") - func searchPreservesParams() { - let query = BigQueryQueryBuilder.encodeSearchQuery( - table: "logs", dataset: "infra", searchText: "error", - searchColumns: ["message"], sortColumns: [], limit: 50, offset: 0 - ) - let params = BigQueryQueryBuilder.decode(query) - #expect(params?.searchText == "error") - #expect(params?.searchColumns == ["message"]) - } -} - -struct BigQueryQueryBuilderCombinedTests { - @Test("Combined query returns combined tag") - func combinedReturnsTag() { - let query = BigQueryQueryBuilder.encodeCombinedQuery( - table: "users", dataset: "main", - filters: [PluginQueryFilter(column: "active", op: "=", value: "true")], - logicMode: "AND", searchText: "test", - searchColumns: ["name"], sortColumns: [], limit: 100, offset: 0 - ) - #expect(query.hasPrefix(BigQueryQueryBuilder.combinedTag)) - } - - @Test("Combined query preserves both filters and search") - func combinedPreservesBoth() { - let query = BigQueryQueryBuilder.encodeCombinedQuery( - table: "users", dataset: "main", - filters: [PluginQueryFilter(column: "status", op: "!=", value: "deleted")], - logicMode: "AND", searchText: "alice", - searchColumns: ["name", "email"], sortColumns: [], limit: 100, offset: 0 - ) - let params = BigQueryQueryBuilder.decode(query) - #expect(params?.filters?.count == 1) - #expect(params?.searchText == "alice") - #expect(params?.searchColumns?.count == 2) - } -} - -struct BigQueryQueryBuilderIsTaggedTests { - @Test("Tagged queries return true") - func taggedQueriesDetected() { - let browse = BigQueryQueryBuilder.encodeBrowseQuery( - table: "t", dataset: "d", sortColumns: [], limit: 10, offset: 0 - ) - let filter = BigQueryQueryBuilder.encodeFilteredQuery( - table: "t", dataset: "d", filters: [PluginQueryFilter(column: "a", op: "=", value: "b")], - logicMode: "AND", sortColumns: [], limit: 10, offset: 0 - ) - #expect(BigQueryQueryBuilder.isTaggedQuery(browse)) - #expect(BigQueryQueryBuilder.isTaggedQuery(filter)) - } - - @Test("Regular SQL returns false") - func regularSqlNotTagged() { - #expect(!BigQueryQueryBuilder.isTaggedQuery("SELECT * FROM users")) - #expect(!BigQueryQueryBuilder.isTaggedQuery("INSERT INTO t VALUES (1)")) - } - - @Test("Decode returns nil for non-tagged query") - func decodeNonTagged() { - #expect(BigQueryQueryBuilder.decode("SELECT 1") == nil) - } -} - -struct BigQueryQueryBuilderSQLTests { - private func params( - table: String = "users", - dataset: String = "main", - sortColumns: [BigQueryQueryParams.SortColumn]? = nil, - filters: [BigQueryFilterSpec]? = nil, - logicMode: String? = nil, - searchText: String? = nil, - searchColumns: [String]? = nil, - columns: [String]? = nil - ) -> BigQueryQueryParams { - BigQueryQueryParams( - table: table, dataset: dataset, sortColumns: sortColumns, - limit: 100, offset: 0, filters: filters, logicMode: logicMode, - searchText: searchText, searchColumns: searchColumns, columns: columns - ) - } - - @Test("Browse SQL generates correct SELECT") - func browseSql() { - let sql = BigQueryQueryBuilder.buildSQL(from: params(columns: ["id", "name"]), projectId: "proj") - #expect(sql == "SELECT * FROM `proj`.`main`.`users` LIMIT 100 OFFSET 0") - } - - @Test("Filtered SQL generates WHERE clause") - func filteredSql() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [BigQueryFilterSpec(column: "status", op: "=", value: "active")], logicMode: "AND"), - projectId: "proj" - ) - #expect(sql.contains("WHERE (`status` = 'active')")) - } - - @Test("Sort columns resolve against the column names carried in the tag") - func sortSql() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(sortColumns: [.init(columnIndex: 1, ascending: false)], columns: ["id", "created_at"]), - projectId: "proj" - ) - #expect(sql.contains("ORDER BY `created_at` DESC")) - } - - @Test("A tag without column names drops the sort rather than guessing") - func sortWithoutColumns() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(sortColumns: [.init(columnIndex: 0, ascending: true)]), - projectId: "proj" - ) - #expect(!sql.contains("ORDER BY")) - } - - @Test("Search generates LIKE clauses with OR") - func searchSql() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(searchText: "test", searchColumns: ["name", "email"]), - projectId: "proj" - ) - #expect(sql.contains("CAST(`name` AS STRING) LIKE '%test%'")) - #expect(sql.contains(" OR ")) - } - - @Test("Search falls back to the tag's column names") - func searchUsesTagColumns() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(searchText: "x", columns: ["a", "b"]), - projectId: "proj" - ) - #expect(sql.contains("CAST(`a` AS STRING) LIKE '%x%' OR CAST(`b` AS STRING) LIKE '%x%'")) - } - - @Test("Count SQL omits LIMIT") - func countSql() { - let sql = BigQueryQueryBuilder.buildCountSQL(from: params(), projectId: "proj") - #expect(sql == "SELECT COUNT(*) FROM `proj`.`main`.`users`") - #expect(!sql.contains("LIMIT")) - } - - @Test("A single quote in a filter value is escaped with a backslash") - func filterEscaping() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [BigQueryFilterSpec(column: "name", op: "=", value: "O'Brien")]), - projectId: "proj" - ) - #expect(sql.contains("`name` = 'O\\'Brien'")) - #expect(!sql.contains("O''Brien")) - } - - @Test("A value that tries to close its literal stays one literal") - func filterInjectionStaysOneLiteral() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [BigQueryFilterSpec(column: "name", op: "=", value: "x\\' OR TRUE --")]), - projectId: "proj" - ) - #expect(sql.contains("`name` = 'x\\\\\\' OR TRUE --'")) - } - - @Test("Newlines and NUL in a value are escaped") - func filterControlCharacters() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [BigQueryFilterSpec(column: "note", op: "=", value: "a\nb\u{0}c")]), - projectId: "proj" - ) - #expect(sql.contains("'a\\nb\\x00c'")) - } - - @Test("CONTAINS escapes LIKE wildcards in the value") - func containsEscapesWildcards() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [BigQueryFilterSpec(column: "code", op: "CONTAINS", value: "50%_off")]), - projectId: "proj" - ) - #expect(sql.contains("CAST(`code` AS STRING) LIKE '%50\\\\%\\\\_off%'")) - } - - @Test("IN operator escapes individual values") - func inOperatorEscaping() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [BigQueryFilterSpec(column: "status", op: "IN", value: "a, b', c")]), - projectId: "proj" - ) - #expect(sql.contains("IN ('a', 'b\\'', 'c')")) - } - - @Test("OR filters are grouped before the search condition") - func orFiltersAreGrouped() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params( - filters: [ - BigQueryFilterSpec(column: "a", op: "=", value: "1", kind: "integer"), - BigQueryFilterSpec(column: "b", op: "=", value: "2", kind: "integer") - ], - logicMode: "OR", - searchText: "z", - searchColumns: ["c"] - ), - projectId: "proj" - ) - #expect(sql.contains("WHERE (`a` = 1 OR `b` = 2) AND (CAST(`c` AS STRING) LIKE '%z%')")) - } - - @Test("A filter-bar BETWEEN takes its lower bound off the value the app joins with the upper bound") - func betweenFromAppEncoding() { - let filter = TableFilter( - columnName: "n", filterOperator: .between, value: "1", secondValue: "9" - ).asPluginQueryFilter - #expect(filter.value == "1,9") - - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [BigQueryFilterSpec(filter, kind: .integer)]), - projectId: "proj" - ) - #expect(sql.contains("WHERE (`n` BETWEEN 1 AND 9)")) - } - - @Test("A filter-bar BETWEEN keeps a comma inside a bound") - func betweenFromAppEncodingWithComma() { - let filter = TableFilter( - columnName: "name", filterOperator: .between, value: "Smith, John", secondValue: "Zed, Ann" - ).asPluginQueryFilter - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [BigQueryFilterSpec(filter, kind: .text)]), - projectId: "proj" - ) - #expect(sql.contains("WHERE (`name` BETWEEN 'Smith, John' AND 'Zed, Ann')")) - } - - @Test("A filter-bar BETWEEN keeps a lower bound whose last scalar joins the comma into one character") - func betweenFromAppEncodingWithPrependScalar() { - let filter = TableFilter( - columnName: "name", filterOperator: .between, value: "x\u{0600}", secondValue: "y" - ).asPluginQueryFilter - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [BigQueryFilterSpec(filter, kind: .text)]), - projectId: "proj" - ) - #expect(sql.contains("WHERE (`name` BETWEEN 'x\u{0600}' AND 'y')")) - } - - @Test("A BETWEEN with no separate upper bound splits the joined value") - func betweenFromJoinedValueAlone() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [BigQueryFilterSpec(column: "n", op: "BETWEEN", value: "1, 9", kind: "integer")]), - projectId: "proj" - ) - #expect(sql.contains("WHERE (`n` BETWEEN 1 AND 9)")) - } - - @Test("A raw SQL filter is wrapped in parentheses") - func rawFilter() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [ - BigQueryFilterSpec(column: BigQueryQueryBuilder.rawFilterColumn, op: "=", value: "a > 1 OR b < 2") - ]), - projectId: "proj" - ) - #expect(sql.contains("WHERE ((a > 1 OR b < 2))")) - } - - @Test("A backtick in a column name is escaped, not dropped") - func identifierEscaping() { - let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [BigQueryFilterSpec(column: "we`ird", op: "IS NULL", value: "")]), - projectId: "proj" - ) - #expect(sql.contains("`we\\`ird` IS NULL")) - } -} - -struct BigQueryQueryBuilderTagColumnTests { - @Test("Browse tags carry the column names") - func browseCarriesColumns() { - let query = BigQueryQueryBuilder.encodeBrowseQuery( - table: "t", dataset: "d", sortColumns: [(columnIndex: 1, ascending: true)], - limit: 10, offset: 0, columns: ["id", "name"] - ) - let params = BigQueryQueryBuilder.decode(query) - #expect(params?.columns == ["id", "name"]) - let sql = params.map { BigQueryQueryBuilder.buildSQL(from: $0, projectId: "p") } ?? "" - #expect(sql.contains("ORDER BY `name` ASC")) - } - - @Test("Filter tags carry the column names and the upper bound") - func filterCarriesColumns() throws { - let filter = TableFilter( - columnName: "n", filterOperator: .between, value: "1", secondValue: "5" - ).asPluginQueryFilter - let query = BigQueryQueryBuilder.encodeFilteredQuery( - table: "t", dataset: "d", - filters: [filter], - logicMode: "AND", sortColumns: [], limit: 10, offset: 0, columns: ["n"] - ) - let params = try #require(BigQueryQueryBuilder.decode(query)) - #expect(params.columns == ["n"]) - #expect(params.filters?.first?.secondValue == "5") - let sql = BigQueryQueryBuilder.buildSQL(from: params, projectId: "p") - #expect(sql.contains("WHERE (`n` BETWEEN 1 AND 5)")) - } - - @Test("A tag written before column names existed still decodes") - func legacyTagDecodes() throws { - let json = #"{"table":"t","dataset":"d","limit":5,"offset":0}"# - let query = BigQueryQueryBuilder.browseTag + Data(json.utf8).base64EncodedString() - let params = try #require(BigQueryQueryBuilder.decode(query)) - #expect(params.columns == nil) - let sql = BigQueryQueryBuilder.buildSQL(from: params, projectId: "p") - #expect(sql == "SELECT * FROM `p`.`d`.`t` LIMIT 5 OFFSET 0") - } -} - -struct BigQueryQueryBuilderExactCountTests { - @Test("A count without filters has no WHERE clause") - func countWithoutFilters() { - let sql = BigQueryQueryBuilder.countSQL( - projectId: "proj", dataset: "main", table: "users", filters: [], logicMode: "AND" - ) - #expect(sql == "SELECT COUNT(*) FROM `proj`.`main`.`users`") - } - - @Test("A quote in a filter value is escaped the GoogleSQL way, never doubled") - func countEscapesQuotes() { - let sql = BigQueryQueryBuilder.countSQL( - projectId: "proj", - dataset: "main", - table: "users", - filters: [PluginQueryFilter(column: "name", op: "=", value: "O'Brien")], - logicMode: "AND", - columnKinds: ["name": .text] - ) - #expect(sql == "SELECT COUNT(*) FROM `proj`.`main`.`users` WHERE (`name` = 'O\\'Brien')") - #expect(!sql.contains("''")) - } - - @Test("Column kinds type the literals and the logic mode joins the filters") - func countUsesKindsAndLogicMode() { - let sql = BigQueryQueryBuilder.countSQL( - projectId: "proj", - dataset: "main", - table: "users", - filters: [ - PluginQueryFilter(column: "code", op: "=", value: "42"), - PluginQueryFilter(column: "age", op: ">", value: "30") - ], - logicMode: "OR", - columnKinds: ["code": .text, "age": .integer] - ) - #expect(sql == "SELECT COUNT(*) FROM `proj`.`main`.`users` WHERE (`code` = '42' OR `age` > 30)") - } -} - -struct BigQueryDescriptionStatementTests { - private let target = "`proj`.`sales`.`orders`" - - @Test("A comment sets the description option with the keyword for the kind") - func setsDescriptionPerKind() { - #expect(BigQueryQueryBuilder.descriptionStatement(qualifiedTable: target, objectType: "TABLE", comment: "Orders") - == "ALTER TABLE `proj`.`sales`.`orders` SET OPTIONS (description = 'Orders')") - #expect(BigQueryQueryBuilder.descriptionStatement(qualifiedTable: target, objectType: "VIEW", comment: "Orders") - == "ALTER VIEW `proj`.`sales`.`orders` SET OPTIONS (description = 'Orders')") - #expect(BigQueryQueryBuilder.descriptionStatement(qualifiedTable: target, objectType: "MATERIALIZED VIEW", comment: "x") - == "ALTER MATERIALIZED VIEW `proj`.`sales`.`orders` SET OPTIONS (description = 'x')") - #expect(BigQueryQueryBuilder.descriptionStatement(qualifiedTable: target, objectType: "MATERIALIZED_VIEW", comment: "x") - == "ALTER MATERIALIZED VIEW `proj`.`sales`.`orders` SET OPTIONS (description = 'x')") - } - - @Test("A nil or empty comment sets the description to NULL") - func clearsDescription() { - #expect(BigQueryQueryBuilder.descriptionStatement(qualifiedTable: target, objectType: "TABLE", comment: nil) - == "ALTER TABLE `proj`.`sales`.`orders` SET OPTIONS (description = NULL)") - #expect(BigQueryQueryBuilder.descriptionStatement(qualifiedTable: target, objectType: "VIEW", comment: "") - == "ALTER VIEW `proj`.`sales`.`orders` SET OPTIONS (description = NULL)") - } - - @Test("A quote in the comment is escaped the GoogleSQL way") - func escapesDescription() { - let sql = BigQueryQueryBuilder.descriptionStatement(qualifiedTable: target, objectType: "TABLE", comment: "O'Brien's") - #expect(sql == "ALTER TABLE `proj`.`sales`.`orders` SET OPTIONS (description = 'O\\'Brien\\'s')") - } - - @Test("Kinds without a description option return nil") - func refusesOtherKinds() { - for kind in ["SEQUENCE", "FOREIGN TABLE", "SYSTEM TABLE", "PARTITIONED TABLE", "EXTERNAL TABLE"] { - #expect(BigQueryQueryBuilder.descriptionStatement(qualifiedTable: target, objectType: kind, comment: nil) == nil) - } - } -} - -struct BigQueryTableMetadataTests { - @Test("The comment is the description alone, without partitioning, labels or dates") - func commentIsTheDescription() throws { - let json = """ - {"description":"Daily orders","numRows":"10","numBytes":"2048","type":"TABLE", - "creationTime":"1700000000000","lastModifiedTime":"1700000100000", - "timePartitioning":{"type":"DAY","field":"created"},"labels":{"team":"data"}, - "expirationTime":"1800000000000"} - """ - let resource = try JSONDecoder().decode(BQTableResource.self, from: Data(json.utf8)) - let metadata = BigQueryPluginDriver.tableMetadata(table: "orders", resource: resource) - #expect(metadata.comment == "Daily orders") - #expect(metadata.rowCount == 10) - #expect(metadata.dataSize == 2_048) - #expect(metadata.engine == "TABLE") - #expect(metadata.createTime == Date(timeIntervalSince1970: 1_700_000_000)) - #expect(metadata.updateTime == Date(timeIntervalSince1970: 1_700_000_100)) - } - - @Test("A table without a description has no comment, whatever else it carries") - func noDescriptionMeansNoComment() throws { - let json = #"{"description":"","timePartitioning":{"type":"DAY"},"labels":{"team":"data"}}"# - let resource = try JSONDecoder().decode(BQTableResource.self, from: Data(json.utf8)) - #expect(BigQueryPluginDriver.tableMetadata(table: "t", resource: resource).comment == nil) - let bare = try JSONDecoder().decode(BQTableResource.self, from: Data("{}".utf8)) - #expect(BigQueryPluginDriver.tableMetadata(table: "t", resource: bare).comment == nil) - } -} diff --git a/TableProTests/Plugins/BigQueryQueryParametersTests.swift b/TableProTests/Plugins/BigQueryQueryParametersTests.swift deleted file mode 100644 index 5baab6430d..0000000000 --- a/TableProTests/Plugins/BigQueryQueryParametersTests.swift +++ /dev/null @@ -1,251 +0,0 @@ -import Foundation -import TableProGoogleCloud -import TableProPluginKit -import Testing - -private func encodedJSON(_ value: T) throws -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - return try #require(String(bytes: try encoder.encode(value), encoding: .utf8)) -} - -struct BigQueryPlaceholderBindingTests { - @Test("Question marks become numbered named parameters") - func rewritesPlaceholders() throws { - let bound = try BigQueryQueryParameters.bind("UPDATE t SET a = ? WHERE b = ?", parameters: ["x", "y"]) - #expect(bound.sql == "UPDATE t SET a = @p1 WHERE b = @p2") - #expect(bound.bindings.map(\.name) == ["p1", "p2"]) - #expect(bound.bindings.map(\.value) == ["x", "y"]) - } - - @Test("A NULL parameter is written as the NULL keyword and not bound") - func inlinesNull() throws { - let bound = try BigQueryQueryParameters.bind("INSERT INTO t (a, b, c) VALUES (?, ?, ?)", parameters: ["1", .null, "3"]) - #expect(bound.sql == "INSERT INTO t (a, b, c) VALUES (@p1, NULL, @p3)") - #expect(bound.bindings.map(\.name) == ["p1", "p3"]) - #expect(bound.bindings.map(\.position) == [1, 3]) - } - - @Test("Question marks inside literals, identifiers and comments stay put") - func skipsQuotedQuestionMarks() throws { - let sql = "SELECT '?', \"?\", `a?` FROM t -- ?\nWHERE x = ? # ?\n/* ? */" - let bound = try BigQueryQueryParameters.bind(sql, parameters: ["v"]) - #expect(bound.sql == "SELECT '?', \"?\", `a?` FROM t -- ?\nWHERE x = @p1 # ?\n/* ? */") - } - - @Test("A backslash-escaped quote does not end the literal early") - func respectsBackslashEscapes() throws { - let bound = try BigQueryQueryParameters.bind("SELECT 'it\\'s ?' WHERE a = ?", parameters: ["v"]) - #expect(bound.sql == "SELECT 'it\\'s ?' WHERE a = @p1") - } - - @Test("A placeholder count that does not match the values throws") - func countMismatchThrows() { - #expect(throws: SQLPlaceholderRewriteError.countMismatch(found: 1, expected: 2)) { - try BigQueryQueryParameters.bind("SELECT ?", parameters: ["a", "b"]) - } - } - - @Test("The mismatch surfaces as a localized driver error") - func countMismatchWraps() { - let wrapped = BigQueryError.wrap(SQLPlaceholderRewriteError.countMismatch(found: 3, expected: 1)) - guard case BigQueryError.placeholderCount(let found, let expected)? = wrapped as? BigQueryError else { - Issue.record("Expected a placeholder count error") - return - } - #expect(found == 3) - #expect(expected == 1) - } -} - -struct BigQueryQueryParameterEncodingTests { - private func parameter( - _ value: PluginCellValue, - type: BigQueryParameterType? - ) throws -> BigQueryQueryParameter { - let binding = BigQueryParameterBinding(name: "p1", position: 1, value: value) - let types = type.map { ["p1": $0] } ?? [:] - let parameters = try BigQueryQueryParameters.queryParameters(for: [binding], types: types) - return try #require(parameters.first) - } - - @Test("A scalar is sent as its text") - func scalarAsText() throws { - let encoded = try encodedJSON(parameter("42", type: BigQueryParameterType(type: "INT64"))) - #expect(encoded == #"{"name":"p1","parameterType":{"type":"INT64"},"parameterValue":{"value":"42"}}"#) - } - - @Test("TIMESTAMP, BOOL, NUMERIC and JSON values are passed through as strings") - func stringTypedScalars() throws { - for (type, value) in [ - ("TIMESTAMP", "2021-04-01 00:00:00.000000+00:00"), - ("BOOL", "true"), - ("NUMERIC", "12.50"), - ("JSON", #"{"a":1}"#) - ] { - let encoded = try parameter(.text(value), type: BigQueryParameterType(type: type)) - #expect(encoded.parameterValue == BigQueryParameterValue(value: value)) - } - } - - @Test("Bytes are sent as base64") - func bytesAsBase64() throws { - let encoded = try parameter(.bytes(Data([0x00, 0xFF, 0x10])), type: BigQueryParameterType(type: "BYTES")) - #expect(encoded.parameterValue == BigQueryParameterValue(value: "AP8Q")) - } - - @Test("An ARRAY is built from its JSON text") - func arrayFromJSON() throws { - let type = BigQueryParameterType(type: "ARRAY", arrayType: BigQueryParameterType(type: "INT64")) - let encoded = try encodedJSON(parameter("[1, 2, 3]", type: type)) - let expected = #"{"name":"p1","parameterType":{"arrayType":{"type":"INT64"},"type":"ARRAY"},"# - + #""parameterValue":{"arrayValues":[{"value":"1"},{"value":"2"},{"value":"3"}]}}"# - #expect(encoded == expected) - } - - @Test("A boolean inside an array stays a boolean, not a number") - func arrayOfBooleans() throws { - let type = BigQueryParameterType(type: "ARRAY", arrayType: BigQueryParameterType(type: "BOOL")) - let encoded = try parameter("[true, false]", type: type) - #expect(encoded.parameterValue?.arrayValues?.map(\.value) == ["true", "false"]) - } - - @Test("A STRUCT is built from its JSON object by field name") - func structFromJSON() throws { - let type = BigQueryParameterType( - type: "STRUCT", - structTypes: [ - BigQueryStructFieldType(name: "x", type: BigQueryParameterType(type: "INT64")), - BigQueryStructFieldType(name: "y", type: BigQueryParameterType(type: "STRING")) - ] - ) - let encoded = try parameter(#"{"x": 1, "y": "foo"}"#, type: type) - #expect(encoded.parameterValue?.structValues?["x"] == BigQueryParameterValue(value: "1")) - #expect(encoded.parameterValue?.structValues?["y"] == BigQueryParameterValue(value: "foo")) - } - - @Test("A RANGE is built from its bracketed text with unbounded ends left out") - func rangeFromText() throws { - let type = BigQueryParameterType(type: "RANGE", rangeElementType: BigQueryParameterType(type: "DATE")) - let encoded = try parameter("[2020-01-01, UNBOUNDED)", type: type) - #expect(encoded.parameterValue?.rangeValue?.start == BigQueryParameterValue(value: "2020-01-01")) - #expect(encoded.parameterValue?.rangeValue?.end == nil) - } - - @Test("A parameter the dry run did not type is sent as STRING") - func untypedFallsBackToString() throws { - let encoded = try parameter("abc", type: nil) - #expect(encoded.parameterType == BigQueryParameterType(type: "STRING")) - } - - @Test("ARRAY text that is not a JSON array is refused") - func invalidArrayThrows() { - let type = BigQueryParameterType(type: "ARRAY", arrayType: BigQueryParameterType(type: "STRING")) - #expect(throws: BigQueryParameterEncodingError.notJSONArray(position: 1)) { - _ = try parameter("not json", type: type) - } - } - - @Test("Binary data bound to a text parameter must be UTF-8") - func binaryForTextThrows() { - #expect(throws: BigQueryParameterEncodingError.notText(position: 1)) { - _ = try parameter(.bytes(Data([0xFF, 0xFE])), type: BigQueryParameterType(type: "STRING")) - } - } -} - -struct BigQueryDryRunDiscoveryTests { - private static let dryRunResponse = """ - { - "kind": "bigquery#job", - "status": {"state": "DONE"}, - "statistics": { - "totalBytesProcessed": "0", - "query": { - "statementType": "UPDATE", - "totalBytesProcessed": "0", - "undeclaredQueryParameters": [ - {"name": "p1", "parameterType": {"type": "STRING"}}, - {"name": "P2", "parameterType": {"type": "ARRAY", "arrayType": {"type": "INT64"}}}, - {"name": "p3", "parameterType": {"type": "STRUCT", "structTypes": [ - {"name": "a", "type": {"type": "BOOL"}, "description": "ignored"} - ]}} - ] - } - } - } - """ - - @Test("The undeclared parameters of a dry run decode into types by name") - func decodesUndeclaredParameters() throws { - let job = try JSONDecoder().decode(BQJobResponse.self, from: Data(Self.dryRunResponse.utf8)) - let types = BigQueryQueryParameters.discoveredTypes(from: job.statistics?.query?.undeclaredQueryParameters) - #expect(types["p1"] == BigQueryParameterType(type: "STRING")) - #expect(types["p2"] == BigQueryParameterType(type: "ARRAY", arrayType: BigQueryParameterType(type: "INT64"))) - #expect(types["p3"]?.structTypes?.first?.name == "a") - #expect(types["p3"]?.structTypes?.first?.type == BigQueryParameterType(type: "BOOL")) - } - - @Test("A dry run request asks for named parameters and sends none") - func dryRunRequestShape() throws { - let request = BQJobRequest( - jobReference: nil, - configuration: BQJobRequest.BQJobConfiguration( - query: BQJobRequest.BQQueryConfig( - query: "SELECT @p1", - useLegacySql: false, - defaultDataset: nil, - maximumBytesBilled: nil, - parameterMode: "NAMED", - queryParameters: nil - ), - dryRun: true, - jobTimeoutMs: nil - ) - ) - let encoded = try encodedJSON(request) - let expected = #"{"configuration":{"dryRun":true,"query":{"parameterMode":"NAMED","# - + #""query":"SELECT @p1","useLegacySql":false}}}"# - #expect(encoded == expected) - } - - @Test("The parameter memo is dropped on eviction") - func memoEviction() { - let cache = BigQueryParameterTypeCache() - cache.store(["p1": BigQueryParameterType(type: "INT64")], for: "k") - #expect(cache.types(for: "k")?["p1"] == BigQueryParameterType(type: "INT64")) - cache.evict("k") - #expect(cache.types(for: "k") == nil) - } -} - -struct BigQueryJobPollingTests { - @Test("A query timeout of zero sets no deadline and no job timeout") - func zeroMeansNoLimit() { - #expect(BigQueryJobPolling.deadline(queryTimeoutSeconds: 0, from: Date()) == nil) - #expect(BigQueryJobPolling.jobTimeoutMilliseconds(queryTimeoutSeconds: 0) == nil) - } - - @Test("A positive timeout passes straight through without a floor") - func positiveTimeoutPassesThrough() { - let start = Date(timeIntervalSince1970: 0) - #expect(BigQueryJobPolling.jobTimeoutMilliseconds(queryTimeoutSeconds: 5) == "5000") - let deadline = BigQueryJobPolling.deadline(queryTimeoutSeconds: 5, from: start) - #expect(deadline == start.addingTimeInterval(TimeInterval(5 + BigQueryJobPolling.deadlineGraceSeconds))) - } - - @Test("An unsafe timeout is clamped before deadline and millisecond conversion") - func unsafeTimeoutIsClamped() { - let start = Date(timeIntervalSince1970: 0) - let maximum = PluginQueryTimeout.maximumSeconds - - #expect( - BigQueryJobPolling.jobTimeoutMilliseconds(queryTimeoutSeconds: Int.max) - == String(maximum * 1_000) - ) - #expect( - BigQueryJobPolling.deadline(queryTimeoutSeconds: Int.max, from: start) - == start.addingTimeInterval(TimeInterval(maximum + BigQueryJobPolling.deadlineGraceSeconds)) - ) - } -} diff --git a/TableProTests/Plugins/BigQueryStatementGeneratorTests.swift b/TableProTests/Plugins/BigQueryStatementGeneratorTests.swift deleted file mode 100644 index 35b6bd8319..0000000000 --- a/TableProTests/Plugins/BigQueryStatementGeneratorTests.swift +++ /dev/null @@ -1,321 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -private func generator( - table: String = "t", - columns: [String] -) -> BigQueryStatementGenerator { - BigQueryStatementGenerator(projectId: "p", dataset: "d", tableName: table, columns: columns) -} - -private func generate( - _ generator: BigQueryStatementGenerator, - _ changes: [PluginRowChange], - insertedRowData: [Int: [PluginCellValue]] = [:], - deleted: Set = [], - inserted: Set = [] -) -> [(statement: String, parameters: [PluginCellValue])]? { - generator.generateStatements( - from: changes, - insertedRowData: insertedRowData, - deletedRowIndices: deleted, - insertedRowIndices: inserted - ) -} - -struct BigQueryStatementGeneratorInsertTests { - @Test("Every value is a placeholder bound in column order") - func insertBindsEveryValue() throws { - let gen = generator(table: "users", columns: ["id", "name", "age"]) - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let result = try #require(generate(gen, [change], insertedRowData: [0: ["1", "Alice", "30"]], inserted: [0])) - #expect(result.count == 1) - #expect(result[0].statement == "INSERT INTO `p`.`d`.`users` (`id`, `name`, `age`) VALUES (?, ?, ?)") - #expect(result[0].parameters == ["1", "Alice", "30"]) - } - - @Test("A NULL value is bound as a null parameter") - func insertBindsNull() throws { - let gen = generator(columns: ["a", "b"]) - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let result = try #require(generate(gen, [change], insertedRowData: [0: ["val", nil]], inserted: [0])) - #expect(result[0].statement == "INSERT INTO `p`.`d`.`t` (`a`, `b`) VALUES (?, ?)") - #expect(result[0].parameters == ["val", .null]) - } - - @Test("The DEFAULT sentinel leaves the column out of the INSERT") - func insertSkipsDefaultSentinel() throws { - let gen = generator(columns: ["id", "created_at", "name"]) - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let row: [PluginCellValue] = ["7", .text(BigQueryStatementGenerator.defaultSentinel), "Bob"] - let result = try #require(generate(gen, [change], insertedRowData: [0: row], inserted: [0])) - #expect(result[0].statement == "INSERT INTO `p`.`d`.`t` (`id`, `name`) VALUES (?, ?)") - #expect(result[0].parameters == ["7", "Bob"]) - } - - @Test("A row of only DEFAULT sentinels inserts DEFAULT into the first column") - func insertAllDefaults() throws { - let gen = generator(columns: ["id", "name"]) - let sentinel = PluginCellValue.text(BigQueryStatementGenerator.defaultSentinel) - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let result = try #require(generate(gen, [change], insertedRowData: [0: [sentinel, sentinel]], inserted: [0])) - #expect(result[0].statement == "INSERT INTO `p`.`d`.`t` (`id`) VALUES (DEFAULT)") - #expect(result[0].parameters.isEmpty) - } - - @Test("A bytes value is bound as a bytes parameter") - func insertBindsBytes() throws { - let gen = generator(columns: ["payload"]) - let bytes = Data([0x00, 0x27, 0xFF]) - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let result = try #require(generate(gen, [change], insertedRowData: [0: [.bytes(bytes)]], inserted: [0])) - #expect(result[0].statement == "INSERT INTO `p`.`d`.`t` (`payload`) VALUES (?)") - #expect(result[0].parameters == [.bytes(bytes)]) - } - - @Test("A quote in a value never reaches the SQL text") - func insertKeepsQuotesOutOfSQL() throws { - let gen = generator(columns: ["name"]) - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let result = try #require(generate(gen, [change], insertedRowData: [0: ["x\\' OR TRUE --"]], inserted: [0])) - #expect(!result[0].statement.contains("OR TRUE")) - #expect(result[0].parameters == ["x\\' OR TRUE --"]) - } - - @Test("An INSERT without row data uses the changed cells") - func insertFromCellChanges() throws { - let gen = generator(columns: ["id", "name"]) - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [(columnIndex: 1, columnName: "name", oldValue: nil, newValue: "Ann")], - originalRow: nil - ) - let result = try #require(generate(gen, [change], inserted: [0])) - #expect(result[0].statement == "INSERT INTO `p`.`d`.`t` (`name`) VALUES (?)") - #expect(result[0].parameters == ["Ann"]) - } -} - -struct BigQueryStatementGeneratorUpdateTests { - @Test("SET values come before WHERE values in the parameter list") - func basicUpdate() throws { - let gen = generator(table: "users", columns: ["id", "name"]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob")], - originalRow: ["1", "Alice"] - ) - let result = try #require(generate(gen, [change])) - #expect(result.count == 1) - #expect(result[0].statement == "UPDATE `p`.`d`.`users` SET `name` = ? WHERE `id` = ? AND `name` = ?") - #expect(result[0].parameters == ["Bob", "1", "Alice"]) - } - - @Test("Values that look like JSON stay in the WHERE clause") - func keepsBracketValuesInWhere() throws { - let gen = generator(columns: ["id", "label", "tags"]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 0, columnName: "id", oldValue: "1", newValue: "2")], - originalRow: ["1", "{draft}", "[tag1]"] - ) - let result = try #require(generate(gen, [change])) - #expect(result[0].statement.contains("`label` = ?")) - #expect(result[0].statement.contains("`tags` = ?")) - #expect(result[0].parameters == ["2", "1", "{draft}", "[tag1]"]) - } - - @Test("NULL original values use IS NULL in WHERE") - func nullInWhere() throws { - let gen = generator(columns: ["id", "note"]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "note", oldValue: nil, newValue: "hello")], - originalRow: ["1", nil] - ) - let result = try #require(generate(gen, [change])) - #expect(result[0].statement == "UPDATE `p`.`d`.`t` SET `note` = ? WHERE `id` = ? AND `note` IS NULL") - #expect(result[0].parameters == ["hello", "1"]) - } - - @Test("The DEFAULT sentinel writes DEFAULT in SET") - func updateWritesDefault() throws { - let gen = generator(columns: ["id", "status"]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - ( - columnIndex: 1, - columnName: "status", - oldValue: "old", - newValue: .text(BigQueryStatementGenerator.defaultSentinel) - ) - ], - originalRow: ["1", "old"] - ) - let result = try #require(generate(gen, [change])) - #expect(result[0].statement == "UPDATE `p`.`d`.`t` SET `status` = DEFAULT WHERE `id` = ? AND `status` = ?") - #expect(result[0].parameters == ["1", "old"]) - } - - @Test("An UPDATE without an original row refuses the whole batch") - func updateWithoutKeyRefusesBatch() { - let gen = generator(columns: ["id", "name"]) - let insert = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let update = PluginRowChange( - rowIndex: 1, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "name", oldValue: "a", newValue: "b")], - originalRow: nil - ) - let result = generate(gen, [insert, update], insertedRowData: [0: ["1", "x"]], inserted: [0]) - #expect(result == nil) - } - - @Test("The ownership probe with no cell changes gets an empty list") - func ownershipProbe() { - let gen = generator(columns: ["id"]) - let probe = PluginRowChange(rowIndex: 0, type: .update, cellChanges: [], originalRow: nil) - let result = generate(gen, [probe]) - #expect(result?.isEmpty == true) - } -} - -struct BigQueryStatementGeneratorDeleteTests { - @Test("Generates DELETE keyed on the original row") - func basicDelete() throws { - let gen = generator(columns: ["id", "name"]) - let change = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["42", "Alice"]) - let result = try #require(generate(gen, [change], deleted: [0])) - #expect(result.count == 1) - #expect(result[0].statement == "DELETE FROM `p`.`d`.`t` WHERE `id` = ? AND `name` = ?") - #expect(result[0].parameters == ["42", "Alice"]) - } - - @Test("A bytes key is bound as a bytes parameter") - func deleteBindsBytesKey() throws { - let gen = generator(columns: ["hash"]) - let bytes = Data([0xDE, 0xAD]) - let change = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: [.bytes(bytes)]) - let result = try #require(generate(gen, [change], deleted: [0])) - #expect(result[0].parameters == [.bytes(bytes)]) - } - - @Test("A DELETE without an original row refuses the whole batch") - func deleteWithoutOriginalRow() { - let gen = generator(columns: ["id"]) - let change = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: nil) - #expect(generate(gen, [change], deleted: [0]) == nil) - } -} - -struct BigQueryStatementGeneratorIdentifierTests { - @Test("Backticks and backslashes in names are escaped") - func escapesIdentifiers() throws { - let gen = BigQueryStatementGenerator( - projectId: "my-proj", - dataset: "d", - tableName: "we`ird\\", - columns: ["c`1"] - ) - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let result = try #require(generate(gen, [change], insertedRowData: [0: ["v"]], inserted: [0])) - #expect(result[0].statement == "INSERT INTO `my-proj`.`d`.`we\\`ird\\\\` (`c\\`1`) VALUES (?)") - } -} - -struct BigQueryStatementGeneratorRowKeyTests { - private func keyed( - columns: [String], - primaryKey: [String] = [], - nonComparable: Set = [] - ) -> BigQueryStatementGenerator { - BigQueryStatementGenerator( - projectId: "p", - dataset: "d", - tableName: "t", - columns: columns, - primaryKeyColumns: primaryKey, - nonComparableColumns: nonComparable - ) - } - - @Test("A declared primary key keys the UPDATE on exactly its columns") - func updateKeysOnPrimaryKey() throws { - let gen = keyed(columns: ["id", "name", "tags"], primaryKey: ["id"]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "name", oldValue: "a", newValue: "b")], - originalRow: ["7", "a", "[1,2]"] - ) - let result = try #require(generate(gen, [change])) - #expect(result[0].statement == "UPDATE `p`.`d`.`t` SET `name` = ? WHERE `id` = ?") - #expect(result[0].parameters == ["b", "7"]) - } - - @Test("A composite primary key keys the DELETE on every key column, NULL as IS NULL") - func deleteKeysOnCompositePrimaryKey() throws { - let gen = keyed(columns: ["a", "b", "note"], primaryKey: ["b", "a"]) - let change = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", nil, "x"]) - let result = try #require(generate(gen, [change], deleted: [0])) - #expect(result[0].statement == "DELETE FROM `p`.`d`.`t` WHERE `b` IS NULL AND `a` = ?") - #expect(result[0].parameters == ["1"]) - } - - @Test("A primary key column missing from the result refuses the batch") - func missingPrimaryKeyColumnRefuses() { - let gen = keyed(columns: ["name"], primaryKey: ["id"]) - let change = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["a"]) - #expect(generate(gen, [change], deleted: [0]) == nil) - } - - @Test("A primary key column past the end of the original row refuses the batch") - func shortOriginalRowRefuses() { - let gen = keyed(columns: ["name", "id"], primaryKey: ["id"]) - let change = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["a"]) - #expect(generate(gen, [change], deleted: [0]) == nil) - } - - @Test("Without a primary key, ARRAY and JSON columns are left out of the key") - func keylessSkipsNonComparable() throws { - let gen = keyed(columns: ["id", "tags", "payload", "name"], nonComparable: ["tags", "payload"]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 3, columnName: "name", oldValue: "a", newValue: "b")], - originalRow: ["1", "[1]", "{}", "a"] - ) - let result = try #require(generate(gen, [change])) - #expect(result[0].statement == "UPDATE `p`.`d`.`t` SET `name` = ? WHERE `id` = ? AND `name` = ?") - #expect(result[0].parameters == ["b", "1", "a"]) - } - - @Test("A column of unknown type stays in the key") - func unknownTypeStaysInKey() throws { - let gen = keyed(columns: ["id", "mystery"], nonComparable: ["tags"]) - let change = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "?"]) - let result = try #require(generate(gen, [change], deleted: [0])) - #expect(result[0].statement == "DELETE FROM `p`.`d`.`t` WHERE `id` = ? AND `mystery` = ?") - } - - @Test("A row with no comparable column refuses the batch") - func nothingComparableRefuses() { - let gen = keyed(columns: ["tags", "shape"], nonComparable: ["tags", "shape"]) - let update = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 0, columnName: "tags", oldValue: "[1]", newValue: "[2]")], - originalRow: ["[1]", "POINT(0 0)"] - ) - let delete = PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["[1]", "POINT(0 0)"]) - #expect(generate(gen, [update]) == nil) - #expect(generate(gen, [delete], deleted: [1]) == nil) - } -} diff --git a/TableProTests/Plugins/BigQueryTypeMapperTests.swift b/TableProTests/Plugins/BigQueryTypeMapperTests.swift deleted file mode 100644 index 295af305b1..0000000000 --- a/TableProTests/Plugins/BigQueryTypeMapperTests.swift +++ /dev/null @@ -1,373 +0,0 @@ -// -// BigQueryTypeMapperTests.swift -// TableProTests -// -// Tests for BigQueryTypeMapper (compiled via symlink from BigQueryDriverPlugin). -// - -import Foundation -import TableProPluginKit -import Testing - -private func field(_ name: String, _ type: String, mode: String? = nil, description: String? = nil, - fields: [BQTableFieldSchema]? = nil) -> BQTableFieldSchema { - BQTableFieldSchema(name: name, type: type, mode: mode, description: description, fields: fields) -} - -private func response(rows: [BQQueryResponse.BQRow]?, totalRows: String = "0") -> BQQueryResponse { - BQQueryResponse(schema: nil, rows: rows, totalRows: totalRows, pageToken: nil, jobComplete: true, jobReference: nil, numDmlAffectedRows: nil) -} - -struct BigQueryTypeMapperColumnTypeTests { - @Test("Simple types return type string as-is") - func simpleTypes() { - let schema = BQTableSchema(fields: [ - field("id", "INT64"), field("name", "STRING"), field("ts", "TIMESTAMP") - ]) - let types = BigQueryTypeMapper.columnTypeNames(from: schema) - #expect(types == ["INT64", "STRING", "TIMESTAMP"]) - } - - @Test("REPEATED mode wraps in ARRAY<>") - func repeatedMode() { - let schema = BQTableSchema(fields: [field("tags", "STRING", mode: "REPEATED")]) - let types = BigQueryTypeMapper.columnTypeNames(from: schema) - #expect(types == ["ARRAY"]) - } - - @Test("RECORD type formats as STRUCT<>") - func recordType() { - let schema = BQTableSchema(fields: [ - field("address", "RECORD", fields: [ - field("city", "STRING"), field("zip", "INT64") - ]) - ]) - let types = BigQueryTypeMapper.columnTypeNames(from: schema) - #expect(types == ["STRUCT"]) - } - - @Test("REPEATED RECORD formats as ARRAY>") - func repeatedRecord() { - let schema = BQTableSchema(fields: [ - field("items", "RECORD", mode: "REPEATED", fields: [field("name", "STRING")]) - ]) - let types = BigQueryTypeMapper.columnTypeNames(from: schema) - #expect(types == ["ARRAY>"]) - } - - @Test("Empty schema returns empty array") - func emptySchema() { - let schema = BQTableSchema(fields: nil) - #expect(BigQueryTypeMapper.columnTypeNames(from: schema).isEmpty) - } -} - -struct BigQueryTypeMapperColumnInfoTests { - @Test("Fields map to PluginColumnInfo correctly") - func basicMapping() { - let fields = [ - field("id", "INT64", mode: "REQUIRED", description: "Primary ID"), - field("name", "STRING", mode: "NULLABLE") - ] - let infos = BigQueryTypeMapper.columnInfos(from: fields) - #expect(infos.count == 2) - #expect(infos[0].name == "id") - #expect(infos[0].dataType == "INT64") - #expect(infos[0].isNullable == false) - #expect(infos[0].isPrimaryKey == false) - #expect(infos[0].comment == "Primary ID") - #expect(infos[1].name == "name") - #expect(infos[1].isNullable == true) - #expect(infos[1].comment == nil) - } - - @Test("Columns in the declared primary key are flagged") - func declaredPrimaryKey() { - let fields = [field("tenant", "STRING"), field("id", "INT64"), field("payload", "JSON")] - let infos = BigQueryTypeMapper.columnInfos(from: fields, primaryKey: ["tenant", "id"]) - #expect(infos.map(\.isPrimaryKey) == [true, true, false]) - } - - @Test("A tables.get response carries its declared primary key") - func tableConstraintsDecode() throws { - let json = #"{"tableConstraints":{"primaryKey":{"columns":["tenant","id"]}}}"# - let resource = try JSONDecoder().decode(BQTableResource.self, from: Data(json.utf8)) - #expect(resource.primaryKeyColumns == ["tenant", "id"]) - let bare = try JSONDecoder().decode(BQTableResource.self, from: Data("{}".utf8)) - #expect(bare.primaryKeyColumns.isEmpty) - } -} - -struct BigQueryTypeMapperRowTests { - @Test("String values pass through") - func stringValues() { - let schema = BQTableSchema(fields: [field("name", "STRING")]) - let resp = response(rows: [ - BQQueryResponse.BQRow(f: [BQQueryResponse.BQCell(v: .string("Alice"))]) - ], totalRows: "1") - #expect(BigQueryTypeMapper.flattenRows(from: resp, schema: schema) == [["Alice"]]) - } - - @Test("Null values map to nil") - func nullValues() { - let schema = BQTableSchema(fields: [field("val", "STRING")]) - let resp = response(rows: [ - BQQueryResponse.BQRow(f: [BQQueryResponse.BQCell(v: .null)]) - ], totalRows: "1") - #expect(BigQueryTypeMapper.flattenRows(from: resp, schema: schema) == [[nil]]) - } - - @Test("Timestamp epoch-seconds convert to ISO8601") - func timestampConversion() { - let schema = BQTableSchema(fields: [field("ts", "TIMESTAMP")]) - let resp = response(rows: [ - BQQueryResponse.BQRow(f: [BQQueryResponse.BQCell(v: .string("1617235200"))]) - ], totalRows: "1") - let rows = BigQueryTypeMapper.flattenRows(from: resp, schema: schema) - let value = rows[0][0] - #expect(value != .null) - #expect(value.asText?.contains("2021-03-31") == true || value.asText?.contains("2021-04-01") == true) - } - - @Test("Boolean values normalize to lowercase") - func booleanConversion() { - let schema = BQTableSchema(fields: [field("flag", "BOOL")]) - let resp = response(rows: [ - BQQueryResponse.BQRow(f: [BQQueryResponse.BQCell(v: .string("true"))]), - BQQueryResponse.BQRow(f: [BQQueryResponse.BQCell(v: .string("false"))]) - ], totalRows: "2") - let rows = BigQueryTypeMapper.flattenRows(from: resp, schema: schema) - #expect(rows[0][0] == "true") - #expect(rows[1][0] == "false") - } - - @Test("STRUCT record flattens to JSON object string") - func structFlattening() { - let schema = BQTableSchema(fields: [ - field("addr", "RECORD", fields: [field("city", "STRING"), field("zip", "INT64")]) - ]) - let resp = response(rows: [ - BQQueryResponse.BQRow(f: [ - BQQueryResponse.BQCell(v: .record( - BQCellValue.BQRecordValue(f: [ - BQQueryResponse.BQCell(v: .string("NYC")), - BQQueryResponse.BQCell(v: .string("10001")) - ]) - )) - ]) - ], totalRows: "1") - let value = BigQueryTypeMapper.flattenRows(from: resp, schema: schema)[0][0] - #expect(value != .null) - #expect(value.asText?.contains("\"city\"") == true) - #expect(value.asText?.contains("\"NYC\"") == true) - } - - @Test("REPEATED array flattens to JSON array string") - func arrayFlattening() { - let schema = BQTableSchema(fields: [field("tags", "STRING", mode: "REPEATED")]) - let resp = response(rows: [ - BQQueryResponse.BQRow(f: [ - BQQueryResponse.BQCell(v: .array([.string("red"), .string("blue")])) - ]) - ], totalRows: "1") - let value = BigQueryTypeMapper.flattenRows(from: resp, schema: schema)[0][0] - #expect(value != .null) - #expect(value.asText?.contains("red") == true) - #expect(value.asText?.contains("blue") == true) - } - - @Test("Empty response returns empty rows") - func emptyResponse() { - let schema = BQTableSchema(fields: [field("id", "INT64")]) - let resp = response(rows: nil) - #expect(BigQueryTypeMapper.flattenRows(from: resp, schema: schema).isEmpty) - } -} - -private func decodeResponse(_ json: String) throws -> BQQueryResponse { - try JSONDecoder().decode(BQQueryResponse.self, from: Data(json.utf8)) -} - -private func firstCell(_ json: String, schema: BQTableSchema) throws -> PluginCellValue { - let rows = BigQueryTypeMapper.flattenRows(from: try decodeResponse(json), schema: schema) - return try #require(rows.first?.first) -} - -struct BigQueryTypeMapperJSONDecodingTests { - @Test("REPEATED STRING unwraps each wrapped array element") - func repeatedStringFromRawJSON() throws { - let schema = BQTableSchema(fields: [field("tags", "STRING", mode: "REPEATED")]) - let json = #""" - {"rows": [{"f": [{"v": [{"v": "red"}, {"v": "blue"}]}]}], "totalRows": "1"} - """# - let value = try firstCell(json, schema: schema) - #expect(value.asText == #"["red","blue"]"#) - } - - @Test("REPEATED INT64 emits JSON numbers, not quoted strings") - func repeatedIntegerFromRawJSON() throws { - let schema = BQTableSchema(fields: [field("scores", "INT64", mode: "REPEATED")]) - let json = #""" - {"rows": [{"f": [{"v": [{"v": "1"}, {"v": "2"}]}]}], "totalRows": "1"} - """# - let value = try firstCell(json, schema: schema) - #expect(value.asText == "[1,2]") - } - - @Test("REPEATED RECORD unwraps each nested struct element") - func repeatedRecordFromRawJSON() throws { - let schema = BQTableSchema(fields: [ - field("items", "RECORD", mode: "REPEATED", fields: [field("name", "STRING")]) - ]) - let json = #""" - {"rows": [{"f": [{"v": [{"v": {"f": [{"v": "a"}]}}, {"v": {"f": [{"v": "b"}]}}]}]}], "totalRows": "1"} - """# - let value = try firstCell(json, schema: schema) - #expect(value.asText == #"[{"name":"a"},{"name":"b"}]"#) - } - - @Test("Empty REPEATED column renders an empty JSON array") - func emptyRepeatedFromRawJSON() throws { - let schema = BQTableSchema(fields: [field("tags", "STRING", mode: "REPEATED")]) - let json = #""" - {"rows": [{"f": [{"v": []}]}], "totalRows": "1"} - """# - let value = try firstCell(json, schema: schema) - #expect(value.asText == "[]") - } - - @Test("Null array element renders as JSON null, not a dropped element") - func nullElementFromRawJSON() throws { - let schema = BQTableSchema(fields: [field("tags", "STRING", mode: "REPEATED")]) - let json = #""" - {"rows": [{"f": [{"v": [{"v": "red"}, {"v": null}]}]}], "totalRows": "1"} - """# - let value = try firstCell(json, schema: schema) - #expect(value.asText == #"["red",null]"#) - } - - @Test("Non-repeated STRUCT still decodes as a record") - func scalarRecordFromRawJSON() throws { - let schema = BQTableSchema(fields: [ - field("addr", "RECORD", fields: [field("city", "STRING"), field("zip", "INT64")]) - ]) - let json = #""" - {"rows": [{"f": [{"v": {"f": [{"v": "NYC"}, {"v": "10001"}]}}]}], "totalRows": "1"} - """# - let value = try firstCell(json, schema: schema) - #expect(value.asText == #"{"city":"NYC","zip":10001}"#) - } - - @Test("STRUCT with a null member keeps the member as JSON null") - func recordWithNullMemberFromRawJSON() throws { - let schema = BQTableSchema(fields: [ - field("addr", "RECORD", fields: [field("city", "STRING"), field("zip", "INT64")]) - ]) - let json = #""" - {"rows": [{"f": [{"v": {"f": [{"v": "NYC"}, {"v": null}]}}]}], "totalRows": "1"} - """# - let value = try firstCell(json, schema: schema) - #expect(value.asText == #"{"city":"NYC","zip":null}"#) - } - - @Test("REPEATED member of a STRUCT nests as a JSON array") - func recordWithRepeatedMemberFromRawJSON() throws { - let schema = BQTableSchema(fields: [ - field("post", "RECORD", fields: [field("tags", "STRING", mode: "REPEATED")]) - ]) - let json = #""" - {"rows": [{"f": [{"v": {"f": [{"v": [{"v": "a"}, {"v": "b"}]}]}}]}], "totalRows": "1"} - """# - let value = try firstCell(json, schema: schema) - #expect(value.asText == #"{"tags":["a","b"]}"#) - } - - @Test("Nested STRUCT nests as a JSON object") - func nestedRecordFromRawJSON() throws { - let schema = BQTableSchema(fields: [ - field("post", "RECORD", fields: [field("addr", "RECORD", fields: [field("city", "STRING")])]) - ]) - let json = #""" - {"rows": [{"f": [{"v": {"f": [{"v": {"f": [{"v": "NYC"}]}}]}}]}], "totalRows": "1"} - """# - let value = try firstCell(json, schema: schema) - #expect(value.asText == #"{"addr":{"city":"NYC"}}"#) - } - - @Test("String elements escape quotes, backslashes, and control characters") - func escapedStringElementsFromRawJSON() throws { - let schema = BQTableSchema(fields: [field("tags", "STRING", mode: "REPEATED")]) - let json = #""" - {"rows": [{"f": [{"v": [{"v": "he said \"hi\"\npath C:\\tmp\tend"}]}]}], "totalRows": "1"} - """# - let text = try #require(try firstCell(json, schema: schema).asText) - #expect(text == #"["he said \"hi\"\npath C:\\tmp\tend"]"#) - let reparsed = try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String] - #expect(reparsed == ["he said \"hi\"\npath C:\\tmp\tend"]) - } - - @Test("Arrays survive a Codable round trip") - func arrayRoundTripsThroughCodable() throws { - let schema = BQTableSchema(fields: [ - field("items", "RECORD", mode: "REPEATED", fields: [field("name", "STRING")]) - ]) - let json = #""" - {"rows": [{"f": [{"v": [{"v": {"f": [{"v": "a"}]}}, {"v": {"f": [{"v": "b"}]}}]}]}], "totalRows": "1"} - """# - let decoded = try decodeResponse(json) - let reencoded = try JSONEncoder().encode(decoded) - let roundTripped = try JSONDecoder().decode(BQQueryResponse.self, from: reencoded) - #expect( - BigQueryTypeMapper.flattenRows(from: roundTripped, schema: schema) - == BigQueryTypeMapper.flattenRows(from: decoded, schema: schema) - ) - #expect(BigQueryTypeMapper.flattenRows(from: roundTripped, schema: schema)[0][0].asText - == #"[{"name":"a"},{"name":"b"}]"#) - } -} - -struct BigQueryTypeMapperComparabilityTests { - @Test("ARRAY, JSON and GEOGRAPHY columns are not comparable") - func nonComparableScalars() { - let names = BigQueryTypeMapper.nonComparableColumnNames(from: [ - field("id", "INT64"), - field("tags", "STRING", mode: "REPEATED"), - field("payload", "JSON"), - field("area", "GEOGRAPHY"), - field("name", "STRING", mode: "NULLABLE") - ]) - #expect(names == ["tags", "payload", "area"]) - } - - @Test("A STRUCT is comparable only when every field inside it is") - func structComparability() { - let names = BigQueryTypeMapper.nonComparableColumnNames(from: [ - field("plain", "RECORD", fields: [field("a", "INT64"), field("b", "STRING")]), - field("nested", "STRUCT", fields: [field("inner", "RECORD", fields: [field("j", "JSON")])]), - field("withArray", "RECORD", fields: [field("xs", "INT64", mode: "REPEATED")]) - ]) - #expect(names == ["nested", "withArray"]) - } -} - -struct BigQueryTypeMapperColumnKindTests { - @Test("Scalar types map to the filter literal kind") - func scalarKinds() { - let kinds = BigQueryTypeMapper.columnKinds(from: [ - field("s", "STRING"), - field("i", "INT64"), - field("n", "NUMERIC"), - field("f", "FLOAT64"), - field("b", "BOOL"), - field("d", "DATE"), - field("xs", "INT64", mode: "REPEATED") - ]) - #expect(kinds["s"] == .text) - #expect(kinds["i"] == .integer) - #expect(kinds["n"] == .decimal) - #expect(kinds["f"] == .decimal) - #expect(kinds["b"] == .boolean) - #expect(kinds["d"] == .other) - #expect(kinds["xs"] == .other) - } -} diff --git a/TableProTests/Plugins/CassandraBrowseClassificationTests.swift b/TableProTests/Plugins/CassandraBrowseClassificationTests.swift deleted file mode 100644 index 660f65e2c8..0000000000 --- a/TableProTests/Plugins/CassandraBrowseClassificationTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// CassandraBrowseClassificationTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -/// A table browse reaches the statement gates as text, so MCP and Safe Mode read it before the plugin -/// does. Its window rides in a leading comment, which the classifier skips, and what follows is a SELECT. -struct CassandraBrowseClassificationTests { - private func browseText(values: [String]) -> String { - CassandraBrowseRenderer.browse( - keyspace: "shop", table: "users", columns: [], - filters: values.map { - PluginQueryFilter(column: "name", op: "=", value: $0, secondValue: nil, elementScope: nil) - }, - matchAll: true, sorted: false, limit: 100, offset: 200 - ).text - } - - @Test("A browse is a read on both engines") - func browseIsARead() { - for type in [DatabaseType.cassandra, .scylladb] { - #expect(QueryClassifier.classify(browseText(values: []), databaseType: type).tier == .safe) - #expect(!QueryClassifier.isWriteQuery(browseText(values: ["x"]), databaseType: type)) - } - } - - @Test("A filter value that reads like a write stays a value") - func writeWordsInAValueStayInTheComment() { - let text = browseText(values: ["x'; DROP TABLE users; --", "*/ DELETE FROM users /*"]) - - #expect(QueryClassifier.classify(text, databaseType: .cassandra).tier == .safe) - #expect(!QueryClassifier.isDangerousQuery(text, databaseType: .cassandra)) - } -} diff --git a/TableProTests/Plugins/CassandraBrowseStatementTests.swift b/TableProTests/Plugins/CassandraBrowseStatementTests.swift deleted file mode 100644 index 7e3d9015bf..0000000000 --- a/TableProTests/Plugins/CassandraBrowseStatementTests.swift +++ /dev/null @@ -1,224 +0,0 @@ -// -// CassandraBrowseStatementTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct CassandraBrowseStatementTests { - private func filter(_ column: String, _ op: String, _ value: String, upper: String? = nil) -> PluginQueryFilter { - PluginQueryFilter(column: column, op: op, value: value, secondValue: upper, elementScope: nil) - } - - private func browse( - filters: [PluginQueryFilter] = [], - matchAll: Bool = true, - sorted: Bool = false, - keyspace: String? = "shop", - limit: Int = 1_000, - offset: Int = 0 - ) -> CassandraBrowseStatement { - CassandraBrowseRenderer.browse( - keyspace: keyspace, table: "users", columns: [], filters: filters, matchAll: matchAll, - sorted: sorted, limit: limit, offset: offset - ) - } - - @Test("A browse is plain CQL with no OFFSET, no ORDER BY and no LIMIT, whatever the page") - func browseNeverSeeksInCQL() { - let statement = browse(offset: 5_000) - - #expect(statement.cql == #"SELECT * FROM "shop"."users""#) - #expect(!statement.text.contains("OFFSET")) - #expect(!statement.text.contains("ORDER BY")) - #expect(statement.window.offset == 5_000) - #expect(statement.window.limit == 1_000) - #expect(statement.window.refusal == nil) - } - - @Test("The window rides in a leading comment, so the statement still runs as CQL anywhere else") - func windowIsALeadingComment() { - let text = browse(offset: 2_000).text - - #expect(text.hasPrefix("/* TablePro browse {")) - #expect(text.hasSuffix(#" */ SELECT * FROM "shop"."users""#)) - } - - @Test("A statement round-trips through its text") - func roundTrip() throws { - let statement = browse(filters: [filter("v", "=", "3"), filter("n", ">", "2")], offset: 1_000) - - let parsed = try #require(CassandraBrowseStatement.parse(statement.text)) - - #expect(parsed == statement) - } - - @Test("A value holding a comment terminator cannot end the header early") - func valueCannotCloseTheComment() throws { - let statement = browse(filters: [filter("name", "=", "a */ DROP TABLE users; /* b")]) - - let parsed = try #require(CassandraBrowseStatement.parse(statement.text)) - - #expect(parsed.window.values == ["a */ DROP TABLE users; /* b"]) - #expect(parsed.cql == #"SELECT * FROM "shop"."users" WHERE "name" = ? ALLOW FILTERING"#) - } - - @Test("Text that is not a browse statement is not parsed as one") - func ordinaryCQLIsNotABrowse() { - #expect(CassandraBrowseStatement.parse("SELECT * FROM users") == nil) - #expect(CassandraBrowseStatement.parse("/* a comment */ SELECT * FROM users") == nil) - #expect(CassandraBrowseStatement.parse("/* TablePro browse not json */ SELECT 1") == nil) - } - - @Test("A condition binds its value and puts ALLOW FILTERING last") - func allowFilteringComesLast() { - let statement = browse(filters: [filter("v", "=", "3"), filter("n", "<=", "9")]) - - #expect(statement.cql == #"SELECT * FROM "shop"."users" WHERE "v" = ? AND "n" <= ? ALLOW FILTERING"#) - #expect(statement.window.values == ["3", "9"]) - #expect(statement.cql.hasSuffix("ALLOW FILTERING")) - } - - @Test("IN binds one marker per item") - func inBindsEachItem() { - let statement = browse(filters: [filter("id", "IN", "1, 2 ,3")]) - - #expect(statement.cql == #"SELECT * FROM "shop"."users" WHERE "id" IN (?, ?, ?) ALLOW FILTERING"#) - #expect(statement.window.values == ["1", "2", "3"]) - } - - @Test("BETWEEN is two bounds CQL can say, read from the upper bound the app carries apart") - func betweenIsTwoBounds() { - let statement = browse(filters: [filter("n", "BETWEEN", "1,5", upper: "5")]) - - #expect(statement.cql == #"SELECT * FROM "shop"."users" WHERE "n" >= ? AND "n" <= ? ALLOW FILTERING"#) - #expect(statement.window.values == ["1", "5"]) - } - - @Test("Contains, starts with and ends with are LIKE patterns") - func textMatchesAreLike() { - let statement = browse(filters: [ - filter("a", "CONTAINS", "x"), filter("b", "STARTS WITH", "y"), filter("c", "ENDS WITH", "z") - ]) - - #expect(statement.cql.contains(#""a" LIKE ? AND "b" LIKE ? AND "c" LIKE ?"#)) - #expect(statement.window.values == ["%x%", "y%", "%z"]) - } - - @Test("A LIKE pattern matches the user's percent, underscore and backslash literally") - func likeWildcardsAreEscaped() { - let statement = browse(filters: [filter("s", "CONTAINS", #"100%_a\b"#)]) - - #expect(statement.window.values == [#"%100\%\_a\\b%"#]) - #expect(CassandraBrowseRenderer.likeLiteral("plain") == "plain") - } - - @Test("A browse capped for a query tab reads one row past the cap and keeps its window otherwise") - func cappedAtReadsOnePastTheCap() { - let statement = browse(filters: [filter("v", "=", "3")], limit: 5_000, offset: 200) - - let capped = statement.cappedAt(rowCap: 1_000) - - #expect(capped.window.limit == 1_001) - #expect(capped.window.offset == 200) - #expect(capped.window.values == ["3"]) - #expect(capped.cql == statement.cql) - #expect(statement.cappedAt(rowCap: 10_000).window.limit == 5_000) - } - - @Test("A raw filter is written as the user typed it") - func rawFilterIsVerbatim() { - let statement = browse(filters: [filter("__RAW__", "=", "token(id) > 0")]) - - #expect(statement.cql == #"SELECT * FROM "shop"."users" WHERE token(id) > 0 ALLOW FILTERING"#) - #expect(statement.window.values.isEmpty) - } - - @Test("What CQL cannot say is refused by name, not sent", arguments: [ - "!=", "NOT CONTAINS", "IS NULL", "IS NOT NULL", "IS EMPTY", "IS NOT EMPTY", "NOT IN", "REGEX" - ]) - func unsupportedOperatorIsRefused(op: String) { - let statement = browse(filters: [filter("v", op, "1")]) - - #expect(statement.window.refusal == CassandraBrowseRefusal.unsupportedOperator(op).pluginErrorMessage) - #expect(!statement.cql.contains("WHERE")) - } - - @Test("Match Any over several conditions is refused") - func matchAnyIsRefused() { - let statement = browse(filters: [filter("a", "=", "1"), filter("b", "=", "2")], matchAll: false) - - #expect(statement.window.refusal == CassandraBrowseRefusal.matchAny.pluginErrorMessage) - } - - @Test("The Match Any refusal states the limit without naming a control, since only an MCP client can reach it") - func matchAnyRefusalNamesNoControl() { - let message = CassandraBrowseRefusal.matchAny.pluginErrorMessage - - #expect(!message.localizedCaseInsensitiveContains("Match All")) - #expect(!message.localizedCaseInsensitiveContains("Match Any")) - } - - @Test("Match Any over a single condition is just that condition") - func matchAnyOfOneRuns() { - let statement = browse(filters: [filter("a", "=", "1")], matchAll: false) - - #expect(statement.window.refusal == nil) - #expect(statement.cql.contains(#"WHERE "a" = ?"#)) - } - - @Test("A sort is refused rather than dropped") - func sortIsRefused() { - let statement = browse(sorted: true) - - #expect(statement.window.refusal == CassandraBrowseRefusal.sorting.pluginErrorMessage) - #expect(!statement.cql.contains("ORDER BY")) - } - - @Test("An empty IN list and a half-open range are refused") - func incompleteOperandsAreRefused() { - #expect(browse(filters: [filter("id", "IN", " , ")]).window.refusal - == CassandraBrowseRefusal.emptyList.pluginErrorMessage) - #expect(browse(filters: [filter("n", "BETWEEN", "1")]).window.refusal - == CassandraBrowseRefusal.incompleteRange.pluginErrorMessage) - } - - @Test("No keyspace leaves the table unqualified, never the system keyspace") - func missingKeyspaceIsUnqualified() { - #expect(browse(keyspace: nil).cql == #"SELECT * FROM "users""#) - #expect(browse(keyspace: "").cql == #"SELECT * FROM "users""#) - } - - @Test("Identifiers are quoted with their own quotes doubled") - func identifiersAreQuoted() { - let statement = CassandraBrowseRenderer.browse( - keyspace: #"k"s"#, table: #"t"1"#, columns: ["a", #"b"c"#], filters: [], matchAll: true, - sorted: false, limit: 10, offset: 0 - ) - - #expect(statement.cql == #"SELECT "a", "b""c" FROM "k""s"."t""1""#) - } - - @Test("A count carries the same conditions and ALLOW FILTERING, and nothing when unfiltered") - func countRendering() throws { - let filtered = try CassandraBrowseRenderer.count( - keyspace: "shop", table: "users", filters: [filter("v", "=", "3")], matchAll: true - ) - let whole = try CassandraBrowseRenderer.count(keyspace: "shop", table: "users", filters: [], matchAll: true) - - #expect(filtered.cql == #"SELECT COUNT(*) FROM "shop"."users" WHERE "v" = ? ALLOW FILTERING"#) - #expect(filtered.values == ["3"]) - #expect(whole.cql == #"SELECT COUNT(*) FROM "shop"."users""#) - } - - @Test("A count refuses what a browse refuses") - func countRefusesMatchAny() { - #expect(throws: CassandraBrowseRefusal.matchAny) { - try CassandraBrowseRenderer.count( - keyspace: nil, table: "users", filters: [filter("a", "=", "1"), filter("b", "=", "2")], matchAll: false - ) - } - } -} diff --git a/TableProTests/Plugins/CassandraCommentStatementTests.swift b/TableProTests/Plugins/CassandraCommentStatementTests.swift deleted file mode 100644 index 8940bc821a..0000000000 --- a/TableProTests/Plugins/CassandraCommentStatementTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation -import Testing - -struct CassandraCommentStatementTests { - @Test("A table comment is set through the table's comment option") - func setsTableComment() { - let sql = CassandraObjectQueries.tableCommentStatement( - keyspace: "Shop", table: "orders", objectType: "TABLE", comment: "Customer's orders" - ) - #expect(sql == #"ALTER TABLE "Shop"."orders" WITH comment = 'Customer''s orders'"#) - } - - @Test("A nil or empty comment resets the option to an empty string") - func clearsTableComment() { - let cleared = #"ALTER TABLE "Shop"."orders" WITH comment = ''"# - #expect(CassandraObjectQueries.tableCommentStatement( - keyspace: "Shop", table: "orders", objectType: "TABLE", comment: nil - ) == cleared) - #expect(CassandraObjectQueries.tableCommentStatement( - keyspace: "Shop", table: "orders", objectType: "TABLE", comment: "" - ) == cleared) - } - - @Test("Names are quoted with their own quotes doubled") - func quotesNames() { - let sql = CassandraObjectQueries.tableCommentStatement( - keyspace: #"a"b"#, table: #"c"d"#, objectType: "TABLE", comment: "x" - ) - #expect(sql == #"ALTER TABLE "a""b"."c""d" WITH comment = 'x'"#) - } - - @Test("Only tables take a comment") - func refusesOtherKinds() { - for kind in ["VIEW", "MATERIALIZED VIEW", "SEQUENCE", "SYSTEM TABLE", "FOREIGN TABLE"] { - #expect(CassandraObjectQueries.tableCommentStatement( - keyspace: "s", table: "t", objectType: kind, comment: nil - ) == nil) - } - } - - @Test("The comment is read from system_schema.tables with escaped literals") - func readsCommentFromSystemSchema() { - let sql = CassandraObjectQueries.tableComment(keyspace: "O'Shop", table: "orders") - #expect(sql.contains("SELECT comment FROM system_schema.tables")) - #expect(sql.contains("keyspace_name = 'O''Shop'")) - #expect(sql.contains("table_name = 'orders'")) - } - - @Test("An apostrophe followed by a combining mark is still doubled") - func apostropheBeforeCombiningMark() { - let sql = CassandraObjectQueries.tableCommentStatement( - keyspace: "Shop", table: "orders", objectType: "TABLE", comment: "a'\u{0301}b" - ) - #expect(sql == "ALTER TABLE \"Shop\".\"orders\" WITH comment = 'a''\u{0301}b'") - } -} diff --git a/TableProTests/Plugins/CassandraConnectTimeoutTests.swift b/TableProTests/Plugins/CassandraConnectTimeoutTests.swift deleted file mode 100644 index cadddd6702..0000000000 --- a/TableProTests/Plugins/CassandraConnectTimeoutTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import Testing - -struct CassandraConnectTimeoutTests { - @Test("The remaining millisecond budget is the DataStax native timeout") - func nativeMilliseconds() { - let timeout = CassandraConnectTimeout(additionalFields: [ - "connectTimeoutMilliseconds": "3210", - "connectTimeoutSeconds": "9" - ]) - - #expect(timeout.milliseconds == 3_210) - #expect(timeout.nativeConfiguration == CassandraConnectTimeout.NativeConfiguration( - connectMilliseconds: 3_210, - resolveMilliseconds: 3_210, - waitMicroseconds: 3_210_000 - )) - } - - @Test("One deadline shrinks before the timed connection future") - func deadlineShrinks() { - let deadline = CassandraConnectDeadline(timeout: CassandraConnectTimeout(milliseconds: 3_250), now: 10) - - #expect(deadline.remainingMilliseconds(now: 11) == 2_250) - #expect(deadline.remainingMilliseconds(now: 13.25) == nil) - } - - @Test("AWS authentication and the native connection share one deadline") - func awsAuthenticationSharesDeadline() throws { - let deadline = CassandraConnectDeadline(timeout: CassandraConnectTimeout(milliseconds: 3_250), now: 10) - - let awsBudget = try #require(deadline.awsSessionBudget(now: 11)) - #expect(awsBudget.remainingSeconds == 2.25) - #expect(awsBudget.expiresAtUptime == 13.25) - #expect(deadline.remainingMilliseconds(now: 12.25) == 1_000) - #expect(deadline.awsSessionBudget(now: 13.25) == nil) - } - - @Test("Server version probe uses the remaining connect budget and preserves its failure") - func serverVersionProbeUsesDeadline() throws { - var root = URL(fileURLWithPath: #filePath) - for _ in 0..<3 { root.deleteLastPathComponent() } - let connection = try String( - contentsOf: root.appendingPathComponent("Plugins/CassandraDriverPlugin/CassandraConnection.swift"), - encoding: .utf8 - ) - let plugin = try String( - contentsOf: root.appendingPathComponent("Plugins/CassandraDriverPlugin/CassandraPlugin.swift"), - encoding: .utf8 - ) - - #expect(connection.contains("func serverVersion(requestTimeoutMilliseconds: UInt32)")) - #expect(connection.contains("cass_statement_set_request_timeout(statement, UInt64(requestTimeoutMilliseconds))")) - #expect(plugin.contains("requestTimeoutMilliseconds: versionProbeMilliseconds")) - #expect(!plugin.contains("try? await connectionActor.serverVersion")) - #expect(plugin.contains("throw CassandraPluginError.connectionFailed(error.localizedDescription)")) - } - - @Test("The compatibility seconds value is checked and clamped") - func secondsFallbackClamps() { - #expect(CassandraConnectTimeout(additionalFields: ["connectTimeoutSeconds": "6"]).milliseconds == 6_000) - #expect(CassandraConnectTimeout(additionalFields: ["connectTimeoutSeconds": "0"]).milliseconds == 1) - #expect( - CassandraConnectTimeout(additionalFields: ["connectTimeoutMilliseconds": "999999999"]).milliseconds - == UInt32(CassandraConnectTimeout.maximumMilliseconds) - ) - } -} diff --git a/TableProTests/Plugins/CassandraObjectTextTests.swift b/TableProTests/Plugins/CassandraObjectTextTests.swift deleted file mode 100644 index 06532cc9cc..0000000000 --- a/TableProTests/Plugins/CassandraObjectTextTests.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// CassandraObjectTextTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct CassandraObjectTextTests { - @Test("A trigger's DDL names its class as a string literal, and quotes its names") - func triggerDefinitionRunsAsCQL() { - let className = CassandraObjectQueries.triggerClass(fromOptions: "{class: com.example.Audit}") - #expect(className == "com.example.Audit") - - let ddl = CassandraObjectQueries.triggerDefinition( - keyspace: "Shop", table: "orders", name: "auditTrigger", className: "com.example.O'Neil" - ) - #expect(ddl.contains(#"CREATE TRIGGER "auditTrigger" ON "Shop"."orders""#)) - #expect(ddl.contains("USING 'com.example.O''Neil';")) - } - - @Test("A trigger with no class entry yields no class") - func triggerWithoutClass() { - #expect(CassandraObjectQueries.triggerClass(fromOptions: "{}") == nil) - #expect(CassandraObjectQueries.triggerClass(fromOptions: nil) == nil) - } - - @Test("A list splits only at commas outside a type's angle brackets") - func typeListKeepsNestedTypes() { - #expect(CassandraObjectQueries.elements(of: "[frozen>, int]") == ["frozen>", "int"]) - #expect(CassandraObjectQueries.elements(of: "[tuple]") == ["tuple"]) - #expect(CassandraObjectQueries.elements(of: "[]").isEmpty) - #expect(CassandraObjectQueries.elements(of: nil).isEmpty) - } - - @Test("A function signature pairs names with whole types, and quotes the names in DDL") - func signaturePairsNamesWithTypes() { - let shown = CassandraObjectQueries.signature(argumentNames: "[m, x]", argumentTypes: "[frozen>, int]") - let ddl = CassandraObjectQueries.signature( - argumentNames: "[m, x]", argumentTypes: "[frozen>, int]", quotingNames: true - ) - - #expect(shown == "(m frozen>, x int)") - #expect(ddl == #"("m" frozen>, "x" int)"#) - } - - @Test("Function and aggregate DDL quote the keyspace and names, so a mixed-case one survives replay") - func routineDefinitionsQuoteNames() { - let function = CassandraObjectQueries.functionDefinition( - keyspace: "Shop", name: "MyFunc", signature: "()", returnType: "int", language: "java", - body: "return 1;", calledOnNullInput: true - ) - let aggregate = CassandraObjectQueries.aggregateDefinition( - keyspace: "Shop", name: "Total", signature: "(int)", stateFunction: "Add", stateType: "int", - finalFunction: nil - ) - - #expect(function.contains(#"CREATE OR REPLACE FUNCTION "Shop"."MyFunc"()"#)) - #expect(aggregate.contains(#"CREATE OR REPLACE AGGREGATE "Shop"."Total"(int)"#)) - #expect(aggregate.contains(#"SFUNC "Add""#)) - #expect(aggregate.hasSuffix(";")) - #expect(!aggregate.contains("FINALFUNC")) - } - - @Test("A duration is written the way Cassandra spells one") - func durationText() { - #expect(CassandraCellText.durationText(months: 14, days: 3, nanoseconds: 9_000_000_000 + 5_000_000) == "1y2mo3d9s5ms") - #expect(CassandraCellText.durationText(months: 0, days: 0, nanoseconds: 3_723_000_000_001) == "1h2m3s1ns") - #expect(CassandraCellText.durationText(months: 0, days: -2, nanoseconds: -3_600_000_000_000) == "-2d1h") - #expect(CassandraCellText.durationText(months: 0, days: 0, nanoseconds: 0) == "0s") - } - - @Test("A float vector shows its numbers; any other custom value shows its bytes") - func customText() { - let floats = Data([0x3F, 0x80, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00]) - let vectorClass = "org.apache.cassandra.db.marshal.VectorType(org.apache.cassandra.db.marshal.FloatType,2)" - let measuredClass = "org.apache.cassandra.db.marshal.VectorType(org.apache.cassandra.db.marshal.FloatType , 2)" - - #expect(CassandraCellText.customText(className: vectorClass, bytes: floats) == "[1.0, 2.5]") - #expect(CassandraCellText.customText(className: measuredClass, bytes: floats) == "[1.0, 2.5]") - #expect(CassandraCellText.customText(className: "com.example.Custom", bytes: Data([0xCA, 0xFE])) == "0xcafe") - #expect(CassandraCellText.customText(className: nil, bytes: Data([0x01])) == "0x01") - } -} diff --git a/TableProTests/Plugins/CassandraResumePointsTests.swift b/TableProTests/Plugins/CassandraResumePointsTests.swift deleted file mode 100644 index 05d80a9ab5..0000000000 --- a/TableProTests/Plugins/CassandraResumePointsTests.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// CassandraResumePointsTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct CassandraResumePointsTests { - private let key = CassandraResumePoints.key(keyspace: "a", cql: "SELECT * FROM t", values: [], pageSize: 1_000) - - @Test("A page resumes from the farthest point at or before its offset") - func nearestPointAtOrBefore() { - var points = CassandraResumePoints() - points.record(Data([1]), at: 1_000, for: key) - points.record(Data([2]), at: 2_000, for: key) - - #expect(points.nearest(atOrBefore: 999, for: key) == nil) - #expect(points.nearest(atOrBefore: 1_500, for: key)?.position == 1_000) - #expect(points.nearest(atOrBefore: 2_000, for: key)?.token == Data([2])) - } - - @Test("A point is only found under the exact keyspace, statement, values and page size it came from") - func pointsAreKeyedByStatement() { - var points = CassandraResumePoints() - points.record(Data([1]), at: 1_000, for: key) - - let otherValues = CassandraResumePoints.key(keyspace: "a", cql: "SELECT * FROM t", values: ["x"], pageSize: 1_000) - let otherPageSize = CassandraResumePoints.key(keyspace: "a", cql: "SELECT * FROM t", values: [], pageSize: 500) - let otherKeyspace = CassandraResumePoints.key(keyspace: "b", cql: "SELECT * FROM t", values: [], pageSize: 1_000) - - #expect(points.nearest(atOrBefore: 5_000, for: otherValues) == nil) - #expect(points.nearest(atOrBefore: 5_000, for: otherPageSize) == nil) - #expect(points.nearest(atOrBefore: 5_000, for: otherKeyspace) == nil) - } - - @Test("A walk that records a point drops the points past it, which came from an earlier walk") - func recordingDropsLaterPoints() { - var points = CassandraResumePoints() - for position in [1_000, 2_000, 3_000, 4_000] { - points.record(Data([UInt8(position / 1_000)]), at: position, for: key) - } - - points.record(Data([9]), at: 1_000, for: key) - - #expect(points.nearest(atOrBefore: 4_000, for: key)?.position == 1_000) - #expect(points.nearest(atOrBefore: 4_000, for: key)?.token == Data([9])) - } - - @Test("Removing one statement's points leaves the others") - func removeOneKey() { - var points = CassandraResumePoints() - let other = CassandraResumePoints.key(keyspace: "a", cql: "SELECT * FROM u", values: [], pageSize: 1_000) - points.record(Data([1]), at: 1_000, for: key) - points.record(Data([2]), at: 1_000, for: other) - - points.remove(key) - - #expect(points.nearest(atOrBefore: 1_000, for: key) == nil) - #expect(points.nearest(atOrBefore: 1_000, for: other) != nil) - } - - @Test("An empty paging state is never recorded") - func emptyTokenIsIgnored() { - var points = CassandraResumePoints() - points.record(Data(), at: 1_000, for: key) - - #expect(points.nearest(atOrBefore: 1_000, for: key) == nil) - } - - private func statementKey(_ index: Int) -> CassandraResumePoints.Key { - CassandraResumePoints.key(keyspace: "a", cql: "SELECT * FROM t\(index)", values: [], pageSize: 1_000) - } - - @Test("Only the most recent statements keep their points") - func oldestStatementIsEvicted() { - var points = CassandraResumePoints() - for index in 0...CassandraResumePoints.maximumStatements { - points.record(Data([1]), at: 100, for: statementKey(index)) - } - - #expect(points.nearest(atOrBefore: 100, for: statementKey(0)) == nil) - #expect(points.nearest(atOrBefore: 100, for: statementKey(CassandraResumePoints.maximumStatements)) != nil) - } - - @Test("Values that join to the same text are still two statements") - func keysDoNotCollideOnAJoinCharacter() { - let first = CassandraResumePoints.key(keyspace: "a", cql: "SELECT", values: ["a\u{0}b", "c"], pageSize: 100) - let second = CassandraResumePoints.key(keyspace: "a", cql: "SELECT", values: ["a", "b\u{0}c"], pageSize: 100) - var points = CassandraResumePoints() - points.record(Data([1]), at: 100, for: first) - - #expect(first != second) - #expect(points.nearest(atOrBefore: 100, for: second) == nil) - } - - @Test("Clearing forgets every point") - func removeAll() { - var points = CassandraResumePoints() - points.record(Data([1]), at: 1_000, for: key) - points.removeAll() - - #expect(points.nearest(atOrBefore: 1_000, for: key) == nil) - } - - @Test("The page size follows the browse limit within the driver's bounds") - func pageSize() { - #expect(CassandraResumePoints.pageSize(forLimit: 5) == 100) - #expect(CassandraResumePoints.pageSize(forLimit: 1_000) == 1_000) - #expect(CassandraResumePoints.pageSize(forLimit: 50_000) == 5_000) - } -} diff --git a/TableProTests/Plugins/CassandraRowWriterTests.swift b/TableProTests/Plugins/CassandraRowWriterTests.swift deleted file mode 100644 index 7b0d56e06c..0000000000 --- a/TableProTests/Plugins/CassandraRowWriterTests.swift +++ /dev/null @@ -1,162 +0,0 @@ -// -// CassandraRowWriterTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct CassandraRowWriterTests { - private let columns = ["tenant", "day", "ts", "name", "n"] - private let primaryKey = ["tenant", "day", "ts"] - private let original: [PluginCellValue] = [.text("acme"), .text("2024-01-01"), .text("5"), .text("x"), .text("1")] - - private func writes(_ changes: [PluginRowChange], inserted: [Int: [PluginCellValue]] = [:]) throws -> [PluginRowWrite] { - try CassandraRowWriter.rowWrites( - keyspace: "shop", table: "events", columns: columns, primaryKeyColumns: primaryKey, - changes: changes, insertedRowData: inserted - ) - } - - private func refusal(_ change: PluginRowChange, inserted: [Int: [PluginCellValue]] = [:]) -> PluginRowWriteRefusal? { - do { - _ = try writes([change], inserted: inserted) - return nil - } catch let refusal as PluginRowWriteRefusal { - return refusal - } catch { - return nil - } - } - - @Test("Deleting several rows sends one DELETE each, named by the whole primary key") - func deletesAreOnePerRow() throws { - let result = try writes([ - PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: original), - PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: original) - ]) - - #expect(result.count == 2) - #expect(result[0].statement == #"DELETE FROM "shop"."events" WHERE "tenant" = ? AND "day" = ? AND "ts" = ?"#) - #expect(result[0].parameters == [.text("acme"), .text("2024-01-01"), .text("5")]) - #expect(result.map(\.rowIndices) == [[0], [1]]) - #expect(!result.contains { $0.statement.contains(" OR ") || $0.statement.contains("(") }) - } - - @Test("An update sets only the changed cells and binds the key after them") - func updateBindsValuesThenKey() throws { - let change = PluginRowChange( - rowIndex: 3, type: .update, - cellChanges: [(columnIndex: 3, columnName: "name", oldValue: .text("x"), newValue: .text("y"))], - originalRow: original - ) - - let result = try writes([change]) - - #expect(result.first?.statement - == #"UPDATE "shop"."events" SET "name" = ? WHERE "tenant" = ? AND "day" = ? AND "ts" = ?"#) - #expect(result.first?.parameters == [.text("y"), .text("acme"), .text("2024-01-01"), .text("5")]) - } - - @Test("An update with nothing changed writes nothing and refuses nothing") - func emptyUpdateWritesNothing() throws { - let change = PluginRowChange(rowIndex: 0, type: .update, cellChanges: [], originalRow: original) - #expect(try writes([change]).isEmpty) - } - - @Test("A new row is written IF NOT EXISTS, leaving out the cells left at the default") - func insertIsConditional() throws { - let change = PluginRowChange(rowIndex: 7, type: .insert, cellChanges: [], originalRow: nil) - let row: [PluginCellValue] = [.text("acme"), .text("2024-01-02"), .text("now()"), .text("__DEFAULT__"), .null] - - let result = try writes([change], inserted: [7: row]) - - #expect(result.first?.statement - == #"INSERT INTO "shop"."events" ("tenant", "day", "ts", "n") VALUES (?, ?, now(), ?) IF NOT EXISTS"#) - #expect(result.first?.parameters == [.text("acme"), .text("2024-01-02"), .null]) - #expect(result.first?.rowIndices == [7]) - } - - @Test("A new row missing part of its primary key is refused by the column's name") - func insertNeedsTheWholeKey() { - let change = PluginRowChange(rowIndex: 2, type: .insert, cellChanges: [], originalRow: nil) - let row: [PluginCellValue] = [.text("acme"), .null, .text("1"), .text("x"), .text("1")] - - #expect(refusal(change, inserted: [2: row])?.reason.contains("day") == true) - } - - @Test("Changing a primary key cell is refused rather than written as an upsert of a new row") - func keyChangeIsRefused() { - let change = PluginRowChange( - rowIndex: 1, type: .update, - cellChanges: [(columnIndex: 0, columnName: "tenant", oldValue: .text("acme"), newValue: .text("globex"))], - originalRow: original - ) - - #expect(refusal(change)?.rowIndex == 1) - } - - @Test("Setting a column to its default is refused, because CQL has none") - func defaultIsRefused() { - let change = PluginRowChange( - rowIndex: 0, type: .update, - cellChanges: [(columnIndex: 3, columnName: "name", oldValue: .text("x"), newValue: .text("__DEFAULT__"))], - originalRow: original - ) - - #expect(refusal(change) != nil) - } - - @Test("A row whose key was not loaded is refused instead of matching another row") - func missingKeyIsRefused() { - var partial = original - partial[1] = .null - let deletion = PluginRowChange(rowIndex: 4, type: .delete, cellChanges: [], originalRow: partial) - let noOriginal = PluginRowChange(rowIndex: 5, type: .delete, cellChanges: [], originalRow: nil) - - #expect(refusal(deletion)?.rowIndex == 4) - #expect(refusal(noOriginal)?.rowIndex == 5) - } - - @Test("A table with no known primary key refuses every write") - func unknownKeyRefusesWrites() { - let insert = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - #expect(throws: PluginRowWriteRefusal.self) { - try CassandraRowWriter.rowWrites( - keyspace: nil, table: "t", columns: ["a"], primaryKeyColumns: [], changes: [insert], - insertedRowData: [0: [.text("1")]] - ) - } - } - - @Test("Undoing a delete writes the row back whole, with a plain INSERT") - func restoreWritesTheRowBack() { - let restored = CassandraRowWriter.restoreInserts( - keyspace: "shop", table: "events", columns: columns, rows: [original] - ) - - #expect(restored.count == 1) - #expect(restored.first?.statement - == #"INSERT INTO "shop"."events" ("tenant", "day", "ts", "name", "n") VALUES (?, ?, ?, ?, ?)"#) - #expect(restored.first?.parameters == original) - } - - @Test("An insert the server did not apply because the key is taken is a refusal, anything else is not") - func unappliedInsertIsNamed() { - let insert = #"INSERT INTO "t" ("id") VALUES (?) IF NOT EXISTS"# - let taken = CassandraRowWriter.unappliedInsertRefusal( - statement: insert, columns: ["[applied]", "id"], rows: [[.text("false"), .text("1")]] - ) - let applied = CassandraRowWriter.unappliedInsertRefusal( - statement: insert, columns: ["[applied]"], rows: [[.text("true")]] - ) - let update = CassandraRowWriter.unappliedInsertRefusal( - statement: #"UPDATE "t" SET "a" = ? WHERE "id" = ? IF EXISTS"#, columns: ["[applied]"], rows: [[.text("false")]] - ) - - #expect(taken != nil) - #expect(applied == nil) - #expect(update == nil) - } -} diff --git a/TableProTests/Plugins/CassandraValueParserTests.swift b/TableProTests/Plugins/CassandraValueParserTests.swift deleted file mode 100644 index 0c6e1abbec..0000000000 --- a/TableProTests/Plugins/CassandraValueParserTests.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// CassandraValueParserTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct CassandraValueParserTests { - @Test("Integers bind at the width their column declares") - func integersKeepTheirWidth() throws { - #expect(try CassandraValueParser.parse("-7", as: .tinyint) == .int8(-7)) - #expect(try CassandraValueParser.parse("300", as: .smallint) == .int16(300)) - #expect(try CassandraValueParser.parse(" 99 ", as: .int) == .int32(99)) - #expect(try CassandraValueParser.parse("9000000000", as: .bigint) == .int64(9_000_000_000)) - #expect(try CassandraValueParser.parse("5", as: .counter) == .int64(5)) - } - - @Test("A value that overflows or is not a number is refused, never sent as text") - func invalidIntegersAreRefused() { - #expect(throws: CassandraValueRefusal.invalid("300", typeName: "tinyint")) { - try CassandraValueParser.parse("300", as: .tinyint) - } - #expect(throws: CassandraValueRefusal.invalid("abc", typeName: "int")) { - try CassandraValueParser.parse("abc", as: .int) - } - } - - @Test("Floats, doubles and booleans") - func scalarTypes() throws { - #expect(try CassandraValueParser.parse("1.5", as: .float) == .float(1.5)) - #expect(try CassandraValueParser.parse("2.25", as: .double) == .double(2.25)) - #expect(try CassandraValueParser.parse("TRUE", as: .boolean) == .bool(true)) - #expect(try CassandraValueParser.parse("0", as: .boolean) == .bool(false)) - #expect(throws: CassandraValueRefusal.self) { try CassandraValueParser.parse("yes", as: .boolean) } - } - - @Test("A uuid is validated and lowercased for either uuid type") - func uuids() throws { - let text = "123E4567-E89B-12D3-A456-426614174000" - #expect(try CassandraValueParser.parse(text, as: .uuid) == .uuid(text.lowercased())) - #expect(try CassandraValueParser.parse(text, as: .timeuuid) == .uuid(text.lowercased())) - #expect(throws: CassandraValueRefusal.self) { try CassandraValueParser.parse("not-a-uuid", as: .uuid) } - } - - @Test("A timestamp reads as the grid shows it, as cqlsh shows it, as a date, or as milliseconds") - func timestamps() throws { - let expected: Int64 = 1_704_067_200_000 - #expect(try CassandraValueParser.timestampMilliseconds("2024-01-01T00:00:00.000Z") == expected) - #expect(try CassandraValueParser.timestampMilliseconds("2024-01-01T00:00:00Z") == expected) - #expect(try CassandraValueParser.timestampMilliseconds("2024-01-01 00:00:00.000000+0000") == expected) - #expect(try CassandraValueParser.timestampMilliseconds("2024-01-01 00:00:00") == expected) - #expect(try CassandraValueParser.timestampMilliseconds("2024-01-01") == expected) - #expect(try CassandraValueParser.timestampMilliseconds("1704067200000") == expected) - #expect(try CassandraValueParser.timestampMilliseconds("2024-01-01T02:00:00+02:00") == expected) - #expect(throws: CassandraValueRefusal.self) { try CassandraValueParser.timestampMilliseconds("yesterday") } - } - - @Test("A date is days since the epoch, biased by 2^31 the way CQL stores it") - func dates() throws { - #expect(try CassandraValueParser.dateDays("1970-01-01") == 2_147_483_648) - #expect(try CassandraValueParser.dateDays("2024-01-01") == 2_147_483_648 + 19_723) - #expect(try CassandraValueParser.dateDays("1969-12-31") == 2_147_483_647) - #expect(throws: CassandraValueRefusal.self) { try CassandraValueParser.dateDays("2024-02-30") } - #expect(throws: CassandraValueRefusal.self) { try CassandraValueParser.dateDays("2024-1-1") } - } - - @Test("A time is nanoseconds since midnight") - func times() throws { - #expect(try CassandraValueParser.timeNanoseconds("00:00:01") == 1_000_000_000) - #expect(try CassandraValueParser.timeNanoseconds("01:02:03.5") == 3_723_500_000_000) - #expect(try CassandraValueParser.timeNanoseconds("23:59:59.123456789") == 86_399_123_456_789) - #expect(throws: CassandraValueRefusal.self) { try CassandraValueParser.timeNanoseconds("24:00:00") } - } - - @Test("A time is shown with every digit it holds, so a time key binds back to the same row", arguments: [ - Int64(0), 1_000_000_000, 3_723_500_000_000, 3_723_000_001_000, 86_399_123_456_789, 1 - ]) - func timeTextRoundTrips(nanoseconds: Int64) throws { - let text = CassandraValueParser.timeText(nanoseconds: nanoseconds) - #expect(try CassandraValueParser.timeNanoseconds(text) == nanoseconds) - } - - @Test("A time keeps the millisecond form when that is all it holds") - func timeTextShape() { - #expect(CassandraValueParser.timeText(nanoseconds: 3_723_500_000_000) == "01:02:03.500") - #expect(CassandraValueParser.timeText(nanoseconds: 3_723_000_001_000) == "01:02:03.000001") - #expect(CassandraValueParser.timeText(nanoseconds: 86_399_123_456_789) == "23:59:59.123456789") - #expect(CassandraValueParser.timeText(nanoseconds: 60_000_000_000) == "00:01:00") - } - - @Test("A blob reads the hex the grid shows, and bytes bind as they are") - func blobs() throws { - #expect(try CassandraValueParser.parse("0xCAFE", as: .blob) == .bytes(Data([0xCA, 0xFE]))) - #expect(try CassandraValueParser.bind(.bytes(Data([1, 2])), as: .blob) == .bytes(Data([1, 2]))) - #expect(throws: CassandraValueRefusal.self) { try CassandraValueParser.parse("CAFE", as: .blob) } - } - - @Test("NULL binds as null whatever the column") - func nullIsNull() throws { - #expect(try CassandraValueParser.bind(.null, as: .int) == .null) - #expect(try CassandraValueParser.bind(.null, as: .unsupported("list")) == .null) - } - - @Test("A collection, tuple or user-defined value is refused by name") - func unsupportedTypesAreRefused() { - #expect(throws: CassandraValueRefusal.unsupported(typeName: "list")) { - try CassandraValueParser.parse("[1, 2]", as: .unsupported("list")) - } - } - - @Test("A varint round-trips through the bytes CQL stores", arguments: [ - "0", "1", "-1", "127", "128", "-128", "-129", "255", "256", "123456789012345678901234567890", - "-98765432109876543210" - ]) - func varintRoundTrip(text: String) throws { - let bytes = try #require(CassandraVarint.bytes(fromDecimalInteger: text)) - #expect(CassandraVarint.decimalString(fromTwosComplement: bytes) == text) - } - - @Test("A varint is minimal two's complement") - func varintEncoding() { - #expect(CassandraVarint.bytes(fromDecimalInteger: "127") == Data([0x7F])) - #expect(CassandraVarint.bytes(fromDecimalInteger: "128") == Data([0x00, 0x80])) - #expect(CassandraVarint.bytes(fromDecimalInteger: "-128") == Data([0x80])) - #expect(CassandraVarint.bytes(fromDecimalInteger: "-129") == Data([0xFF, 0x7F])) - #expect(CassandraVarint.bytes(fromDecimalInteger: "12a") == nil) - } - - @Test("A decimal round-trips through its unscaled value and scale", arguments: [ - "0.5", "-12.340", "1000", "0.001", "-0.05", "123456789.987654321" - ]) - func decimalRoundTrip(text: String) throws { - let decimal = try #require(CassandraVarint.decimal(fromText: text)) - #expect(CassandraVarint.decimalString(unscaled: decimal.unscaled, scale: decimal.scale) == text) - } - - @Test("A decimal whose scale would expand past the bound is shown with an exponent, even at the wire's limits") - func extremeDecimalScalesDoNotExpand() throws { - let one = try #require(CassandraVarint.bytes(fromDecimalInteger: "1")) - - #expect(CassandraVarint.decimalString(unscaled: one, scale: Int32.min) == "1E2147483648") - #expect(CassandraVarint.decimalString(unscaled: one, scale: Int32.max) == "1E-2147483647") - #expect(CassandraVarint.decimalString(unscaled: one, scale: -64).count == 65) - #expect(CassandraVarint.decimalString(unscaled: one, scale: -65) == "1E65") - } - - @Test("An exponent too large for any scale is refused rather than trapping") - func extremeExponentIsRefused() { - #expect(CassandraVarint.decimal(fromText: "1e-9223372036854775808") == nil) - #expect(CassandraVarint.decimal(fromText: "1e9223372036854775807") == nil) - #expect(throws: CassandraValueRefusal.self) { try CassandraValueParser.parse("1e-9223372036854775808", as: .decimal) } - } - - @Test("A decimal in exponent form keeps its value") - func decimalExponent() throws { - let decimal = try #require(CassandraVarint.decimal(fromText: "1.5E3")) - #expect(decimal.scale == -2) - #expect(CassandraVarint.decimalString(unscaled: decimal.unscaled, scale: decimal.scale) == "1500") - #expect(CassandraVarint.decimal(fromText: "1.2.3") == nil) - } -} diff --git a/TableProTests/Plugins/CheckConstraintParsingTests.swift b/TableProTests/Plugins/CheckConstraintParsingTests.swift index 92cfee311a..a2966e9b4a 100644 --- a/TableProTests/Plugins/CheckConstraintParsingTests.swift +++ b/TableProTests/Plugins/CheckConstraintParsingTests.swift @@ -140,19 +140,3 @@ struct MySQLServerVersionTests { #expect(!MySQLServerVersion.isKnownBelow((5, 7, 6), banner: "unknown")) } } - -struct MSSQLCheckConstraintDefinitionTests { - @Test("the wrapping parentheses SQL Server adds are removed") - func stripsWrapper() { - #expect(MSSQLCheckConstraintDefinition.expression(fromDefinition: "([a]>(0))") == "[a]>(0)") - } - - @Test("an expression whose parentheses do not wrap the whole thing is left alone") - func leavesNonEnclosingAlone() { - let definition = "([a]>(0) AND len([b])<(10))" - #expect( - MSSQLCheckConstraintDefinition.expression(fromDefinition: definition) - == "[a]>(0) AND len([b])<(10)" - ) - } -} diff --git a/TableProTests/Plugins/CloudPluginConnectionFieldsTests.swift b/TableProTests/Plugins/CloudPluginConnectionFieldsTests.swift deleted file mode 100644 index 2db61ecce6..0000000000 --- a/TableProTests/Plugins/CloudPluginConnectionFieldsTests.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// CloudPluginConnectionFieldsTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct CloudPluginConnectionFieldsTests { - private func registrySnapshot(forTypeId typeId: String) throws -> PluginMetadataSnapshot { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - let entry = try #require(defaults.first { $0.typeId == typeId }) - return entry.snapshot - } - - @Test("DynamoDB, BigQuery, Spanner, and Snowflake never offer the built-in password") - func cloudPluginsHideBuiltInPassword() throws { - for typeId in ["DynamoDB", "BigQuery", "Spanner", "Snowflake"] { - let snapshot = try registrySnapshot(forTypeId: typeId) - #expect(snapshot.connection.hidesBuiltInPassword) - } - } -} diff --git a/TableProTests/Plugins/CloudflareR2SQLConnectTimeoutTests.swift b/TableProTests/Plugins/CloudflareR2SQLConnectTimeoutTests.swift deleted file mode 100644 index c6810ca508..0000000000 --- a/TableProTests/Plugins/CloudflareR2SQLConnectTimeoutTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation -import TableProPluginKit -import TableProR2SQLCore -import Testing - -private final class RecordingR2SQLTransport: R2SQLTransport, @unchecked Sendable { - private let lock = NSLock() - private var recordedRequests: [R2SQLHTTPRequest] = [] - - var requests: [R2SQLHTTPRequest] { - lock.withLock { recordedRequests } - } - - func send(_ request: R2SQLHTTPRequest) async throws -> R2SQLHTTPResponse { - lock.withLock { recordedRequests.append(request) } - let body = #"{"success":true,"errors":[],"result":{"schema":[],"rows":[]}}"# - return R2SQLHTTPResponse(statusCode: 200, body: Data(body.utf8)) - } - - func cancelAll() {} -} - -struct CloudflareR2SQLConnectTimeoutTests { - @Test("Connect uses its deadline without changing later query timeouts", .timeLimit(.minutes(1))) - func connectDeadlineIsRequestScoped() async throws { - let transport = RecordingR2SQLTransport() - let driver = CloudflareR2SQLPluginDriver( - config: DriverConnectionConfig( - host: "", - port: 0, - username: "", - password: "token", - database: "", - additionalFields: [ - "r2AccountId": "account", - "r2Bucket": "bucket", - "connectTimeoutMilliseconds": "2500" - ] - ), - transport: transport - ) - - try await driver.connect() - - let connectRequest = try #require(transport.requests.first) - #expect(connectRequest.timeoutInterval > 0) - #expect(connectRequest.timeoutInterval <= 2.5) - - try await driver.applyQueryTimeout(300) - _ = try await driver.run(sql: "SELECT 1") - - #expect(transport.requests.count == 2) - let queryRequest = try #require(transport.requests.last) - #expect(queryRequest.timeoutInterval == HttpQueryTimeout(serverTimeoutSeconds: 300).requestTimeoutInterval) - } -} diff --git a/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift b/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift deleted file mode 100644 index d5e2d711a7..0000000000 --- a/TableProTests/Plugins/CloudflareR2SQLMetadataParityTests.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// CloudflareR2SQLMetadataParityTests.swift -// TableProTests -// -// The app shows Cloudflare R2 SQL in the database picker, form and editor before its registry -// plugin is installed, from a curated copy of the plugin's metadata. `CloudflareR2SQLMetadata` is -// the plugin's own file, compiled into this target, so the two copies are compared here instead of -// drifting apart until the plugin loads and silently replaces one with the other. -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct CloudflareR2SQLMetadataParityTests { - private func curated() throws -> PluginMetadataSnapshot { - try #require( - PluginMetadataRegistry.shared.builtInDefaults().first { $0.typeId == "Cloudflare R2 SQL" }?.snapshot - ) - } - - @Test("Identity and schema vocabulary match the plugin") - func identity() throws { - let snapshot = try curated() - - #expect(snapshot.displayName == CloudflareR2SQLMetadata.displayName) - #expect(snapshot.iconName == CloudflareR2SQLMetadata.iconName) - #expect(snapshot.brandColorHex == CloudflareR2SQLMetadata.brandColorHex) - #expect(snapshot.schema.defaultSchemaName == CloudflareR2SQLMetadata.defaultSchemaName) - #expect(snapshot.schema.schemaEntityName == CloudflareR2SQLMetadata.schemaEntityName) - #expect(snapshot.schema.containerEntityName == CloudflareR2SQLMetadata.containerEntityName) - #expect(snapshot.schema.structureColumnFields == CloudflareR2SQLMetadata.structureColumnFields) - } - - @Test("Editor metadata matches the plugin") - func editor() throws { - let snapshot = try curated() - let dialect = try #require(snapshot.editor.sqlDialect) - let shipped = CloudflareR2SQLMetadata.dialect - - #expect(dialect.identifierQuote == shipped.identifierQuote) - #expect(dialect.keywords == shipped.keywords) - #expect(dialect.functions == shipped.functions) - #expect(dialect.dataTypes == shipped.dataTypes) - #expect(dialect.paginationStyle == shipped.paginationStyle) - #expect(dialect.booleanLiteralStyle == shipped.booleanLiteralStyle) - #expect(dialect.likeEscapeStyle == shipped.likeEscapeStyle) - #expect(dialect.regexSyntax == shipped.regexSyntax) - #expect(snapshot.editor.columnTypesByCategory == CloudflareR2SQLMetadata.columnTypesByCategory) - #expect(snapshot.editor.statementCompletions.map { [$0.label, $0.insertText] } - == CloudflareR2SQLMetadata.statementCompletions.map { [$0.label, $0.insertText] }) - #expect(snapshot.explainVariants.map { [$0.id, $0.label, $0.sqlPrefix] } - == CloudflareR2SQLMetadata.explainVariants.map { [$0.id, $0.label, $0.sqlPrefix] }) - } - - @Test("Connection fields match the plugin") - func connectionFields() throws { - let fields = try curated().connection.additionalConnectionFields - let shipped = CloudflareR2SQLMetadata.connectionFields - - #expect(fields.map(\.id) == shipped.map(\.id)) - #expect(fields.map(\.label) == shipped.map(\.label)) - #expect(fields.map(\.placeholder) == shipped.map(\.placeholder)) - #expect(fields.map(\.isRequired) == shipped.map(\.isRequired)) - #expect(fields.map(\.section) == shipped.map(\.section)) - } - - @Test("The app-only capabilities describe a read-only engine that cannot skip rows") - func appOnlyCapabilities() throws { - let capabilities = try curated().capabilities - - #expect(capabilities.isEngineReadOnly) - #expect(capabilities.pagination == .leadingRowsOnly(maximumRows: CloudflareR2SQLMetadata.maximumRows)) - } -} diff --git a/TableProTests/Plugins/DamengConnectTimeoutTests.swift b/TableProTests/Plugins/DamengConnectTimeoutTests.swift deleted file mode 100644 index 2c67004896..0000000000 --- a/TableProTests/Plugins/DamengConnectTimeoutTests.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import Testing - -@testable import TablePro - -struct DamengConnectTimeoutTests { - @Test("Cancellation wins even when it arrives before native connect starts") - func cancellationBeforeArmWins() async { - let gate = DamengConnectGate() - var abandoned = false - gate.fail(CancellationError()) { abandoned = true } - - do { - try await withCheckedThrowingContinuation { - gate.arm($0) - } - Issue.record("The cancelled attempt was adopted") - } catch is CancellationError { - #expect(abandoned) - } catch { - Issue.record("Unexpected error: \(error)") - } - - // Native connect can return after the host gate has already resumed the caller. The - // late delivery must be ignored rather than resuming the continuation a second time. - gate.finish(with: .success(())) - } - - @Test("Initial connect uses the remaining budget and reconnect uses the full setting") - func remainingAndReconnectBudgets() { - let timeout = DamengConnectTimeout(additionalFields: [ - "connectTimeoutMilliseconds": "4321", - "connectTimeoutSeconds": "30" - ]) - - #expect(timeout.milliseconds == 4_321) - #expect(timeout.reconnectMilliseconds == 30_000) - } - - @Test("Missing and invalid values keep the default while valid extremes clamp") - func fallbacksAndClamps() { - #expect(DamengConnectTimeout(additionalFields: [:]).milliseconds == 30_000) - #expect( - DamengConnectTimeout(additionalFields: ["connectTimeoutMilliseconds": "bad"]).milliseconds == 30_000 - ) - #expect(DamengConnectTimeout(additionalFields: ["connectTimeoutSeconds": "0"]).milliseconds == 1) - #expect( - DamengConnectTimeout(additionalFields: ["connectTimeoutSeconds": String(Int.max)]).milliseconds - == 3_600_000 - ) - #expect( - DamengConnectTimeout(additionalFields: ["connectTimeoutSeconds": String(Int64.min)]).milliseconds == 1 - ) - } -} diff --git a/TableProTests/Plugins/DamengParameterBinderTests.swift b/TableProTests/Plugins/DamengParameterBinderTests.swift deleted file mode 100644 index c6f3c17479..0000000000 --- a/TableProTests/Plugins/DamengParameterBinderTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -struct DamengParameterBinderTests { - @Test("binds text, null, and binary values") - func bindsSupportedValues() throws { - let sql = try DamengParameterBinder.bind( - query: "INSERT INTO sample VALUES (?, ?, ?)", - parameters: [.text("达梦 O'Brien"), .null, .bytes(Data([0x00, 0xAB, 0xFF]))], - escaping: .backslashLiteral - ) - #expect(sql == "INSERT INTO sample VALUES ('达梦 O''Brien', NULL, HEXTORAW('00ABFF'))") - } - - @Test("does not bind placeholders inside SQL syntax regions") - func preservesSyntaxRegions() throws { - let sql = try DamengParameterBinder.bind( - query: """ - SELECT '?', "?", q'[?]', ? - FROM sample - -- ? - /* outer ? /* inner ? */ */ - """, - parameters: [.text("bound")], - escaping: .backslashLiteral - ) - #expect(sql.contains("SELECT '?', \"?\", q'[?]', 'bound'")) - #expect(sql.contains("-- ?")) - #expect(sql.contains("/* outer ? /* inner ? */ */")) - } - - @Test("escapes injection-shaped text as one literal") - func escapesInjectionText() throws { - let sql = try DamengParameterBinder.bind( - query: "SELECT ? FROM DUAL", - parameters: [.text("x'; DROP SCHEMA SYSDBA CASCADE; --")], - escaping: .backslashLiteral - ) - #expect(sql == "SELECT 'x''; DROP SCHEMA SYSDBA CASCADE; --' FROM DUAL") - } - - @Test("rejects parameter count mismatches") - func rejectsCountMismatches() { - #expect(throws: DamengParameterBindingError.insufficientParameters) { - try DamengParameterBinder.bind( - query: "SELECT ?, ?", parameters: [.text("one")], escaping: .backslashLiteral - ) - } - #expect(throws: DamengParameterBindingError.unusedParameters) { - try DamengParameterBinder.bind( - query: "SELECT ?", parameters: [.text("one"), .text("two")], escaping: .backslashLiteral - ) - } - } - - @Test("rejects embedded null bytes") - func rejectsEmbeddedNull() { - #expect(throws: DamengParameterBindingError.embeddedNull) { - try DamengParameterBinder.bind( - query: "SELECT ?", parameters: [.text("a\0b")], escaping: .backslashLiteral - ) - } - } - - @Test("escapes backslashes when the server treats them as escapes") - func escapesBackslashInEscapeMode() throws { - let sql = try DamengParameterBinder.bind( - query: "SELECT * FROM t WHERE a = ? AND b = ?", - parameters: [.text("\\"), .text(" OR 1=1 -- ")], - escaping: .backslashEscape - ) - #expect(sql == "SELECT * FROM t WHERE a = '\\\\' AND b = ' OR 1=1 -- '") - } - - @Test("keeps backslashes literal when the server does not escape them") - func preservesBackslashInLiteralMode() throws { - let sql = try DamengParameterBinder.bind( - query: "SELECT ? FROM DUAL", - parameters: [.text("C:\\temp")], - escaping: .backslashLiteral - ) - #expect(sql == "SELECT 'C:\\temp' FROM DUAL") - } - - @Test("rejects backslashes when backslash handling is unknown") - func rejectsBackslashWhenUnprobed() { - #expect(throws: DamengParameterBindingError.unknownBackslashHandling) { - try DamengParameterBinder.bind( - query: "SELECT ?", parameters: [.text("a\\b")], escaping: .unknown - ) - } - #expect(throws: DamengParameterBindingError.unknownBackslashHandling) { - try DamengParameterBinder.escapedText("a\\\u{0301}b", escaping: .unknown) - } - #expect(DamengParameterBindingError.unknownBackslashHandling.errorDescription?.isEmpty == false) - } - - @Test("escapes a quote that carries a combining mark") - func escapesQuoteWithCombiningMark() throws { - let sql = try DamengParameterBinder.bind( - query: "SELECT * FROM t WHERE a = ?", - parameters: [.text("x'\u{0301} OR 1=1 -- ")], - escaping: .backslashLiteral - ) - #expect(sql == "SELECT * FROM t WHERE a = 'x''\u{0301} OR 1=1 -- '") - } - - @Test("doubles a backslash that carries a combining mark in escape mode") - func escapesBackslashWithCombiningMark() throws { - let sql = try DamengParameterBinder.bind( - query: "SELECT ?", - parameters: [.text("a\\\u{0301}b")], - escaping: .backslashEscape - ) - #expect(sql == "SELECT 'a\\\\\u{0301}b'") - } - - @Test("tracks an escaped quote inside a literal when backslashes escape") - func tracksBackslashEscapedQuoteInsideLiterals() throws { - let sql = try DamengParameterBinder.bind( - query: "SELECT '\\'', ? FROM DUAL", - parameters: [.text("bound")], - escaping: .backslashEscape - ) - #expect(sql == "SELECT '\\'', 'bound' FROM DUAL") - } -} diff --git a/TableProTests/Plugins/DamengStatementClassifierTests.swift b/TableProTests/Plugins/DamengStatementClassifierTests.swift deleted file mode 100644 index b327a63680..0000000000 --- a/TableProTests/Plugins/DamengStatementClassifierTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Testing -@testable import TablePro - -struct DamengStatementClassifierTests { - @Test("recognizes row-producing statements after comments") - func recognizesRowStatements() { - #expect(DamengStatementClassifier.likelyReturnsRows("/* nested /* note */ */ SELECT 1")) - #expect(DamengStatementClassifier.likelyReturnsRows("-- note\nWITH value AS (SELECT 1) SELECT * FROM value")) - #expect(DamengStatementClassifier.likelyReturnsRows("EXPLAIN SELECT 1")) - #expect(!DamengStatementClassifier.likelyReturnsRows("UPDATE sample SET value = 1")) - } - - @Test("recognizes a select behind a byte order mark or exotic whitespace") - func recognizesRowStatementsAfterUnusualWhitespace() { - #expect(DamengStatementClassifier.likelyReturnsRows("\u{FEFF}SELECT 1")) - #expect(DamengStatementClassifier.likelyReturnsRows("\u{00A0}SELECT 1")) - #expect(DamengStatementClassifier.likelyReturnsRows("\u{000B}\u{000C}SELECT 1")) - #expect(DamengStatementClassifier.likelyReturnsRows("\u{2028}-- note\nSELECT 1")) - #expect(!DamengStatementClassifier.likelyReturnsRows("\u{FEFF}UPDATE sample SET value = 1")) - } - - @Test("returns no keyword for statements that never reach one") - func ignoresStatementsWithoutAKeyword() { - #expect(!DamengStatementClassifier.likelyReturnsRows("")) - #expect(!DamengStatementClassifier.likelyReturnsRows(" \n\t ")) - #expect(!DamengStatementClassifier.likelyReturnsRows("-- only a comment")) - #expect(!DamengStatementClassifier.likelyReturnsRows("/* unterminated")) - #expect(!DamengStatementClassifier.likelyReturnsRows("123")) - } - - @Test("a misjudged statement costs framing, not its rows") - func misjudgedStatementsAreOnlyAFramingHint() { - // MERGE and CALL fall outside the keyword set. The bridge converts whatever columns - // came back regardless, so a false negative here must not be treated as "no rows". - #expect(!DamengStatementClassifier.likelyReturnsRows("MERGE INTO target USING source ON (1 = 1)")) - #expect(!DamengStatementClassifier.likelyReturnsRows("CALL sp_report()")) - } -} diff --git a/TableProTests/Plugins/DamengSystemSchemasTests.swift b/TableProTests/Plugins/DamengSystemSchemasTests.swift deleted file mode 100644 index ae1709e022..0000000000 --- a/TableProTests/Plugins/DamengSystemSchemasTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// DamengSystemSchemasTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct DamengSystemSchemasTests { - @Test("The app lists the same system schemas the plugin does") - func curatedListMatchesThePlugin() { - let curated = PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: "Dameng")?.schema.systemSchemaNames - #expect(curated == DamengSystemSchemas.listed) - } - - /// DM8 gives every user a default schema named after it, so a `SYSDBA` login's own tables land in `SYSDBA`. - @Test("SYSDBA lists with the user's schemas") - func sysdbaIsNotListedAsSystem() { - #expect(!DamengSystemSchemas.listed.contains("SYSDBA")) - } - - @Test("Every listed system schema, SYSDBA and SYSDBO are refused a drop") - func dropGuardCoversListedAndAdministratorSchemas() { - for name in DamengSystemSchemas.listed + ["SYSDBA", "SYSDBO"] { - #expect(DamengSystemSchemas.isProtectedFromDrop(name), "\(name) must not be dropped") - } - #expect(!DamengSystemSchemas.isProtectedFromDrop("APP")) - } - - /// A DM8 instance created case-insensitive treats `sys` and `SYS` as one schema. - @Test("The drop guard matches any spelling") - func dropGuardIgnoresCase() { - #expect(DamengSystemSchemas.isProtectedFromDrop("sys")) - #expect(DamengSystemSchemas.isProtectedFromDrop("SysDba")) - } -} diff --git a/TableProTests/Plugins/DocumentStoreCaseSensitivityTests.swift b/TableProTests/Plugins/DocumentStoreCaseSensitivityTests.swift deleted file mode 100644 index 83e7039c7e..0000000000 --- a/TableProTests/Plugins/DocumentStoreCaseSensitivityTests.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// DocumentStoreCaseSensitivityTests.swift -// TableProTests -// -// Case sensitivity in the drivers that match without SQL (#2048) -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoDBCaseSensitivityTests { - private let builder = MongoDBQueryBuilder() - - private func document(_ op: String, _ value: String, isCaseSensitive: Bool) -> String { - builder.buildFilterDocument(from: [ - PluginQueryFilter(column: "name", op: op, value: value, isCaseSensitive: isCaseSensitive) - ]) - } - - @Test("Contains ignoring case asks for the ignore-case option") - func testContainsIgnoringCase() { - #expect(document("CONTAINS", "ali", isCaseSensitive: false).contains("\"$options\": \"i\"")) - } - - @Test("Contains matching case asks for no option") - func testContainsMatchingCase() { - #expect(!document("CONTAINS", "ali", isCaseSensitive: true).contains("$options")) - } - - @Test("Starts with and ends with follow the same setting") - func testAnchoredOperators() { - #expect(document("STARTS WITH", "a", isCaseSensitive: false).contains("\"$options\": \"i\"")) - #expect(!document("ENDS WITH", "a", isCaseSensitive: true).contains("$options")) - } - - @Test("Equals matching case stays an exact value match") - func testEqualsMatchingCase() { - #expect(document("=", "Alice", isCaseSensitive: true) == "{\"name\": \"Alice\"}") - } - - @Test("Equals ignoring case becomes an anchored ignore-case match") - func testEqualsIgnoringCase() { - let doc = document("=", "Alice", isCaseSensitive: false) - #expect(doc.contains("^Alice$")) - #expect(doc.contains("\"$options\": \"i\"")) - } - - @Test("Equals ignoring case escapes regex metacharacters in the value") - func testEqualsIgnoringCaseEscapes() { - #expect(document("=", "a.b", isCaseSensitive: false).contains("^a\\\\.b$")) - } - - @Test("IN matching case keeps the list operator") - func testInListMatchingCase() { - #expect(document("IN", "a,b", isCaseSensitive: true).contains("$in")) - } - - @Test("IN ignoring case becomes a set of anchored matches") - func testInListIgnoringCase() { - let doc = document("IN", "a,b", isCaseSensitive: false) - #expect(doc.contains("$or")) - #expect(doc.contains("^a$")) - #expect(doc.contains("^b$")) - } - - @Test("NOT IN ignoring case negates the whole set") - func testNotInListIgnoringCase() { - #expect(document("NOT IN", "a,b", isCaseSensitive: false).contains("$nor")) - } -} - -struct ElasticsearchCaseSensitivityTests { - private let keywordField = ["status": ElasticsearchFieldInfo(type: "keyword", hasKeywordSubfield: false)] - - private func clause(_ op: String, _ value: String, isCaseSensitive: Bool) -> [String: Any] { - ElasticsearchQueryBuilder.clause( - for: ElasticsearchFilterSpec( - column: "status", op: op, value: value, caseSensitive: isCaseSensitive - ), - fields: keywordField - ) - } - - @Test("Contains ignoring case sets the wildcard option") - func testContainsIgnoringCase() { - let wildcard = clause("CONTAINS", "x", isCaseSensitive: false)["wildcard"] as? [String: Any] - let options = wildcard?["status"] as? [String: Any] - #expect(options?["case_insensitive"] as? Bool == true) - } - - @Test("Contains matching case leaves the option off") - func testContainsMatchingCase() { - let wildcard = clause("CONTAINS", "x", isCaseSensitive: true)["wildcard"] as? [String: Any] - let options = wildcard?["status"] as? [String: Any] - #expect(options?["case_insensitive"] == nil) - } - - @Test("Equals ignoring case uses a term with the option") - func testEqualsIgnoringCase() { - let term = clause("=", "active", isCaseSensitive: false)["term"] as? [String: Any] - let options = term?["status"] as? [String: Any] - #expect(options?["case_insensitive"] as? Bool == true) - #expect(options?["value"] as? String == "active") - } - - @Test("IN matching case keeps the terms query") - func testInListMatchingCase() { - #expect(clause("IN", "a,b", isCaseSensitive: true)["terms"] != nil) - } - - @Test("IN ignoring case becomes a should of terms, since terms has no option") - func testInListIgnoringCase() { - let bool = clause("IN", "a,b", isCaseSensitive: false)["bool"] as? [String: Any] - let should = bool?["should"] as? [[String: Any]] - #expect(should?.count == 2) - #expect(bool?["minimum_should_match"] as? Int == 1) - } - - @Test("Regex ignoring case sets the option") - func testRegexIgnoringCase() { - let regexp = clause("REGEX", "a.*", isCaseSensitive: false)["regexp"] as? [String: Any] - let options = regexp?["status"] as? [String: Any] - #expect(options?["case_insensitive"] as? Bool == true) - } - - @Test("A server too old for the option never receives it, whatever the row asks for") - func testVersionGateWinsOverTheRow() { - let gated = ElasticsearchQueryBuilder.clause( - for: ElasticsearchFilterSpec(column: "status", op: "CONTAINS", value: "x", caseSensitive: false), - fields: keywordField, - supportsCaseInsensitive: false - ) - let options = (gated["wildcard"] as? [String: Any])?["status"] as? [String: Any] - #expect(options?["case_insensitive"] == nil) - } - - @Test("A spec with no case setting keeps the behaviour each operator had before") - func testOperatorDefaultsMatchPriorBehaviour() { - #expect(ElasticsearchFilterSpec(column: "a", op: "CONTAINS", value: "x").ignoresCase) - #expect(ElasticsearchFilterSpec(column: "a", op: "STARTS WITH", value: "x").ignoresCase) - #expect(ElasticsearchFilterSpec(column: "a", op: "REGEX", value: "x").ignoresCase) - #expect(ElasticsearchFilterSpec(column: "a", op: "=", value: "x").ignoresCase == false) - #expect(ElasticsearchFilterSpec(column: "a", op: "IN", value: "x").ignoresCase == false) - } -} - -struct EtcdCaseSensitivityTests { - private let builder = EtcdQueryBuilder() - - private func parsed(_ isCaseSensitive: Bool) -> EtcdParsedQuery? { - let query = builder.buildFilteredQuery( - prefix: "/app", - filters: [ - PluginQueryFilter(column: "Key", op: "CONTAINS", value: "cfg", isCaseSensitive: isCaseSensitive) - ], - logicMode: "AND", - sortColumns: [], - limit: 100, - offset: 0 - ) - return EtcdQueryBuilder.parseRangeQuery(query) - } - - @Test("The encoded query carries the row's case setting") - func testCaseFlagRoundTrips() { - #expect(parsed(true)?.filter.conditions.first?.isCaseSensitive == true) - #expect(parsed(false)?.filter.conditions.first?.isCaseSensitive == false) - } - - @Test("The rest of the query survives the extra field") - func testOtherFieldsSurvive() { - let range = parsed(false) - #expect(range?.prefix == "/app") - #expect(range?.filter.conditions.first?.comparison == .contains) - #expect(range?.filter.conditions.first?.value == "cfg") - #expect(range?.limit == 100) - } -} - -struct BigQueryCaseSensitivityTests { - private func sql(_ op: String, _ value: String, isCaseSensitive: Bool) -> String { - let query = BigQueryQueryBuilder.encodeFilteredQuery( - table: "users", dataset: "main", - filters: [ - PluginQueryFilter(column: "name", op: op, value: value, isCaseSensitive: isCaseSensitive) - ], - logicMode: "AND", sortColumns: [], limit: 10, offset: 0, columns: ["name"] - ) - guard let params = BigQueryQueryBuilder.decode(query) else { return "" } - return BigQueryQueryBuilder.buildSQL(from: params, projectId: "p") - } - - @Test("Contains ignoring case folds both sides") - func testContainsIgnoringCase() { - let generated = sql("CONTAINS", "ali", isCaseSensitive: false) - #expect(generated.contains("LOWER(CAST(`name` AS STRING)) LIKE LOWER('%ali%')")) - } - - @Test("Contains matching case stays untouched") - func testContainsMatchingCase() { - #expect(sql("CONTAINS", "ali", isCaseSensitive: true).contains("CAST(`name` AS STRING) LIKE '%ali%'")) - } - - @Test("Equals ignoring case folds both sides") - func testEqualsIgnoringCase() { - #expect(sql("=", "Alice", isCaseSensitive: false).contains("LOWER(`name`) = LOWER('Alice')")) - } - - @Test("BigQuery never emits an ESCAPE clause, which it does not support") - func testNoEscapeClause() { - #expect(!sql("CONTAINS", "ali", isCaseSensitive: false).contains("ESCAPE")) - #expect(!sql("CONTAINS", "ali", isCaseSensitive: true).contains("ESCAPE")) - } -} diff --git a/TableProTests/Plugins/DuckDBConnectAttemptTests.swift b/TableProTests/Plugins/DuckDBConnectAttemptTests.swift deleted file mode 100644 index efc350e98c..0000000000 --- a/TableProTests/Plugins/DuckDBConnectAttemptTests.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// DuckDBConnectAttemptTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct DuckDBConnectAttemptTests { - @Test("Every bootstrap phase consumes the same injected deadline") - func phasesShareDeadline() throws { - let start = ContinuousClock.now - let attempt = DuckDBConnectAttempt( - additionalFields: ["connectTimeoutMilliseconds": "2500"], - now: start, - interrupt: {} - ) - - #expect(attempt.remainingMilliseconds(at: start) == 2_500) - #expect(attempt.remainingMilliseconds(at: start.advanced(by: .milliseconds(1_200))) == 1_300) - #expect(attempt.remainingMilliseconds(at: start.advanced(by: .milliseconds(2_499))) == 1) - #expect(attempt.remainingMilliseconds(at: start.advanced(by: .milliseconds(2_500))) == nil) - try attempt.check(at: start.advanced(by: .milliseconds(2_499))) - } - - @Test("Expiry interrupts once and makes later phase checks fail") - func expiryInterruptsOnce() { - let start = ContinuousClock.now - let recorder = DuckDBConnectInterruptRecorder() - let attempt = DuckDBConnectAttempt( - additionalFields: ["connectTimeoutMilliseconds": "1000"], - now: start, - interrupt: { recorder.record() } - ) - - attempt.expire(at: start.advanced(by: .milliseconds(999))) - #expect(!recorder.didInterrupt) - - attempt.expire(at: start.advanced(by: .milliseconds(1_000))) - attempt.expire(at: start.advanced(by: .milliseconds(2_000))) - #expect(recorder.count == 1) - #expect(throws: DuckDBConnectAttemptError.timedOut) { - try attempt.check(at: start.advanced(by: .milliseconds(2_000))) - } - } - - @Test("Cancellation interrupts synchronously and remains cancellation") - func cancellationInterrupts() { - let recorder = DuckDBConnectInterruptRecorder() - let attempt = DuckDBConnectAttempt( - additionalFields: ["connectTimeoutMilliseconds": "1000"], - interrupt: { recorder.record() } - ) - - attempt.cancel() - #expect(recorder.count == 1) - #expect(throws: CancellationError.self) { - try attempt.check() - } - #expect(attempt.stop() is CancellationError) - } - - @Test("A late result cannot be adopted after the deadline") - func deadlineFencesLateAdoption() { - let start = ContinuousClock.now - let recorder = DuckDBConnectInterruptRecorder() - let attempt = DuckDBConnectAttempt( - additionalFields: ["connectTimeoutMilliseconds": "1000"], - now: start, - interrupt: { recorder.record() } - ) - var adopted = false - - #expect(throws: DuckDBConnectAttemptError.timedOut) { - try attempt.finish(at: start.advanced(by: .milliseconds(1_000))) { - adopted = true - } - } - - #expect(!adopted) - #expect(recorder.count == 1) - } - - @Test("A result adopted before the deadline ignores a stale timer") - func adoptionFencesStaleTimer() throws { - let start = ContinuousClock.now - let recorder = DuckDBConnectInterruptRecorder() - let attempt = DuckDBConnectAttempt( - additionalFields: ["connectTimeoutMilliseconds": "1000"], - now: start, - interrupt: { recorder.record() } - ) - var adopted = false - - try attempt.finish(at: start.advanced(by: .milliseconds(999))) { - adopted = true - } - attempt.expire(at: start.advanced(by: .milliseconds(1_000))) - - #expect(adopted) - #expect(!recorder.didInterrupt) - } -} - -private final class DuckDBConnectInterruptRecorder: @unchecked Sendable { - private let lock = NSLock() - private var value = 0 - - var count: Int { - lock.withLock { value } - } - - var didInterrupt: Bool { - lock.withLock { value > 0 } - } - - func record() { - lock.withLock { value += 1 } - } -} diff --git a/TableProTests/Plugins/DuckDBConnectionFieldsTests.swift b/TableProTests/Plugins/DuckDBConnectionFieldsTests.swift deleted file mode 100644 index ce1ef3483f..0000000000 --- a/TableProTests/Plugins/DuckDBConnectionFieldsTests.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// DuckDBConnectionFieldsTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct DuckDBConnectionFieldsTests { - private func duckdbFields() throws -> [ConnectionField] { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - let entry = try #require(defaults.first { $0.typeId == "DuckDB" }) - return entry.snapshot.connection.additionalConnectionFields - } - - @Test("Registry declares mode, local, remote and options fields in order") - func registryDeclaresAllFields() throws { - let fields = try duckdbFields() - #expect(fields.map(\.id) == [ - "duckdbMode", - "duckdbFilePath", - "duckdbHost", - "duckdbPort", - "duckdbToken", - "duckdbAlias", - "duckdbReadOnly", - "duckdbIdleReleaseMinutes" - ]) - } - - /// Both belong to a local file: `:memory:` and a remote Quack server hold no file lock to - /// release, and DuckDB refuses `access_mode` outright for an in-memory database. - @Test("Read-only and idle release are local-file settings, under Options") - func fileSettingsAreLocalOnly() throws { - let fields = try duckdbFields() - let localRule = FieldVisibilityRule(fieldId: "duckdbMode", values: ["local"]) - for id in ["duckdbReadOnly", "duckdbIdleReleaseMinutes"] { - let field = try #require(fields.first { $0.id == id }) - #expect(field.visibleWhen == localRule, "\(id)") - #expect(field.section == .advanced, "\(id)") - } - } - - /// The lock is held unless a user asks otherwise, because a release costs the session its - /// temporary objects and its attached catalogs when one is taken. - @Test("Both file settings default to leaving the connection as it is today") - func fileSettingsDefaultToNoChange() throws { - let fields = try duckdbFields() - let readOnly = try #require(fields.first { $0.id == "duckdbReadOnly" }) - #expect(readOnly.defaultValue == "false") - #expect(readOnly.fieldType == .toggle) - - let idle = try #require(fields.first { $0.id == "duckdbIdleReleaseMinutes" }) - #expect(idle.defaultValue == "0") - #expect(idle.fieldType == .stepper(range: ConnectionField.IntRange(0...240))) - } - - @Test("Mode dropdown defaults to local and offers a remote option") - func modeDropdownDefaultsToLocal() throws { - let fields = try duckdbFields() - let mode = try #require(fields.first { $0.id == "duckdbMode" }) - #expect(mode.defaultValue == "local") - guard case .dropdown(let options) = mode.fieldType else { - Issue.record("Expected a dropdown field type") - return - } - #expect(options.map(\.value) == ["local", "remote"]) - } - - @Test("File path is required, visible only for local, and carries a Browse button") - func filePathVisibleOnlyForLocal() throws { - let fields = try duckdbFields() - let path = try #require(fields.first { $0.id == "duckdbFilePath" }) - #expect(path.isRequired) - #expect(path.fieldType == .text) - #expect(path.visibleWhen == FieldVisibilityRule(fieldId: "duckdbMode", values: ["local"])) - #expect(path.id.hasSuffix("FilePath")) - } - - @Test("Host, port, token, and alias are visible only for remote") - func remoteFieldsVisibleOnlyForRemote() throws { - let fields = try duckdbFields() - let remoteRule = FieldVisibilityRule(fieldId: "duckdbMode", values: ["remote"]) - for id in ["duckdbHost", "duckdbPort", "duckdbToken", "duckdbAlias"] { - let field = try #require(fields.first { $0.id == id }) - #expect(field.visibleWhen == remoteRule) - } - } - - @Test("Token is a secure field that hides the main password row") - func tokenIsSecureAndHidesPassword() throws { - let fields = try duckdbFields() - let token = try #require(fields.first { $0.id == "duckdbToken" }) - #expect(token.isSecure) - #expect(token.hidesPassword) - } - - @Test("Registry marks DuckDB as never using the built-in password") - func hidesBuiltInPassword() throws { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - let entry = try #require(defaults.first { $0.typeId == "DuckDB" }) - #expect(entry.snapshot.connection.hidesBuiltInPassword) - } - - @Test("Port defaults to 9494") - func portDefaultsTo9494() throws { - let fields = try duckdbFields() - let port = try #require(fields.first { $0.id == "duckdbPort" }) - #expect(port.defaultValue == "9494") - } - - @Test("Field visibility swaps between local and remote modes") - @MainActor - func visibilitySwapsByMode() throws { - let type = DatabaseType(rawValue: "DuckDB") - let fields = try duckdbFields() - let path = try #require(fields.first { $0.id == "duckdbFilePath" }) - let host = try #require(fields.first { $0.id == "duckdbHost" }) - - let local = ["duckdbMode": "local"] - #expect(PluginFieldRendering.isFieldVisible(path, type: type, values: local)) - #expect(!PluginFieldRendering.isFieldVisible(host, type: type, values: local)) - - let remote = ["duckdbMode": "remote"] - #expect(!PluginFieldRendering.isFieldVisible(path, type: type, values: remote)) - #expect(PluginFieldRendering.isFieldVisible(host, type: type, values: remote)) - } -} diff --git a/TableProTests/Plugins/DuckDBFileKindsTests.swift b/TableProTests/Plugins/DuckDBFileKindsTests.swift deleted file mode 100644 index a561255d9f..0000000000 --- a/TableProTests/Plugins/DuckDBFileKindsTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// DuckDBFileKindsTests.swift -// TableProTests -// -// Tests for DuckDBFileKinds (compiled via project.yml from DuckDBDriverPlugin). -// - -import Foundation -import Testing - -struct DuckDBFileKindsTests { - @Test("The database formats lead, so the file field's placeholder names one") - func databaseFormatsComeFirst() { - #expect(DuckDBFileKinds.all.first == "duckdb") - #expect(DuckDBFileKinds.all.prefix(2) == ["duckdb", "ddb"]) - } - - @Test("Every kind DuckDB can open is offered, with no duplicates") - func allCoversBothGroups() { - #expect(DuckDBFileKinds.all == DuckDBFileKinds.database + DuckDBFileKinds.readOnlyData) - #expect(Set(DuckDBFileKinds.all).count == DuckDBFileKinds.all.count) - for kind in ["parquet", "csv", "tsv", "json"] { - #expect(DuckDBFileKinds.all.contains(kind)) - } - } - - @Test("A missing database path may be created, because that is how you make a new one") - func databasePathsCanBeCreated() { - #expect(DuckDBFileKinds.canBeCreated(atPath: "/tmp/new.duckdb")) - #expect(DuckDBFileKinds.canBeCreated(atPath: "/tmp/new.ddb")) - #expect(DuckDBFileKinds.canBeCreated(atPath: "/tmp/new")) - #expect(DuckDBFileKinds.canBeCreated(atPath: ":memory:")) - } - - @Test("A missing data-file path is a typo, not a request to create an empty database") - func dataFilePathsAreNotCreated() { - #expect(!DuckDBFileKinds.canBeCreated(atPath: "/tmp/sales.parquet")) - #expect(!DuckDBFileKinds.canBeCreated(atPath: "/tmp/sales.csv")) - #expect(!DuckDBFileKinds.canBeCreated(atPath: "/tmp/sales.tsv")) - #expect(!DuckDBFileKinds.canBeCreated(atPath: "/tmp/sales.json")) - #expect(!DuckDBFileKinds.canBeCreated(atPath: "/tmp/sales.ndjson")) - } - - @Test("The extension check ignores case") - func extensionMatchIgnoresCase() { - #expect(!DuckDBFileKinds.canBeCreated(atPath: "/tmp/Sales.PARQUET")) - #expect(!DuckDBFileKinds.canBeCreated(atPath: "/tmp/Sales.Csv")) - } -} diff --git a/TableProTests/Plugins/DuckDBIdleReleaseTests.swift b/TableProTests/Plugins/DuckDBIdleReleaseTests.swift deleted file mode 100644 index 8a9ba339f2..0000000000 --- a/TableProTests/Plugins/DuckDBIdleReleaseTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// DuckDBIdleReleaseTests.swift -// TableProTests -// -// Tests for DuckDBIdleRelease and DuckDBAccessMode (compiled via project.yml from -// DuckDBDriverPlugin). -// - -import Foundation -import Testing - -struct DuckDBIdleReleaseTests { - /// Every one of these is a value the connection form or a hand-edited file can produce, and - /// each has to mean "never" rather than some interval nobody chose. A malformed setting that - /// released the file on a guessed schedule would drop the lock under a user who never asked. - @Test("Anything that is not a positive number of minutes means never") - func malformedValuesMeanNever() { - for value in [nil, "", " ", "0", "-1", "-30", "abc", "5 minutes", "1.5", "٣"] { - #expect(DuckDBIdleRelease.minutes(fromFieldValue: value) == nil, "\(value ?? "nil")") - #expect(DuckDBIdleRelease.interval(fromFieldValue: value) == nil, "\(value ?? "nil")") - } - } - - @Test("A positive number of minutes becomes that interval") - func positiveValuesParse() { - #expect(DuckDBIdleRelease.minutes(fromFieldValue: "1") == 1) - #expect(DuckDBIdleRelease.minutes(fromFieldValue: " 15 ") == 15) - #expect(DuckDBIdleRelease.interval(fromFieldValue: "5") == .seconds(300)) - } - - @Test("A value past the maximum is clamped rather than honoured") - func clampsToTheMaximum() { - #expect(DuckDBIdleRelease.minutes(fromFieldValue: "100000") == DuckDBIdleRelease.maximumMinutes) - #expect(DuckDBIdleRelease.minutes(fromFieldValue: "240") == 240) - } - - /// The poll is what decides how late a release can be. A quarter of the interval bounds the - /// lateness at 25%, and the floor and ceiling stop a one-minute setting spinning and a - /// four-hour setting waking up once an hour and releasing 59 minutes late. - @Test("The poll interval stays between five and sixty seconds") - func pollIntervalIsBounded() { - #expect(DuckDBIdleRelease.pollInterval(for: .seconds(60)) == .seconds(15)) - #expect(DuckDBIdleRelease.pollInterval(for: .seconds(20)) == .seconds(5)) - #expect(DuckDBIdleRelease.pollInterval(for: .seconds(14_400)) == .seconds(60)) - } - - @Test("The field's default value parses as never") - func defaultValueIsNever() { - #expect(DuckDBIdleRelease.minutes(fromFieldValue: DuckDBIdleRelease.neverValue) == nil) - } -} - -struct DuckDBAccessModeTests { - /// Measured against the shipped library: `duckdb_set_config` accepts `READ_ONLY` and - /// `read_only` and rejects everything else, and a rejected value leaves the database open - /// read-write with writes allowed. The raw value is the whole guard against that. - @Test("The raw values are the spellings DuckDB accepts") - func rawValuesMatchDuckDB() { - #expect(DuckDBAccessMode.readOnly.rawValue == "READ_ONLY") - #expect(DuckDBAccessMode.readWrite.rawValue == "READ_WRITE") - #expect(DuckDBAccessMode.configurationOption == "access_mode") - } - - @Test("The toggle is honoured for DuckDB's own storage") - func readOnlyAppliesToDatabaseFiles() { - let on = [DuckDBAccessMode.fieldId: "true"] - #expect(DuckDBAccessMode.resolve(fields: on, path: "/data/sales.duckdb") == .readOnly) - #expect(DuckDBAccessMode.resolve(fields: on, path: "/data/sales.DDB") == .readOnly) - } - - /// Measured: `access_mode=READ_ONLY` against `:memory:` or a Parquet, CSV or JSON path fails - /// the open outright with "Cannot launch in-memory database in read-only mode!". None of them - /// holds a write lock to give up and DuckDB never writes back to them, so the setting is - /// ignored rather than turned into a connection that refuses to open. - @Test("The toggle is ignored for anything that cannot carry an access mode") - func readOnlyIsIgnoredElsewhere() { - let on = [DuckDBAccessMode.fieldId: "true"] - for path in [":memory:", "/data/events.parquet", "/data/rows.csv", "/data/rows.json", "/data/rows"] { - #expect(DuckDBAccessMode.resolve(fields: on, path: path) == .readWrite, "\(path)") - #expect(DuckDBAccessMode.carriesAccessMode(path: path) == false, "\(path)") - } - } - - @Test("An absent or off toggle opens read-write") - func defaultsToReadWrite() { - #expect(DuckDBAccessMode.resolve(fields: [:], path: "/data/sales.duckdb") == .readWrite) - #expect( - DuckDBAccessMode.resolve( - fields: [DuckDBAccessMode.fieldId: "false"], - path: "/data/sales.duckdb" - ) == .readWrite - ) - } -} diff --git a/TableProTests/Plugins/DuckDBIndexClausesTests.swift b/TableProTests/Plugins/DuckDBIndexClausesTests.swift deleted file mode 100644 index b389077195..0000000000 --- a/TableProTests/Plugins/DuckDBIndexClausesTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// DuckDBIndexClausesTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct DuckDBIndexClausesTests { - private func keys(_ sql: String?) -> DuckDBIndexClauses.KeyParts { - DuckDBIndexClauses.keyParts(ofCreateIndex: sql) - } - - @Test("A parenthesized key in duckdb_indexes().sql is an expression, the rest are columns") - func expressionKeysAreRead() { - #expect(keys("CREATE INDEX i_mix ON t(id, (COALESCE(a, b)));") == DuckDBIndexClauses.KeyParts( - columns: ["id", "COALESCE(a, b)"], expressions: ["COALESCE(a, b)"] - )) - #expect(keys("CREATE INDEX i_fn ON t((lower(v)));").expressions == ["lower(v)"]) - } - - @Test("DuckDB's own deparse keeps its inner parentheses and doubled quotes") - func deparsedExpressionsAreKept() { - #expect(keys("CREATE INDEX i_concat ON t((((a || ', ') || b)));").columns == ["((a || ', ') || b)"]) - #expect(keys("CREATE INDEX i_str ON t(((a || 'it''s')));").columns == ["(a || 'it''s')"]) - #expect( - keys("CREATE INDEX i_case ON t((CASE WHEN ((a IS NULL)) THEN (b) ELSE a END));").columns - == ["CASE WHEN ((a IS NULL)) THEN (b) ELSE a END"] - ) - } - - @Test("A quoted column name is read without its quotes, commas included") - func quotedColumnsAreUnquoted() { - #expect(keys(#"CREATE INDEX i_quoted ON t("we ird", "Weird, Name", "say ""hi""");"#) == DuckDBIndexClauses.KeyParts( - columns: ["we ird", "Weird, Name", #"say "hi""#], expressions: [] - )) - #expect(keys(#"CREATE INDEX "Mixed (Name)" ON main.t(id);"#).columns == ["id"]) - } - - @Test("An index with no statement has no key parts to read") - func missingStatement() { - #expect(keys(nil) == DuckDBIndexClauses.KeyParts(columns: [], expressions: [])) - } - - @Test("The writer parenthesizes an expression and quotes a column") - func writer() { - let index = PluginIndexDefinition( - name: "i_mix", - columns: ["id", "COALESCE(a, b)"], - isUnique: true, - expressions: ["COALESCE(a, b)"], - includedColumns: nil, - ddlMethodAndKeys: nil, - ddlWhereClause: nil - ) - let sql = DuckDBIndexClauses.createStatement(for: index, qualifiedTable: #""main"."t""#) { "\"\($0)\"" } - #expect(sql == #"CREATE UNIQUE INDEX "i_mix" ON "main"."t" ("id", (COALESCE(a, b)))"#) - } -} diff --git a/TableProTests/Plugins/DuckDBLockConflictTests.swift b/TableProTests/Plugins/DuckDBLockConflictTests.swift deleted file mode 100644 index 3bc62d2dce..0000000000 --- a/TableProTests/Plugins/DuckDBLockConflictTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// DuckDBLockConflictTests.swift -// TableProTests -// -// Tests for DuckDBLockConflict (compiled via project.yml from DuckDBDriverPlugin). -// -// The two messages below are verbatim output from the shipped Libs/libduckdb_arm64.a, captured -// by a C probe holding the file open in a second process. Do not tidy them: the parser exists to -// read exactly this text, and a paraphrase would test nothing. -// - -import Foundation -import Testing - -struct DuckDBLockConflictTests { - private static let readWriteHolder = """ - IO Error: Could not set lock on file "/tmp/scratch/t.duckdb": Conflicting lock is held in \ - /tmp/scratch/lockprobe (PID 64911) by user ngoquocdat. \ - See also https://duckdb.org/docs/stable/connect/concurrency - """ - - private static let readOnlyHolder = """ - IO Error: Could not set lock on file "/tmp/scratch/t.duckdb": Conflicting lock is held in \ - /tmp/scratch/lockprobe (PID 65167) by user ngoquocdat. However, you would be able to open \ - this database in read-only mode, e.g. by using the -readonly parameter in the CLI. \ - See also https://duckdb.org/docs/stable/connect/concurrency - """ - - @Test("A read-write holder's message yields the file, the holder, its process and its user") - func parsesReadWriteHolder() throws { - let conflict = try #require(DuckDBLockConflict.parse(Self.readWriteHolder)) - #expect(conflict.filePath == "/tmp/scratch/t.duckdb") - #expect(conflict.holderExecutablePath == "/tmp/scratch/lockprobe") - #expect(conflict.holderProcessId == 64_911) - #expect(conflict.holderUser == "ngoquocdat") - #expect(conflict.holderName == "lockprobe") - } - - /// DuckDB appends this sentence only when the process holding the file opened it read-only, - /// which is the one case where retrying read-only actually gets in. Offering that recovery - /// against a read-write holder would send the user to a setting that fails identically. - @Test("Only a read-only holder's message offers the read-only route") - func readOnlyHintTracksTheHolder() throws { - let readWrite = try #require(DuckDBLockConflict.parse(Self.readWriteHolder)) - let readOnly = try #require(DuckDBLockConflict.parse(Self.readOnlyHolder)) - #expect(readWrite.readOnlyWouldSucceed == false) - #expect(readOnly.readOnlyWouldSucceed == true) - #expect(readOnly.holderProcessId == 65_167) - } - - /// Measured alongside the others, and worth its own case because it also mentions read-only - /// mode. Classifying it as a lock conflict would tell the user another app has a file that - /// does not exist. - @Test("A missing read-only database is not a lock conflict") - func missingDatabaseIsNotALockConflict() { - let message = """ - IO Error: Cannot open database "/tmp/scratch/nope.duckdb" in read-only mode: \ - database does not exist - """ - #expect(DuckDBLockConflict.parse(message) == nil) - } - - @Test("An unrelated DuckDB error is not a lock conflict") - func unrelatedErrorIsNotALockConflict() { - #expect(DuckDBLockConflict.parse("Catalog Error: Table with name t does not exist!") == nil) - #expect(DuckDBLockConflict.parse("") == nil) - } - - /// Everything past the anchor is optional so a reworded DuckDB release still classifies. The - /// message then says another process has the file without naming it, which is worse than the - /// full sentence and far better than surfacing the raw driver text. - @Test("A reworded message still classifies, with the parts it can no longer find left empty") - func degradesWhenTheWordingChanges() throws { - let conflict = try #require( - DuckDBLockConflict.parse("IO Error: Could not set lock on file \"/tmp/a.duckdb\": busy") - ) - #expect(conflict.filePath == "/tmp/a.duckdb") - #expect(conflict.holderExecutablePath == nil) - #expect(conflict.holderProcessId == nil) - #expect(conflict.holderName == nil) - } - - @Test("The message names the holder and the file, and drops DuckDB's documentation URL") - func describesTheHolderNotTheDriver() throws { - let conflict = try #require(DuckDBLockConflict.parse(Self.readWriteHolder)) - #expect(conflict.localizedDescription.contains("lockprobe")) - #expect(conflict.localizedDescription.contains("t.duckdb")) - #expect(conflict.localizedDescription.contains("duckdb.org") == false) - } - - @Test("Read-only is suggested only when DuckDB said it would work") - func recoverySuggestionFollowsTheHint() throws { - let readWrite = try #require(DuckDBLockConflict.parse(Self.readWriteHolder)) - let readOnly = try #require(DuckDBLockConflict.parse(Self.readOnlyHolder)) - #expect(readWrite.recoverySuggestion?.contains("Read-Only") == false) - #expect(readOnly.recoverySuggestion?.contains("Read-Only") == true) - } -} diff --git a/TableProTests/Plugins/DuckDBPositionParserTests.swift b/TableProTests/Plugins/DuckDBPositionParserTests.swift deleted file mode 100644 index 4f0fae53d6..0000000000 --- a/TableProTests/Plugins/DuckDBPositionParserTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// DuckDBPositionParserTests.swift -// TableProTests -// -// Tests for DuckDBPositionParser (compiled via project.yml from DuckDBDriverPlugin). -// The settings values below are what the shipped libduckdb 1.5.2 actually reports: empty -// before any USE, `second.main` after `USE second`, `second.other` after `USE second.other`. -// - -import Foundation -import Testing - -struct DuckDBPositionParserTests { - @Test("A connection that has never run USE reports no catalog and the schema setting") - func freshConnectionHasNoCatalog() { - let position = DuckDBPositionParser.parse(settings: ["search_path": "", "schema": "main"]) - #expect(position.catalog == nil) - #expect(position.schema == "main") - } - - @Test("USE on a catalog is read back as catalog and schema") - func catalogAndSchemaAreSplit() { - let position = DuckDBPositionParser.parse( - settings: ["search_path": "second.main", "schema": "main"] - ) - #expect(position.catalog == "second") - #expect(position.schema == "main") - } - - @Test("USE on a two-part name reports the schema it landed on, not the default") - func schemaFollowsTheSearchPath() { - let position = DuckDBPositionParser.parse( - settings: ["search_path": "second.other", "schema": "other"] - ) - #expect(position.catalog == "second") - #expect(position.schema == "other") - } - - /// `search_path` is a list, and only its first entry is where the connection sits. - @Test("Only the first search_path entry is read") - func onlyTheFirstEntryCounts() { - let position = DuckDBPositionParser.parse( - settings: ["search_path": "second.other, third.main", "schema": "other"] - ) - #expect(position.catalog == "second") - #expect(position.schema == "other") - } - - /// A schema name may contain a dot, so the split takes the first separator only. - @Test("A schema name containing a dot is kept whole") - func schemaWithADotSurvives() { - let position = DuckDBPositionParser.parse(settings: ["search_path": "db.a.b", "schema": "a.b"]) - #expect(position.catalog == "db") - #expect(position.schema == "a.b") - } - - @Test("A search_path with no dot is not mistaken for a catalog") - func bareSearchPathIsNotACatalog() { - let position = DuckDBPositionParser.parse(settings: ["search_path": "main", "schema": "main"]) - #expect(position.catalog == nil) - #expect(position.schema == "main") - } - - @Test("Missing settings produce no position rather than empty strings") - func missingSettingsAreNil() { - let position = DuckDBPositionParser.parse(settings: [:]) - #expect(position.catalog == nil) - #expect(position.schema == nil) - } - - @Test("An empty schema setting is absent, not blank") - func emptySchemaIsNil() { - let position = DuckDBPositionParser.parse(settings: ["search_path": "", "schema": ""]) - #expect(position.schema == nil) - } - - /// DuckDB resolves a catalog case-insensitively but echoes the caller's spelling back, - /// so the parser must not pretend it is canonical. The driver matches it against - /// duckdb_databases() before anything filters on it. - @Test("The catalog is reported exactly as DuckDB echoed it") - func catalogCasingIsNotNormalised() { - let position = DuckDBPositionParser.parse( - settings: ["search_path": "FIXTURE.main", "schema": "main"] - ) - #expect(position.catalog == "FIXTURE") - } -} diff --git a/TableProTests/Plugins/DuckDBQuackConnectTests.swift b/TableProTests/Plugins/DuckDBQuackConnectTests.swift deleted file mode 100644 index 1e8b09f09b..0000000000 --- a/TableProTests/Plugins/DuckDBQuackConnectTests.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// DuckDBQuackConnectTests.swift -// TableProTests -// -// Tests for QuackConnectBuilder (compiled via symlink from DuckDBDriverPlugin). -// Remote Quack connections need a live server, so these cover the SQL the -// driver sends rather than the network round trip. -// - -import Foundation -import Testing - -struct DuckDBQuackConnectTests { - @Test("Secret statement escapes single quotes in the token") - func secretEscapesQuotes() { - let sql = QuackConnectBuilder.secretSQL(token: "my'token") - #expect(sql == "CREATE OR REPLACE SECRET (TYPE quack, TOKEN 'my''token')") - } - - @Test("Secret statement cannot be broken out of with injection payloads") - func secretResistsInjection() { - let sql = QuackConnectBuilder.secretSQL(token: "x'); DROP TABLE users; --") - #expect(sql == "CREATE OR REPLACE SECRET (TYPE quack, TOKEN 'x''); DROP TABLE users; --')") - } - - @Test("Attach statement builds a quack target and quotes the alias") - func attachBuildsTarget() { - let sql = QuackConnectBuilder.attachSQL(host: "localhost", port: 9_494, alias: "remotedb") - #expect(sql == "ATTACH 'quack:localhost:9494' AS \"remotedb\"") - } - - @Test("Attach quotes aliases that contain spaces and double quotes") - func attachQuotesAlias() { - let sql = QuackConnectBuilder.attachSQL(host: "h", port: 1, alias: "my \"db\"") - #expect(sql == "ATTACH 'quack:h:1' AS \"my \"\"db\"\"\"") - } - - @Test("Attach escapes a single quote in the host") - func attachEscapesHost() { - let sql = QuackConnectBuilder.attachSQL(host: "h'x", port: 9_494, alias: "a") - #expect(sql == "ATTACH 'quack:h''x:9494' AS \"a\"") - } - - @Test("USE statement quotes the alias") - func useStatementQuotesAlias() { - #expect(QuackConnectBuilder.useSQL(alias: "remotedb") == "USE \"remotedb\"") - } - - @Test("Empty port falls back to the default") - func emptyPortUsesDefault() { - #expect(QuackConnectBuilder.normalizedPort("") == 9_494) - #expect(QuackConnectBuilder.normalizedPort(" ") == 9_494) - } - - @Test("Valid ports are accepted, invalid ones rejected") - func portValidation() { - #expect(QuackConnectBuilder.normalizedPort("5432") == 5_432) - #expect(QuackConnectBuilder.normalizedPort("not-a-port") == nil) - #expect(QuackConnectBuilder.normalizedPort("0") == nil) - #expect(QuackConnectBuilder.normalizedPort("65536") == nil) - #expect(QuackConnectBuilder.normalizedPort("-1") == nil) - } - - @Test("Host validation rejects empty and whitespace-only values") - func hostValidation() { - #expect(!QuackConnectBuilder.isValidHost("")) - #expect(!QuackConnectBuilder.isValidHost(" ")) - #expect(QuackConnectBuilder.isValidHost("localhost")) - } -} diff --git a/TableProTests/Plugins/DuckDBSchemaQueriesTests.swift b/TableProTests/Plugins/DuckDBSchemaQueriesTests.swift deleted file mode 100644 index d104016ad7..0000000000 --- a/TableProTests/Plugins/DuckDBSchemaQueriesTests.swift +++ /dev/null @@ -1,386 +0,0 @@ -// -// DuckDBSchemaQueriesTests.swift -// TableProTests -// -// Tests for DuckDBSchemaQueries (compiled via project.yml from DuckDBDriverPlugin). -// DuckDB's namespace is catalog.schema.table and its catalog views span every attached -// catalog, so these pin the catalog predicate that keeps one catalog's objects from -// appearing under another's identically named schema. -// -// They also pin the queries away from `core_functions`. The macOS libduckdb links no -// extensions, so `current_database()`, `current_schema()`, -// `information_schema.key_column_usage` and `information_schema.referential_constraints` -// only answer once DuckDB has downloaded core_functions from extensions.duckdb.org. -// scripts/probes/check-duckdb-offline-metadata.sh runs the same queries against the shipped -// library with no extensions available. -// - -import Foundation -import Testing - -struct DuckDBSchemaQueriesTests { - private static let catalogScopedQueries: [(name: String, sql: String)] = [ - ("listSchemas", DuckDBSchemaQueries.listSchemas), - ("listTables(in: .schema)", DuckDBSchemaQueries.listTables(in: .schema)), - ("listTables(in: .schemas)", DuckDBSchemaQueries.listTables(in: .schemas(["core", "main"]))), - ("listTables(in: .allSchemas)", DuckDBSchemaQueries.listTables(in: .allSchemas)), - ("columnsForTable", DuckDBSchemaQueries.columnsForTable), - ("columnsForSchema", DuckDBSchemaQueries.columnsForSchema), - ("primaryKeyColumnsForSchema", DuckDBSchemaQueries.primaryKeyColumnsForSchema), - ("primaryKeyColumnsForTable", DuckDBSchemaQueries.primaryKeyColumnsForTable), - ("indexesForTable", DuckDBSchemaQueries.indexesForTable), - ("foreignKeysForTable", DuckDBSchemaQueries.foreignKeysForTable), - ("tableDDL", DuckDBSchemaQueries.tableDDL), - ("viewDefinition", DuckDBSchemaQueries.viewDefinition), - ("objectComment", DuckDBSchemaQueries.objectComment), - ] - - private static let allQueries: [(name: String, sql: String)] = - catalogScopedQueries + [ - ("listDatabases", DuckDBSchemaQueries.listDatabases), - ("currentPosition", DuckDBSchemaQueries.currentPosition), - ( - "allTablesMetadata", - DuckDBSchemaQueries.allTablesMetadata(catalog: "repro", escapedSchema: "core") - ), - ] - - // MARK: - Extension independence - - @Test("No metadata query calls a function that lives in the core_functions extension") - func metadataQueriesNeedNoExtension() { - for query in Self.allQueries { - #expect( - !query.sql.contains("current_database()"), - "\(query.name) calls current_database(), which needs the core_functions download" - ) - #expect( - !query.sql.contains("current_schema()"), - "\(query.name) calls current_schema(), which needs the core_functions download" - ) - } - } - - @Test("No metadata query reads an information_schema view built on core_functions") - func metadataQueriesAvoidExtensionBackedViews() { - for query in Self.allQueries { - #expect( - !query.sql.contains("key_column_usage"), - "\(query.name) reads key_column_usage, which needs the core_functions download" - ) - #expect( - !query.sql.contains("referential_constraints"), - "\(query.name) reads referential_constraints, which needs the core_functions download" - ) - } - } - - // MARK: - Catalog scoping - - @Test("Every metadata query is anchored to the catalog through its first parameter") - func metadataQueriesAreCatalogScoped() { - for query in Self.catalogScopedQueries { - #expect( - query.sql.contains("database_name = $1"), - "\(query.name) has no catalog predicate, so it matches same-named schemas in attached catalogs" - ) - } - } - - @Test("The all-tables metadata query is catalog scoped") - func allTablesMetadataIsCatalogScoped() { - let sql = DuckDBSchemaQueries.allTablesMetadata(catalog: "repro", escapedSchema: "core") - #expect(sql.contains("database_name = 'repro'")) - #expect(sql.contains("schema_name = 'core'")) - } - - // MARK: - Position - - @Test("The current position comes from settings, not from a scalar function") - func currentPositionReadsSettings() { - #expect(DuckDBSchemaQueries.currentPosition.contains("duckdb_settings()")) - #expect(DuckDBSchemaQueries.currentPosition.contains("search_path")) - #expect(DuckDBSchemaQueries.currentPosition.contains("'schema'")) - } - - // MARK: - Schema listing - - @Test("The schema list never filters on the schema's own internal flag") - func schemaListDoesNotFilterOnInternal() { - #expect(!DuckDBSchemaQueries.listSchemas.contains("internal")) - } - - @Test("The schema list reads duckdb_schemas, not the catalog-blind schemata view") - func schemaListUsesDuckDBSchemas() { - #expect(DuckDBSchemaQueries.listSchemas.contains("duckdb_schemas()")) - #expect(!DuckDBSchemaQueries.listSchemas.contains("information_schema.schemata")) - } - - // MARK: - Object listing - - @Test( - "The table list reports views alongside tables and hides internal objects in both", - arguments: [DuckDBTableListingScope.schema, .schemas(["core", "main"]), .allSchemas] - ) - func tableListIncludesViews(scope: DuckDBTableListingScope) { - let sql = DuckDBSchemaQueries.listTables(in: scope) - #expect(sql.contains("duckdb_tables()")) - #expect(sql.contains("duckdb_views()")) - #expect(sql.contains("'BASE TABLE'")) - #expect(sql.contains("'VIEW'")) - #expect( - sql.components(separatedBy: "internal = false").count == 3, - "both the table branch and the view branch must hide internal objects" - ) - } - - @Test("The one-schema listing binds the schema and projects no schema column") - func oneSchemaListingBindsTheSchema() { - let sql = DuckDBSchemaQueries.listTables(in: .schema) - #expect(sql.components(separatedBy: "schema_name = $2").count == 3) - #expect(!sql.contains(DuckDBSchemaQueries.listSchemas)) - #expect(!sql.contains(", schema_name")) - #expect(sql.hasSuffix("ORDER BY 1")) - } - - @Test("Every arm of the all-schema listing is filtered by the schema list query itself") - func allSchemaListingFiltersByTheSchemaList() { - let sql = DuckDBSchemaQueries.listTables(in: .allSchemas) - #expect(sql.components(separatedBy: "schema_name IN (\n\(DuckDBSchemaQueries.listSchemas)\n)").count == 3) - #expect(!sql.contains("$2")) - #expect(sql.components(separatedBy: ", schema_name\n").count == 3) - #expect(sql.hasSuffix("ORDER BY 3, 1")) - } - - @Test("Every arm of the schema-set listing is filtered by the named schemas, with the catalog bound alone") - func schemaSetListingFiltersByTheNamedSchemas() { - let sql = DuckDBSchemaQueries.listTables(in: .schemas(["core", "main"])) - #expect(sql.components(separatedBy: "AND schema_name IN ('core', 'main')\n").count == 3) - #expect(!sql.contains(DuckDBSchemaQueries.listSchemas)) - #expect(!sql.contains("$2")) - #expect(sql.components(separatedBy: ", schema_name\n").count == 3) - #expect(sql.hasSuffix("ORDER BY 3, 1")) - } - - @Test("The schema-set listing doubles a single quote and keeps a backslash as it is") - func schemaSetListingQuotesEachName() { - let sql = DuckDBSchemaQueries.listTables(in: .schemas(["it's", #"back\slash"#])) - #expect(sql.contains(#"schema_name IN ('it''s', 'back\slash')"#)) - #expect(!sql.contains("'it's'")) - } - - @Test("An empty schema set renders a listing that matches nothing") - func emptySchemaSetMatchesNothing() { - let sql = DuckDBSchemaQueries.listTables(in: .schemas([])) - #expect(sql.components(separatedBy: "AND 1 = 0\n").count == 3) - #expect(!sql.contains("IN ()")) - } - - // MARK: - Keys - - @Test("Primary key columns are unnested so a composite key yields one row per column") - func primaryKeyColumnsUnnestTheList() { - #expect(DuckDBSchemaQueries.primaryKeyColumnsForTable.contains("UNNEST(constraint_column_names)")) - #expect(DuckDBSchemaQueries.primaryKeyColumnsForSchema.contains("UNNEST(constraint_column_names)")) - #expect(DuckDBSchemaQueries.primaryKeyColumnsForTable.contains("'PRIMARY KEY'")) - } - - @Test("Foreign keys unnest both column lists so a composite key pairs by position") - func foreignKeysUnnestBothLists() { - let sql = DuckDBSchemaQueries.foreignKeysForTable - #expect(sql.contains("UNNEST(constraint_column_names)")) - #expect(sql.contains("UNNEST(referenced_column_names)")) - #expect(sql.contains("'FOREIGN KEY'")) - } - - @Test("Foreign key rules are NO ACTION, which is all DuckDB accepts") - func foreignKeyRulesAreNoAction() { - let sql = DuckDBSchemaQueries.foreignKeysForTable - #expect(sql.contains("'NO ACTION' AS delete_rule")) - #expect(sql.contains("'NO ACTION' AS update_rule")) - } - - // MARK: - Enums - - /// DuckDB spells an ENUM's members into the column's own type as `ENUM('ok', 'bad')`, so - /// nothing needs to look them up. The query that used to try keyed its result on - /// `type_name`, which never matched a column's `data_type`, so it answered nothing. - @Test("No query looks enum labels up separately") - func noQueryFetchesEnumLabels() { - for query in Self.allQueries { - #expect(!query.sql.contains("enum_range"), "\(query.name) still calls enum_range") - #expect(!query.sql.contains("duckdb_types()"), "\(query.name) still reads duckdb_types()") - } - } - - // MARK: - Database listing - - @Test("The database list hides DuckDB's built-in catalogs") - func databaseListExcludesInternalCatalogs() { - #expect(DuckDBSchemaQueries.listDatabases.contains("internal = false")) - #expect(DuckDBSchemaQueries.listDatabases.contains("duckdb_databases()")) - } - - // MARK: - Switching - - @Test("Switching schema names the catalog so the two-part form is unambiguous") - func useSchemaQualifiesWithCatalog() { - #expect(DuckDBSchemaQueries.useSchema("core", in: "repro") == #"USE "repro"."core""#) - } - - @Test("Switching schema without a known catalog falls back to the bare form") - func useSchemaWithoutCatalog() { - #expect(DuckDBSchemaQueries.useSchema("core", in: nil) == #"USE "core""#) - #expect(DuckDBSchemaQueries.useSchema("core", in: "") == #"USE "core""#) - } - - @Test("Switching schema never uses SET schema, which splits its value on a dot") - func useSchemaDoesNotUseSetSchema() { - #expect(!DuckDBSchemaQueries.useSchema("a.b", in: "db").contains("SET schema")) - #expect(DuckDBSchemaQueries.useSchema("a.b", in: "db") == #"USE "db"."a.b""#) - } - - @Test("Switching database quotes the catalog name") - func useDatabaseQuotesIdentifier() { - #expect(DuckDBSchemaQueries.useDatabase("my-db") == #"USE "my-db""#) - } - - // MARK: - Quoting - - @Test("Embedded double quotes are doubled, not dropped") - func identifierQuotingEscapesQuotes() { - #expect(DuckDBSchemaQueries.quoteIdentifier(#"we"ird"#) == #""we""ird""#) - #expect(DuckDBSchemaQueries.useSchema(#"we"ird"#, in: "repro") == #"USE "repro"."we""ird""#) - } - - @Test("A quoted identifier cannot be broken out of with an injection payload") - func identifierQuotingResistsInjection() { - let payload = #"x"; DROP TABLE users; --"# - #expect(DuckDBSchemaQueries.useDatabase(payload) == #"USE "x""; DROP TABLE users; --""#) - } - - /// The catalog arrives raw and the schema arrives already escaped by - /// `SchemaSwitchable.escapedSchema`, so only one of the two may be escaped here. - @Test("The catalog is escaped and the already-escaped schema is passed through") - func onlyTheCatalogIsEscaped() { - let sql = DuckDBSchemaQueries.allTablesMetadata(catalog: #"o'db"#, escapedSchema: #"it''s"#) - #expect(sql.contains(#"database_name = 'o''db'"#)) - #expect(sql.contains(#"schema_name = 'it''s'"#)) - #expect(!sql.contains(#"it''''s"#)) - } - - @Test("A raw catalog cannot be broken out of with an injection payload") - func catalogQuotingResistsInjection() { - let sql = DuckDBSchemaQueries.allTablesMetadata( - catalog: #"x'; DROP TABLE users; --"#, escapedSchema: "main" - ) - #expect(sql.contains(#"database_name = 'x''; DROP TABLE users; --'"#)) - } - - @Test("The row count probe qualifies the table with its schema") - func rowCountProbeQualifiesTable() { - let sql = DuckDBSchemaQueries.rowCountProbe(schema: "core", table: "bars", limit: 100_001) - #expect(sql.contains(#""core"."bars""#)) - #expect(sql.contains("LIMIT 100001")) - } - - // MARK: - Comments - - @Test("The object comment is read from tables and views, by catalog, schema and name") - func objectCommentReadsTablesAndViews() { - let sql = DuckDBSchemaQueries.objectComment - #expect(sql.contains("FROM duckdb_tables()")) - #expect(sql.contains("FROM duckdb_views()")) - #expect(sql.contains("table_name = $3")) - #expect(sql.contains("view_name = $3")) - #expect(sql.components(separatedBy: "schema_name = $2").count == 3) - } - - @Test("Both column queries select the column comment") - func columnQueriesSelectTheComment() { - #expect(DuckDBSchemaQueries.columnsForTable.contains("column_index, comment\n")) - #expect(DuckDBSchemaQueries.columnsForSchema.contains("column_index, comment\n")) - } - - @Test("A table and a view each get their own keyword") - func objectCommentKeywords() { - #expect(DuckDBSchemaQueries.objectCommentStatement(objectType: "TABLE", schema: "s", name: "t", comment: "c") - == #"COMMENT ON TABLE "s"."t" IS 'c'"#) - #expect(DuckDBSchemaQueries.objectCommentStatement(objectType: "VIEW", schema: "s", name: "v", comment: "c") - == #"COMMENT ON VIEW "s"."v" IS 'c'"#) - } - - @Test("Other kinds cannot be commented", arguments: ["MATERIALIZED VIEW", "SEQUENCE", "SYSTEM TABLE", ""]) - func otherKindsHaveNoCommentStatement(kind: String) { - #expect(DuckDBSchemaQueries.objectCommentStatement(objectType: kind, schema: "s", name: "t", comment: nil) == nil) - } - - /// Measured on 1.5.2: `IS ''` stores an empty string, and only `IS NULL` clears the comment. - @Test("A nil or empty comment removes it with NULL") - func emptyCommentClears() { - #expect(DuckDBSchemaQueries.objectCommentStatement(objectType: "TABLE", schema: "s", name: "t", comment: nil) - == #"COMMENT ON TABLE "s"."t" IS NULL"#) - #expect(DuckDBSchemaQueries.objectCommentStatement(objectType: "TABLE", schema: "s", name: "t", comment: "") - == #"COMMENT ON TABLE "s"."t" IS NULL"#) - } - - @Test("A comment doubles its quotes and keeps a backslash as written") - func commentEscaping() { - let sql = DuckDBSchemaQueries.objectCommentStatement( - objectType: "TABLE", schema: #"we"ird"#, name: "t", comment: #"it's a \ table"# - ) - #expect(sql == #"COMMENT ON TABLE "we""ird"."t" IS 'it''s a \ table'"#) - } - - @Test("A quote followed by a combining mark is still doubled") - func quoteBeforeCombiningMark() { - #expect(DuckDBSchemaQueries.quoteLiteral("a'\u{301}b") == "'a''\u{301}b'") - #expect(DuckDBSchemaQueries.quoteIdentifier("x\"\u{301}") == "\"x\"\"\u{301}\"") - } - - @Test("A column comment names the schema, the table and the column") - func columnComment() { - #expect(DuckDBSchemaQueries.columnCommentStatement(schema: "s", table: "t", column: "c", comment: "x'y") - == #"COMMENT ON COLUMN "s"."t"."c" IS 'x''y'"#) - } - - // MARK: - Auto-increment - - /// `DROP TABLE` leaves the sequence behind, so creating the table again must not fail on it. - @Test("The sequence is created only when it does not exist yet") - func sequenceIfNotExists() { - #expect(DuckDBSchemaQueries.createSequenceStatement(schema: "main", sequence: "t_id_seq") - == #"CREATE SEQUENCE IF NOT EXISTS "main"."t_id_seq""#) - } - - @Test("The sequence is named after the table and the column, without any double quote") - func sequenceName() { - #expect(DuckDBSchemaQueries.autoIncrementSequenceName(table: "orders", column: "id") == "orders_id_984a0109_seq") - #expect(DuckDBSchemaQueries.autoIncrementSequenceName(table: #"q"x"#, column: "id") == "qx_id_0f74cfff_seq") - } - - @Test("Two table and column pairs that read the same get different sequences") - func sequenceNamesDoNotCollide() { - let underscoreInTable = DuckDBSchemaQueries.autoIncrementSequenceName(table: "a_b", column: "id") - let underscoreInColumn = DuckDBSchemaQueries.autoIncrementSequenceName(table: "a", column: "b_id") - #expect(underscoreInTable != underscoreInColumn) - let quoted = DuckDBSchemaQueries.autoIncrementSequenceName(table: #"q"x"#, column: "id") - let plain = DuckDBSchemaQueries.autoIncrementSequenceName(table: "qx", column: "id") - #expect(quoted != plain) - } - - @Test("nextval takes the qualified sequence as a string literal") - func nextvalQualifiesTheSequence() { - #expect(DuckDBSchemaQueries.nextvalDefault(schema: "main", sequence: "t_id_seq") - == #"nextval('"main"."t_id_seq"')"#) - #expect(DuckDBSchemaQueries.nextvalDefault(schema: "main", sequence: "t's.x_seq") - == #"nextval('"main"."t''s.x_seq"')"#) - } - - /// Measured on 1.5.2: `nextval('"we""ird"."s"')` looks for `s` in no schema at all. - @Test("A schema holding a double quote is left to resolve as the table's own") - func nextvalWithQuotedSchema() { - #expect(DuckDBSchemaQueries.nextvalDefault(schema: #"we"ird"#, sequence: "t_id_seq") - == #"nextval('"t_id_seq"')"#) - } -} diff --git a/TableProTests/Plugins/DuckDBTransactionProbeTests.swift b/TableProTests/Plugins/DuckDBTransactionProbeTests.swift deleted file mode 100644 index 1f708d2c56..0000000000 --- a/TableProTests/Plugins/DuckDBTransactionProbeTests.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// DuckDBTransactionProbeTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct DuckDBTransactionProbeTests { - /// Measured against the shipped libduckdb v1.5.2: outside a transaction two calls answered 6 - /// then 10, and inside one they both answered 12. - @Test("The same transaction id twice is one open transaction, two different ones is none") - func repeatedIdMeansAnOpenTransaction() { - #expect(DuckDBTransactionProbe.state(first: .value("12"), second: .value("12")) == .inTransaction) - #expect(DuckDBTransactionProbe.state(first: .value("6"), second: .value("10")) == .idle) - } - - @Test("A refusal because the transaction is aborted is the answer, not a failure") - func abortedTransactionIsReported() { - #expect(DuckDBTransactionProbe.state(first: .abortedTransaction, second: .value("1")) == .abortedTransaction) - #expect(DuckDBTransactionProbe.state(first: .value("1"), second: .abortedTransaction) == .abortedTransaction) - } - - @Test("A reading that could not be taken says so rather than guessing") - func unreadableIsUnknown() { - #expect(DuckDBTransactionProbe.state(first: .unreadable, second: .value("1")) == .unknown) - #expect(DuckDBTransactionProbe.state(first: .value("1"), second: .unreadable) == .unknown) - #expect(DuckDBTransactionProbe.state(first: .unreadable, second: .unreadable) == .unknown) - } - - @Test("A DuckDB catalog settles nothing on its own: the transaction id has to be probed") - func nativeCatalogNeedsTheProbe() { - #expect(DuckDBTransactionProbe.state(catalogType: .value("duckdb"), tracksOpenTransaction: false) == nil) - #expect(DuckDBTransactionProbe.state(catalogType: .value("DuckDB"), tracksOpenTransaction: true) == nil) - } - - /// The probe is destructive outside DuckDB's own transaction manager. Measured against v1.5.2 - /// with a SQLite catalog in front: `txid_current()` failed with `DuckTransaction::Get called on - /// non-DuckDB transaction` and aborted the user's transaction, losing the row they had inserted. - @Test("A catalog DuckDB does not own is answered from the statements the driver saw") - func foreignCatalogUsesTheTrackedAnswer() { - #expect(DuckDBTransactionProbe.state(catalogType: .value("sqlite"), tracksOpenTransaction: true) == .inTransaction) - #expect(DuckDBTransactionProbe.state(catalogType: .value("sqlite"), tracksOpenTransaction: false) == .idle) - #expect(DuckDBTransactionProbe.state(catalogType: .value("postgres"), tracksOpenTransaction: true) == .inTransaction) - } - - @Test("A catalog read that was refused for being in an aborted transaction says so") - func abortedCatalogReadIsReported() { - let state = DuckDBTransactionProbe.state(catalogType: .abortedTransaction, tracksOpenTransaction: false) - #expect(state == .abortedTransaction) - } - - @Test("A catalog nobody could read leaves the caller deciding as if it had not asked") - func unreadableCatalogIsUnknown() { - #expect(DuckDBTransactionProbe.state(catalogType: .unreadable, tracksOpenTransaction: true) == .unknown) - } - - @Test("The probe asks for text, because the deprecated value API faults on other types") - func probeQueriesCastToText() { - #expect(DuckDBTransactionProbe.transactionIdQuery == "SELECT txid_current()::VARCHAR") - #expect(DuckDBTransactionProbe.catalogTypeQuery.contains("duckdb_databases()")) - #expect(DuckDBTransactionProbe.catalogTypeQuery.contains("current_database()")) - } -} diff --git a/TableProTests/Plugins/DuckDBTypeRenderingTests.swift b/TableProTests/Plugins/DuckDBTypeRenderingTests.swift deleted file mode 100644 index accf48969a..0000000000 --- a/TableProTests/Plugins/DuckDBTypeRenderingTests.swift +++ /dev/null @@ -1,157 +0,0 @@ -// -// DuckDBTypeRenderingTests.swift -// TableProTests -// -// The renderability table mirrors what duckdb_value_varchar actually returns for -// the linked libduckdb. scripts/probes/check-duckdb-value-api.sh re-measures it. -// - -import Foundation -import Testing - -struct DuckDBTypeRenderingTests { - @Test("Timestamp flavors the value API cannot decode need a text projection") - func undecodableTimestampsRequireProjection() { - #expect(DuckDBLogicalType.requiresTextProjection(.timestampSeconds)) - #expect(DuckDBLogicalType.requiresTextProjection(.timestampMilliseconds)) - #expect(DuckDBLogicalType.requiresTextProjection(.timestampNanoseconds)) - #expect(DuckDBLogicalType.requiresTextProjection(.timestampTz)) - #expect(DuckDBLogicalType.requiresTextProjection(.timeTz)) - } - - @Test("Types the value API renders directly are left alone") - func decodableTypesAreNotProjected() { - #expect(!DuckDBLogicalType.requiresTextProjection(.timestamp)) - #expect(!DuckDBLogicalType.requiresTextProjection(.date)) - #expect(!DuckDBLogicalType.requiresTextProjection(.time)) - #expect(!DuckDBLogicalType.requiresTextProjection(.timeNs)) - #expect(!DuckDBLogicalType.requiresTextProjection(.interval)) - #expect(!DuckDBLogicalType.requiresTextProjection(.varchar)) - #expect(!DuckDBLogicalType.requiresTextProjection(.blob)) - #expect(!DuckDBLogicalType.requiresTextProjection(.decimal)) - #expect(!DuckDBLogicalType.requiresTextProjection(.hugeint)) - #expect(!DuckDBLogicalType.requiresTextProjection(.uhugeint)) - } - - @Test("Nested and extension types the value API cannot decode need a text projection") - func undecodableComplexTypesRequireProjection() { - #expect(DuckDBLogicalType.requiresTextProjection(.list)) - #expect(DuckDBLogicalType.requiresTextProjection(.array)) - #expect(DuckDBLogicalType.requiresTextProjection(.structure)) - #expect(DuckDBLogicalType.requiresTextProjection(.map)) - #expect(DuckDBLogicalType.requiresTextProjection(.union)) - #expect(DuckDBLogicalType.requiresTextProjection(.enumeration)) - #expect(DuckDBLogicalType.requiresTextProjection(.uuid)) - #expect(DuckDBLogicalType.requiresTextProjection(.bit)) - #expect(DuckDBLogicalType.requiresTextProjection(.bignum)) - #expect(DuckDBLogicalType.requiresTextProjection(.geometry)) - } - - @Test("An unknown type is projected rather than trusted") - func unknownTypeRequiresProjection() { - #expect(DuckDBLogicalType.requiresTextProjection(nil)) - } - - @Test("A result needs projecting when any single column does") - func anyColumnDrivesProjection() { - #expect(!DuckDBLogicalType.requiresTextProjection(anyOf: [.integer, .varchar, .timestamp])) - #expect(DuckDBLogicalType.requiresTextProjection(anyOf: [.integer, .timestampMilliseconds])) - #expect(DuckDBLogicalType.requiresTextProjection(anyOf: [.integer, nil])) - #expect(!DuckDBLogicalType.requiresTextProjection(anyOf: [])) - } - - @Test("Display names match the names DuckDB reports for each type") - func displayNames() { - #expect(DuckDBLogicalType.timestampMilliseconds.displayName == "TIMESTAMP_MS") - #expect(DuckDBLogicalType.timestampSeconds.displayName == "TIMESTAMP_S") - #expect(DuckDBLogicalType.timestampNanoseconds.displayName == "TIMESTAMP_NS") - #expect(DuckDBLogicalType.timestampTz.displayName == "TIMESTAMPTZ") - #expect(DuckDBLogicalType.timeTz.displayName == "TIMETZ") - #expect(DuckDBLogicalType.structure.displayName == "STRUCT") - #expect(DuckDBLogicalType.enumeration.displayName == "ENUM") - } - - @Test("Every type name is distinct") - func displayNamesAreUnique() { - let names = DuckDBLogicalType.allCases.map(\.displayName) - #expect(Set(names).count == names.count) - } - - @Test("A projected column is read back through DuckDB's own VARCHAR cast") - func castProjectionUsesPositionalAlias() { - let sql = DuckDBProjectedQuery.projection(for: .timestampMilliseconds, at: 0, column: "open_ts") - #expect(sql == "CASE WHEN c1 IS NULL THEN NULL ELSE CAST(c1 AS VARCHAR) END AS \"open_ts\"") - } - - @Test("Geometry is projected through ST_AsText rather than a plain cast") - func geometryProjection() { - let sql = DuckDBProjectedQuery.projection(for: .geometry, at: 2, column: "shape") - #expect(sql == "CASE WHEN c3 IS NULL THEN NULL ELSE ST_AsText(c3) END AS \"shape\"") - } - - @Test("A directly readable column passes through by position") - func passthroughProjection() { - #expect(DuckDBProjectedQuery.projection(for: .integer, at: 1, column: "id") == "c2 AS \"id\"") - } - - @Test("Column aliases are quoted and embedded quotes escaped") - func aliasQuoting() { - let sql = DuckDBProjectedQuery.projection(for: .integer, at: 0, column: "we\"ird") - #expect(sql == "c1 AS \"we\"\"ird\"") - } - - @Test("The wrapper names the subquery's columns by position, so duplicates stay distinct") - func wrapperUsesDerivedTableAliasList() { - let sql = DuckDBProjectedQuery.build( - originalQuery: "SELECT a.ts, b.ts FROM a, b", - columns: [("ts", .timestampMilliseconds), ("ts", .timestampMilliseconds)] - ) - #expect(sql == """ - SELECT CASE WHEN c1 IS NULL THEN NULL ELSE CAST(c1 AS VARCHAR) END AS "ts", \ - CASE WHEN c2 IS NULL THEN NULL ELSE CAST(c2 AS VARCHAR) END AS "ts" \ - FROM (SELECT a.ts, b.ts FROM a, b - ) AS _tp_cast(c1, c2) - """) - } - - @Test("The closing parenthesis starts a new line so a trailing comment cannot swallow it") - func wrapperTerminatesTrailingComment() { - let sql = DuckDBProjectedQuery.build( - originalQuery: "SELECT ts FROM bars -- latest", - columns: [("ts", .timestampMilliseconds)] - ) - #expect(sql?.contains("-- latest\n) AS _tp_cast(c1)") == true) - } - - @Test("A trailing semicolon is dropped before wrapping") - func wrapperStripsTrailingSemicolon() { - let sql = DuckDBProjectedQuery.build( - originalQuery: "SELECT ts FROM bars; ", - columns: [("ts", .timestampMilliseconds)] - ) - #expect(sql?.contains("FROM (SELECT ts FROM bars\n) AS _tp_cast(c1)") == true) - } - - @Test("Mixed results only cast the columns that need it") - func wrapperMixesCastAndPassthrough() { - let sql = DuckDBProjectedQuery.build( - originalQuery: "SELECT id, ts FROM bars", - columns: [("id", .integer), ("ts", .timestampMilliseconds)] - ) - #expect(sql == """ - SELECT c1 AS "id", CASE WHEN c2 IS NULL THEN NULL ELSE CAST(c2 AS VARCHAR) END AS "ts" \ - FROM (SELECT id, ts FROM bars - ) AS _tp_cast(c1, c2) - """) - } - - @Test("A result with no columns cannot be wrapped") - func wrapperRejectsEmptyColumns() { - #expect(DuckDBProjectedQuery.build(originalQuery: "SELECT 1", columns: []) == nil) - } - - @Test("A statement that is only a semicolon cannot be wrapped") - func wrapperRejectsEmptyStatement() { - #expect(DuckDBProjectedQuery.build(originalQuery: " ; ", columns: [("a", .integer)]) == nil) - } -} diff --git a/TableProTests/Plugins/DuckDBViewDefinitionTests.swift b/TableProTests/Plugins/DuckDBViewDefinitionTests.swift deleted file mode 100644 index ab68b316b8..0000000000 --- a/TableProTests/Plugins/DuckDBViewDefinitionTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// DuckDBViewDefinitionTests.swift -// TableProTests -// -// Tests for DuckDBViewDefinition (compiled via project.yml from DuckDBDriverPlugin). -// The input below is what libduckdb 1.5.2 stores and returns for a view. -// - -import Foundation -import Testing - -struct DuckDBViewDefinitionTests { - @Test("A stored CREATE VIEW is promoted so it can be run again") - func createViewBecomesReplaceable() { - let stored = "CREATE VIEW v_child AS SELECT cid, pid FROM child;" - #expect( - DuckDBViewDefinition.makeReplaceable(stored) - == "CREATE OR REPLACE VIEW v_child AS SELECT cid, pid FROM child;" - ) - } - - @Test("A definition that already replaces is left alone") - func alreadyReplaceableIsUntouched() { - let stored = "CREATE OR REPLACE VIEW v AS SELECT 1;" - #expect(DuckDBViewDefinition.makeReplaceable(stored) == stored) - } - - @Test("The keyword match ignores case") - func keywordMatchIgnoresCase() { - #expect( - DuckDBViewDefinition.makeReplaceable("create view v AS SELECT 1;") - == "CREATE OR REPLACE VIEW v AS SELECT 1;" - ) - } - - @Test("Leading whitespace is trimmed rather than defeating the match") - func leadingWhitespaceIsTrimmed() { - #expect( - DuckDBViewDefinition.makeReplaceable("\n CREATE VIEW v AS SELECT 1;") - == "CREATE OR REPLACE VIEW v AS SELECT 1;" - ) - } - - /// Anything that is not a plain CREATE VIEW is returned untouched rather than rewritten - /// on a guess. - @Test("Something other than a CREATE VIEW is returned unchanged") - func otherStatementsAreUnchanged() { - #expect(DuckDBViewDefinition.makeReplaceable("SELECT 1;") == "SELECT 1;") - #expect(DuckDBViewDefinition.makeReplaceable("CREATE TEMP VIEW v AS SELECT 1;") - == "CREATE TEMP VIEW v AS SELECT 1;") - #expect(DuckDBViewDefinition.makeReplaceable("") == "") - #expect(DuckDBViewDefinition.makeReplaceable("CREATE") == "CREATE") - } - - /// A view whose name starts with the keyword must not be mangled. - @Test("Only the leading keyword is rewritten") - func onlyTheLeadingKeywordIsRewritten() { - let stored = "CREATE VIEW v AS SELECT 'CREATE VIEW' AS label;" - #expect( - DuckDBViewDefinition.makeReplaceable(stored) - == "CREATE OR REPLACE VIEW v AS SELECT 'CREATE VIEW' AS label;" - ) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBAccessPlannerTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBAccessPlannerTests.swift deleted file mode 100644 index 945357138f..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBAccessPlannerTests.swift +++ /dev/null @@ -1,721 +0,0 @@ -// -// DynamoDBAccessPlannerTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct DynamoDBAccessPlannerTests { - struct Scenario: Sendable, CustomTestStringConvertible { - let name: String - let filters: [DynamoDBBrowseFilter] - let matchAll: Bool - let order: [DynamoDBOrderTerm] - let columns: [String] - - init( - _ name: String, - _ filters: [DynamoDBBrowseFilter], - matchAll: Bool = true, - order: [DynamoDBOrderTerm] = [], - columns: [String] = [] - ) { - self.name = name - self.filters = filters - self.matchAll = matchAll - self.order = order - self.columns = columns - } - - var testDescription: String { name } - } - - static let ordersDescription = """ - {"Table": { - "TableName": "orders", - "KeySchema": [ - {"AttributeName": "pk", "KeyType": "HASH"}, - {"AttributeName": "sk", "KeyType": "RANGE"} - ], - "AttributeDefinitions": [ - {"AttributeName": "pk", "AttributeType": "S"}, - {"AttributeName": "sk", "AttributeType": "N"}, - {"AttributeName": "status", "AttributeType": "S"}, - {"AttributeName": "total", "AttributeType": "N"}, - {"AttributeName": "zip", "AttributeType": "S"}, - {"AttributeName": "created", "AttributeType": "S"}, - {"AttributeName": "email", "AttributeType": "S"}, - {"AttributeName": "phone", "AttributeType": "S"} - ], - "GlobalSecondaryIndexes": [ - { - "IndexName": "byStatus", - "KeySchema": [ - {"AttributeName": "status", "KeyType": "HASH"}, - {"AttributeName": "total", "KeyType": "RANGE"} - ], - "Projection": {"ProjectionType": "ALL"}, - "IndexStatus": "ACTIVE" - }, - { - "IndexName": "byZip", - "KeySchema": [{"AttributeName": "zip", "KeyType": "HASH"}], - "Projection": {"ProjectionType": "KEYS_ONLY"}, - "IndexStatus": "ACTIVE" - }, - { - "IndexName": "byEmail", - "KeySchema": [{"AttributeName": "email", "KeyType": "HASH"}], - "Projection": {"ProjectionType": "ALL"}, - "IndexStatus": "CREATING" - }, - { - "IndexName": "byPhone", - "KeySchema": [{"AttributeName": "phone", "KeyType": "HASH"}], - "Projection": {"ProjectionType": "ALL"}, - "IndexStatus": "ACTIVE", - "Backfilling": true - } - ], - "LocalSecondaryIndexes": [ - { - "IndexName": "byCreated", - "KeySchema": [ - {"AttributeName": "pk", "KeyType": "HASH"}, - {"AttributeName": "created", "KeyType": "RANGE"} - ], - "Projection": {"ProjectionType": "KEYS_ONLY"} - } - ] - }} - """ - - static let sessionsDescription = """ - {"Table": { - "TableName": "sessions", - "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], - "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}] - }} - """ - - static let eventsDescription = """ - {"Table": { - "TableName": "events", - "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}], - "AttributeDefinitions": [ - {"AttributeName": "id", "AttributeType": "S"}, - {"AttributeName": "tenant", "AttributeType": "S"}, - {"AttributeName": "region", "AttributeType": "S"}, - {"AttributeName": "year", "AttributeType": "N"}, - {"AttributeName": "month", "AttributeType": "N"} - ], - "GlobalSecondaryIndexes": [ - { - "IndexName": "byTenantRegion", - "KeySchema": [ - {"AttributeName": "tenant", "KeyType": "HASH"}, - {"AttributeName": "region", "KeyType": "HASH"}, - {"AttributeName": "year", "KeyType": "RANGE"}, - {"AttributeName": "month", "KeyType": "RANGE"} - ], - "Projection": {"ProjectionType": "ALL"}, - "IndexStatus": "ACTIVE" - } - ] - }} - """ - - static func schema(_ description: String) throws -> DynamoDBTableSchema { - try DynamoDBTableSchema(describeTableResponse: try DynamoDBJSON.parse(description)) - } - - static func filter( - _ attribute: String, - _ op: String, - _ value: String = "", - second: String? = nil, - caseSensitive: Bool = true - ) -> DynamoDBBrowseFilter { - DynamoDBBrowseFilter( - attribute: attribute, op: op, value: value, secondValue: second, kind: nil, caseSensitive: caseSensitive - ) - } - - static func plan( - _ filters: [DynamoDBBrowseFilter], - matchAll: Bool = true, - order: [DynamoDBOrderTerm] = [], - columns: [String] = [], - on description: String = ordersDescription - ) throws -> DynamoDBReadPlan { - let tableSchema = try Self.schema(description) - let request = DynamoDBBrowseRequest( - table: tableSchema.name, filters: filters, matchAll: matchAll, columns: columns - ) - return try DynamoDBAccessPlanner(schema: tableSchema).plan(request, order: order) - } - - static func text(_ key: String, in request: [String: DynamoDBJSON]) -> String? { - request[key]?.stringValue - } - - static func names(in request: [String: DynamoDBJSON]) -> [String: String] { - (request["ExpressionAttributeNames"]?.objectValue ?? [:]).compactMapValues(\.stringValue) - } - - static func values(in request: [String: DynamoDBJSON]) throws -> [String: DynamoDBAttributeValue] { - try (request["ExpressionAttributeValues"]?.objectValue ?? [:]).mapValues(DynamoDBAttributeValue.init(wireJSON:)) - } - - static func clientPredicate(_ filter: DynamoDBBrowseFilter) -> DynamoDBClientPredicate { - DynamoDBClientPredicate( - path: DynamoDBAttributePath(attribute: filter.attribute), - op: filter.op, - value: filter.value, - secondValue: filter.secondValue, - caseSensitive: filter.caseSensitive - ) - } - - // MARK: - Placeholder check - - static let expressionKeys = [ - "KeyConditionExpression", "FilterExpression", "ProjectionExpression", "ConditionExpression", "UpdateExpression" - ] - - static func placeholders(in text: String) -> Set { - guard let regex = try? NSRegularExpression(pattern: "[#:][A-Za-z0-9_]+") else { return [] } - let range = NSRange(text.startIndex..., in: text) - return Set(regex.matches(in: text, range: range).compactMap { match in - Range(match.range, in: text).map { String(text[$0]) } - }) - } - - static func placeholderProblems(in request: [String: DynamoDBJSON]) -> [String] { - let expressions = expressionKeys.compactMap { request[$0]?.stringValue }.joined(separator: " ") - let referenced = placeholders(in: expressions) - var declared: Set = [] - var problems: [String] = [] - for (key, prefix) in [("ExpressionAttributeNames", "#"), ("ExpressionAttributeValues", ":")] { - guard let entry = request[key] else { continue } - guard let object = entry.objectValue else { - problems.append("\(key) is not an object") - continue - } - if object.isEmpty { problems.append("\(key) is empty") } - for placeholder in object.keys.sorted() { - declared.insert(placeholder) - if !placeholder.hasPrefix(prefix) { problems.append("\(placeholder) is in \(key)") } - if !referenced.contains(placeholder) { problems.append("\(placeholder) is declared but unused") } - } - } - for placeholder in referenced.subtracting(declared).sorted() { - problems.append("\(placeholder) is used but not declared") - } - return problems - } - - static func placeholderProblems(in plan: DynamoDBReadPlan) -> [String] { - plan.requests.flatMap { placeholderProblems(in: $0) } - } - - @Test("The placeholder check flags unused, undeclared and empty placeholder maps") - func placeholderCheckCanFail() { - let request: [String: DynamoDBJSON] = [ - "FilterExpression": .string("#pk = :pk2 AND #sk > :sk"), - "ExpressionAttributeNames": .object(["#pk": .string("pk")]), - "ExpressionAttributeValues": .object([ - ":pk": .object(["S": .string("a")]), - ":pk2": .object(["S": .string("b")]), - ":sk": .object(["N": .string("1")]) - ]) - ] - let empty: [String: DynamoDBJSON] = ["ExpressionAttributeNames": .object([:])] - - #expect(Self.placeholderProblems(in: request) == [":pk is declared but unused", "#sk is used but not declared"]) - #expect(Self.placeholderProblems(in: empty) == ["ExpressionAttributeNames is empty"]) - } - - // MARK: - Query on the table - - @Test("Equality on the partition key queries the table") - func partitionEqualityQueriesTable() throws { - let plan = try Self.plan([Self.filter("pk", "=", "x")]) - - #expect(plan.access == .query) - #expect(plan.indexName == nil) - #expect(plan.requests.count == 1) - let request = try #require(plan.requests.first) - #expect(request["TableName"] == .string("orders")) - #expect(Self.text("KeyConditionExpression", in: request) == "#pk = :pk") - #expect(request["IndexName"] == nil) - #expect(request["FilterExpression"] == nil) - #expect(Self.names(in: request) == ["#pk": "pk"]) - #expect(try Self.values(in: request) == [":pk": .string("x")]) - #expect(plan.clientPredicates.isEmpty) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("A range on the sort key joins the key condition") - func sortRangeJoinsKeyCondition() throws { - let plan = try Self.plan([Self.filter("pk", "=", "x"), Self.filter("sk", "BETWEEN", "1", second: "5")]) - - let request = try #require(plan.requests.first) - #expect(plan.access == .query) - #expect(Self.text("KeyConditionExpression", in: request) == "#pk = :pk AND #sk BETWEEN :sk AND :sk2") - #expect(request["FilterExpression"] == nil) - #expect(try Self.values(in: request) == [":pk": .string("x"), ":sk": .number("1"), ":sk2": .number("5")]) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("A reversed range on the sort key reads nothing") - func reversedSortRangeReadsNothing() throws { - let plan = try Self.plan([Self.filter("pk", "=", "x"), Self.filter("sk", "BETWEEN", "9", second: "1")]) - - #expect(plan.access == .nothing) - #expect(plan.requests.isEmpty) - } - - @Test("Descending order on the sort key reads the index backwards") - func descendingSortKeyOrder() throws { - let order = [DynamoDBOrderTerm(attribute: "sk", descending: true)] - - let plan = try Self.plan([Self.filter("pk", "=", "x")], order: order) - - let request = try #require(plan.requests.first) - #expect(request["ScanIndexForward"] == .bool(false)) - #expect(plan.unsatisfiedOrder.isEmpty) - } - - @Test("Ascending order on the sort key is the natural order") - func ascendingSortKeyOrder() throws { - let order = [DynamoDBOrderTerm(attribute: "sk", descending: false)] - - let plan = try Self.plan([Self.filter("pk", "=", "x")], order: order) - - let request = try #require(plan.requests.first) - #expect(request["ScanIndexForward"] == nil) - #expect(plan.unsatisfiedOrder.isEmpty) - } - - @Test("Order on any other attribute is left to the reader") - func otherOrderIsUnsatisfied() throws { - let order = [DynamoDBOrderTerm(attribute: "customer", descending: true)] - - let plan = try Self.plan([Self.filter("pk", "=", "x")], order: order) - - let request = try #require(plan.requests.first) - #expect(request["ScanIndexForward"] == nil) - #expect(plan.unsatisfiedOrder == order) - } - - @Test("A filter on a non-key attribute becomes the FilterExpression of the query") - func nonKeyFilterOnQuery() throws { - let plan = try Self.plan([Self.filter("pk", "=", "x"), Self.filter("customer", "=", "ann")]) - - let request = try #require(plan.requests.first) - #expect(plan.access == .query) - #expect(plan.indexName == nil) - #expect(Self.text("KeyConditionExpression", in: request) == "#pk = :pk") - #expect(Self.text("FilterExpression", in: request) == "#customer = :customer") - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("A key attribute the key condition cannot use is checked on the client, never in a FilterExpression") - func unusableKeyFilterGoesToClient() throws { - let notEqual = Self.filter("sk", "!=", "3") - - let plan = try Self.plan([Self.filter("pk", "=", "x"), notEqual]) - - let request = try #require(plan.requests.first) - #expect(plan.access == .query) - #expect(Self.text("KeyConditionExpression", in: request) == "#pk = :pk") - #expect(request["FilterExpression"] == nil) - #expect(plan.clientPredicates == [Self.clientPredicate(notEqual)]) - #expect(plan.clientMatchAll) - #expect(!plan.clientMatches(["pk": .string("x"), "sk": .number("3")])) - #expect(plan.clientMatches(["pk": .string("x"), "sk": .number("4")])) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - // MARK: - Indexes - - @Test("Equality on a global index partition key queries that index") - func queriesGlobalIndex() throws { - let plan = try Self.plan([Self.filter("status", "=", "shipped"), Self.filter("total", ">=", "100")]) - - let request = try #require(plan.requests.first) - #expect(plan.access == .query) - #expect(plan.indexName == "byStatus") - #expect(request["IndexName"] == .string("byStatus")) - #expect(request["Select"] == nil) - #expect(Self.text("KeyConditionExpression", in: request) == "#status = :status AND #total >= :total") - #expect(try Self.values(in: request) == [":status": .string("shipped"), ":total": .number("100")]) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("A key attribute of the chosen index is checked on the client") - func indexKeyFilterGoesToClient() throws { - let notEqual = Self.filter("total", "!=", "5") - - let plan = try Self.plan([Self.filter("status", "=", "shipped"), notEqual]) - - let request = try #require(plan.requests.first) - #expect(plan.indexName == "byStatus") - #expect(request["FilterExpression"] == nil) - #expect(plan.clientPredicates == [Self.clientPredicate(notEqual)]) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test( - "A global index is not used while an item missing its sort key could match", - arguments: [ - [DynamoDBAccessPlannerTests.filter("status", "=", "shipped")], - [DynamoDBAccessPlannerTests.filter("status", "=", "shipped"), DynamoDBAccessPlannerTests.filter("total", "IS NULL")] - ] - ) - func sparseGlobalIndexIsSkipped(_ filters: [DynamoDBBrowseFilter]) throws { - let plan = try Self.plan(filters) - - let request = try #require(plan.requests.first) - #expect(plan.access == .scan) - #expect(plan.indexName == nil) - #expect(request["IndexName"] == nil) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("A local index is not used while an item missing its sort key could match") - func sparseLocalIndexIsSkipped() throws { - let plan = try Self.plan([Self.filter("pk", "=", "x"), Self.filter("created", "IS NULL")]) - - #expect(plan.access == .query) - #expect(plan.indexName == nil) - #expect(plan.requests.first?["IndexName"] == nil) - } - - @Test("A global index that does not project every attribute is not used") - func keysOnlyGlobalIndexIsSkipped() throws { - let plan = try Self.plan([Self.filter("zip", "=", "10001")]) - - let request = try #require(plan.requests.first) - #expect(plan.access == .scan) - #expect(plan.indexName == nil) - #expect(request["IndexName"] == nil) - #expect(request["KeyConditionExpression"] == nil) - #expect(Self.text("FilterExpression", in: request) == "#zip = :zip") - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("A local index with a sort filter is preferred and reads every attribute") - func localIndexWithSortFilter() throws { - let plan = try Self.plan([Self.filter("pk", "=", "x"), Self.filter("created", ">=", "2024")]) - - let request = try #require(plan.requests.first) - #expect(plan.access == .query) - #expect(plan.indexName == "byCreated") - #expect(request["IndexName"] == .string("byCreated")) - #expect(request["Select"] == .string("ALL_ATTRIBUTES")) - #expect(Self.text("KeyConditionExpression", in: request) == "#pk = :pk AND #created >= :created") - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("The table is preferred over a local index when no sort filter uses the index") - func tablePreferredOverLocalIndex() throws { - let plan = try Self.plan([Self.filter("pk", "=", "x")]) - - #expect(plan.indexName == nil) - #expect(plan.requests.first?["IndexName"] == nil) - } - - @Test("An index that is being built or backfilled is never chosen", arguments: [ - DynamoDBAccessPlannerTests.filter("email", "=", "a@example.com"), - DynamoDBAccessPlannerTests.filter("phone", "=", "555") - ]) - func unavailableIndexIsSkipped(_ filter: DynamoDBBrowseFilter) throws { - let plan = try Self.plan([filter]) - - #expect(plan.access == .scan) - #expect(plan.indexName == nil) - #expect(plan.requests.allSatisfy { $0["IndexName"] == nil }) - } - - // MARK: - Multi-attribute index keys - - @Test("A multi-attribute index is queried with every key attribute in the key condition") - func multiAttributeIndexFullKey() throws { - let plan = try Self.plan([ - Self.filter("tenant", "=", "t1"), - Self.filter("region", "=", "eu"), - Self.filter("year", "=", "2024"), - Self.filter("month", ">", "3") - ], on: Self.eventsDescription) - - let request = try #require(plan.requests.first) - #expect(plan.access == .query) - #expect(plan.indexName == "byTenantRegion") - #expect( - Self.text("KeyConditionExpression", in: request) - == "#tenant = :tenant AND #region = :region AND #year = :year AND #month > :month" - ) - #expect(try Self.values(in: request) == [ - ":tenant": .string("t1"), ":region": .string("eu"), ":year": .number("2024"), ":month": .number("3") - ]) - #expect(plan.clientPredicates.isEmpty) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("A multi-attribute index needs equality on every partition attribute", arguments: [ - [DynamoDBAccessPlannerTests.filter("tenant", "=", "t1")], - [DynamoDBAccessPlannerTests.filter("tenant", "=", "t1"), DynamoDBAccessPlannerTests.filter("region", ">", "a")], - [DynamoDBAccessPlannerTests.filter("tenant", "IN", "t1, t2"), DynamoDBAccessPlannerTests.filter("region", "=", "eu")] - ]) - func multiAttributeIndexNeedsWholePartition(_ filters: [DynamoDBBrowseFilter]) throws { - let plan = try Self.plan(filters, on: Self.eventsDescription) - - #expect(plan.access == .scan) - #expect(plan.indexName == nil) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("A range on the second sort attribute needs equality on the first") - func multiAttributeSortNeedsLeadingEquality() throws { - let hasYear = Self.filter("year", "IS NOT NULL") - let month = Self.filter("month", ">", "3") - - let plan = try Self.plan([ - Self.filter("tenant", "=", "t1"), - Self.filter("region", "=", "eu"), - hasYear, - month - ], on: Self.eventsDescription) - - let request = try #require(plan.requests.first) - #expect(plan.indexName == "byTenantRegion") - #expect(Self.text("KeyConditionExpression", in: request) == "#tenant = :tenant AND #region = :region") - #expect(request["FilterExpression"] == nil) - #expect(plan.clientPredicates == [Self.clientPredicate(hasYear), Self.clientPredicate(month)]) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("A multi-attribute index is not used while an item missing one of its sort attributes could match") - func multiAttributeIndexNeedsEverySortAttribute() throws { - let plan = try Self.plan([ - Self.filter("tenant", "=", "t1"), - Self.filter("region", "=", "eu"), - Self.filter("month", ">", "3") - ], on: Self.eventsDescription) - - #expect(plan.access == .scan) - #expect(plan.indexName == nil) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("A range on the first sort attribute keeps the second out of the key condition") - func multiAttributeRangeOnFirstSortAttribute() throws { - let plan = try Self.plan([ - Self.filter("tenant", "=", "t1"), - Self.filter("region", "=", "eu"), - Self.filter("year", ">", "2020"), - Self.filter("month", "=", "3") - ], on: Self.eventsDescription) - - let request = try #require(plan.requests.first) - let keyCondition = try #require(Self.text("KeyConditionExpression", in: request)) - #expect(plan.indexName == "byTenantRegion") - #expect(keyCondition.hasPrefix("#tenant = :tenant AND #region = :region")) - #expect(!keyCondition.contains("#month")) - #expect(request["FilterExpression"] == nil) - #expect(plan.clientPredicates.contains(Self.clientPredicate(Self.filter("month", "=", "3")))) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - // MARK: - Partition lists - - @Test("An OR of partition keys on a table without a sort key reads the keys in one batch, in order") - func orOfKeysWithoutSortKeyIsBatchGet() throws { - let plan = try Self.plan( - [Self.filter("pk", "=", "b"), Self.filter("pk", "=", "a"), Self.filter("pk", "=", "b")], - matchAll: false, - on: Self.sessionsDescription - ) - - #expect(plan.access == .batchGet) - #expect(plan.requests == [[ - "RequestItems": .object(["sessions": .object([ - "Keys": .array([ - .object(["pk": .object(["S": .string("b")])]), - .object(["pk": .object(["S": .string("a")])]) - ]), - "ConsistentRead": .bool(true) - ])]) - ]]) - } - - @Test("A batch of more than 100 keys is split into requests of at most 100") - func batchGetChunks() throws { - let filters = (0..<150).map { Self.filter("pk", "=", "k\($0)") } - - let plan = try Self.plan(filters, matchAll: false, on: Self.sessionsDescription) - - let counts = plan.requests.map { request in - request["RequestItems"]?["sessions"]?["Keys"]?.arrayValue?.count ?? 0 - } - #expect(plan.access == .batchGet) - #expect(counts == [100, 50]) - } - - @Test("An OR of partition keys on a table with a sort key queries each partition") - func orOfKeysWithSortKeyIsQueries() throws { - let plan = try Self.plan([Self.filter("pk", "=", "a"), Self.filter("pk", "=", "b")], matchAll: false) - - #expect(plan.access == .query) - #expect(plan.indexName == nil) - #expect(plan.requests.map { Self.text("KeyConditionExpression", in: $0) } == ["#pk = :pk", "#pk = :pk"]) - #expect(try plan.requests.map { try Self.values(in: $0) } == [[":pk": .string("a")], [":pk": .string("b")]]) - #expect(plan.requests.allSatisfy { $0["ConsistentRead"] == .bool(true) }) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("A page position belongs to the request it was read from, not only to the filters") - func positionKeyNamesTheAccessPath() throws { - let scan = try Self.plan([Self.filter("customer", "=", "ann")]) - let index = try Self.plan([Self.filter("status", "=", "shipped"), Self.filter("total", ">", "1")]) - let table = try Self.plan([Self.filter("pk", "=", "a")]) - - #expect(Set([scan.positionKey, index.positionKey, table.positionKey]).count == 3) - } - - @Test("In on the partition key with another filter queries each partition with that filter") - func partitionInWithFilter() throws { - let plan = try Self.plan([Self.filter("pk", "IN", "a, b"), Self.filter("customer", "=", "ann")]) - - #expect(plan.access == .query) - #expect(plan.requests.count == 2) - #expect(plan.requests.map { Self.text("KeyConditionExpression", in: $0) } == ["#pk = :pk", "#pk = :pk"]) - #expect(plan.requests.map { Self.text("FilterExpression", in: $0) } == ["#customer = :customer", "#customer = :customer"]) - #expect(try plan.requests.map { try Self.values(in: $0) } == [ - [":pk": .string("a"), ":customer": .string("ann")], - [":pk": .string("b"), ":customer": .string("ann")] - ]) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("In on the partition key queries each distinct partition once") - func partitionInDeduplicates() throws { - let plan = try Self.plan([Self.filter("pk", "IN", "a, a, b")]) - - #expect(plan.access == .query) - #expect(try plan.requests.map { try Self.values(in: $0) } == [[":pk": .string("a")], [":pk": .string("b")]]) - } - - @Test("Order on the sort key across several partitions is left to the reader") - func partitionInOrderIsUnsatisfied() throws { - let order = [DynamoDBOrderTerm(attribute: "sk", descending: true)] - - let plan = try Self.plan([Self.filter("pk", "IN", "a, b")], order: order) - - #expect(plan.requests.count == 2) - #expect(plan.unsatisfiedOrder == order) - } - - // MARK: - Match any - - @Test("An OR with a non-key filter scans with an OR FilterExpression") - func orWithNonKeyFilterScans() throws { - let plan = try Self.plan([Self.filter("pk", "=", "a"), Self.filter("customer", "=", "ann")], matchAll: false) - - let request = try #require(plan.requests.first) - #expect(plan.access == .scan) - #expect(Self.text("FilterExpression", in: request) == "#pk = :pk OR #customer = :customer") - #expect(plan.clientPredicates.isEmpty) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - @Test("An OR where one filter needs the client checks every filter on the client") - func orWithClientFilter() throws { - let key = Self.filter("pk", "=", "a") - let suffix = Self.filter("customer", "ENDS WITH", "nn") - - let plan = try Self.plan([key, suffix], matchAll: false) - - #expect(plan.access == .scan) - #expect(plan.requests == [["TableName": .string("orders")]]) - #expect(plan.clientPredicates == [Self.clientPredicate(key), Self.clientPredicate(suffix)]) - #expect(!plan.clientMatchAll) - #expect(plan.clientMatches(["pk": .string("a"), "customer": .string("bob")])) - #expect(plan.clientMatches(["pk": .string("b"), "customer": .string("ann")])) - #expect(!plan.clientMatches(["pk": .string("b"), "customer": .string("bob")])) - } - - @Test("An OR term that can match nothing leaves no placeholder behind") - func orDropsImpossibleTermCleanly() throws { - let plan = try Self.plan([Self.filter("customer", "=", "ann"), Self.filter("sk", "=", "abc")], matchAll: false) - - let request = try #require(plan.requests.first) - #expect(plan.access == .scan) - #expect(Self.text("FilterExpression", in: request) == "#customer = :customer") - #expect(Self.placeholderProblems(in: plan).isEmpty) - } - - // MARK: - Impossible and refused filters - - @Test("A Number key compared with text that is not a number reads nothing", arguments: [ - DynamoDBAccessPlannerTests.filter("sk", "=", "abc"), - DynamoDBAccessPlannerTests.filter("total", "=", "abc") - ]) - func numberKeyWithTextReadsNothing(_ filter: DynamoDBBrowseFilter) throws { - let plan = try Self.plan([filter]) - - #expect(plan.access == .nothing) - #expect(plan.requests.isEmpty) - } - - @Test("A raw filter is refused") - func rawFilterThrows() throws { - let raw = Self.filter(DynamoDBFilterTranslator.rawFilterColumn, "=", "a = 1") - let reason = try #require(DynamoDBFilterTranslator.unsupportedReason(for: raw)) - - #expect(throws: DynamoDBError.invalidStatement(reason)) { - try Self.plan([Self.filter("pk", "=", "x"), raw]) - } - } - - // MARK: - Placeholders - - static let scenarios: [Scenario] = [ - Scenario("table query with mixed filters", [ - filter("pk", "=", "x"), filter("sk", ">", "3"), filter("customer", "CONTAINS", "a"), - filter("total", "IS NULL"), filter("note", "IS NOT EMPTY") - ]), - Scenario("index query with filters", [ - filter("status", "=", "s"), filter("customer", "NOT IN", "a, b"), filter("flag", "=", "true") - ]), - Scenario("local index query with filters", [ - filter("pk", "=", "x"), filter("created", "BETWEEN", "a", second: "b"), filter("customer", "!=", "z") - ]), - Scenario("scan with typed readings", [ - filter("customer", "=", "5"), filter("flag", "=", "true"), filter("note", "BETWEEN", "1,9") - ]), - Scenario("per-partition queries", [filter("pk", "IN", "a, b"), filter("customer", "BETWEEN", "1", second: "9")]), - Scenario("match any scan", [ - filter("customer", "=", "5"), filter("note", "STARTS WITH", "x"), filter("total", ">", "3") - ], matchAll: false), - Scenario("nested paths", [filter("address.city", "=", "Hanoi"), filter("items[0].sku", "IN", "A1, B2")]), - Scenario( - "names that need placeholders", - [filter("order-id", "=", "1"), filter("a.b", "=", "2"), filter("name", "IS NOT NULL")], - columns: ["a.b"] - ), - Scenario("client filter beside a server filter", [filter("customer", "=", "ann"), filter("note", "ENDS WITH", "z")]), - Scenario("descending sort key", [filter("pk", "=", "x")], order: [DynamoDBOrderTerm(attribute: "sk", descending: true)]) - ] - - @Test("No request declares a placeholder its expressions do not use", arguments: scenarios) - func everyPlaceholderIsUsed(_ scenario: Scenario) throws { - let plan = try Self.plan(scenario.filters, matchAll: scenario.matchAll, order: scenario.order, columns: scenario.columns) - - #expect(plan.access != .nothing) - #expect(Self.placeholderProblems(in: plan).isEmpty) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBAttributeValueTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBAttributeValueTests.swift deleted file mode 100644 index 9233ea93a9..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBAttributeValueTests.swift +++ /dev/null @@ -1,188 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBAttributeValueTests { - struct ValueCase: Sendable, CustomTestStringConvertible { - let name: String - let value: DynamoDBAttributeValue - let wire: String - var testDescription: String { name } - } - - struct PayloadCase: Sendable, CustomTestStringConvertible { - let name: String - let wire: String - var testDescription: String { name } - } - - static let valueCases: [ValueCase] = [ - ValueCase(name: "String", value: .string("02134"), wire: #"{"S":"02134"}"#), - ValueCase( - name: "Number", - value: .number("12345678901234567890123456789012345678"), - wire: #"{"N":"12345678901234567890123456789012345678"}"# - ), - ValueCase(name: "Number with scale", value: .number("1.50"), wire: #"{"N":"1.50"}"#), - ValueCase(name: "Binary", value: .binary(Data([1, 2, 3])), wire: #"{"B":"AQID"}"#), - ValueCase(name: "Boolean", value: .bool(false), wire: #"{"BOOL":false}"#), - ValueCase(name: "Null", value: .null, wire: #"{"NULL":true}"#), - ValueCase( - name: "List", - value: .list([.string("x"), .number("1"), .list([]), .null]), - wire: #"{"L":[{"S":"x"},{"N":"1"},{"L":[]},{"NULL":true}]}"# - ), - ValueCase( - name: "Map", - value: .map(["b": .bool(true), "a": .map(["n": .number("-0.5")])]), - wire: #"{"M":{"a":{"M":{"n":{"N":"-0.5"}}},"b":{"BOOL":true}}}"# - ), - ValueCase(name: "String Set", value: .stringSet(["b", "a"]), wire: #"{"SS":["b","a"]}"#), - ValueCase(name: "Number Set", value: .numberSet(["10", "1.5"]), wire: #"{"NS":["10","1.5"]}"#), - ValueCase(name: "Binary Set", value: .binarySet([Data([2]), Data([1])]), wire: #"{"BS":["Ag==","AQ=="]}"#) - ] - - @Test("Every type round-trips through its wire form", arguments: valueCases) - func wireRoundTrip(valueCase: ValueCase) throws { - #expect(try DynamoDBAttributeValue(wireJSON: valueCase.value.wireJSON) == valueCase.value) - } - - @Test("Every type writes the wire JSON DynamoDB expects", arguments: valueCases) - func wireText(valueCase: ValueCase) { - #expect(valueCase.value.wireJSON.serialized() == valueCase.wire) - } - - @Test("Every type decodes from the wire JSON DynamoDB sends", arguments: valueCases) - func decodesWireText(valueCase: ValueCase) throws { - #expect(try DynamoDBAttributeValue(wireJSON: DynamoDBJSON.parse(valueCase.wire)) == valueCase.value) - } - - @Test("A value reports its own type", arguments: valueCases) - func reportsType(valueCase: ValueCase) throws { - let json = try DynamoDBJSON.parse(valueCase.wire) - let tag = try #require(json.objectValue?.keys.first) - #expect(valueCase.value.type.rawValue == tag) - } - - @Test("A Number keeps its exact text on the wire, never passing through a Double") - func numberStaysText() throws { - let value = try DynamoDBAttributeValue(wireJSON: DynamoDBJSON.parse(#"{"N":"0.1000000000000000000000000000000000001"}"#)) - #expect(value == .number("0.1000000000000000000000000000000000001")) - #expect(value.wireJSON == .object(["N": .string("0.1000000000000000000000000000000000001")])) - } - - @Test( - "A payload of the wrong shape is rejected", - arguments: [ - PayloadCase(name: "String holding a number", wire: #"{"S":5}"#), - PayloadCase(name: "Number holding a bare number", wire: #"{"N":5}"#), - PayloadCase(name: "Binary that is not base64", wire: #"{"B":"not base64!"}"#), - PayloadCase(name: "Boolean holding a string", wire: #"{"BOOL":"true"}"#), - PayloadCase(name: "Null holding a string", wire: #"{"NULL":"x"}"#), - PayloadCase(name: "List holding an object", wire: #"{"L":{}}"#), - PayloadCase(name: "List element without a type", wire: #"{"L":["x"]}"#), - PayloadCase(name: "Map holding an array", wire: #"{"M":[]}"#), - PayloadCase(name: "Map entry without a type", wire: #"{"M":{"a":"x"}}"#), - PayloadCase(name: "String Set member that is a number", wire: #"{"SS":[1]}"#), - PayloadCase(name: "Number Set member that is a bare number", wire: #"{"NS":[1]}"#), - PayloadCase(name: "Binary Set member that is not base64", wire: #"{"BS":["!!"]}"#), - PayloadCase(name: "String Set that is not an array", wire: #"{"SS":"a"}"#) - ] - ) - func rejectsWrongShape(payload: PayloadCase) throws { - let json = try DynamoDBJSON.parse(payload.wire) - #expect(throws: DynamoDBError.self) { try DynamoDBAttributeValue(wireJSON: json) } - } - - @Test( - "A value that does not name exactly one known type is rejected", - arguments: [ - PayloadCase(name: "unknown tag", wire: #"{"X":"a"}"#), - PayloadCase(name: "lowercase tag", wire: #"{"s":"a"}"#), - PayloadCase(name: "two tags", wire: #"{"S":"a","N":"1"}"#), - PayloadCase(name: "no tag", wire: "{}"), - PayloadCase(name: "bare string", wire: #""a""#), - PayloadCase(name: "array", wire: #"[{"S":"a"}]"#), - PayloadCase(name: "null", wire: "null") - ] - ) - func rejectsUnknownOrAmbiguousTag(payload: PayloadCase) throws { - let json = try DynamoDBJSON.parse(payload.wire) - #expect(throws: DynamoDBError.self) { try DynamoDBAttributeValue(wireJSON: json) } - } - - @Test("The error for an unknown tag names the tag") - func unknownTagIsNamed() throws { - let json = try DynamoDBJSON.parse(#"{"XYZ":"a"}"#) - let error = #expect(throws: DynamoDBError.self) { try DynamoDBAttributeValue(wireJSON: json) } - #expect(error?.localizedDescription.contains("XYZ") == true) - } - - @Test("An item round-trips through its wire form") - func itemRoundTrip() throws { - let item: DynamoDBItem = [ - "pk": .string("user#1"), - "sk": .number("1700000000"), - "balance": .number("12345678901234567890123456789012345678"), - "avatar": .binary(Data([0xFF, 0x00])), - "active": .bool(true), - "nickname": .null, - "tags": .stringSet(["a", "b"]), - "scores": .numberSet(["1", "2.5"]), - "keys": .binarySet([Data([1])]), - "history": .list([.map(["at": .number("1"), "what": .string("login")])]), - "profile": .map(["address": .map(["zip": .string("02134")])]) - ] - #expect(try DynamoDBItem(wireItem: item.wireJSON) == item) - #expect(try DynamoDBItem(wireItem: DynamoDBJSON.parse(item.wireJSON.serialized())) == item) - } - - @Test("An item decodes from a GetItem response body") - func itemFromResponse() throws { - let response = try DynamoDBJSON.parse(#""" - {"Item":{"pk":{"S":"a"},"n":{"N":"1.50"},"doc":{"M":{"inner":{"L":[{"BOOL":true},{"NULL":true}]}}},"blob":{"B":"AQID"}}} - """#) - let item = try DynamoDBItem(wireItem: try #require(response["Item"])) - #expect(item == [ - "pk": .string("a"), - "n": .number("1.50"), - "doc": .map(["inner": .list([.bool(true), .null])]), - "blob": .binary(Data([1, 2, 3])) - ]) - } - - @Test("An empty item is an empty object on the wire") - func emptyItem() throws { - let item: DynamoDBItem = [:] - #expect(item.wireJSON == .object([:])) - #expect(try DynamoDBItem(wireItem: .object([:])).isEmpty) - } - - @Test("An item that is not an object is rejected", arguments: [#"[]"#, #""item""#, "null", "1"]) - func itemMustBeObject(text: String) throws { - let json = try DynamoDBJSON.parse(text) - #expect(throws: DynamoDBError.self) { try DynamoDBItem(wireItem: json) } - } - - @Test("An item with one malformed attribute is rejected as a whole") - func itemWithBadAttributeIsRejected() throws { - let json = try DynamoDBJSON.parse(#"{"pk":{"S":"a"},"bad":{"N":1}}"#) - #expect(throws: DynamoDBError.self) { try DynamoDBItem(wireItem: json) } - } - - @Test("A type is found by its display name or its tag, in any case") - func typeFromDisplayName() { - #expect(DynamoDBAttributeType(displayName: "Number Set") == .numberSet) - #expect(DynamoDBAttributeType(displayName: "number set") == .numberSet) - #expect(DynamoDBAttributeType(displayName: "ns") == .numberSet) - #expect(DynamoDBAttributeType(displayName: "BOOL") == .boolean) - #expect(DynamoDBAttributeType(displayName: "Boolean") == .boolean) - #expect(DynamoDBAttributeType(displayName: "Float") == nil) - } - - @Test("Only String, Number and Binary can be key types") - func keyTypes() { - let keyTypes = DynamoDBAttributeType.allCases.filter(\.isKeyType) - #expect(keyTypes == [.string, .number, .binary]) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBCatalogTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBCatalogTests.swift deleted file mode 100644 index fa358da32d..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBCatalogTests.swift +++ /dev/null @@ -1,292 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBCatalogTests { - private static let fetchedAt = Date(timeIntervalSince1970: 1_700_000_000) - private static let local = DynamoDBCatalog.Scope(endpoint: "http://localhost:8000", region: "us-east-1", identity: "local") - private static let remote = DynamoDBCatalog.Scope( - endpoint: "https://dynamodb.eu-west-1.amazonaws.com/", region: "eu-west-1", identity: "key:AKIDEXAMPLE" - ) - - private static func schema(_ name: String) throws -> DynamoDBTableSchema { - let json = try DynamoDBJSON.parse( - #"{"Table":{"TableName":"\#(name)","KeySchema":[{"AttributeName":"pk","KeyType":"HASH"}],"AttributeDefinitions":[{"AttributeName":"pk","AttributeType":"S"}]}}"# - ) - return try DynamoDBTableSchema(describeTableResponse: json) - } - - private static func point(_ requestIndex: Int, key: String? = nil, skip: Int = 0) -> DynamoDBResumePoint { - DynamoDBResumePoint(requestIndex: requestIndex, startKey: key.map { ["pk": .string($0)] }, skip: skip) - } - - private static func nearest( - _ catalog: DynamoDBCatalog, - table: String = "Orders", - fingerprint: String = "scan", - offset: Int, - scope: DynamoDBCatalog.Scope = local - ) -> DynamoDBResumePoint? { - catalog.nearestResumePoint(table: table, fingerprint: fingerprint, atOrBefore: offset, in: scope)?.point - } - - @Test("A stored schema is served until it is a minute old") - func schemaLifetime() throws { - let catalog = DynamoDBCatalog() - let orders = try Self.schema("Orders") - catalog.store(orders, in: Self.local, now: Self.fetchedAt) - - #expect(catalog.schema(for: "Orders", in: Self.local, now: Self.fetchedAt) == orders) - #expect(catalog.schema(for: "Orders", in: Self.local, now: Self.fetchedAt.addingTimeInterval(59.9)) == orders) - #expect(catalog.schema(for: "Orders", in: Self.local, now: Self.fetchedAt.addingTimeInterval(60)) == nil) - #expect(catalog.schema(for: "Orders", in: Self.local, now: Self.fetchedAt.addingTimeInterval(3_600)) == nil) - } - - @Test("Storing a schema again restarts its lifetime") - func restoringRestartsLifetime() throws { - let catalog = DynamoDBCatalog() - let orders = try Self.schema("Orders") - catalog.store(orders, in: Self.local, now: Self.fetchedAt) - catalog.store(orders, in: Self.local, now: Self.fetchedAt.addingTimeInterval(50)) - - #expect(catalog.schema(for: "Orders", in: Self.local, now: Self.fetchedAt.addingTimeInterval(100)) == orders) - } - - @Test("A schema belongs to its own scope and table") - func schemaIsolation() throws { - let catalog = DynamoDBCatalog() - catalog.store(try Self.schema("Orders"), in: Self.local, now: Self.fetchedAt) - - #expect(catalog.schema(for: "Orders", in: Self.remote, now: Self.fetchedAt) == nil) - #expect(catalog.schema(for: "Customers", in: Self.local, now: Self.fetchedAt) == nil) - } - - @Test("Cached schemas are listed by name for their scope only") - func cachedSchemasByScope() throws { - let catalog = DynamoDBCatalog() - catalog.store(try Self.schema("Orders"), in: Self.local) - catalog.store(try Self.schema("Customers"), in: Self.local) - catalog.store(try Self.schema("Invoices"), in: Self.remote) - - #expect(catalog.cachedSchemas(in: Self.local).map(\.name) == ["Customers", "Orders"]) - #expect(catalog.cachedSchemas(in: Self.remote).map(\.name) == ["Invoices"]) - } - - @Test("Invalidating a table drops its schema and every resume point of it, and nothing else") - func invalidateTable() throws { - let catalog = DynamoDBCatalog() - catalog.store(try Self.schema("Orders"), in: Self.local, now: Self.fetchedAt) - catalog.store(try Self.schema("Customers"), in: Self.local, now: Self.fetchedAt) - catalog.store(try Self.schema("Orders"), in: Self.remote, now: Self.fetchedAt) - catalog.storeResumePoint(Self.point(0, key: "a"), table: "Orders", fingerprint: "scan", offset: 100, in: Self.local) - catalog.storeResumePoint(Self.point(0, key: "b"), table: "Orders", fingerprint: "query", offset: 100, in: Self.local) - catalog.storeResumePoint(Self.point(0, key: "c"), table: "Customers", fingerprint: "scan", offset: 100, in: Self.local) - catalog.storeResumePoint(Self.point(0, key: "d"), table: "Orders", fingerprint: "scan", offset: 100, in: Self.remote) - - catalog.invalidate(table: "Orders", in: Self.local) - - #expect(catalog.schema(for: "Orders", in: Self.local, now: Self.fetchedAt) == nil) - #expect(Self.nearest(catalog, fingerprint: "scan", offset: 100) == nil) - #expect(Self.nearest(catalog, fingerprint: "query", offset: 100) == nil) - #expect(catalog.schema(for: "Customers", in: Self.local, now: Self.fetchedAt)?.name == "Customers") - #expect(catalog.schema(for: "Orders", in: Self.remote, now: Self.fetchedAt)?.name == "Orders") - #expect(Self.nearest(catalog, table: "Customers", offset: 100) == Self.point(0, key: "c")) - #expect(Self.nearest(catalog, offset: 100, scope: Self.remote) == Self.point(0, key: "d")) - } - - @Test("Invalidating a table forgets the types seen in its items, and a write forgets only its read positions") - func invalidateForgetsTypesButWritesKeepThem() throws { - let catalog = DynamoDBCatalog() - catalog.store(try Self.schema("Orders"), in: Self.local, now: Self.fetchedAt) - catalog.mergeColumnTypes(["age": .number], for: "Orders", in: Self.local) - catalog.storeResumePoint(Self.point(0, key: "a"), table: "Orders", fingerprint: "scan", offset: 100, in: Self.local) - - catalog.forgetReadPositions(table: "Orders", in: Self.local) - - #expect(Self.nearest(catalog, fingerprint: "scan", offset: 100) == nil) - #expect(catalog.schema(for: "Orders", in: Self.local, now: Self.fetchedAt) != nil) - #expect(catalog.columnTypes(for: "Orders", in: Self.local) == ["age": .number]) - - catalog.invalidate(table: "Orders", in: Self.local) - - #expect(catalog.columnTypes(for: "Orders", in: Self.local).isEmpty) - } - - @Test("An invalidated table's fingerprints no longer count toward eviction") - func invalidateTableFreesEvictionSlots() { - let catalog = DynamoDBCatalog() - let kept = DynamoDBCatalog.maximumFingerprints / 2 - for index in 0.. DynamoDBAttributeValue? { - try DynamoDBCellCodec.decode(.text(text), template: template, columnType: columnType, attribute: attribute) - } - - private static func invalidValueAttribute(of error: DynamoDBError?) -> String? { - guard case .invalidValue(let attribute, _) = error else { return nil } - return attribute - } - - @Test( - "A scalar attribute becomes the cell its type reads as", - arguments: [ - CellCase(name: "String", value: .string("02134"), expected: .text("02134")), - CellCase(name: "Number", value: .number("1.50"), expected: .text("1.50")), - CellCase( - name: "38 digit Number", - value: .number("12345678901234567890123456789012345678"), - expected: .text("12345678901234567890123456789012345678") - ), - CellCase(name: "true", value: .bool(true), expected: .text("true")), - CellCase(name: "false", value: .bool(false), expected: .text("false")), - CellCase(name: "NULL", value: .null, expected: .null), - CellCase(name: "Binary", value: .binary(Data([0x00, 0xFF, 0x10])), expected: .bytes(Data([0x00, 0xFF, 0x10]))) - ] - ) - func scalarCells(cellCase: CellCase) { - #expect(DynamoDBCellCodec.cell(for: cellCase.value) == cellCase.expected) - } - - @Test("A missing attribute is a null cell") - func missingAttributeIsNull() { - #expect(DynamoDBCellCodec.cell(for: nil) == .null) - } - - @Test("A map renders as plain JSON with sorted keys and exact numbers") - func mapRendersAsPlainJSON() { - let value = DynamoDBAttributeValue.map([ - "b": .number("12345678901234567890123456789012345678"), - "a": .string("x"), - "c": .map(["z": .bool(true), "y": .null]), - "d": .number("1.50") - ]) - let expected = #"{"a":"x","b":12345678901234567890123456789012345678,"c":{"y":null,"z":true},"d":1.50}"# - #expect(DynamoDBCellCodec.cell(for: value) == .text(expected)) - } - - @Test("A list renders its elements in order, with binary as base64") - func listRendersInOrder() { - let value = DynamoDBAttributeValue.list([.string("x"), .number("1.50"), .binary(Data([0xFF])), .null]) - #expect(DynamoDBCellCodec.cell(for: value) == .text(#"["x",1.50,"/w==",null]"#)) - } - - @Test("A String Set renders its members sorted") - func stringSetRendersSorted() { - #expect(DynamoDBCellCodec.cell(for: .stringSet(["b", "a", "c"])) == .text(#"["a","b","c"]"#)) - } - - @Test("A Number Set renders its members in numeric order") - func numberSetRendersNumerically() { - let value = DynamoDBAttributeValue.numberSet(["10", "9", "-1", "1.5", "1e3"]) - #expect(DynamoDBCellCodec.cell(for: value) == .text("[-1,1.5,9,10,1e3]")) - } - - @Test("A Binary Set renders its members as sorted base64") - func binarySetRendersSortedBase64() { - let value = DynamoDBAttributeValue.binarySet([Data([2]), Data([1])]) - #expect(DynamoDBCellCodec.cell(for: value) == .text(#"["AQ==","Ag=="]"#)) - } - - @Test("Number spellings JSON does not allow are rewritten as JSON numbers inside a map") - func dynamoDBOnlyNumberFormsBecomeJSON() { - let value = DynamoDBAttributeValue.map([ - "plus": .number("+5"), - "point": .number(".5"), - "negativePoint": .number("-.5"), - "trailingPoint": .number("5.") - ]) - #expect(DynamoDBCellCodec.cell(for: value) == .text(#"{"negativePoint":-0.5,"plus":5,"point":0.5,"trailingPoint":5}"#)) - } - - @Test("Every number the codec accepts renders as a number, never as null", arguments: ["007.5", "-0012", "+.5"]) - func acceptedNumbersNeverRenderAsNull(text: String) throws { - #expect(DynamoDBNumber.isValid(text)) - guard case .text(let rendered) = DynamoDBCellCodec.cell(for: .numberSet([text])) else { - Issue.record("A Number Set must render as text") - return - } - let member = try #require(try DynamoDBJSON.parse(rendered).arrayValue?.first) - let number = try #require(member.numberText, "rendered \(rendered)") - #expect(DynamoDBNumber.areEqual(number, text)) - } - - @Test("Digits typed over a String stay a String") - func stringTemplateKeepsLeadingZeros() throws { - #expect(try Self.decodeText("02134", template: .string("x")) == .string("02134")) - } - - @Test("A template's type wins over the column's type") - func templateBeatsColumnType() throws { - #expect(try Self.decodeText("02134", template: .string("x"), columnType: .number) == .string("02134")) - } - - @Test("Digits typed over a Number become a Number with the trimmed text") - func numberTemplateKeepsTrimmedText() throws { - #expect(try Self.decodeText("02134", template: .number("5")) == .number("02134")) - #expect(try Self.decodeText(" 1.50 ", template: .number("5")) == .number("1.50")) - } - - @Test("Text that is not a number, typed over a Number, is an error naming the attribute") - func invalidNumberNamesAttribute() { - let error = #expect(throws: DynamoDBError.self) { - try Self.decodeText("12abc", template: .number("5"), attribute: "price") - } - #expect(Self.invalidValueAttribute(of: error) == "price") - #expect(error?.localizedDescription.contains("price") == true) - } - - @Test("A number outside DynamoDB's range is an error") - func outOfRangeNumberIsRejected() { - #expect(throws: DynamoDBError.self) { try Self.decodeText("1E+126", template: .number("5")) } - #expect(throws: DynamoDBError.self) { try Self.decodeText("1234567890123456789012345678901234567890", template: .number("5")) } - } - - @Test("A JSON array over a String Set stays a String Set") - func stringSetStaysStringSet() throws { - #expect(try Self.decodeText(#"["b", "a"]"#, template: .stringSet(["x"])) == .stringSet(["b", "a"])) - } - - @Test("A JSON array over a Number Set stays a Number Set, from numbers or numeric strings") - func numberSetStaysNumberSet() throws { - #expect(try Self.decodeText(#"[3, "1.5", 12345678901234567890123456789012345678]"#, template: .numberSet(["1"])) - == .numberSet(["3", "1.5", "12345678901234567890123456789012345678"])) - } - - @Test("A JSON array over a Binary Set stays a Binary Set") - func binarySetStaysBinarySet() throws { - #expect(try Self.decodeText(#"["AQ==", "AgM="]"#, template: .binarySet([Data([9])])) - == .binarySet([Data([1]), Data([2, 3])])) - } - - @Test("A JSON array over a List stays a List and each element keeps its own template") - func listKeepsElementTemplates() throws { - let template = DynamoDBAttributeValue.list([.binary(Data([9])), .stringSet(["a"]), .number("1")]) - let decoded = try Self.decodeText(#"["AQ==", ["c", "b"], 2, "extra"]"#, template: template) - #expect(decoded == .list([.binary(Data([1])), .stringSet(["c", "b"]), .number("2"), .string("extra")])) - } - - @Test("Editing a map as JSON keeps nested sets and binary from the template") - func mapKeepsNestedTypes() throws { - let template = DynamoDBAttributeValue.map([ - "tags": .numberSet(["1", "2"]), - "names": .stringSet(["a"]), - "blob": .binary(Data([1])), - "zip": .string("02134"), - "inner": .map(["hashes": .binarySet([Data([1])])]) - ]) - let text = #"{"tags":[3,1],"names":["b"],"blob":"Ag==","zip":"02134","inner":{"hashes":["AwQ="]},"new":"z"}"# - let decoded = try Self.decodeText(text, template: template) - #expect(decoded == .map([ - "tags": .numberSet(["3", "1"]), - "names": .stringSet(["b"]), - "blob": .binary(Data([2])), - "zip": .string("02134"), - "inner": .map(["hashes": .binarySet([Data([3, 4])])]), - "new": .string("z") - ])) - } - - @Test("An unedited cell decodes back to the value it was rendered from") - func renderedCellRoundTrips() throws { - let value = DynamoDBAttributeValue.map([ - "s": .string("02134"), - "n": .number("12345678901234567890123456789012345678"), - "scaled": .number("1.50"), - "b": .binary(Data([1, 2])), - "t": .bool(true), - "z": .null, - "l": .list([.string("x"), .number("1.5"), .binary(Data([7]))]), - "m": .map(["inner": .numberSet(["1", "2"])]), - "ss": .stringSet(["a", "b"]), - "ns": .numberSet(["-1", "10"]), - "bs": .binarySet([Data([1]), Data([2])]) - ]) - guard case .text(let text) = DynamoDBCellCodec.cell(for: value) else { - Issue.record("A map must render as text") - return - } - #expect(try Self.decodeText(text, template: value) == value) - } - - @Test("Map text that is not JSON is an error naming the attribute") - func invalidMapJSONNamesAttribute() { - let error = #expect(throws: DynamoDBError.self) { - try Self.decodeText(#"{"a": "#, template: .map(["a": .string("x")]), attribute: "profile") - } - #expect(Self.invalidValueAttribute(of: error) == "profile") - } - - @Test( - "An empty set is an error", - arguments: [ - SetCase(name: "String Set", template: .stringSet(["a"]), text: "[]"), - SetCase(name: "Number Set", template: .numberSet(["1"]), text: "[]"), - SetCase(name: "Binary Set", template: .binarySet([Data([1])]), text: " [ ] ") - ] - ) - func emptySetIsRejected(setCase: SetCase) { - #expect(throws: DynamoDBError.self) { try Self.decodeText(setCase.text, template: setCase.template) } - } - - @Test( - "A set holding the same member twice is an error", - arguments: [ - SetCase(name: "String Set", template: .stringSet(["a"]), text: #"["a", "b", "a"]"#), - SetCase(name: "Number Set as numbers", template: .numberSet(["1"]), text: "[1, 1.0]"), - SetCase(name: "Number Set as strings", template: .numberSet(["1"]), text: #"["1", "1.0"]"#), - SetCase(name: "Number Set in exponent form", template: .numberSet(["1"]), text: "[100, 1e2]"), - SetCase(name: "Binary Set", template: .binarySet([Data([1])]), text: #"["AQ==", "AQ=="]"#) - ] - ) - func duplicateSetMemberIsRejected(setCase: SetCase) { - #expect(throws: DynamoDBError.self) { try Self.decodeText(setCase.text, template: setCase.template) } - } - - @Test("String Set members DynamoDB stores as different bytes are not duplicates") - func canonicallyEquivalentStringsAreDistinctMembers() throws { - let composed = "caf\u{00E9}" - let decomposed = "cafe\u{0301}" - let text = DynamoDBJSON.array([.string(composed), .string(decomposed)]).serialized() - let decoded = try Self.decodeText(text, template: .stringSet(["x"])) - guard case .stringSet(let members) = decoded else { - Issue.record("Expected a String Set, got \(String(describing: decoded))") - return - } - #expect(members.map { Array($0.utf8) } == [Array(composed.utf8), Array(decomposed.utf8)]) - } - - @Test( - "Boolean text decodes over a Boolean", - arguments: [ - TextCase(text: "true", expected: .bool(true)), - TextCase(text: "1", expected: .bool(true)), - TextCase(text: " TRUE ", expected: .bool(true)), - TextCase(text: "false", expected: .bool(false)), - TextCase(text: "0", expected: .bool(false)), - TextCase(text: "False", expected: .bool(false)) - ] - ) - func booleanText(textCase: TextCase) throws { - #expect(try Self.decodeText(textCase.text, template: .bool(false)) == textCase.expected) - } - - @Test("Text that is not a boolean is an error over a Boolean", arguments: ["yes", "", "2", "truthy"]) - func invalidBooleanIsRejected(text: String) { - let error = #expect(throws: DynamoDBError.self) { - try Self.decodeText(text, template: .bool(true), attribute: "active") - } - #expect(Self.invalidValueAttribute(of: error) == "active") - } - - @Test("Empty or null text over a NULL stays NULL", arguments: ["", " ", "null", "NULL"]) - func nullTemplateKeepsNull(text: String) throws { - #expect(try Self.decodeText(text, template: .null) == .null) - } - - @Test("Other text over a NULL is read as if the attribute had no type") - func nullTemplateInfersOtherText() throws { - #expect(try Self.decodeText("02134", template: .null) == .string("02134")) - #expect(try Self.decodeText(#"{"a": 1}"#, template: .null) == .map(["a": .number("1")])) - } - - @Test("Base64 text over a Binary decodes to its bytes, and other text is an error") - func binaryTemplateDecodesBase64() throws { - #expect(try Self.decodeText(" AQID\n", template: .binary(Data([9]))) == .binary(Data([1, 2, 3]))) - #expect(throws: DynamoDBError.self) { try Self.decodeText("not base64!", template: .binary(Data([9]))) } - } - - @Test("A bytes cell is Binary whatever the template") - func bytesCellIsBinary() throws { - let decoded = try DynamoDBCellCodec.decode( - .bytes(Data([1, 2])), template: .string("x"), columnType: .string, attribute: "attr" - ) - #expect(decoded == .binary(Data([1, 2]))) - } - - @Test("A null cell removes the attribute") - func nullCellRemovesAttribute() throws { - let decoded = try DynamoDBCellCodec.decode(.null, template: .string("x"), columnType: .string, attribute: "attr") - #expect(decoded == nil) - } - - @Test( - "With no template and no column type, text is a String unless it is a JSON object or array", - arguments: [ - TextCase(text: "hello", expected: .string("hello")), - TextCase(text: "02134", expected: .string("02134")), - TextCase(text: "true", expected: .string("true")), - TextCase(text: "{oops", expected: .string("{oops")), - TextCase(text: #"{"a": 1, "b": {"c": "d"}}"#, expected: .map(["a": .number("1"), "b": .map(["c": .string("d")])])), - TextCase(text: #"[1, "x", true, null]"#, expected: .list([.number("1"), .string("x"), .bool(true), .null])), - TextCase(text: " [] ", expected: .list([])) - ] - ) - func inferredWithoutTemplate(textCase: TextCase) throws { - #expect(try Self.decodeText(textCase.text) == textCase.expected) - } - - @Test("A Number column types new text as a Number") - func numberColumnTypeDecodesNumber() throws { - #expect(try Self.decodeText("12", columnType: .number) == .number("12")) - #expect(throws: DynamoDBError.self) { try Self.decodeText("twelve", columnType: .number) } - } - - @Test("A set or map column types new JSON text as that type") - func collectionColumnTypes() throws { - #expect(try Self.decodeText(#"["a", "b"]"#, columnType: .stringSet) == .stringSet(["a", "b"])) - #expect(try Self.decodeText("[1, 2]", columnType: .numberSet) == .numberSet(["1", "2"])) - #expect(try Self.decodeText(#"{"a": [1]}"#, columnType: .map) == .map(["a": .list([.number("1")])])) - #expect(try Self.decodeText("[1]", columnType: .list) == .list([.number("1")])) - } - - @Test("Text that is not JSON is an error in a Map column") - func nonJSONInMapColumnIsRejected() { - #expect(throws: DynamoDBError.self) { try Self.decodeText("hello", columnType: .map) } - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBClientTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBClientTests.swift deleted file mode 100644 index b803012d73..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBClientTests.swift +++ /dev/null @@ -1,426 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBClientTests { - private static let exampleDate = Date(timeIntervalSince1970: 1_440_938_160) - - private static let throttled = DynamoDBClientTestTransport.Reply.http( - status: 400, - body: #"{"__type":"com.amazonaws.dynamodb.v20120810#ProvisionedThroughputExceededException","message":"Rate exceeded"}"# - ) - private static let internalError = DynamoDBClientTestTransport.Reply.http( - status: 500, body: #"{"__type":"com.amazonaws.dynamodb.v20120810#InternalServerError","message":"Internal error"}"# - ) - private static let expiredToken = DynamoDBClientTestTransport.Reply.http( - status: 400, body: #"{"__type":"com.amazonaws.dynamodb.v20120810#ExpiredTokenException","message":"expired"}"# - ) - private static let signatureExpired = DynamoDBClientTestTransport.Reply.http( - status: 400, - body: #"{"__type":"com.amazonaws.dynamodb.v20120810#InvalidSignatureException","# - + #""message":"Signature expired: 20150830T123600Z is now earlier than 20150830T124100Z"}"#, - headers: ["Date": "Sun, 30 Aug 2015 12:46:00 GMT"] - ) - private static let tableNames = DynamoDBClientTestTransport.Reply.http(status: 200, body: #"{"TableNames":["Orders"]}"#) - - private static func makeClient( - _ transport: DynamoDBClientTestTransport, - sleeps: DynamoDBClientSleepLog = DynamoDBClientSleepLog(), - sleep: (@Sendable (TimeInterval) async throws -> Void)? = nil - ) throws -> DynamoDBClient { - let endpoint = try DynamoDBEndpoint.resolve(fields: ["awsAuthMethod": "local"], profileRegion: { _ in nil }) - let credentials = DynamoDBCredentialsProvider(fields: ["awsAuthMethod": "local"], username: "", password: "") - let fixedNow = exampleDate - return DynamoDBClient( - endpoint: endpoint, - credentials: credentials, - transport: transport, - retryPolicy: DynamoDBRetryPolicy(random: { 0 }), - now: { fixedNow }, - sleep: sleep ?? { seconds in sleeps.record(seconds) } - ) - } - - private static func serviceCode(of error: (any Error)?) -> String? { - guard case .service(let service)? = error as? DynamoDBError else { return nil } - return service.code - } - - @Test("Throttling is retried until DynamoDB accepts the request") - func throttlingRetriesUntilSuccess() async throws { - let transport = DynamoDBClientTestTransport([Self.throttled, Self.throttled, Self.tableNames]) - let sleeps = DynamoDBClientSleepLog() - let client = try Self.makeClient(transport, sleeps: sleeps) - - let response = try await client.send(.listTables, [:]) - - #expect(response["TableNames"] == .array([.string("Orders")])) - #expect(transport.requests.count == 3) - #expect(sleeps.delays == [0, 0]) - } - - @Test("Throttling gives up after four attempts") - func throttlingGivesUp() async throws { - let transport = DynamoDBClientTestTransport(Array(repeating: Self.throttled, count: 6)) - let client = try Self.makeClient(transport) - - let error = await #expect(throws: DynamoDBError.self) { - try await client.send(.updateItem, [:]) - } - - #expect(Self.serviceCode(of: error) == "ProvisionedThroughputExceededException") - #expect(transport.requests.count == DynamoDBRetryPolicy.maximumAttempts) - } - - @Test("A 500 on an UpdateItem is not sent again") - func updateItemServerErrorIsNotRetried() async throws { - let transport = DynamoDBClientTestTransport([Self.internalError, Self.tableNames]) - let client = try Self.makeClient(transport) - - let error = await #expect(throws: DynamoDBError.self) { - try await client.send(.updateItem, ["TableName": .string("Orders")]) - } - - #expect(Self.serviceCode(of: error) == "InternalServerError") - #expect(transport.requests.count == 1) - } - - @Test("A 500 on a read is sent again") - func readServerErrorIsRetried() async throws { - let transport = DynamoDBClientTestTransport([Self.internalError, Self.tableNames]) - let client = try Self.makeClient(transport) - - _ = try await client.send(.getItem, ["TableName": .string("Orders")]) - - #expect(transport.requests.count == 2) - } - - @Test("A redirect is a configuration error, refused without a retry") - func redirectIsRefused() async throws { - let transport = DynamoDBClientTestTransport([ - .http(status: 307, body: "", headers: ["Location": "http://attacker.example.com/"]) - ]) - let client = try Self.makeClient(transport) - - await #expect(throws: DynamoDBError.configuration(String( - localized: "The endpoint answered with a redirect, which DynamoDB never sends. Check the Custom Endpoint." - ))) { - try await client.send(.updateItem, [:]) - } - #expect(transport.requests.count == 1) - } - - @Test("A clock skew error moves the signing clock to the server's Date") - func clockSkewShiftsSigningDate() async throws { - let transport = DynamoDBClientTestTransport([Self.signatureExpired, Self.tableNames]) - let client = try Self.makeClient(transport) - - _ = try await client.send(.listTables, [:]) - - let requests = transport.requests - #expect(requests.count == 2) - #expect(requests.first?.value(forHTTPHeaderField: "X-Amz-Date") == "20150830T123600Z") - #expect(requests.last?.value(forHTTPHeaderField: "X-Amz-Date") == "20150830T124600Z") - } - - @Test("The clock is corrected once, and a second skew error fails") - func clockSkewCorrectsOnce() async throws { - let transport = DynamoDBClientTestTransport([Self.signatureExpired, Self.signatureExpired, Self.tableNames]) - let client = try Self.makeClient(transport) - - let error = await #expect(throws: DynamoDBError.self) { - try await client.send(.listTables, [:]) - } - - #expect(Self.serviceCode(of: error) == "InvalidSignatureException") - #expect(transport.requests.count == 2) - } - - @Test("An expired token is refreshed once, and a second expiry fails") - func expiredTokenRefreshesOnce() async throws { - let transport = DynamoDBClientTestTransport([Self.expiredToken, Self.expiredToken, Self.tableNames]) - let client = try Self.makeClient(transport) - - let error = await #expect(throws: DynamoDBError.self) { - try await client.send(.updateItem, [:]) - } - - #expect(Self.serviceCode(of: error) == "ExpiredTokenException") - #expect(transport.requests.count == 2) - } - - @Test("Cancelling the task while it waits to retry ends with cancelled") - func cancellingDuringSleep() async throws { - let transport = DynamoDBClientTestTransport([Self.throttled, Self.tableNames]) - let (started, startedContinuation) = AsyncStream.makeStream() - let client = try Self.makeClient(transport, sleep: { _ in - startedContinuation.yield() - try await Task.sleep(nanoseconds: 60_000_000_000) - }) - - let task = Task { try await client.send(.listTables, [:]) } - var iterator = started.makeAsyncIterator() - _ = await iterator.next() - task.cancel() - let result = await task.result - - guard case .failure(let error) = result else { - Issue.record("The request finished after its task was cancelled") - return - } - #expect(error as? DynamoDBError == .cancelled) - #expect(transport.requests.count == 1) - } - - @Test("A task cancelled before it starts sends nothing") - func cancelledBeforeStart() async throws { - let transport = DynamoDBClientTestTransport([Self.tableNames]) - let client = try Self.makeClient(transport) - let (gate, gateContinuation) = AsyncStream.makeStream() - - let task = Task { - var iterator = gate.makeAsyncIterator() - _ = await iterator.next() - return try await client.send(.listTables, [:]) - } - task.cancel() - gateContinuation.yield() - let result = await task.result - - guard case .failure(let error) = result else { - Issue.record("A cancelled task sent its request") - return - } - #expect(error as? DynamoDBError == .cancelled) - #expect(transport.requests.isEmpty) - } - - @Test("A cancellation the transport reports is surfaced as cancelled", arguments: [true, false]) - func transportCancellation(_ asURLError: Bool) async throws { - let reply: DynamoDBClientTestTransport.Reply = asURLError ? .urlError(.cancelled) : .taskCancelled - let transport = DynamoDBClientTestTransport([reply, Self.tableNames]) - let client = try Self.makeClient(transport) - - await #expect(throws: DynamoDBError.cancelled) { - try await client.send(.listTables, [:]) - } - #expect(transport.requests.count == 1) - } - - @Test("A dropped connection is retried for a read and not for an UpdateItem") - func transportFailureRetriesIdempotentOnly() async throws { - let readTransport = DynamoDBClientTestTransport([.urlError(.networkConnectionLost), Self.tableNames]) - _ = try await Self.makeClient(readTransport).send(.scan, ["TableName": .string("Orders")]) - #expect(readTransport.requests.count == 2) - - let writeTransport = DynamoDBClientTestTransport([.urlError(.networkConnectionLost), Self.tableNames]) - let error = await #expect(throws: DynamoDBError.self) { - try await Self.makeClient(writeTransport).send(.updateItem, ["TableName": .string("Orders")]) - } - guard case .transport? = error else { - Issue.record("A dropped connection was not reported as a transport error: \(String(describing: error))") - return - } - #expect(writeTransport.requests.count == 1) - } - - @Test("An empty 200 answers an empty object") - func emptySuccessBody() async throws { - let transport = DynamoDBClientTestTransport([.http(status: 200, body: "")]) - let response = try await Self.makeClient(transport).send(.deleteItem, [:]) - #expect(response == .object([:])) - } - - @Test("A 200 that is not JSON is an invalid response") - func unparsableSuccessBody() async throws { - let transport = DynamoDBClientTestTransport([.http(status: 200, body: "")]) - let error = await #expect(throws: DynamoDBError.self) { - try await Self.makeClient(transport).send(.listTables, [:]) - } - guard case .invalidResponse? = error else { - Issue.record("Expected an invalid response, got \(String(describing: error))") - return - } - } - - @Test("Every request is a signed POST carrying the target, the content type and the body") - func requestShape() async throws { - let transport = DynamoDBClientTestTransport([Self.throttled, Self.tableNames]) - let client = try Self.makeClient(transport) - let body: [String: DynamoDBJSON] = ["Limit": .number("1")] - - _ = try await client.send(.listTables, body) - - let requests = transport.requests - #expect(requests.count == 2) - for request in requests { - #expect(request.httpMethod == "POST") - #expect(request.url?.absoluteString == "http://localhost:8000") - #expect(request.value(forHTTPHeaderField: "X-Amz-Target") == "DynamoDB_20120810.ListTables") - #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/x-amz-json-1.0") - #expect(request.value(forHTTPHeaderField: "Host") == "localhost:8000") - #expect(request.httpBody == DynamoDBJSON.object(body).serializedData) - let authorization = request.value(forHTTPHeaderField: "Authorization") ?? "" - #expect(authorization.hasPrefix( - "AWS4-HMAC-SHA256 Credential=local/20150830/us-east-1/dynamodb/aws4_request, " - + "SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=" - )) - } - } -} - -@Suite("DynamoDB credentials provider", .serialized) -struct DynamoDBCredentialsProviderTests { - @Test("Local auth signs with the fixed local key and needs no AWS files") - func localCredentials() async throws { - let provider = DynamoDBCredentialsProvider(fields: ["awsAuthMethod": "local"], username: "ignored", password: "ignored") - let credentials = try await provider.credentials() - #expect(credentials.accessKeyId == DynamoDBCredentialsProvider.localAccessKey) - #expect(credentials.secretAccessKey == DynamoDBCredentialsProvider.localAccessKey) - #expect(credentials.sessionToken == nil) - #expect(provider.identity == "local") - } - - @Test("Access key auth falls back to the username and password") - func accessKeyFromUsernameAndPassword() async throws { - let provider = DynamoDBCredentialsProvider( - fields: ["awsAuthMethod": "credentials"], username: "AKIDEXAMPLE", password: "secret" - ) - let credentials = try await provider.credentials() - #expect(credentials.accessKeyId == "AKIDEXAMPLE") - #expect(credentials.secretAccessKey == "secret") - #expect(provider.identity == "key:AKIDEXAMPLE") - } - - @Test("An SSO profile that does not exist throws the AWS error, not a DynamoDB error") - func missingSSOProfileKeepsAWSError() async throws { - let profile = "tablepro-missing-\(UUID().uuidString)" - let provider = DynamoDBCredentialsProvider( - fields: ["awsAuthMethod": "sso", "awsProfileName": profile], username: "", password: "" - ) - - let error = try await DynamoDBTestAWSFiles.withEmptyConfiguration { - await #expect(throws: (any Error).self) { - try await provider.credentials() - } - } - - let thrown = try #require(error) - #expect(!(thrown is DynamoDBError)) - #expect(thrown is AWSAuthError || thrown is AWSSSOError) - #expect(provider.identity == "profile:\(profile)") - } - - @Test("The client passes a credential error through unwrapped and sends nothing") - func clientPassesCredentialErrorThrough() async throws { - let profile = "tablepro-missing-\(UUID().uuidString)" - let transport = DynamoDBClientTestTransport([]) - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsAuthMethod": "sso", "awsRegion": "us-east-1"], profileRegion: { _ in nil } - ) - let client = DynamoDBClient( - endpoint: endpoint, - credentials: DynamoDBCredentialsProvider( - fields: ["awsAuthMethod": "sso", "awsProfileName": profile], username: "", password: "" - ), - transport: transport, - retryPolicy: DynamoDBRetryPolicy(random: { 0 }), - sleep: { _ in } - ) - - let error = try await DynamoDBTestAWSFiles.withEmptyConfiguration { - await #expect(throws: (any Error).self) { - try await client.send(.listTables, [:]) - } - } - - let thrown = try #require(error) - #expect(!(thrown is DynamoDBError)) - #expect(transport.requests.isEmpty) - } -} - -private enum DynamoDBTestAWSFiles { - static let variables = ["AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE"] - - static func withEmptyConfiguration(_ body: () async throws -> Value) async throws -> Value { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("tablepro-dynamodb-aws-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let previous = variables.map { name in getenv(name).map { String(cString: $0) } } - for name in variables { - let file = directory.appendingPathComponent(name.lowercased()) - try Data().write(to: file) - setenv(name, file.path, 1) - } - defer { - for (name, value) in zip(variables, previous) { - if let value { - setenv(name, value, 1) - } else { - unsetenv(name) - } - } - try? FileManager.default.removeItem(at: directory) - } - return try await body() - } -} - -private final class DynamoDBClientTestTransport: DynamoDBTransport, @unchecked Sendable { - enum Reply: Sendable { - case http(status: Int, body: String, headers: [String: String] = [:]) - case urlError(URLError.Code) - case taskCancelled - } - - private let lock = NSLock() - private var replies: [Reply] - private var recorded: [URLRequest] = [] - - init(_ replies: [Reply]) { - self.replies = replies - } - - var requests: [URLRequest] { - lock.withLock { recorded } - } - - func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { - let reply = lock.withLock { () -> Reply? in - recorded.append(request) - return replies.isEmpty ? nil : replies.removeFirst() - } - guard let reply else { - throw URLError(.resourceUnavailable) - } - switch reply { - case .urlError(let code): - throw URLError(code) - case .taskCancelled: - throw CancellationError() - case .http(let status, let body, let headers): - guard let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers) - else { - throw URLError(.badServerResponse) - } - return (Data(body.utf8), response) - } - } - - func invalidate() {} -} - -private final class DynamoDBClientSleepLog: @unchecked Sendable { - private let lock = NSLock() - private var recorded: [TimeInterval] = [] - - func record(_ seconds: TimeInterval) { - lock.withLock { recorded.append(seconds) } - } - - var delays: [TimeInterval] { - lock.withLock { recorded } - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBDriverTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBDriverTests.swift deleted file mode 100644 index 66487c8d57..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBDriverTests.swift +++ /dev/null @@ -1,1056 +0,0 @@ -// -// DynamoDBDriverTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -final class DynamoDBScriptedTransport: DynamoDBTransport, @unchecked Sendable { - struct Call: Sendable { - let action: String - let body: DynamoDBJSON - let timeoutInterval: TimeInterval - } - - enum Reply: Sendable { - case json(String) - case failure(status: Int, type: String, message: String) - } - - typealias Handler = @Sendable (_ action: String, _ body: DynamoDBJSON, _ attempt: Int) -> Reply - - private let lock = NSLock() - private let handler: Handler - private var recorded: [Call] = [] - private var attempts: [String: Int] = [:] - - init(_ handler: @escaping Handler) { - self.handler = handler - } - - var calls: [Call] { - lock.withLock { recorded } - } - - func bodies(of action: String) -> [DynamoDBJSON] { - calls.filter { $0.action == action }.map(\.body) - } - - func count(of action: String) -> Int { - lock.withLock { attempts[action] ?? 0 } - } - - func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { - guard let url = request.url else { throw URLError(.badURL) } - let target = request.value(forHTTPHeaderField: "X-Amz-Target") ?? "" - let action = target.split(separator: ".").last.map(String.init) ?? target - let body = try DynamoDBJSON.parse(request.httpBody ?? Data()) - let attempt = lock.withLock { () -> Int in - recorded.append(Call(action: action, body: body, timeoutInterval: request.timeoutInterval)) - attempts[action, default: 0] += 1 - return attempts[action] ?? 1 - } - let status: Int - let text: String - switch handler(action, body, attempt) { - case .json(let json): - status = 200 - text = json - case .failure(let code, let type, let message): - status = code - text = #"{"__type":"com.amazonaws.dynamodb.v20120810#\#(type)","message":"\#(message)"}"# - } - guard let response = HTTPURLResponse( - url: url, statusCode: status, httpVersion: "HTTP/1.1", - headerFields: ["Content-Type": "application/x-amz-json-1.0"] - ) else { - throw URLError(.badServerResponse) - } - return (Data(text.utf8), response) - } - - func invalidate() {} -} - -enum DynamoDBDriverFixture { - static func driver(_ transport: DynamoDBScriptedTransport) async throws -> DynamoDBPluginDriver { - let config = DriverConnectionConfig( - host: "", port: 0, username: "", password: "", database: "", - additionalFields: ["awsAuthMethod": "local", "awsRegion": "us-east-1"] - ) - let driver = DynamoDBPluginDriver(config: config, catalog: DynamoDBCatalog()) { endpoint, credentials in - DynamoDBClient( - endpoint: endpoint, - credentials: credentials, - transport: transport, - retryPolicy: DynamoDBRetryPolicy(random: { 0 }), - sleep: { _ in } - ) - } - try await driver.connect() - return driver - } - - static let orders = describe() - - static func describe(provisioned: Bool = false) -> String { - let billing = provisioned - ? #""ProvisionedThroughput":{"ReadCapacityUnits":5,"WriteCapacityUnits":7}"# - : #""BillingModeSummary":{"BillingMode":"PAY_PER_REQUEST"}"# - return #""" - {"Table":{"TableName":"orders","TableStatus":"ACTIVE", - "KeySchema":[{"AttributeName":"pk","KeyType":"HASH"},{"AttributeName":"sk","KeyType":"RANGE"}], - "AttributeDefinitions":[{"AttributeName":"pk","AttributeType":"S"},{"AttributeName":"sk","AttributeType":"N"}, - {"AttributeName":"status","AttributeType":"S"},{"AttributeName":"total","AttributeType":"N"}], - "GlobalSecondaryIndexes":[{"IndexName":"byStatus","IndexStatus":"ACTIVE", - "KeySchema":[{"AttributeName":"status","KeyType":"HASH"},{"AttributeName":"total","KeyType":"RANGE"}], - "Projection":{"ProjectionType":"ALL"}}], - \#(billing)}} - """# - } - - static func item(_ pk: String, _ sk: Int, _ extra: String = "") -> String { - #"{"pk":{"S":"\#(pk)"},"sk":{"N":"\#(sk)"}\#(extra)}"# - } - - static func key(_ pk: String, _ sk: Int) -> String { - item(pk, sk) - } - - static func startIndex(_ body: DynamoDBJSON) -> Int? { - body["ExclusiveStartKey"]?["sk"]?["N"]?.stringValue.flatMap(Int.init) - } - - static func text(_ cell: PluginCellValue?) -> String? { - guard case .text(let value)? = cell else { return nil } - return value - } - - static func column(_ name: String, of result: PluginQueryResult) -> [String?] { - guard let index = result.columns.firstIndex(of: name) else { return [] } - return result.rows.map { text($0[index]) } - } -} - -struct DynamoDBDriverTests { - private typealias Fixture = DynamoDBDriverFixture - - @Test("Connection probe uses the configured connect timeout") - func connectUsesConfiguredTimeout() async throws { - let transport = DynamoDBScriptedTransport { _, _, _ in - .json(#"{"TableNames":[]}"#) - } - let config = DriverConnectionConfig( - host: "", port: 0, username: "", password: "", database: "", - additionalFields: [ - "awsAuthMethod": "local", - "awsRegion": "us-east-1", - "connectTimeoutMilliseconds": "45000" - ] - ) - let driver = DynamoDBPluginDriver(config: config, catalog: DynamoDBCatalog()) { endpoint, credentials in - DynamoDBClient(endpoint: endpoint, credentials: credentials, transport: transport) - } - - try await driver.connect() - - let timeout = try #require(transport.calls.first?.timeoutInterval) - #expect(timeout > 44) - #expect(timeout <= 45) - } - - @Test("AWS credential resolution uses one monotonic connect deadline") - func credentialsUseOneConnectDeadline() throws { - var root = URL(fileURLWithPath: #filePath) - for _ in 0..<4 { root.deleteLastPathComponent() } - let source = try String( - contentsOf: root.appendingPathComponent("Plugins/DynamoDBDriverPlugin/DynamoDBClient.swift"), - encoding: .utf8 - ) - - #expect(source.contains("PluginAWSConnectSessionBudget(deadline: deadline).makeSession()")) - #expect(!source.contains("max(AWSHTTP.requestTimeout, remaining)")) - #expect(!source.contains("max(AWSHTTP.resourceTimeout, remaining)")) - } - - // MARK: - Streaming - - @Test("A stream delivers every row of every page to a consumer that starts late") - func streamDeliversEveryRow() async throws { - let transport = DynamoDBScriptedTransport { action, body, _ in - switch action { - case "DescribeTable": - return .json(Fixture.orders) - case "Scan": - let from = (Fixture.startIndex(body) ?? -1) + 1 - let isLast = from + 1_000 >= 12_000 - let items = (from..<(from + 1_000)).map { index in - Fixture.item("p", index, index == 11_999 ? #","late":{"S":"x"}"# : "") - } - let next = isLast ? "" : #","LastEvaluatedKey":\#(Fixture.key("p", from + 999))"# - return .json(#"{"Items":[\#(items.joined(separator: ","))]\#(next)}"#) - default: - return .json(#"{"TableNames":[]}"#) - } - } - let driver = try await Fixture.driver(transport) - let query = try #require(driver.defaultExportQuery(table: "orders")) - - let stream = driver.streamRows(query: query) - try await Task.sleep(nanoseconds: 200_000_000) - var columns: [String] = [] - var rowCount = 0 - for try await element in stream { - switch element { - case .header(let header): columns = header.columns - case .rows(let rows): rowCount += rows.count - } - } - - #expect(rowCount == 12_000) - #expect(columns.contains("late")) - } - - @Test("A stream applies ORDER BY and OFFSET, and OFFSET without LIMIT does not trap") - func streamAppliesWindow() async throws { - let transport = DynamoDBScriptedTransport { action, _, _ in - switch action { - case "DescribeTable": - return .json(Fixture.orders) - case "Scan": - let items = [2, 3, 1, 5].enumerated().map { index, value in - Fixture.item("p", index, #","v":{"N":"\#(value)"}"#) - } - return .json(#"{"Items":[\#(items.joined(separator: ","))]}"#) - default: - return .json(#"{"TableNames":[]}"#) - } - } - let driver = try await Fixture.driver(transport) - - let sorted = try await Self.collect(driver.streamRows(query: #"Scan {"TableName":"orders"} ORDER BY "v" DESC"#)) - let skipped = try await Self.collect(driver.streamRows(query: #"Scan {"TableName":"orders"} OFFSET 1"#)) - - #expect(sorted.column("v") == ["5", "3", "2", "1"]) - #expect(skipped.rows.count == 3) - } - - @Test("A stream finishes while another reader in the process waits on a quiet pipe") - func streamIgnoresABlockedAsyncBytesReader() async throws { - let transport = DynamoDBScriptedTransport { action, _, _ in - switch action { - case "DescribeTable": - return .json(Fixture.orders) - case "Scan": - return .json(#"{"Items":[\#(Fixture.item("p", 0)),\#(Fixture.item("p", 1))]}"#) - default: - return .json(#"{"TableNames":[]}"#) - } - } - let driver = try await Fixture.driver(transport) - let query = try #require(driver.defaultExportQuery(table: "orders")) - let pipe = Pipe() - let blocker = Task { - for try await _ in pipe.fileHandleForReading.bytes {} - } - try await Task.sleep(nanoseconds: 100_000_000) - - let finished = await withTaskGroup(of: Bool.self) { group in - group.addTask { - let result = try? await Self.collect(driver.streamRows(query: query)) - return result?.rows.count == 2 - } - group.addTask { - try? await Task.sleep(nanoseconds: 10_000_000_000) - return false - } - let first = await group.next() ?? false - try? pipe.fileHandleForWriting.close() - group.cancelAll() - return first - } - blocker.cancel() - - #expect(finished) - } - - // MARK: - Paging - - @Test("An empty filtered page that still has a LastEvaluatedKey is not the end") - func filteredPagesKeepReading() async throws { - let transport = DynamoDBScriptedTransport { action, body, _ in - switch action { - case "DescribeTable": - return .json(Fixture.orders) - case "Scan": - guard Fixture.startIndex(body) != nil else { - return .json(#"{"Items":[],"LastEvaluatedKey":\#(Fixture.key("p", 1))}"#) - } - return .json(#"{"Items":[\#(Fixture.item("p", 2, #","v":{"N":"5"}"#))]}"#) - default: - return .json(#"{"TableNames":[]}"#) - } - } - let driver = try await Fixture.driver(transport) - let query = try #require(driver.buildFilteredQuery( - table: "orders", schema: nil, - queryFilters: [PluginQueryFilter(column: "v", op: "=", value: "5")], logicMode: "AND", - sortColumns: [], columns: [], limit: 10, offset: 0, columnKinds: [:] - )) - - let result = try await driver.execute(query: query) - - let scans = transport.bodies(of: "Scan") - #expect(result.rows.count == 1) - #expect(scans.count == 2) - #expect(scans.first?["Limit"] == nil) - #expect(scans.last.flatMap(Fixture.startIndex) == 1) - } - - @Test("The next page starts from the key where the previous one stopped") - func nextPageResumes() async throws { - let transport = DynamoDBScriptedTransport { action, body, _ in - switch action { - case "DescribeTable": - return .json(Fixture.orders) - case "Scan": - let from = (Fixture.startIndex(body) ?? -1) + 1 - let limit = body["Limit"]?.intValue ?? 5 - let end = min(from + limit, 5) - let items = (from..) async throws -> PluginQueryResult { - var columns: [String] = [] - var rows: [[PluginCellValue]] = [] - for try await element in stream { - switch element { - case .header(let header): columns = header.columns - case .rows(let batch): rows += batch - } - } - return PluginQueryResult( - columns: columns, columnTypeNames: columns.map { _ in "" }, rows: rows, - rowsAffected: 0, timing: PluginQueryTiming(total: 0) - ) - } - - private static func writeTransport( - currentItem: String = #"{"Item":\#(DynamoDBDriverFixture.item("p", 1, #","v":{"N":"1"}"#))}"#, - execute: DynamoDBScriptedTransport.Reply - ) -> DynamoDBScriptedTransport { - DynamoDBScriptedTransport { action, _, _ in - switch action { - case "DescribeTable": return .json(Fixture.orders) - case "GetItem": return .json(currentItem) - case "Scan": return .json(#"{"Items":[]}"#) - case "ExecuteStatement": return execute - default: return .json(#"{"TableNames":[]}"#) - } - } - } - - private static func updateStatement( - _ driver: DynamoDBPluginDriver - ) throws -> (statement: String, parameters: [PluginCellValue]) { - let change = PluginRowChange( - rowIndex: 0, type: .update, - cellChanges: [(columnIndex: 2, columnName: "v", oldValue: .text("1"), newValue: .text("2"))], - originalRow: [.text("p"), .text("1"), .text("1")] - ) - return try #require(driver.generateStatements( - table: "orders", columns: ["pk", "sk", "v"], primaryKeyColumns: ["pk", "sk"], changes: [change], - insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - )?.first) - } - - private static func partiQLTransport(items: [Int]) -> DynamoDBScriptedTransport { - DynamoDBScriptedTransport { action, _, _ in - switch action { - case "DescribeTable": - return .json(Fixture.orders) - case "ExecuteStatement": - let rows = items.map { Fixture.item("p", $0, #","status":{"S":"open"},"total":{"N":"1"}"#) } - return .json(#"{"Items":[\#(rows.joined(separator: ","))]}"#) - default: - return .json(#"{"TableNames":[]}"#) - } - } - } - - private static func endlessTransport() -> DynamoDBScriptedTransport { - DynamoDBScriptedTransport { action, body, _ in - switch action { - case "DescribeTable": - return .json(Fixture.orders) - case "Scan": - let next = (Fixture.startIndex(body) ?? 0) + 1 - return .json(#"{"Items":[],"LastEvaluatedKey":\#(Fixture.key("p", next))}"#) - default: - return .json(#"{"TableNames":[]}"#) - } - } - } - - private static func endlessQuery(_ driver: DynamoDBPluginDriver) throws -> String { - try #require(driver.buildFilteredQuery( - table: "orders", schema: nil, - queryFilters: [PluginQueryFilter(column: "v", op: "=", value: "5")], logicMode: "AND", - sortColumns: [], columns: [], limit: 10, offset: 0, columnKinds: [:] - )) - } -} - -private extension PluginQueryResult { - func column(_ name: String) -> [String?] { - DynamoDBDriverFixture.column(name, of: self) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBEndpointTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBEndpointTests.swift deleted file mode 100644 index 66d7815966..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBEndpointTests.swift +++ /dev/null @@ -1,245 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBEndpointTests { - struct RegionCase: Sendable, CustomTestStringConvertible { - let region: String - let url: String - var testDescription: String { region } - } - - struct LoopbackCase: Sendable, CustomTestStringConvertible { - let host: String - let isLoopback: Bool - var testDescription: String { host } - } - - private static func noProfileRegion(_ name: String) -> String? { - nil - } - - private static var plainHTTPRefusal: DynamoDBError { - .configuration(String( - localized: "Plain HTTP is only allowed for an endpoint on this Mac (localhost). Use https:// for any other host." - )) - } - - private static func configurationMessage(of error: (any Error)?) -> String? { - guard case .configuration(let message)? = error as? DynamoDBError else { return nil } - return message - } - - @Test( - "The AWS host follows the region's partition", - arguments: [ - RegionCase(region: "us-east-1", url: "https://dynamodb.us-east-1.amazonaws.com/"), - RegionCase(region: "cn-north-1", url: "https://dynamodb.cn-north-1.amazonaws.com.cn/"), - RegionCase(region: "us-gov-west-1", url: "https://dynamodb.us-gov-west-1.amazonaws.com/"), - RegionCase(region: "eusc-de-east-1", url: "https://dynamodb.eusc-de-east-1.amazonaws.eu/") - ] - ) - func partitionHost(_ testCase: RegionCase) throws { - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsAuthMethod": "credentials", "awsRegion": testCase.region], - profileRegion: Self.noProfileRegion - ) - #expect(endpoint.url.absoluteString == testCase.url) - #expect(endpoint.signingRegion == testCase.region) - #expect(!endpoint.isLocal) - } - - @Test( - "A region that is not a region name is refused before it becomes part of a host", - arguments: ["evil.example#", "us-east-1/../x", "user@evil.example", "us east 1", "-us-east-1", "us-east-1.", "a\r\nb"] - ) - func malformedRegionIsRefused(_ region: String) { - #expect(throws: DynamoDBError.self) { - try DynamoDBEndpoint.resolve(fields: ["awsRegion": region], profileRegion: Self.noProfileRegion) - } - } - - @Test("A profile's malformed region is refused too") - func malformedProfileRegionIsRefused() { - #expect(throws: DynamoDBError.self) { - try DynamoDBEndpoint.resolve(fields: ["awsAuthMethod": "profile"], profileRegion: { _ in "evil.example#" }) - } - } - - @Test("A typed region is trimmed and lowercased") - func typedRegionIsCanonical() throws { - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsRegion": " EU-West-2 \n"], profileRegion: Self.noProfileRegion - ) - #expect(endpoint.signingRegion == "eu-west-2") - #expect(endpoint.url.absoluteString == "https://dynamodb.eu-west-2.amazonaws.com/") - } - - @Test("An empty region takes the profile's region for profile and SSO auth", arguments: ["profile", "sso"]) - func emptyRegionUsesProfileRegion(_ method: String) throws { - let asked = EndpointProfileLookupLog() - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsAuthMethod": method, "awsRegion": "", "awsProfileName": "work"], - profileRegion: { name in - asked.append(name) - return name == "work" ? "ap-southeast-2" : nil - } - ) - #expect(endpoint.signingRegion == "ap-southeast-2") - #expect(endpoint.url.absoluteString == "https://dynamodb.ap-southeast-2.amazonaws.com/") - #expect(asked.names == ["work"]) - } - - @Test("An empty profile name reads the default profile's region") - func emptyProfileNameReadsDefault() throws { - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsAuthMethod": "profile", "awsProfileName": ""], - profileRegion: { $0 == "default" ? "sa-east-1" : nil } - ) - #expect(endpoint.signingRegion == "sa-east-1") - } - - @Test("A profile with no region falls back to us-east-1") - func profileWithoutRegionFallsBack() throws { - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsAuthMethod": "profile", "awsProfileName": "work"], profileRegion: Self.noProfileRegion - ) - #expect(endpoint.signingRegion == "us-east-1") - } - - @Test("Access key auth with no region uses us-east-1 and never reads a profile") - func accessKeyIgnoresProfileRegion() throws { - let asked = EndpointProfileLookupLog() - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsAuthMethod": "credentials", "awsProfileName": "work"], - profileRegion: { name in - asked.append(name) - return "eu-central-1" - } - ) - #expect(endpoint.signingRegion == "us-east-1") - #expect(asked.names.isEmpty) - } - - @Test("The region field wins over the profile's region") - func regionFieldWinsOverProfile() throws { - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsAuthMethod": "profile", "awsProfileName": "work", "awsRegion": "ap-northeast-1"], - profileRegion: { _ in "eu-west-1" } - ) - #expect(endpoint.signingRegion == "ap-northeast-1") - #expect(endpoint.url.absoluteString == "https://dynamodb.ap-northeast-1.amazonaws.com/") - } - - @Test("A custom HTTPS endpoint is kept with its path and signs for the typed region") - func customHTTPSEndpointKeepsPath() throws { - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsRegion": "eu-west-1", "awsEndpointUrl": " https://ddb.example.com/prefix/ "], - profileRegion: Self.noProfileRegion - ) - #expect(endpoint.url.absoluteString == "https://ddb.example.com/prefix/") - #expect(endpoint.url.path(percentEncoded: true) == "/prefix/") - #expect(endpoint.signingRegion == "eu-west-1") - #expect(!endpoint.isLocal) - } - - @Test( - "Plain HTTP to this Mac is allowed and marked local", - arguments: ["http://localhost:8000", "http://127.0.0.1:8000", "http://[::1]:8000"] - ) - func loopbackHTTPIsAllowed(_ text: String) throws { - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsAuthMethod": "credentials", "awsEndpointUrl": text], profileRegion: Self.noProfileRegion - ) - #expect(endpoint.url.absoluteString == text) - #expect(endpoint.isLocal) - #expect(endpoint.signingRegion == "us-east-1") - } - - @Test( - "Plain HTTP to any other host is refused", - arguments: ["http://192.168.1.5:8000", "http://localhost.evil.com", "http://127.0.0.1.nip.io:8000"] - ) - func remoteHTTPIsRefused(_ text: String) { - #expect(throws: Self.plainHTTPRefusal) { - try DynamoDBEndpoint.resolve( - fields: ["awsAuthMethod": "credentials", "awsEndpointUrl": text], profileRegion: Self.noProfileRegion - ) - } - } - - @Test("HTTPS to a remote host is allowed and not local") - func remoteHTTPSIsAllowed() throws { - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsEndpointUrl": "https://192.168.1.5:8000"], profileRegion: Self.noProfileRegion - ) - #expect(!endpoint.isLocal) - } - - @Test("Local auth with no endpoint reaches localhost:8000", arguments: ["", " "]) - func localAuthDefaultsToLocalhost(_ endpointText: String) throws { - let endpoint = try DynamoDBEndpoint.resolve( - fields: ["awsAuthMethod": "local", "awsEndpointUrl": endpointText], profileRegion: Self.noProfileRegion - ) - #expect(endpoint.url.absoluteString == "http://localhost:8000") - #expect(endpoint.isLocal) - #expect(endpoint.signingRegion == "us-east-1") - } - - @Test("Local auth with no endpoint field at all reaches localhost:8000") - func localAuthWithoutField() throws { - let endpoint = try DynamoDBEndpoint.resolve(fields: ["awsAuthMethod": "local"], profileRegion: Self.noProfileRegion) - #expect(endpoint.url.absoluteString == DynamoDBEndpoint.localDefaultURL) - #expect(endpoint.isLocal) - } - - @Test("A malformed endpoint is a configuration error", arguments: ["not a url", "localhost:8000", "https://"]) - func malformedEndpointIsRejected(_ text: String) { - let error = #expect(throws: DynamoDBError.self) { - try DynamoDBEndpoint.resolve(fields: ["awsEndpointUrl": text], profileRegion: Self.noProfileRegion) - } - #expect(Self.configurationMessage(of: error) != nil) - } - - @Test("An endpoint with another scheme is a configuration error") - func otherSchemeIsRejected() { - #expect(throws: DynamoDBError.configuration(String(localized: "The endpoint must start with https:// or http://"))) { - try DynamoDBEndpoint.resolve(fields: ["awsEndpointUrl": "ftp://localhost:8000"], profileRegion: Self.noProfileRegion) - } - } - - @Test( - "Loopback is decided from the literal host", - arguments: [ - LoopbackCase(host: "localhost", isLoopback: true), - LoopbackCase(host: "LocalHost", isLoopback: true), - LoopbackCase(host: "127.0.0.1", isLoopback: true), - LoopbackCase(host: "127.1.2.3", isLoopback: true), - LoopbackCase(host: "::1", isLoopback: true), - LoopbackCase(host: "[::1]", isLoopback: true), - LoopbackCase(host: "127.0.0.1.nip.io", isLoopback: false), - LoopbackCase(host: "localhost.evil.com", isLoopback: false), - LoopbackCase(host: "128.0.0.1", isLoopback: false), - LoopbackCase(host: "127.0.0", isLoopback: false), - LoopbackCase(host: "127.256.0.1", isLoopback: false), - LoopbackCase(host: "0.0.0.0", isLoopback: false), - LoopbackCase(host: "", isLoopback: false) - ] - ) - func loopbackHost(_ testCase: LoopbackCase) { - #expect(DynamoDBEndpoint.isLoopbackHost(testCase.host) == testCase.isLoopback) - } -} - -private final class EndpointProfileLookupLog: @unchecked Sendable { - private let lock = NSLock() - private var stored: [String] = [] - - func append(_ name: String) { - lock.withLock { stored.append(name) } - } - - var names: [String] { - lock.withLock { stored } - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBErrorTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBErrorTests.swift deleted file mode 100644 index b1c2d45dfe..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBErrorTests.swift +++ /dev/null @@ -1,243 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBErrorTests { - struct CategoryCase: Sendable, CustomTestStringConvertible { - let code: String - let status: Int - let message: String - let category: DynamoDBServiceError.Category - var testDescription: String { "\(code) \(status)" } - - init(_ code: String, status: Int = 400, message: String = "", _ category: DynamoDBServiceError.Category) { - self.code = code - self.status = status - self.message = message - self.category = category - } - } - - private static func parse(_ body: String, status: Int = 400) -> DynamoDBServiceError { - DynamoDBServiceError.parse(body: Data(body.utf8), httpStatus: status) - } - - @Test("The code is the part of the DynamoDB __type after the hash") - func parsesDynamoDBNamespace() { - let error = Self.parse( - #"{"__type":"com.amazonaws.dynamodb.v20120810#ConditionalCheckFailedException","message":"The conditional request failed"}"# - ) - #expect(error.code == "ConditionalCheckFailedException") - #expect(error.message == "The conditional request failed") - #expect(error.httpStatus == 400) - #expect(error.cancellationReasons.isEmpty) - #expect(error.isConditionalCheckFailure) - } - - @Test("The coral validation namespace and a capitalised Message are read too") - func parsesCoralNamespace() { - let error = Self.parse( - #"{"__type":"com.amazon.coral.validate#ValidationException","Message":"One or more parameter values were invalid"}"# - ) - #expect(error.code == "ValidationException") - #expect(error.message == "One or more parameter values were invalid") - #expect(!error.isConditionalCheckFailure) - } - - @Test("A lowercase message wins over a capitalised one") - func lowercaseMessageWins() { - let error = Self.parse(#"{"__type":"x#ValidationException","message":"lower","Message":"upper"}"#) - #expect(error.message == "lower") - } - - @Test("A __type with no namespace is the code as is") - func typeWithoutNamespace() { - #expect(Self.parse(#"{"__type":"ThrottlingException","message":"slow down"}"#).code == "ThrottlingException") - } - - @Test("A body with no message names the error by its code") - func missingMessageUsesCode() { - let error = Self.parse(#"{"__type":"com.amazonaws.dynamodb.v20120810#ResourceNotFoundException"}"#) - #expect(error.message == "ResourceNotFoundException") - } - - @Test("A JSON body with no __type is named by its HTTP status") - func missingTypeUsesStatus() { - let error = Self.parse(#"{"message":"upstream failed"}"#, status: 503) - #expect(error.code == "HTTP503") - #expect(error.message == "upstream failed") - #expect(error.category == .transient) - } - - @Test("A body that is not JSON becomes an HTTP error with its size", arguments: ["Bad Gateway", ""]) - func nonJSONBody(_ body: String) { - let error = Self.parse(body, status: 502) - #expect(error.code == "HTTP502") - #expect(error.message == String(format: String(localized: "HTTP %d with a body of %d bytes"), 502, Data(body.utf8).count)) - #expect(error.httpStatus == 502) - #expect(error.category == .transient) - } - - @Test("Cancellation reasons are decoded in order, with None for a reason that has no code") - func decodesCancellationReasons() { - let error = Self.parse( - #""" - {"__type":"com.amazonaws.dynamodb.v20120810#TransactionCanceledException", - "Message":"Transaction cancelled, please refer cancellation reasons for specific reasons [None, ConditionalCheckFailed]", - "CancellationReasons":[{"Code":"None"},{"Code":"ConditionalCheckFailed","Message":"The conditional request failed"},{}]} - """# - ) - #expect(error.code == "TransactionCanceledException") - #expect(error.cancellationReasons == [ - DynamoDBCancellationReason(code: "None", message: nil), - DynamoDBCancellationReason(code: "ConditionalCheckFailed", message: "The conditional request failed"), - DynamoDBCancellationReason(code: "None", message: nil) - ]) - } - - @Test( - "Every error falls into the category that decides its retry", - arguments: [ - CategoryCase("ProvisionedThroughputExceededException", .throttling), - CategoryCase("ThrottlingException", .throttling), - CategoryCase("RequestLimitExceeded", .throttling), - CategoryCase("LimitExceededException", .throttling), - CategoryCase("TransactionInProgressException", .throttling), - CategoryCase("ReplicatedWriteConflictException", .throttling), - CategoryCase("ItemCollectionSizeLimitExceededException", .throttling), - CategoryCase("InternalServerError", status: 500, .transient), - CategoryCase("ServiceUnavailable", status: 503, .transient), - CategoryCase("HTTP500", status: 500, .transient), - CategoryCase("SomethingNew", status: 500, .transient), - CategoryCase("ExpiredTokenException", .expiredCredentials), - CategoryCase("ExpiredToken", .expiredCredentials), - CategoryCase( - "InvalidSignatureException", - message: "Signature expired: 20150830T123600Z is now earlier than 20150830T124100Z (20150830T124600Z - 5 min.)", - .clockSkew - ), - CategoryCase( - "InvalidSignatureException", - message: "Signature not yet current: 20150830T130100Z is still later than 20150830T125100Z", - .clockSkew - ), - CategoryCase("RequestTimeTooSkewed", .clockSkew), - CategoryCase("RequestExpired", .clockSkew), - CategoryCase( - "InvalidSignatureException", - message: "The request signature we calculated does not match the signature you provided.", - .authentication - ), - CategoryCase("UnrecognizedClientException", message: "The security token included in the request is invalid.", .authentication), - CategoryCase("MissingAuthenticationTokenException", .authentication), - CategoryCase("IncompleteSignatureException", .authentication), - CategoryCase("AccessDeniedException", message: "User is not authorized to perform: dynamodb:Scan", .fatal), - CategoryCase("ValidationException", .fatal), - CategoryCase("ResourceNotFoundException", .fatal), - CategoryCase("ConditionalCheckFailedException", .fatal), - CategoryCase("TransactionCanceledException", .fatal) - ] - ) - func category(_ testCase: CategoryCase) { - let error = DynamoDBServiceError(code: testCase.code, message: testCase.message, httpStatus: testCase.status) - #expect(error.category == testCase.category) - } - - @Test("An authentication failure says so") - func authenticationUserMessage() { - let error = DynamoDBServiceError( - code: "UnrecognizedClientException", message: "The security token included in the request is invalid.", httpStatus: 400 - ) - #expect(error.userMessage == String( - format: String(localized: "Authentication failed: %@"), "The security token included in the request is invalid." - )) - } - - @Test("Access denied is reported as a DynamoDB error, not as a failed sign-in") - func accessDeniedIsNotAuthentication() { - let error = DynamoDBServiceError( - code: "AccessDeniedException", message: "User is not authorized to perform: dynamodb:Scan", httpStatus: 400 - ) - #expect(error.userMessage == String( - format: String(localized: "DynamoDB error: [%1$@] %2$@"), - "AccessDeniedException", "User is not authorized to perform: dynamodb:Scan" - )) - #expect(!error.userMessage.hasPrefix(String(format: String(localized: "Authentication failed: %@"), ""))) - } - - @Test("A cancelled transaction lists each failed action by its 1-based number and skips None") - func cancellationReasonsInUserMessage() { - let error = DynamoDBServiceError( - code: "TransactionCanceledException", - message: "Transaction cancelled", - httpStatus: 400, - cancellationReasons: [ - DynamoDBCancellationReason(code: "None", message: nil), - DynamoDBCancellationReason(code: "ConditionalCheckFailed", message: "The conditional request failed"), - DynamoDBCancellationReason(code: "None", message: nil), - DynamoDBCancellationReason(code: "ValidationException", message: nil) - ] - ) - let lines = error.userMessage.components(separatedBy: "\n") - #expect(lines == [ - String(format: String(localized: "DynamoDB error: [%1$@] %2$@"), "TransactionCanceledException", "Transaction cancelled"), - String( - format: String(localized: "Action %1$d: %2$@%3$@"), 2, "ConditionalCheckFailed", " The conditional request failed" - ), - String(format: String(localized: "Action %1$d: %2$@%3$@"), 4, "ValidationException", "") - ]) - } - - @Test("A cancelled transaction with only None reasons adds no lines") - func noneReasonsAddNothing() { - let error = DynamoDBServiceError( - code: "TransactionCanceledException", - message: "Transaction cancelled", - httpStatus: 400, - cancellationReasons: [DynamoDBCancellationReason(code: "None", message: nil)] - ) - #expect(!error.userMessage.contains("\n")) - } - - @Test("A service error describes itself with its user message") - func serviceErrorDescription() { - let service = DynamoDBServiceError(code: "ValidationException", message: "bad key", httpStatus: 400) - #expect(DynamoDBError.service(service).errorDescription == service.userMessage) - } - - @Test("A partial batch reports how many writes applied, then each failure on its own line") - func partialBatchDescription() { - let error = DynamoDBError.partialBatch(applied: 3, total: 5, failures: ["pk = a: throttled", "pk = b: throttled"]) - #expect(error.errorDescription == [ - String(format: String(localized: "%1$d of %2$d were applied."), 3, 5), - "pk = a: throttled", - "pk = b: throttled" - ].joined(separator: "\n")) - } - - @Test("A changed item names its key and says to refresh") - func itemChangedDescription() { - let error = DynamoDBError.itemChanged(key: "pk = a, sk = 3") - #expect(error.errorDescription == String( - format: String(localized: "The item %@ changed after it was loaded. Refresh the table and edit it again."), - "pk = a, sk = 3" - )) - #expect(error.errorDescription?.contains("pk = a, sk = 3") == true) - } - - @Test("A timeout names the seconds it waited") - func timedOutDescription() { - let error = DynamoDBError.timedOut(seconds: 30) - #expect(error.errorDescription == String( - format: String(localized: "Stopped after %d seconds, the query timeout"), 30 - )) - #expect(error.errorDescription?.contains("30") == true) - } - - @Test("An invalid value names its attribute unless it has none") - func invalidValueDescription() { - #expect(DynamoDBError.invalidValue(attribute: "", reason: "Not a number").errorDescription == "Not a number") - #expect(DynamoDBError.invalidValue(attribute: "age", reason: "Not a number").errorDescription - == String(format: String(localized: "%@: %@"), "age", "Not a number")) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBExpressionTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBExpressionTests.swift deleted file mode 100644 index 20400cac5c..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBExpressionTests.swift +++ /dev/null @@ -1,257 +0,0 @@ -// -// DynamoDBExpressionTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct DynamoDBAttributePathTests { - struct ParseCase: Sendable, CustomTestStringConvertible { - let text: String - let known: Set - let segments: [DynamoDBAttributePath.Segment] - - var testDescription: String { text } - } - - static let parseCases: [ParseCase] = [ - ParseCase(text: "status", known: [], segments: [.name("status")]), - ParseCase(text: "a.b", known: [], segments: [.name("a"), .name("b")]), - ParseCase(text: "address.city.name", known: [], segments: [.name("address"), .name("city"), .name("name")]), - ParseCase(text: "items[0].sku", known: [], segments: [.name("items"), .index(0), .name("sku")]), - ParseCase(text: "matrix[1][2]", known: [], segments: [.name("matrix"), .index(1), .index(2)]), - ParseCase(text: "a.b", known: ["a.b"], segments: [.name("a.b")]), - ParseCase(text: "tags[0]", known: ["tags[0]"], segments: [.name("tags[0]")]), - ParseCase(text: "items[x]", known: [], segments: [.name("items[x]")]), - ParseCase(text: "items[0", known: [], segments: [.name("items[0")]), - ParseCase(text: "items[]", known: [], segments: [.name("items[]")]), - ParseCase(text: "[0].sku", known: [], segments: [.name("[0].sku")]) - ] - - @Test("Parses a document path, keeping a known or malformed name whole", arguments: parseCases) - func parses(_ testCase: ParseCase) { - let path = DynamoDBAttributePath.parse(testCase.text, knownAttributes: testCase.known) - - #expect(path.segments == testCase.segments) - } - - @Test("A negative list index is not a document path") - func negativeIndexStaysOneName() { - let path = DynamoDBAttributePath.parse("items[-1]", knownAttributes: []) - - #expect(path.segments == [.name("items[-1]")]) - } - - @Test("The root is the first name and only a single name is top level") - func rootAndTopLevel() { - let nested = DynamoDBAttributePath.parse("items[0].sku", knownAttributes: []) - let single = DynamoDBAttributePath(attribute: "a.b") - - #expect(nested.root == "items") - #expect(nested.isTopLevel == false) - #expect(single.root == "a.b") - #expect(single.isTopLevel) - } - - private static let item: DynamoDBItem = [ - "a.b": .string("literal"), - "address": .map(["city": .string("Hanoi"), "geo": .map(["lat": .number("21.03")])]), - "items": .list([.map(["sku": .string("A1")]), .map(["sku": .string("B2")])]), - "matrix": .list([.list([.number("1"), .number("2")]), .list([.number("3")])]), - "tags": .stringSet(["x"]) - ] - - @Test("Walks maps and lists to the value a path names") - func walksMapsAndLists() { - let known: Set = [] - - #expect(DynamoDBAttributePath.parse("address.city", knownAttributes: known).value(in: Self.item) == .string("Hanoi")) - #expect(DynamoDBAttributePath.parse("address.geo.lat", knownAttributes: known).value(in: Self.item) == .number("21.03")) - #expect(DynamoDBAttributePath.parse("items[1].sku", knownAttributes: known).value(in: Self.item) == .string("B2")) - #expect(DynamoDBAttributePath.parse("matrix[0][1]", knownAttributes: known).value(in: Self.item) == .number("2")) - #expect(DynamoDBAttributePath.parse("items", knownAttributes: known).value(in: Self.item) == Self.item["items"]) - } - - @Test("A literal attribute named with a dot is read as that attribute") - func literalDottedName() { - let path = DynamoDBAttributePath.parse("a.b", knownAttributes: ["a.b"]) - - #expect(path.value(in: Self.item) == .string("literal")) - } - - @Test("A path that leaves the document or crosses the wrong type reads nothing") - func missingValues() { - let known: Set = [] - - #expect(DynamoDBAttributePath.parse("items[5].sku", knownAttributes: known).value(in: Self.item) == nil) - #expect(DynamoDBAttributePath.parse("items.sku", knownAttributes: known).value(in: Self.item) == nil) - #expect(DynamoDBAttributePath.parse("address[0]", knownAttributes: known).value(in: Self.item) == nil) - #expect(DynamoDBAttributePath.parse("address.zip", knownAttributes: known).value(in: Self.item) == nil) - #expect(DynamoDBAttributePath.parse("tags[0]", knownAttributes: known).value(in: Self.item) == nil) - #expect(DynamoDBAttributePath.parse("missing.x", knownAttributes: known).value(in: Self.item) == nil) - } -} - -struct DynamoDBExpressionContextTests { - static func isValidPlaceholder(_ placeholder: String, prefix: Character) -> Bool { - guard placeholder.first == prefix else { return false } - let body = placeholder.dropFirst() - guard let first = body.first, !first.isNumber else { return false } - return body.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "_") } - } - - @Test( - "Every attribute name gets a valid placeholder that maps back to it", - arguments: ["status", "name", "order-id", "a.b", "1st", "2024-total", "näme", "with space", "x:y#z"] - ) - func namePlaceholderIsValid(attribute: String) { - var context = DynamoDBExpressionContext() - - let placeholder = context.name(attribute) - - #expect(Self.isValidPlaceholder(placeholder, prefix: "#")) - #expect(context.names == [placeholder: attribute]) - } - - @Test("A reserved word is still sent through a placeholder") - func reservedWord() { - var context = DynamoDBExpressionContext() - - #expect(context.name("status") == "#status") - #expect(context.name("data") == "#data") - } - - @Test("A name used twice reuses its placeholder") - func reusesNamePlaceholder() { - var context = DynamoDBExpressionContext() - - let first = context.name("order-id") - let second = context.name("order-id") - - #expect(first == second) - #expect(context.names.count == 1) - } - - @Test("Names that sanitize to the same text get distinct placeholders") - func distinctNamesStayDistinct() { - var context = DynamoDBExpressionContext() - - let dashed = context.name("a-b") - let dotted = context.name("a.b") - let underscored = context.name("a_b") - - #expect(Set([dashed, dotted, underscored]).count == 3) - #expect(context.names[dashed] == "a-b") - #expect(context.names[dotted] == "a.b") - #expect(context.names[underscored] == "a_b") - } - - @Test("Long names that share a prefix get distinct placeholders") - func longNamesStayDistinct() { - var context = DynamoDBExpressionContext() - let stem = String(repeating: "x", count: 45) - - let first = context.name(stem + "1") - let second = context.name(stem + "2") - - #expect(first != second) - #expect(context.names[first] == stem + "1") - #expect(context.names[second] == stem + "2") - } - - @Test("A path renders each name through its placeholder and keeps list indexes") - func rendersPath() { - var context = DynamoDBExpressionContext() - - let rendered = context.path(DynamoDBAttributePath.parse("items[0].sku", knownAttributes: [])) - let repeated = context.path(DynamoDBAttributePath.parse("a.a", knownAttributes: [])) - - #expect(rendered == "#items[0].#sku") - #expect(repeated == "#a.#a") - #expect(context.names == ["#items": "items", "#sku": "sku", "#a": "a"]) - } - - @Test("The same value under the same hint reuses its placeholder") - func dedupesValues() { - var context = DynamoDBExpressionContext() - - let first = context.value(.string("x"), hint: "pk") - let second = context.value(.string("x"), hint: "pk") - - #expect(first == ":pk") - #expect(second == ":pk") - #expect(context.values == [":pk": .string("x")]) - } - - @Test("Different values under one hint get numbered placeholders") - func numbersDistinctValues() { - var context = DynamoDBExpressionContext() - - let first = context.value(.string("x"), hint: "pk") - let second = context.value(.string("y"), hint: "pk") - let third = context.value(.number("1"), hint: "pk") - let textOne = context.value(.string("1"), hint: "pk") - - #expect([first, second, third, textOne] == [":pk", ":pk2", ":pk3", ":pk4"]) - #expect(context.values == [":pk": .string("x"), ":pk2": .string("y"), ":pk3": .number("1"), ":pk4": .string("1")]) - } - - @Test( - "A value placeholder is valid whatever the hint", - arguments: ["order-id", "a.b", "1st", "näme", "", "with space"] - ) - func valuePlaceholderIsValid(hint: String) { - var context = DynamoDBExpressionContext() - - let placeholder = context.value(.string("v"), hint: hint) - - #expect(Self.isValidPlaceholder(placeholder, prefix: ":")) - } - - @Test("An empty context adds nothing to a request") - func emptyApplyAddsNothing() { - let context = DynamoDBExpressionContext() - var body: [String: DynamoDBJSON] = ["TableName": .string("orders")] - - context.apply(to: &body) - - #expect(body == ["TableName": .string("orders")]) - } - - @Test("A context holding only names writes no ExpressionAttributeValues") - func namesOnly() { - var context = DynamoDBExpressionContext() - _ = context.name("status") - var body: [String: DynamoDBJSON] = ["TableName": .string("orders")] - - context.apply(to: &body) - - #expect(body["ExpressionAttributeNames"] == .object(["#status": .string("status")])) - #expect(body["ExpressionAttributeValues"] == nil) - } - - @Test("Apply writes the names and the wire form of each value, keeping the rest of the body") - func appliesNamesAndValues() { - var context = DynamoDBExpressionContext() - let status = context.name("status") - let total = context.name("total") - let text = context.value(.string("shipped"), hint: "status") - let number = context.value(.number("12345678901234567890123456789012345678"), hint: "total") - let filter = "\(status) = \(text) AND \(total) > \(number)" - var body: [String: DynamoDBJSON] = [ - "TableName": .string("orders"), - "FilterExpression": .string(filter) - ] - - context.apply(to: &body) - - #expect(body["TableName"] == .string("orders")) - #expect(body["FilterExpression"] == .string(filter)) - #expect(body["ExpressionAttributeNames"] == .object(["#status": .string("status"), "#total": .string("total")])) - #expect(body["ExpressionAttributeValues"] == .object([ - ":status": .object(["S": .string("shipped")]), - ":total": .object(["N": .string("12345678901234567890123456789012345678")]) - ])) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBFilterTranslatorTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBFilterTranslatorTests.swift deleted file mode 100644 index d6dff0c7c8..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBFilterTranslatorTests.swift +++ /dev/null @@ -1,640 +0,0 @@ -// -// DynamoDBFilterTranslatorTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct DynamoDBFilterTranslatorTests { - struct Translation { - let outcome: DynamoDBFilterTranslator.Outcome - let context: DynamoDBExpressionContext - } - - struct OperatorCase: Sendable, CustomTestStringConvertible { - let op: String - let value: String - - var testDescription: String { "\(op) \(value)" } - } - - static func schema() throws -> DynamoDBTableSchema { - let json = try DynamoDBJSON.parse(""" - {"Table": { - "TableName": "orders", - "KeySchema": [ - {"AttributeName": "pk", "KeyType": "HASH"}, - {"AttributeName": "sk", "KeyType": "RANGE"} - ], - "AttributeDefinitions": [ - {"AttributeName": "pk", "AttributeType": "S"}, - {"AttributeName": "sk", "AttributeType": "N"} - ] - }} - """) - return try DynamoDBTableSchema(describeTableResponse: json) - } - - static func filter( - _ attribute: String, - _ op: String, - _ value: String = "", - second: String? = nil, - kind: String? = nil, - caseSensitive: Bool = true - ) -> DynamoDBBrowseFilter { - DynamoDBBrowseFilter( - attribute: attribute, op: op, value: value, secondValue: second, kind: kind, caseSensitive: caseSensitive - ) - } - - static func translate(_ filter: DynamoDBBrowseFilter) throws -> Translation { - let translator = DynamoDBFilterTranslator(schema: try schema()) - var context = DynamoDBExpressionContext() - let path: DynamoDBAttributePath? = filter.attribute == DynamoDBFilterTranslator.anyAttributeColumn - ? nil - : DynamoDBAttributePath.parse(filter.attribute, knownAttributes: []) - let outcome = translator.translate(filter, path: path, context: &context) - return Translation(outcome: outcome, context: context) - } - - static func keyCondition(_ filter: DynamoDBBrowseFilter) throws -> (term: String?, context: DynamoDBExpressionContext) { - let translator = DynamoDBFilterTranslator(schema: try schema()) - var context = DynamoDBExpressionContext() - let term = translator.keyCondition(filter, attribute: filter.attribute, context: &context) - return (term, context) - } - - static func clientPredicate(_ filter: DynamoDBBrowseFilter, path: String?) -> DynamoDBClientPredicate { - DynamoDBClientPredicate( - path: path.map { DynamoDBAttributePath(attribute: $0) }, - op: filter.op, - value: filter.value, - secondValue: filter.secondValue, - caseSensitive: filter.caseSensitive - ) - } - - // MARK: - Equality and ordering - - @Test("Numeric text on a non-key attribute is compared as both a String and a Number") - func equalityOnNumericText() throws { - let result = try Self.translate(Self.filter("total", "=", "5")) - - #expect(result.outcome == .server("(#total = :total OR #total = :total2)")) - #expect(result.context.names == ["#total": "total"]) - #expect(result.context.values == [":total": .string("5"), ":total2": .number("5")]) - } - - @Test("Equality with true also matches a Boolean") - func equalityOnTrue() throws { - let result = try Self.translate(Self.filter("flag", "=", "true")) - - #expect(result.outcome == .server("(#flag = :flag OR #flag = :flag2)")) - #expect(result.context.values == [":flag": .string("true"), ":flag2": .bool(true)]) - } - - @Test("Plain text is compared as a String only") - func equalityOnText() throws { - let result = try Self.translate(Self.filter("name", "=", "Ann")) - - #expect(result.outcome == .server("#name = :name")) - #expect(result.context.values == [":name": .string("Ann")]) - } - - @Test("An ordering comparison on numeric text uses a String and a Number", arguments: [">", ">=", "<", "<="]) - func orderingOnNumericText(op: String) throws { - let result = try Self.translate(Self.filter("total", op, "5")) - - #expect(result.outcome == .server("(#total \(op) :total OR #total \(op) :total2)")) - #expect(result.context.values == [":total": .string("5"), ":total2": .number("5")]) - } - - @Test("An ordering comparison never compares a Boolean", arguments: [">", ">=", "<", "<="]) - func orderingNeverUsesBoolean(op: String) throws { - let result = try Self.translate(Self.filter("flag", op, "true")) - - #expect(result.outcome == .server("#flag \(op) :flag")) - #expect(result.context.values == [":flag": .string("true")]) - } - - @Test("Not equal requires the attribute and excludes every reading of the text", arguments: ["!=", "<>"]) - func notEqual(op: String) throws { - let result = try Self.translate(Self.filter("total", op, "5")) - - #expect(result.outcome == .server("(attribute_exists(#total) AND #total <> :total AND #total <> :total2)")) - #expect(result.context.values == [":total": .string("5"), ":total2": .number("5")]) - } - - @Test("Not equal to true excludes the text and the Boolean") - func notEqualToTrue() throws { - let result = try Self.translate(Self.filter("flag", "!=", "true")) - - #expect(result.outcome == .server("(attribute_exists(#flag) AND #flag <> :flag AND #flag <> :flag2)")) - #expect(result.context.values == [":flag": .string("true"), ":flag2": .bool(true)]) - } - - // MARK: - Text operators - - @Test("Contains becomes the contains function") - func contains() throws { - let text = try Self.translate(Self.filter("name", "CONTAINS", "ab")) - let numeric = try Self.translate(Self.filter("total", "CONTAINS", "5")) - - #expect(text.outcome == .server("contains(#name, :name)")) - #expect(text.context.values == [":name": .string("ab")]) - #expect(numeric.outcome == .server("(contains(#total, :total) OR contains(#total, :total2))")) - #expect(numeric.context.values == [":total": .string("5"), ":total2": .number("5")]) - } - - @Test("Not contains requires the attribute") - func notContains() throws { - let result = try Self.translate(Self.filter("name", "NOT CONTAINS", "ab")) - - #expect(result.outcome == .server("(attribute_exists(#name) AND NOT contains(#name, :name))")) - #expect(result.context.values == [":name": .string("ab")]) - } - - @Test("Starts with becomes begins_with on a String") - func startsWith() throws { - let text = try Self.translate(Self.filter("name", "STARTS WITH", "ab")) - let digits = try Self.translate(Self.filter("name", "STARTS WITH", "12")) - - #expect(text.outcome == .server("begins_with(#name, :name)")) - #expect(text.context.values == [":name": .string("ab")]) - #expect(digits.outcome == .server("begins_with(#name, :name)")) - #expect(digits.context.values == [":name": .string("12")]) - } - - @Test("Ends with and a regular expression are evaluated on the client", arguments: ["ENDS WITH", "REGEX"]) - func clientOnlyOperators(op: String) throws { - let filter = Self.filter("name", op, "son") - - let result = try Self.translate(filter) - - #expect(result.outcome == .client(Self.clientPredicate(filter, path: "name"))) - #expect(result.context == DynamoDBExpressionContext()) - } - - // MARK: - Null and empty - - @Test("Is null matches a missing attribute or the NULL type") - func isNull() throws { - let result = try Self.translate(Self.filter("total", "IS NULL")) - - #expect(result.outcome == .server("(attribute_not_exists(#total) OR attribute_type(#total, :null))")) - #expect(result.context.values == [":null": .string("NULL")]) - } - - @Test("Is not null requires the attribute and rejects the NULL type") - func isNotNull() throws { - let result = try Self.translate(Self.filter("total", "IS NOT NULL")) - - #expect(result.outcome == .server("(attribute_exists(#total) AND NOT attribute_type(#total, :null))")) - #expect(result.context.values == [":null": .string("NULL")]) - } - - @Test("Is empty compares with the empty String") - func isEmpty() throws { - let result = try Self.translate(Self.filter("name", "IS EMPTY")) - - #expect(result.outcome == .server("#name = :empty")) - #expect(result.context.values == [":empty": .string("")]) - } - - @Test("Is not empty requires the attribute and a value other than the empty String") - func isNotEmpty() throws { - let result = try Self.translate(Self.filter("name", "IS NOT EMPTY")) - - #expect(result.outcome == .server("(attribute_exists(#name) AND #name <> :empty)")) - #expect(result.context.values == [":empty": .string("")]) - } - - // MARK: - Membership - - @Test("In lists every typed reading of each value") - func inList() throws { - let text = try Self.translate(Self.filter("name", "IN", "a, b")) - let numeric = try Self.translate(Self.filter("total", "IN", "1, 2")) - - #expect(text.outcome == .server("#name IN (:name, :name2)")) - #expect(text.context.values == [":name": .string("a"), ":name2": .string("b")]) - #expect(numeric.outcome == .server("#total IN (:total, :total2, :total3, :total4)")) - #expect(numeric.context.values == [ - ":total": .string("1"), ":total2": .number("1"), ":total3": .string("2"), ":total4": .number("2") - ]) - } - - @Test("Not in requires the attribute") - func notInList() throws { - let result = try Self.translate(Self.filter("name", "NOT IN", "a, b")) - - #expect(result.outcome == .server("(attribute_exists(#name) AND NOT #name IN (:name, :name2))")) - } - - @Test("In with 150 values is split into IN terms of at most 100") - func inListChunks() throws { - let values = (0..<150).map { "v\($0)" }.joined(separator: ", ") - - let result = try Self.translate(Self.filter("name", "IN", values)) - - guard case .server(let expression) = result.outcome else { - Issue.record("Expected a server expression, got \(result.outcome)") - return - } - let regex = try NSRegularExpression(pattern: #"IN \(([^)]*)\)"#) - let range = NSRange(expression.startIndex..., in: expression) - let chunkSizes = regex.matches(in: expression, range: range).compactMap { match -> Int? in - Range(match.range(at: 1), in: expression).map { expression[$0].components(separatedBy: ", ").count } - } - #expect(chunkSizes == [100, 50]) - #expect(expression.hasPrefix("(#name IN (")) - #expect(expression.contains(") OR #name IN (")) - #expect(result.context.values.count == 150) - } - - // MARK: - Between - - @Test("Between with a second value compares as a String range and a Number range") - func betweenWithSecondValue() throws { - let result = try Self.translate(Self.filter("total", "BETWEEN", "1", second: "9")) - - #expect(result.outcome == .server( - "(#total BETWEEN :total AND :total2 OR #total BETWEEN :total3 AND :total4)" - )) - #expect(result.context.values == [ - ":total": .string("1"), ":total2": .string("9"), ":total3": .number("1"), ":total4": .number("9") - ]) - } - - @Test("Between reads both bounds from one comma separated value", arguments: [ - OperatorCase(op: "BETWEEN", value: "1,9"), - OperatorCase(op: "BETWEEN", value: " 1 , 9 ") - ]) - func betweenFromOneValue(_ testCase: OperatorCase) throws { - let result = try Self.translate(Self.filter("total", testCase.op, testCase.value)) - - #expect(result.outcome == .server( - "(#total BETWEEN :total AND :total2 OR #total BETWEEN :total3 AND :total4)" - )) - #expect(result.context.values[":total3"] == .number("1")) - #expect(result.context.values[":total4"] == .number("9")) - } - - @Test("Between drops the String range when its bounds are out of order as text") - func betweenDropsReversedTextRange() throws { - let result = try Self.translate(Self.filter("total", "BETWEEN", "2", second: "10")) - - #expect(result.outcome == .server("#total BETWEEN :total AND :total2")) - #expect(result.context.values == [":total": .number("2"), ":total2": .number("10")]) - } - - @Test("Between never compares Booleans") - func betweenNeverUsesBoolean() throws { - let result = try Self.translate(Self.filter("flag", "BETWEEN", "false", second: "true")) - - #expect(result.outcome == .server("#flag BETWEEN :flag AND :flag2")) - #expect(result.context.values == [":flag": .string("false"), ":flag2": .string("true")]) - } - - // MARK: - Key attributes - - @Test("A Number key compares as a Number only") - func numberKeyIsTypedNumber() throws { - let equal = try Self.translate(Self.filter("sk", "=", "5")) - let greater = try Self.translate(Self.filter("sk", ">", "5")) - - #expect(equal.outcome == .server("#sk = :sk")) - #expect(equal.context.values == [":sk": .number("5")]) - #expect(greater.outcome == .server("#sk > :sk")) - #expect(greater.context.values == [":sk": .number("5")]) - } - - @Test("A String key compares numeric text as a String only") - func stringKeyIsTypedString() throws { - let result = try Self.translate(Self.filter("pk", "=", "5")) - - #expect(result.outcome == .server("#pk = :pk")) - #expect(result.context.values == [":pk": .string("5")]) - } - - @Test("A Number key compared with text that is not a number matches nothing", arguments: [ - OperatorCase(op: "=", value: "abc"), - OperatorCase(op: ">", value: "abc"), - OperatorCase(op: ">=", value: "abc"), - OperatorCase(op: "<", value: "abc"), - OperatorCase(op: "<=", value: "abc"), - OperatorCase(op: "IN", value: "abc, def"), - OperatorCase(op: "BETWEEN", value: "a,b"), - OperatorCase(op: "BETWEEN", value: "9,1") - ]) - func numberKeyWithTextMatchesNothing(_ testCase: OperatorCase) throws { - let result = try Self.translate(Self.filter("sk", testCase.op, testCase.value)) - - #expect(result.outcome == .never) - } - - @Test("Starts with on a Number key matches nothing") - func startsWithOnNumberKey() throws { - let result = try Self.translate(Self.filter("sk", "STARTS WITH", "1")) - - #expect(result.outcome == .never) - } - - @Test("In on a Number key keeps only the values that are numbers") - func numberKeyInKeepsNumbers() throws { - let result = try Self.translate(Self.filter("sk", "IN", "1, abc, 2")) - - #expect(result.outcome == .server("#sk IN (:sk, :sk2)")) - #expect(result.context.values == [":sk": .number("1"), ":sk2": .number("2")]) - } - - @Test("Not equal or not in on a Number key with text keeps every item that has the key", arguments: [ - OperatorCase(op: "!=", value: "abc"), - OperatorCase(op: "NOT IN", value: "abc") - ]) - func numberKeyExclusionWithText(_ testCase: OperatorCase) throws { - let result = try Self.translate(Self.filter("sk", testCase.op, testCase.value)) - - #expect(result.outcome == .server("attribute_exists(#sk)")) - #expect(result.context.values.isEmpty) - } - - // MARK: - Client side filters - - @Test("A search across every attribute is evaluated on the client") - func anyAttributeColumn() throws { - let filter = Self.filter("*", "=", "x") - - let result = try Self.translate(filter) - - #expect(result.outcome == .client(Self.clientPredicate(filter, path: nil))) - #expect(DynamoDBFilterTranslator(schema: try Self.schema()).needsClient(filter)) - } - - @Test( - "A case-insensitive match on text with letters is evaluated on the client", - arguments: ["=", "!=", "<>", "CONTAINS", "NOT CONTAINS", "STARTS WITH", "IN", "NOT IN"] - ) - func caseInsensitiveNeedsClient(op: String) throws { - let filter = Self.filter("name", op, "Ab", caseSensitive: false) - - let result = try Self.translate(filter) - - #expect(result.outcome == .client(Self.clientPredicate(filter, path: "name"))) - #expect(result.context == DynamoDBExpressionContext()) - } - - @Test("A case-insensitive match on text without letters stays on the server") - func caseInsensitiveWithoutLetters() throws { - let result = try Self.translate(Self.filter("name", "CONTAINS", "12", caseSensitive: false)) - - #expect(result.outcome == .server("(contains(#name, :name) OR contains(#name, :name2))")) - } - - @Test("Only the raw filter column is refused") - func unsupportedReason() { - let raw = DynamoDBFilterTranslator.unsupportedReason(for: Self.filter("__RAW__", "=", "a = 1")) - - #expect(raw?.isEmpty == false) - #expect(DynamoDBFilterTranslator.unsupportedReason(for: Self.filter("name", "=", "a")) == nil) - #expect(DynamoDBFilterTranslator.unsupportedReason(for: Self.filter("*", "=", "a")) == nil) - } - - // MARK: - Key conditions - - @Test("Equality on a String key is a key condition") - func keyConditionEquality() throws { - let result = try Self.keyCondition(Self.filter("pk", "=", "a")) - - #expect(result.term == "#pk = :pk") - #expect(result.context.names == ["#pk": "pk"]) - #expect(result.context.values == [":pk": .string("a")]) - } - - @Test("A range on a Number key is a key condition typed as a Number") - func keyConditionRange() throws { - let result = try Self.keyCondition(Self.filter("sk", "<", "5")) - - #expect(result.term == "#sk < :sk") - #expect(result.context.values == [":sk": .number("5")]) - } - - @Test("Between on a Number key is a key condition", arguments: [ - OperatorCase(op: "BETWEEN", value: "1,9"), - OperatorCase(op: "BETWEEN", value: "1 , 9") - ]) - func keyConditionBetween(_ testCase: OperatorCase) throws { - let result = try Self.keyCondition(Self.filter("sk", testCase.op, testCase.value)) - - #expect(result.term == "#sk BETWEEN :sk AND :sk2") - #expect(result.context.values == [":sk": .number("1"), ":sk2": .number("9")]) - } - - @Test("Between with a second value is a key condition") - func keyConditionBetweenWithSecondValue() throws { - let result = try Self.keyCondition(Self.filter("sk", "BETWEEN", "1", second: "9")) - - #expect(result.term == "#sk BETWEEN :sk AND :sk2") - #expect(result.context.values == [":sk": .number("1"), ":sk2": .number("9")]) - } - - @Test("Starts with on a String key is a begins_with key condition") - func keyConditionStartsWith() throws { - let result = try Self.keyCondition(Self.filter("pk", "STARTS WITH", "ab")) - - #expect(result.term == "begins_with(#pk, :pk)") - #expect(result.context.values == [":pk": .string("ab")]) - } - - @Test("Filters a key condition cannot express are refused", arguments: [ - DynamoDBFilterTranslatorTests.filter("sk", "STARTS WITH", "1"), - DynamoDBFilterTranslatorTests.filter("sk", "=", "abc"), - DynamoDBFilterTranslatorTests.filter("sk", "BETWEEN", "a,b"), - DynamoDBFilterTranslatorTests.filter("pk", "!=", "a"), - DynamoDBFilterTranslatorTests.filter("pk", "CONTAINS", "a"), - DynamoDBFilterTranslatorTests.filter("pk", "IN", "a, b"), - DynamoDBFilterTranslatorTests.filter("pk", "ENDS WITH", "a"), - DynamoDBFilterTranslatorTests.filter("pk", "=", "Ab", caseSensitive: false), - DynamoDBFilterTranslatorTests.filter("name", "=", "a") - ]) - func keyConditionRefusals(_ filter: DynamoDBBrowseFilter) throws { - let result = try Self.keyCondition(filter) - - #expect(result.term == nil) - } -} - -struct DynamoDBClientPredicateMatchingTests { - static func predicate( - _ op: String, - _ value: String = "", - second: String? = nil, - path: String? = "name", - caseSensitive: Bool = true - ) -> DynamoDBClientPredicate { - DynamoDBClientPredicate( - path: path.map { DynamoDBAttributePath.parse($0, knownAttributes: []) }, - op: op, - value: value, - secondValue: second, - caseSensitive: caseSensitive - ) - } - - static let alice: DynamoDBItem = ["name": .string("Alice"), "city": .string("Hanoi")] - - @Test("Equality compares the displayed text, folding case only when asked") - func equality() { - #expect(Self.predicate("=", "Alice").matches(Self.alice)) - #expect(!Self.predicate("=", "alice").matches(Self.alice)) - #expect(Self.predicate("=", "alice", caseSensitive: false).matches(Self.alice)) - #expect(Self.predicate("=", "true").matches(["name": .bool(true)])) - } - - @Test("A Number equals the same number written another way") - func numericEquality() { - #expect(Self.predicate("=", "5.0").matches(["name": .number("5")])) - #expect(!Self.predicate("!=", "5.0").matches(["name": .number("5")])) - } - - @Test("Not equal is the inverse of equality for an attribute that exists", arguments: ["!=", "<>"]) - func notEqual(op: String) { - #expect(Self.predicate(op, "Bob").matches(Self.alice)) - #expect(!Self.predicate(op, "Alice").matches(Self.alice)) - #expect(!Self.predicate(op, "ALICE", caseSensitive: false).matches(Self.alice)) - } - - @Test("Contains and not contains test a substring") - func contains() { - #expect(Self.predicate("CONTAINS", "lic").matches(Self.alice)) - #expect(!Self.predicate("CONTAINS", "LIC").matches(Self.alice)) - #expect(Self.predicate("CONTAINS", "LIC", caseSensitive: false).matches(Self.alice)) - #expect(Self.predicate("NOT CONTAINS", "xyz").matches(Self.alice)) - #expect(!Self.predicate("NOT CONTAINS", "lic").matches(Self.alice)) - } - - @Test("Starts with and ends with test a prefix and a suffix") - func prefixAndSuffix() { - #expect(Self.predicate("STARTS WITH", "Al").matches(Self.alice)) - #expect(!Self.predicate("STARTS WITH", "li").matches(Self.alice)) - #expect(Self.predicate("STARTS WITH", "al", caseSensitive: false).matches(Self.alice)) - #expect(Self.predicate("ENDS WITH", "ice").matches(Self.alice)) - #expect(!Self.predicate("ENDS WITH", "Ali").matches(Self.alice)) - #expect(Self.predicate("ENDS WITH", "ICE", caseSensitive: false).matches(Self.alice)) - } - - @Test("Is empty and is not empty need the attribute") - func emptiness() { - #expect(Self.predicate("IS EMPTY").matches(["name": .string("")])) - #expect(!Self.predicate("IS EMPTY").matches(Self.alice)) - #expect(!Self.predicate("IS EMPTY").matches([:])) - #expect(Self.predicate("IS NOT EMPTY").matches(Self.alice)) - #expect(!Self.predicate("IS NOT EMPTY").matches(["name": .string("")])) - #expect(!Self.predicate("IS NOT EMPTY").matches([:])) - } - - @Test("Is null matches a missing attribute and the NULL type, not the text NULL") - func nullness() { - #expect(Self.predicate("IS NULL").matches([:])) - #expect(Self.predicate("IS NULL").matches(["name": .null])) - #expect(!Self.predicate("IS NULL").matches(["name": .string("NULL")])) - #expect(!Self.predicate("IS NULL").matches(Self.alice)) - #expect(Self.predicate("IS NOT NULL").matches(Self.alice)) - #expect(!Self.predicate("IS NOT NULL").matches(["name": .null])) - #expect(!Self.predicate("IS NOT NULL").matches([:])) - } - - @Test("In and not in test membership of a comma separated list") - func membership() { - #expect(Self.predicate("IN", "Bob, Alice").matches(Self.alice)) - #expect(!Self.predicate("IN", "Bob, Carol").matches(Self.alice)) - #expect(Self.predicate("IN", "bob, alice", caseSensitive: false).matches(Self.alice)) - #expect(Self.predicate("NOT IN", "Bob, Carol").matches(Self.alice)) - #expect(!Self.predicate("NOT IN", "Alice").matches(Self.alice)) - } - - @Test("A regular expression is searched in the text, folding case only when asked") - func regularExpression() { - #expect(Self.predicate("REGEX", "^A.*e$").matches(Self.alice)) - #expect(Self.predicate("REGEX", "lic").matches(Self.alice)) - #expect(!Self.predicate("REGEX", "^a").matches(Self.alice)) - #expect(Self.predicate("REGEX", "^a", caseSensitive: false).matches(Self.alice)) - #expect(!Self.predicate("REGEX", "(").matches(Self.alice)) - } - - @Test("Numbers are ordered by value, not as text") - func numericOrdering() { - let ten: DynamoDBItem = ["name": .number("10")] - - #expect(Self.predicate(">", "9").matches(ten)) - #expect(!Self.predicate("<", "9").matches(ten)) - #expect(Self.predicate(">=", "10.0").matches(ten)) - #expect(Self.predicate("<=", "1E1").matches(ten)) - #expect(!Self.predicate(">", "10").matches(ten)) - } - - @Test("Text is ordered as text") - func textOrdering() { - let banana: DynamoDBItem = ["name": .string("banana")] - - #expect(Self.predicate(">", "apple").matches(banana)) - #expect(Self.predicate("<", "cherry").matches(banana)) - #expect(!Self.predicate("<", "apple").matches(banana)) - } - - @Test("Between is inclusive and numeric when both bounds are numbers") - func between() { - #expect(Self.predicate("BETWEEN", "2", second: "10").matches(["name": .number("5")])) - #expect(Self.predicate("BETWEEN", "2,10").matches(["name": .number("5")])) - #expect(Self.predicate("BETWEEN", "2", second: "10").matches(["name": .number("2")])) - #expect(Self.predicate("BETWEEN", "2", second: "10").matches(["name": .number("10")])) - #expect(!Self.predicate("BETWEEN", "2", second: "10").matches(["name": .number("11")])) - #expect(Self.predicate("BETWEEN", "a", second: "c").matches(["name": .string("b")])) - #expect(!Self.predicate("BETWEEN", "5").matches(["name": .number("5")])) - } - - @Test("CONTAINS and STARTS WITH on the client answer as DynamoDB's contains and begins_with would") - func clientMatchingFollowsDynamoDB() { - let list: DynamoDBItem = ["tags": .list([.string("Abc"), .number("5")])] - let number: DynamoDBItem = ["tags": .number("123")] - let numbers: DynamoDBItem = ["tags": .numberSet(["5", "7"])] - let path = DynamoDBAttributePath(attribute: "tags") - - func matches(_ op: String, _ value: String, _ item: DynamoDBItem) -> Bool { - DynamoDBClientPredicate(path: path, op: op, value: value, secondValue: nil, caseSensitive: false).matches(item) - } - - #expect(matches("CONTAINS", "abc", list)) - #expect(!matches("CONTAINS", "ab", list)) - #expect(matches("CONTAINS", "5.0", list)) - #expect(!matches("CONTAINS", "12", number)) - #expect(matches("NOT CONTAINS", "12", number)) - #expect(matches("CONTAINS", "5.0", numbers)) - #expect(!matches("STARTS WITH", "12", number)) - } - - @Test("A missing attribute matches no comparison", arguments: [ - "=", "!=", "<>", ">", "<", "CONTAINS", "NOT CONTAINS", "STARTS WITH", "ENDS WITH", "IN", "NOT IN", "REGEX" - ]) - func missingAttribute(op: String) { - #expect(!Self.predicate(op, "x").matches(["other": .string("x")])) - } - - @Test("A search across every attribute matches when any attribute does") - func anyAttribute() { - #expect(Self.predicate("=", "Hanoi", path: nil).matches(Self.alice)) - #expect(!Self.predicate("=", "Paris", path: nil).matches(Self.alice)) - #expect(Self.predicate("CONTAINS", "NOI", path: nil, caseSensitive: false).matches(Self.alice)) - #expect(Self.predicate("REGEX", "^Han", path: nil).matches(Self.alice)) - } - - @Test("A nested path reads the value inside a map") - func nestedPath() { - let item: DynamoDBItem = ["address": .map(["city": .string("Hanoi")])] - - #expect(Self.predicate("=", "Hanoi", path: "address.city").matches(item)) - #expect(!Self.predicate("=", "Hanoi", path: "address.zip").matches(item)) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBItemTableTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBItemTableTests.swift deleted file mode 100644 index 3ab88fb0e2..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBItemTableTests.swift +++ /dev/null @@ -1,315 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBItemTableTests { - struct ClassificationCase: Sendable, CustomTestStringConvertible { - let value: DynamoDBAttributeValue - let displayName: String - let classification: String - var testDescription: String { displayName } - } - - private static func schema() throws -> DynamoDBTableSchema { - try DynamoDBTableSchema(describeTableResponse: DynamoDBJSON.parse(#""" - { - "Table": { - "TableName": "Orders", - "KeySchema": [ - {"AttributeName": "pk", "KeyType": "HASH"}, - {"AttributeName": "sk", "KeyType": "RANGE"} - ], - "AttributeDefinitions": [ - {"AttributeName": "pk", "AttributeType": "S"}, - {"AttributeName": "sk", "AttributeType": "N"}, - {"AttributeName": "status", "AttributeType": "S"} - ], - "GlobalSecondaryIndexes": [ - { - "IndexName": "byStatus", - "KeySchema": [{"AttributeName": "status", "KeyType": "HASH"}], - "Projection": {"ProjectionType": "ALL"} - } - ] - } - } - """#)) - } - - private static func item(_ values: [String: DynamoDBAttributeValue]) -> DynamoDBItem { - values - } - - private static func ids(_ items: [DynamoDBItem]) -> [String] { - items.map { item in - guard case .string(let id)? = item["id"] else { return "?" } - return id - } - } - - @Test("Columns run preferred first, then table keys, then the rest alphabetically") - func columnOrder() throws { - let items = [ - Self.item(["pk": .string("a"), "sk": .number("1"), "zeta": .string("z"), "beta": .string("b")]), - Self.item(["pk": .string("b"), "alpha": .number("2")]) - ] - let table = DynamoDBItemTable(items: items, schema: try Self.schema(), preferredColumns: ["zeta", "missing"]) - #expect(table.columns == ["zeta", "missing", "pk", "sk", "alpha", "beta"]) - } - - @Test("A preferred column that is also a key is listed once, where it was preferred") - func preferredKeyIsNotRepeated() throws { - let items = [Self.item(["pk": .string("a"), "sk": .number("1"), "note": .string("n")])] - let table = DynamoDBItemTable(items: items, schema: try Self.schema(), preferredColumns: ["note", "sk"]) - #expect(table.columns == ["note", "sk", "pk"]) - } - - @Test("Every table key is a column even when no item carries it") - func allKeysIncludedByDefault() throws { - let table = DynamoDBItemTable(items: [Self.item(["other": .string("x")])], schema: try Self.schema()) - #expect(table.columns == ["pk", "sk", "other"]) - #expect(table.rows == [[.null, .null, .text("x")]]) - } - - @Test("With includeAllKeys off, only the keys some item carries are columns") - func onlyPresentKeysWhenNotIncludingAll() throws { - let items = [Self.item(["pk": .string("a"), "other": .string("x")])] - let table = DynamoDBItemTable(items: items, schema: try Self.schema(), includeAllKeys: false) - #expect(table.columns == ["pk", "other"]) - } - - @Test("An index key no item carries is not a column") - func indexKeysAreNotForced() throws { - let table = DynamoDBItemTable(items: [Self.item(["pk": .string("a")])], schema: try Self.schema()) - #expect(!table.columns.contains("status")) - } - - @Test("With no schema and no items the table is empty") - func emptyTable() { - let table = DynamoDBItemTable(items: [], schema: nil) - #expect(table.columns.isEmpty) - #expect(table.rows.isEmpty) - #expect(table.types.isEmpty) - } - - @Test("Each row holds its item's cells in column order, with a missing attribute as null") - func rowsFollowColumns() { - let items = [ - Self.item(["a": .string("x"), "b": .number("1.50")]), - Self.item(["b": .bool(true), "c": .map(["k": .number("2")])]) - ] - let table = DynamoDBItemTable(items: items, schema: nil) - #expect(table.columns == ["a", "b", "c"]) - #expect(table.rows == [ - [.text("x"), .text("1.50"), .null], - [.null, .text("true"), .text(#"{"k":2}"#)] - ]) - } - - @Test("A column takes the type most of its values have, ignoring NULL") - func majorityType() { - let items = [ - Self.item(["v": .number("1")]), - Self.item(["v": .string("x")]), - Self.item(["v": .number("2")]), - Self.item(["v": .null]), - Self.item(["v": .null]), - Self.item(["v": .null]) - ] - #expect(DynamoDBItemTable.majorityType(of: "v", in: items) == .number) - #expect(DynamoDBItemTable(items: items, schema: nil).types == [.number]) - } - - @Test("A column whose values are all NULL or missing has no type") - func nullOnlyColumnHasNoType() { - let items = [Self.item(["v": .null]), Self.item(["w": .string("x")])] - #expect(DynamoDBItemTable.majorityType(of: "v", in: items) == nil) - #expect(DynamoDBItemTable.majorityType(of: "absent", in: items) == nil) - } - - @Test("A tie between types resolves the same way whatever order the items arrive in") - func majorityTieIsDeterministic() { - let tied = [ - Self.item(["v": .string("x")]), - Self.item(["v": .number("1")]), - Self.item(["v": .map([:])]), - Self.item(["v": .string("y")]), - Self.item(["v": .number("2")]), - Self.item(["v": .map([:])]) - ] - let forward = DynamoDBItemTable.majorityType(of: "v", in: tied) - let backward = DynamoDBItemTable.majorityType(of: "v", in: tied.reversed()) - let rotated = DynamoDBItemTable.majorityType(of: "v", in: Array(tied[2...] + tied[..<2])) - #expect(forward != nil) - #expect(forward == backward) - #expect(forward == rotated) - #expect([DynamoDBAttributeType.string, .number, .map].contains(forward ?? .null)) - } - - @Test("Table key columns take their type from the schema, not from the items") - func keyColumnsTypedFromSchema() throws { - let items = [ - Self.item(["pk": .number("1"), "sk": .string("not a number")]), - Self.item(["pk": .number("2"), "sk": .string("still not")]) - ] - let table = DynamoDBItemTable(items: items, schema: try Self.schema()) - #expect(table.columns == ["pk", "sk"]) - #expect(table.types == [.string, .number]) - } - - @Test("A key column no item carries is still typed from the schema") - func absentKeyTypedFromSchema() throws { - let table = DynamoDBItemTable(items: [], schema: try Self.schema()) - #expect(table.columns == ["pk", "sk"]) - #expect(table.typeNames == ["String", "Number"]) - } - - @Test("typeNames uses display names and falls back to String for an untyped column") - func typeNamesFallBackToString() { - let items = [Self.item(["n": .numberSet(["1"]), "z": .null])] - let table = DynamoDBItemTable(items: items, schema: nil, preferredColumns: ["missing"]) - #expect(table.columns == ["missing", "n", "z"]) - #expect(table.typeNames == ["String", "Number Set", "String"]) - } - - @Test("observedTypes lists only the columns that have a type") - func observedTypesSkipUntyped() { - let items = [Self.item(["n": .number("1"), "z": .null])] - let table = DynamoDBItemTable(items: items, schema: nil) - #expect(table.observedTypes == ["n": .number]) - } - - @Test( - "Column metadata carries the type the app classifies the column by", - arguments: [ - ClassificationCase(value: .map(["a": .string("x")]), displayName: "Map", classification: "JSON"), - ClassificationCase(value: .list([.string("x")]), displayName: "List", classification: "JSON"), - ClassificationCase(value: .stringSet(["x"]), displayName: "String Set", classification: "JSON"), - ClassificationCase(value: .numberSet(["1"]), displayName: "Number Set", classification: "JSON"), - ClassificationCase(value: .binarySet([Data([1])]), displayName: "Binary Set", classification: "JSON"), - ClassificationCase(value: .number("1"), displayName: "Number", classification: "NUMERIC"), - ClassificationCase(value: .binary(Data([1])), displayName: "Binary", classification: "BLOB"), - ClassificationCase(value: .bool(true), displayName: "Boolean", classification: "BOOLEAN"), - ClassificationCase(value: .string("x"), displayName: "String", classification: "TEXT") - ] - ) - func columnMetaClassification(expected: ClassificationCase) throws { - let table = DynamoDBItemTable(items: [Self.item(["v": expected.value])], schema: nil) - let meta = try #require(table.columnMeta(schema: nil).first) - #expect(meta.name == "v") - #expect(meta.dataType == expected.displayName) - #expect(meta.classificationTypeName == expected.classification) - #expect(meta.typeNameForClassification == expected.classification) - } - - @Test("An untyped column's metadata reads as a String classified as TEXT") - func untypedColumnMeta() throws { - let table = DynamoDBItemTable(items: [Self.item(["v": .null])], schema: nil) - let meta = try #require(table.columnMeta(schema: nil).first) - #expect(meta.dataType == "String") - #expect(meta.classificationTypeName == "TEXT") - } - - @Test("Key columns are primary keys and not nullable, other columns the reverse") - func columnMetaMarksKeys() throws { - let schema = try Self.schema() - let items = [Self.item(["pk": .string("a"), "sk": .number("1"), "status": .string("open")])] - let meta = DynamoDBItemTable(items: items, schema: schema).columnMeta(schema: schema) - #expect(meta.map(\.name) == ["pk", "sk", "status"]) - #expect(meta.map(\.isPrimaryKey) == [true, true, false]) - #expect(meta.map(\.isNullable) == [false, false, true]) - #expect(meta.map(\.dataType) == ["String", "Number", "String"]) - #expect(meta.map(\.classificationTypeName) == ["TEXT", "NUMERIC", "TEXT"]) - } - - @Test("Numbers sort by value, not by text") - func sortsNumbersNumerically() { - let items = [ - Self.item(["id": .string("a"), "n": .number("10")]), - Self.item(["id": .string("b"), "n": .number("9")]), - Self.item(["id": .string("c"), "n": .number("-1")]), - Self.item(["id": .string("d"), "n": .number("1e1")]), - Self.item(["id": .string("e"), "n": .number("12345678901234567890123456789012345678")]) - ] - let ascending = DynamoDBItemTable.sorted(items, by: [DynamoDBOrderTerm(attribute: "n", descending: false)]) - #expect(Self.ids(ascending) == ["c", "b", "a", "d", "e"]) - let descending = DynamoDBItemTable.sorted(items, by: [DynamoDBOrderTerm(attribute: "n", descending: true)]) - #expect(Self.ids(descending) == ["e", "a", "d", "b", "c"]) - } - - @Test("Strings sort by their text") - func sortsText() { - let items = [ - Self.item(["id": .string("1"), "s": .string("b")]), - Self.item(["id": .string("2"), "s": .string("a")]), - Self.item(["id": .string("3"), "s": .string("C")]) - ] - let sorted = DynamoDBItemTable.sorted(items, by: [DynamoDBOrderTerm(attribute: "s", descending: false)]) - #expect(Self.ids(sorted) == ["3", "2", "1"]) - } - - @Test("Items missing the attribute sort first ascending and last descending") - func missingSortsFirst() { - let items = [ - Self.item(["id": .string("two"), "n": .number("2")]), - Self.item(["id": .string("none")]), - Self.item(["id": .string("one"), "n": .number("1")]) - ] - let ascending = DynamoDBItemTable.sorted(items, by: [DynamoDBOrderTerm(attribute: "n", descending: false)]) - #expect(Self.ids(ascending) == ["none", "one", "two"]) - let descending = DynamoDBItemTable.sorted(items, by: [DynamoDBOrderTerm(attribute: "n", descending: true)]) - #expect(Self.ids(descending) == ["two", "one", "none"]) - } - - @Test("Ties keep the order DynamoDB returned, in both directions") - func tiesKeepOrder() { - let items = (0..<8).map { index in - Self.item(["id": .string("\(index)"), "group": .number(index.isMultiple(of: 2) ? "1" : "1.0")]) - } - let ascending = DynamoDBItemTable.sorted(items, by: [DynamoDBOrderTerm(attribute: "group", descending: false)]) - let descending = DynamoDBItemTable.sorted(items, by: [DynamoDBOrderTerm(attribute: "group", descending: true)]) - let original = (0..<8).map { "\($0)" } - #expect(Self.ids(ascending) == original) - #expect(Self.ids(descending) == original) - } - - @Test("Later terms break ties left by earlier ones") - func multipleTerms() { - let items = [ - Self.item(["id": .string("a"), "g": .string("x"), "n": .number("1")]), - Self.item(["id": .string("b"), "g": .string("w"), "n": .number("5")]), - Self.item(["id": .string("c"), "g": .string("x"), "n": .number("3")]), - Self.item(["id": .string("d"), "g": .string("w"), "n": .number("2")]) - ] - let order = [ - DynamoDBOrderTerm(attribute: "g", descending: false), - DynamoDBOrderTerm(attribute: "n", descending: true) - ] - #expect(Self.ids(DynamoDBItemTable.sorted(items, by: order)) == ["b", "d", "c", "a"]) - } - - @Test("No order terms leaves the items as they were") - func noTermsKeepsOrder() { - let items = (0..<5).map { Self.item(["id": .string("\($0)")]) } - #expect(Self.ids(DynamoDBItemTable.sorted(items, by: [])) == ["0", "1", "2", "3", "4"]) - } - - @Test("A column mixing Numbers and Strings sorts the same whatever order the items arrive in") - func mixedTypesSortConsistently() { - let items = [ - Self.item(["id": .string("nine"), "v": .number("9")]), - Self.item(["id": .string("ten"), "v": .number("10")]), - Self.item(["id": .string("text"), "v": .string("5a")]) - ] - let order = [DynamoDBOrderTerm(attribute: "v", descending: false)] - let permutations = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] - let results = permutations.map { permutation in - Self.ids(DynamoDBItemTable.sorted(permutation.map { items[$0] }, by: order)) - } - #expect(Set(results).count == 1, "orders seen: \(results)") - let first = results.first ?? [] - let nine = first.firstIndex(of: "nine") ?? -1 - let ten = first.firstIndex(of: "ten") ?? -1 - #expect(nine < ten) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBJSONTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBJSONTests.swift deleted file mode 100644 index 1ea13b7f36..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBJSONTests.swift +++ /dev/null @@ -1,255 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBJSONTests { - struct NumberCase: Sendable, CustomTestStringConvertible { - let literal: String - var testDescription: String { literal } - } - - struct MalformedCase: Sendable, CustomTestStringConvertible { - let name: String - let text: String - var testDescription: String { name } - } - - @Test("An object with nested arrays, literals and strings parses into the matching tree") - func parsesObject() throws { - let json = try DynamoDBJSON.parse(#"{ "a" : 1, "b" : [true, false, null, "x"], "c" : {} }"#) - #expect(json == .object([ - "a": .number("1"), - "b": .array([.bool(true), .bool(false), .null, .string("x")]), - "c": .object([:]) - ])) - } - - @Test("A top-level array and an empty array parse") - func parsesArrays() throws { - #expect(try DynamoDBJSON.parse("[]") == .array([])) - #expect(try DynamoDBJSON.parse(" [ [1], [\"a\"] ] ") == .array([.array([.number("1")]), .array([.string("a")])])) - } - - @Test("String escapes decode to the characters they name") - func decodesEscapes() throws { - let json = try DynamoDBJSON.parse(#""line\nbreak \"quoted\" back\\slash \/ tab\t café""#) - #expect(json == .string("line\nbreak \"quoted\" back\\slash / tab\t caf\u{00E9}")) - } - - @Test("A surrogate pair escape decodes to one scalar outside the basic plane") - func decodesSurrogatePair() throws { - let json = try DynamoDBJSON.parse(#""😀""#) - #expect(json == .string("\u{1F600}")) - #expect(json.stringValue?.unicodeScalars.count == 1) - } - - @Test("A lone surrogate escape is rejected", arguments: [#""\ud83d""#, #""\ud83dx""#, #""\ude00""#]) - func rejectsLoneSurrogate(text: String) { - #expect(throws: DynamoDBJSON.ParseError.self) { try DynamoDBJSON.parse(text) } - } - - @Test("An unknown escape and a raw control character are rejected", arguments: [#""\x""#, "\"a\u{01}b\""]) - func rejectsBadStringContent(text: String) { - #expect(throws: DynamoDBJSON.ParseError.self) { try DynamoDBJSON.parse(text) } - } - - @Test( - "A number keeps the exact text it was written with", - arguments: [ - NumberCase(literal: "12345678901234567890123456789012345678"), - NumberCase(literal: "1.50"), - NumberCase(literal: "-0"), - NumberCase(literal: "1e-130"), - NumberCase(literal: "9.9999999999999999999999999999999999999E+125"), - NumberCase(literal: "0.000") - ] - ) - func keepsNumberText(number: NumberCase) throws { - #expect(try DynamoDBJSON.parse(number.literal) == .number(number.literal)) - let wrapped = try DynamoDBJSON.parse("{\"n\":\(number.literal)}") - #expect(wrapped["n"]?.numberText == number.literal) - } - - @Test("Text that is not a JSON number is rejected", arguments: ["01", "+1", ".5", "1.", "1e", "-", "NaN", "0x10"]) - func rejectsNonJSONNumbers(text: String) { - #expect(throws: DynamoDBJSON.ParseError.self) { try DynamoDBJSON.parse(text) } - } - - @Test("A key that appears twice in one object is rejected") - func rejectsDuplicateKey() { - #expect(throws: DynamoDBJSON.ParseError.duplicateKey("a")) { - try DynamoDBJSON.parse(#"{"a": 1, "b": 2, "a": 3}"#) - } - } - - @Test("The same key in two different objects is not a duplicate") - func allowsKeyInSiblingObjects() throws { - let json = try DynamoDBJSON.parse(#"[{"a": 1}, {"a": 2}]"#) - #expect(json == .array([.object(["a": .number("1")]), .object(["a": .number("2")])])) - } - - @Test("Text after the document is rejected with its position") - func rejectsTrailingText() { - #expect(throws: DynamoDBJSON.ParseError.trailingText(offset: 8)) { - try DynamoDBJSON.parse(#"{"a":1} x"#) - } - #expect(throws: DynamoDBJSON.ParseError.trailingText(offset: 3)) { - try DynamoDBJSON.parse("[1][2]") - } - } - - @Test("Whitespace around the document is allowed") - func allowsSurroundingWhitespace() throws { - #expect(try DynamoDBJSON.parse("\n\t {\"a\": true} \r\n") == .object(["a": .bool(true)])) - } - - @Test( - "Malformed documents are rejected", - arguments: [ - MalformedCase(name: "empty", text: ""), - MalformedCase(name: "unclosed object", text: #"{"a": 1"#), - MalformedCase(name: "unclosed array", text: "[1, 2"), - MalformedCase(name: "unclosed string", text: #""abc"#), - MalformedCase(name: "unquoted key", text: "{a: 1}"), - MalformedCase(name: "trailing comma", text: "[1, 2,]"), - MalformedCase(name: "misspelled literal", text: "tru"), - MalformedCase(name: "missing colon", text: #"{"a" 1}"#) - ] - ) - func rejectsMalformed(malformed: MalformedCase) { - #expect(throws: DynamoDBJSON.ParseError.self) { try DynamoDBJSON.parse(malformed.text) } - } - - @Test("Data that is not UTF-8 is rejected") - func rejectsInvalidUTF8() { - #expect(throws: DynamoDBJSON.ParseError.self) { try DynamoDBJSON.parse(Data([0x22, 0xFF, 0xFE, 0x22])) } - } - - @Test("A document nested 32 levels deep parses") - func parsesDynamoDBNestingDepth() throws { - let text = String(repeating: #"{"a":"#, count: 32) + "1" + String(repeating: "}", count: 32) - var json = try DynamoDBJSON.parse(text) - for _ in 0..<32 { - json = try #require(json["a"]) - } - #expect(json == .number("1")) - } - - @Test("A document nested far past the limit is rejected as too deep") - func rejectsExcessiveNesting() { - let text = String(repeating: "[", count: 200) + String(repeating: "]", count: 200) - #expect(throws: DynamoDBJSON.ParseError.tooDeep) { try DynamoDBJSON.parse(text) } - } - - @Test("A Scan page holding an item nested as deeply as DynamoDB allows parses") - func parsesResponseAtDynamoDBNestingLimit() throws { - var attribute = #"{"S":"leaf"}"# - for _ in 0..<31 { - attribute = #"{"M":{"child":"# + attribute + "}}" - } - let response = #"{"Count":1,"Items":[{"pk":{"S":"a"},"deep":"# + attribute + "}]}" - let json = try DynamoDBJSON.parse(response) - let items = try #require(json["Items"]?.arrayValue) - let item = try DynamoDBItem(wireItem: try #require(items.first)) - var value = try #require(item["deep"]) - for _ in 0..<31 { - guard case .map(let entries) = value else { - Issue.record("Expected a map, got \(value)") - return - } - value = try #require(entries["child"]) - } - #expect(value == .string("leaf")) - } - - @Test("parsePrefix returns the value and the text after it") - func parsePrefixReturnsRemainder() throws { - let result = try DynamoDBJSON.parsePrefix(#" {"a": [1, 2]} WHERE "pk" = 'x'"#) - #expect(result.value == .object(["a": .array([.number("1"), .number("2")])])) - #expect(result.remainder == #" WHERE "pk" = 'x'"#) - } - - @Test("parsePrefix of a complete document leaves no remainder") - func parsePrefixOfWholeDocument() throws { - let result = try DynamoDBJSON.parsePrefix("[1]") - #expect(result.value == .array([.number("1")])) - #expect(result.remainder.isEmpty) - } - - @Test("parsePrefix keeps text that follows directly after the value") - func parsePrefixKeepsAdjacentText() throws { - let result = try DynamoDBJSON.parsePrefix(#""café"rest"#) - #expect(result.value == .string("caf\u{00E9}")) - #expect(result.remainder == "rest") - } - - @Test("parsePrefix rejects text that does not start with JSON") - func parsePrefixRejectsNonJSON() { - #expect(throws: DynamoDBJSON.ParseError.self) { try DynamoDBJSON.parsePrefix("WHERE x = 1") } - } - - @Test("Serialized output sorts object keys at every level") - func serializedSortsKeys() { - let json = DynamoDBJSON.object([ - "b": .number("2"), - "a": .object(["z": .bool(true), "y": .null]), - "c": .array([.string("x")]) - ]) - #expect(json.serialized() == #"{"a":{"y":null,"z":true},"b":2,"c":["x"]}"#) - } - - @Test("Serialized output writes numbers exactly as they were given") - func serializedWritesNumbersVerbatim() { - let json = DynamoDBJSON.array([ - .number("12345678901234567890123456789012345678"), - .number("1.50"), - .number("-0"), - .number("1e-130"), - .number("1E+125") - ]) - #expect(json.serialized() == "[12345678901234567890123456789012345678,1.50,-0,1e-130,1E+125]") - } - - @Test("Serialized output escapes quotes, backslashes and control characters") - func serializedEscapesStrings() { - let json = DynamoDBJSON.string("a\"b\\c\nd\u{01}") - #expect(json.serialized() == #""a\"b\\c\nd\u0001""#) - } - - @Test("Serialized output parses back to the same value") - func serializedRoundTrips() throws { - let json = Self.sample - #expect(try DynamoDBJSON.parse(json.serialized()) == json) - #expect(try DynamoDBJSON.parse(json.serializedData) == json) - } - - @Test("Pretty output spans several lines and parses back to the same value") - func prettyOutputRoundTrips() throws { - let json = Self.sample - let pretty = json.serialized(pretty: true) - #expect(pretty.contains("\n")) - #expect(try DynamoDBJSON.parse(pretty) == json) - } - - @Test("Accessors return the payload of their own case only") - func accessorsMatchTheirCase() { - #expect(DynamoDBJSON.number("42").intValue == 42) - #expect(DynamoDBJSON.number("1.5").doubleValue == 1.5) - #expect(DynamoDBJSON.string("42").intValue == nil) - #expect(DynamoDBJSON.string("x").numberText == nil) - #expect(DynamoDBJSON.bool(true).boolValue == true) - #expect(DynamoDBJSON.null.boolValue == nil) - #expect(DynamoDBJSON.array([]).objectValue == nil) - #expect(DynamoDBJSON.array([.null])["key"] == nil) - } - - private static let sample = DynamoDBJSON.object([ - "text": .string("quote \" backslash \\ newline \n caf\u{00E9} \u{1F600}"), - "big": .number("12345678901234567890123456789012345678"), - "scaled": .number("1.50"), - "tiny": .number("1e-130"), - "negative zero": .number("-0"), - "flags": .array([.bool(true), .bool(false), .null]), - "nested": .object(["empty object": .object([:]), "empty array": .array([])]) - ]) -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBLocalIntegrationTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBLocalIntegrationTests.swift deleted file mode 100644 index 089a04f841..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBLocalIntegrationTests.swift +++ /dev/null @@ -1,367 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -enum DynamoDBTestLocal { - static let host = "127.0.0.1" - static let port = 18_000 - - /// A socket probe rather than an environment variable: `xcodebuild` does not pass the shell's - /// environment to the test host, so a variable would read as unset on every run. - static let isReachable: Bool = { - let descriptor = socket(AF_INET, SOCK_STREAM, 0) - guard descriptor >= 0 else { return false } - defer { close(descriptor) } - var timeout = timeval(tv_sec: 1, tv_usec: 0) - setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.size) - address.sin_family = sa_family_t(AF_INET) - address.sin_port = UInt16(port).bigEndian - guard inet_pton(AF_INET, host, &address.sin_addr) == 1 else { return false } - let result = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(descriptor, $0, socklen_t(MemoryLayout.size)) } - } - return result == 0 - }() - - static func connectedDriver() async throws -> DynamoDBPluginDriver { - let config = DriverConnectionConfig( - host: "", port: 0, username: "", password: "", database: "", - additionalFields: [ - "awsAuthMethod": "local", - "awsEndpointUrl": "http://\(host):\(port)", - "awsRegion": "us-east-1" - ] - ) - let driver = DynamoDBPluginDriver(config: config, catalog: DynamoDBCatalog()) - try await driver.connect() - return driver - } - - static func uniqueTableName(_ prefix: String) -> String { - "tp_it_\(prefix)_\(UUID().uuidString.prefix(8))" - } -} - -/// Orders across three customers, keyed by customer and order number, with a global index on -/// status and total. Order numbers divisible by four are FAILED. -private struct OrdersFixture { - static let columns = ["customer", "orderId", "status", "total", "zip", "doc", "tags", "bin", "big"] - static let keys = ["customer", "orderId"] - - let driver: DynamoDBPluginDriver - let table: String - - static func make() async throws -> OrdersFixture { - let driver = try await DynamoDBTestLocal.connectedDriver() - let table = DynamoDBTestLocal.uniqueTableName("orders") - let form = PluginCreateTableRequest( - tableName: table, - values: [ - "partitionKeyName": "customer", "partitionKeyType": "S", - "sortKeyName": "orderId", "sortKeyType": "N", - "billingMode": "PAY_PER_REQUEST", "tableClass": "STANDARD", "deletionProtection": "false" - ], - repeatedValues: ["globalIndexes": [[ - "indexName": "byStatus", "partitionKeyName": "status", "partitionKeyType": "S", - "sortKeyName": "total", "sortKeyType": "N", "projection": "ALL" - ]]] - ) - for statement in try driver.createTableStatements(for: form, schema: nil) { - _ = try await driver.execute(query: statement) - } - let fixture = OrdersFixture(driver: driver, table: table) - try await fixture.seed() - return fixture - } - - private func seed() async throws { - for customer in ["c1", "c2", "c3"] { - for order in 0..<40 { - let row: [PluginCellValue] = [ - .text(customer), .text(String(order)), .text(order % 4 == 0 ? "FAILED" : "PAID"), - .text(String(order * 10)), .text("0213\(order % 10)"), .text(#"{"a":{"b":[1,2]}}"#), - .text(#"["x","y"]"#), .bytes(Data([0xDE, 0xAD, UInt8(order)])), - .text("12345678901234567890123456789012345678") - ] - let insert = try #require(driver.generateIdentityPreservingInsert( - table: table, schema: nil, columns: Self.columns, primaryKeyColumns: Self.keys, rows: [row] - )?.first) - _ = try await driver.executeParameterized(query: insert.statement, parameters: insert.parameters) - } - } - } - - func tearDown() async { - _ = try? await driver.execute(query: #"DeleteTable {"TableName": "\#(table)"}"#) - driver.disconnect() - } - - func browse( - _ filters: [PluginQueryFilter], - logic: String = "and", - sort: [(columnIndex: Int, ascending: Bool)] = [], - columns: [String] = [], - limit: Int = 1_000, - offset: Int = 0, - kinds: [String: PluginColumnKind] = [:] - ) async throws -> PluginQueryResult { - let statement = try #require(driver.buildFilteredQuery( - table: table, schema: nil, queryFilters: filters, logicMode: logic, sortColumns: sort, - columns: columns, limit: limit, offset: offset, columnKinds: kinds - )) - return try #require(try await driver.executeBoundedQuery(query: statement, rowCap: 10_000)) - } - - func item(_ customer: String, _ order: Int) async throws -> [String: PluginCellValue] { - let result = try await driver.execute(query: #"GetItem {"TableName": "\#(table)", "Key": {"customer": {"S": "\#(customer)"}, "orderId": {"N": "\#(order)"}}}"#) - return Dictionary(uniqueKeysWithValues: zip(result.columns, result.rows.first ?? [])) - } -} - -private func text(_ cell: PluginCellValue?) -> String? { - guard case .text(let value) = cell else { return nil } - return value -} - -@Suite("DynamoDB Local integration", .enabled(if: DynamoDBTestLocal.isReachable), .serialized) -struct DynamoDBLocalIntegrationTests { - @Test("A grid insert keeps a zip code a String and a 38-digit value exact") - func insertKeepsTypes() async throws { - let fixture = try await OrdersFixture.make() - let row = try await fixture.item("c1", 4) - await fixture.tearDown() - #expect(text(row["zip"]) == "02134") - #expect(text(row["big"]) == "12345678901234567890123456789012345678") - #expect(row["bin"] == .bytes(Data([0xDE, 0xAD, 4]))) - } - - @Test("Pages cover every item exactly once without re-reading earlier pages") - func pagesCoverEveryItem() async throws { - let fixture = try await OrdersFixture.make() - var keys = Set() - var columns: [String] = [] - for page in 0..<3 { - let result = try await fixture.browse([], columns: columns, limit: 50, offset: page * 50) - columns = result.columns - for row in result.rows { keys.insert("\(row[0])|\(row[1])") } - } - await fixture.tearDown() - #expect(keys.count == 120) - #expect(Array(columns.prefix(2)) == ["customer", "orderId"]) - } - - @Test("A partition key with a sort key range becomes a Query sorted by the sort key") - func queryOnTableKeys() async throws { - let fixture = try await OrdersFixture.make() - let result = try await fixture.browse( - [PluginQueryFilter(column: "customer", op: "=", value: "c2"), PluginQueryFilter(column: "orderId", op: ">=", value: "30")], - sort: [(columnIndex: 1, ascending: false)], - columns: ["customer", "orderId"] - ) - await fixture.tearDown() - #expect(result.rows.compactMap { text($0[1]) } == (30...39).reversed().map(String.init)) - #expect(result.statusMessage?.contains("Query on the table") == true) - } - - @Test("A filter on a global index key queries that index") - func queryOnGlobalIndex() async throws { - let fixture = try await OrdersFixture.make() - let result = try await fixture.browse([ - PluginQueryFilter(column: "status", op: "=", value: "FAILED"), - PluginQueryFilter(column: "total", op: "BETWEEN", value: "100,200", secondValue: "200", elementScope: nil) - ]) - await fixture.tearDown() - #expect(result.rows.count == 9) - #expect(result.statusMessage?.contains("byStatus") == true) - } - - @Test("An OR that names a partition key still returns matches from other partitions") - func orKeepsOtherPartitions() async throws { - let fixture = try await OrdersFixture.make() - let result = try await fixture.browse( - [PluginQueryFilter(column: "customer", op: "=", value: "c1"), PluginQueryFilter(column: "status", op: "=", value: "FAILED")], - logic: "or" - ) - await fixture.tearDown() - #expect(result.rows.count == 60) - } - - struct OperatorCase: Sendable, CustomStringConvertible { - let op: String - let value: String - let expected: Int - var description: String { op } - } - - @Test("Every filter-bar operator matches what it names", arguments: [ - OperatorCase(op: "IS NULL", value: "", expected: 0), - OperatorCase(op: "IS NOT NULL", value: "", expected: 120), - OperatorCase(op: "IN", value: "FAILED, PAID", expected: 120), - OperatorCase(op: "NOT IN", value: "FAILED", expected: 90), - OperatorCase(op: "CONTAINS", value: "AIL", expected: 30), - OperatorCase(op: "NOT CONTAINS", value: "AIL", expected: 90), - OperatorCase(op: "STARTS WITH", value: "PA", expected: 90), - OperatorCase(op: "ENDS WITH", value: "ED", expected: 30), - OperatorCase(op: "REGEX", value: "^F.*D$", expected: 30), - OperatorCase(op: "!=", value: "PAID", expected: 30) - ]) - func operators(_ testCase: OperatorCase) async throws { - let fixture = try await OrdersFixture.make() - let result = try await fixture.browse([PluginQueryFilter(column: "status", op: testCase.op, value: testCase.value)]) - await fixture.tearDown() - #expect(result.rows.count == testCase.expected) - } - - @Test("Filtered pages fill up and cover every match") - func filteredPagesFill() async throws { - let fixture = try await OrdersFixture.make() - var keys = Set() - for page in 0..<5 { - let result = try await fixture.browse( - [PluginQueryFilter(column: "status", op: "=", value: "FAILED")], limit: 7, offset: page * 7 - ) - for row in result.rows { keys.insert("\(row[0])|\(row[1])") } - } - let exact = try await fixture.driver.fetchExactRowCount( - table: fixture.table, schema: nil, - queryFilters: [PluginQueryFilter(column: "status", op: "=", value: "FAILED")], logicMode: "and" - ) - await fixture.tearDown() - #expect(keys.count == 30) - #expect(exact == 30) - } - - @Test("An edit keeps each attribute's type, removes a cleared one, and refuses a stale edit") - func editKeepsTypes() async throws { - let fixture = try await OrdersFixture.make() - _ = try await fixture.driver.execute(query: #"UpdateItem {"TableName": "\#(fixture.table)", "Key": {"customer": {"S": "c1"}, "orderId": {"N": "4"}}, "UpdateExpression": "SET tags = :ss", "ExpressionAttributeValues": {":ss": {"SS": ["x", "y"]}}}"#) - let loaded = try await fixture.browse( - [PluginQueryFilter(column: "customer", op: "=", value: "c1"), PluginQueryFilter(column: "orderId", op: "=", value: "4")] - ) - let row = try #require(loaded.rows.first) - let index = { (name: String) in loaded.columns.firstIndex(of: name) ?? 0 } - let change = PluginRowChange( - rowIndex: 0, type: .update, - cellChanges: [ - (columnIndex: index("total"), columnName: "total", oldValue: row[index("total")], newValue: .text("999")), - (columnIndex: index("tags"), columnName: "tags", oldValue: row[index("tags")], newValue: .text(#"["x","y","z"]"#)), - (columnIndex: index("zip"), columnName: "zip", oldValue: row[index("zip")], newValue: .null) - ], - originalRow: row - ) - let statements = try #require(fixture.driver.generateStatements( - table: fixture.table, columns: loaded.columns, primaryKeyColumns: OrdersFixture.keys, changes: [change], - insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - )) - for statement in statements { - _ = try await fixture.driver.executeParameterized(query: statement.statement, parameters: statement.parameters) - } - let after = try await fixture.driver.execute(query: #"GetItem {"TableName": "\#(fixture.table)", "Key": {"customer": {"S": "c1"}, "orderId": {"N": "4"}}}"#) - var staleError: Error? - do { - for statement in statements { - _ = try await fixture.driver.executeParameterized(query: statement.statement, parameters: statement.parameters) - } - } catch { - staleError = error - } - await fixture.tearDown() - let types = Dictionary(uniqueKeysWithValues: zip(after.columns, after.columnTypeNames)) - #expect(types["total"] == "Number") - #expect(types["tags"] == "String Set") - #expect(!after.columns.contains("zip")) - #expect(staleError?.localizedDescription.contains("changed after it was loaded") == true) - } - - @Test("A delete reports one item, then none") - func deleteReportsCount() async throws { - let fixture = try await OrdersFixture.make() - let loaded = try await fixture.browse( - [PluginQueryFilter(column: "customer", op: "=", value: "c3"), PluginQueryFilter(column: "orderId", op: "=", value: "7")] - ) - let original = try #require(loaded.rows.first) - let change = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: original) - let statement = try #require(fixture.driver.generateStatements( - table: fixture.table, columns: loaded.columns, primaryKeyColumns: OrdersFixture.keys, changes: [change], - insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] - )?.first) - let first = try await fixture.driver.executeParameterized(query: statement.statement, parameters: statement.parameters) - let second = try await fixture.driver.executeParameterized(query: statement.statement, parameters: statement.parameters) - await fixture.tearDown() - #expect(first.rowsAffected == 1) - #expect(second.rowsAffected == 0) - } - - @Test("A stream carries an attribute that only the last item has") - func streamKeepsLateAttributes() async throws { - let fixture = try await OrdersFixture.make() - _ = try await fixture.driver.execute(query: #"UpdateItem {"TableName": "\#(fixture.table)", "Key": {"customer": {"S": "c3"}, "orderId": {"N": "39"}}, "UpdateExpression": "SET late = :v", "ExpressionAttributeValues": {":v": {"S": "only here"}}}"#) - var columns: [String] = [] - var rows = 0 - for try await element in fixture.driver.streamRows(query: try #require(fixture.driver.defaultExportQuery(table: fixture.table))) { - switch element { - case .header(let header): columns = header.columns - case .rows(let batch): rows += batch.count - } - } - await fixture.tearDown() - #expect(columns.contains("late")) - #expect(rows == 120) - } - - @Test("PartiQL reads every page, honours LIMIT, and sorts a complete result") - func partiQLReads() async throws { - let fixture = try await OrdersFixture.make() - let limited = try await fixture.driver.executeBoundedQuery( - query: #"SELECT * FROM "\#(fixture.table)" WHERE status = 'FAILED' LIMIT 5"#, rowCap: 1_000 - ) - let projected = try await fixture.driver.executeBoundedQuery( - query: #"SELECT customer FROM "\#(fixture.table)" WHERE status = 'FAILED'"#, rowCap: 1_000 - ) - let sorted = try await fixture.driver.executeBoundedQuery( - query: #"SELECT * FROM "\#(fixture.table)" WHERE customer = 'c2' ORDER BY "total" DESC"#, rowCap: 1_000 - ) - await fixture.tearDown() - #expect(limited?.rows.count == 5) - #expect(projected?.rows.count == 30) - #expect(projected?.columns == ["customer"]) - let totalIndex = try #require(sorted?.columns.firstIndex(of: "total")) - let totals = (sorted?.rows ?? []).compactMap { text($0[totalIndex]).flatMap(Int.init) } - #expect(totals.count == 40) - #expect(totals == totals.sorted(by: >)) - } - - @Test("Structure: executable DDL, indexes, nested paths, and a new global index") - func structure() async throws { - let fixture = try await OrdersFixture.make() - let ddl = try await fixture.driver.fetchTableDDL(table: fixture.table, schema: nil) - let indexes = try await fixture.driver.fetchIndexes(table: fixture.table, schema: nil) - let paths = try await fixture.driver.sampleFieldPaths(table: fixture.table, schema: nil, limit: 20) - let addIndex = try #require(fixture.driver.generateAddIndexSQL( - table: fixture.table, index: PluginIndexDefinition(name: "byZip", columns: ["zip"], isUnique: false) - )) - _ = try await fixture.driver.execute(query: addIndex) - let afterAdd = try await fixture.driver.fetchIndexes(table: fixture.table, schema: nil) - await fixture.tearDown() - #expect(ddl.hasPrefix("CreateTable {")) - #expect(indexes.map(\.name) == ["PRIMARY", "byStatus"]) - #expect(paths.contains { $0.path == "doc.a.b" }) - #expect(afterAdd.contains { $0.name == "byZip" }) - } - - @Test("A batch with one failing statement reports the failure, not success") - func batchReportsPartialFailure() async throws { - let fixture = try await OrdersFixture.make() - let statement = #"BatchExecuteStatement {"Statements": [{"Statement": "INSERT INTO \"\#(fixture.table)\" VALUE {'customer': 'c9', 'orderId': 1}"}, {"Statement": "INSERT INTO \"\#(fixture.table)\" VALUE {'customer': 'c1', 'orderId': 1}"}]}"# - var failure: Error? - do { - _ = try await fixture.driver.execute(query: statement) - } catch { - failure = error - } - await fixture.tearDown() - #expect(failure?.localizedDescription.contains("1 of 2") == true) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBNumberTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBNumberTests.swift deleted file mode 100644 index 16967685cf..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBNumberTests.swift +++ /dev/null @@ -1,169 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBNumberTests { - struct PartsCase: Sendable, CustomTestStringConvertible { - let text: String - let isNegative: Bool - let digits: [UInt8] - let leadingExponent: Int - var testDescription: String { text } - } - - struct ComparisonCase: Sendable, CustomTestStringConvertible { - let lhs: String - let rhs: String - let expected: ComparisonResult - var testDescription: String { "\(lhs) vs \(rhs)" } - } - - private static let largestNumber = "9." + String(repeating: "9", count: 37) + "E+125" - - @Test("Thirty-eight significant digits are valid and thirty-nine are not") - func significantDigitLimit() { - let thirtyEight = "12345678901234567890123456789012345678" - let thirtyNine = thirtyEight + "9" - #expect(DynamoDBNumber.isValid(thirtyEight)) - #expect(!DynamoDBNumber.isValid(thirtyNine)) - #expect(!DynamoDBNumber.isValid("-" + thirtyNine)) - #expect(!DynamoDBNumber.isValid("0." + thirtyNine)) - } - - @Test("Leading and trailing zeros are not significant digits") - func zerosAreNotSignificant() { - let thirtyEight = "12345678901234567890123456789012345678" - #expect(DynamoDBNumber.isValid(thirtyEight + "000")) - #expect(DynamoDBNumber.isValid("000" + thirtyEight)) - #expect(DynamoDBNumber.isValid("0.000" + thirtyEight)) - #expect(DynamoDBNumber.isValid("1.2345678901234567890123456789012345678000")) - } - - @Test("The magnitude limits are 1E-130 and below 1E+126") - func magnitudeLimits() { - #expect(DynamoDBNumber.isValid(Self.largestNumber)) - #expect(DynamoDBNumber.isValid("-" + Self.largestNumber)) - #expect(!DynamoDBNumber.isValid("1E+126")) - #expect(!DynamoDBNumber.isValid("-1E+126")) - #expect(!DynamoDBNumber.isValid("10E+125")) - #expect(DynamoDBNumber.isValid("1E-130")) - #expect(DynamoDBNumber.isValid("-1E-130")) - #expect(DynamoDBNumber.isValid("1.5E-130")) - #expect(!DynamoDBNumber.isValid("1E-131")) - #expect(!DynamoDBNumber.isValid("0.1E-130")) - #expect(DynamoDBNumber.isValid("0.0001e129")) - #expect(!DynamoDBNumber.isValid("0.0001e130")) - } - - @Test( - "Forms DynamoDB accepts are valid", - arguments: ["0", "-0.000", ".5", "5.", "+5", "007.50e1", "-12.5", "1e2", "1E+2", "2e-3", " 42 "] - ) - func acceptsNumberForms(text: String) { - #expect(DynamoDBNumber.isValid(text)) - } - - @Test( - "Text that is not a number is invalid", - arguments: ["", " ", "abc", "1e", "e5", "--1", "+-1", "NaN", "Infinity", ".", "1.2.3", "1,5", "0x10", "1e+-5", "1"] - ) - func rejectsNonNumbers(text: String) { - #expect(!DynamoDBNumber.isValid(text)) - #expect(DynamoDBNumber.parts(of: text) == nil) - } - - @Test("An exponent at the edge of Int is rejected rather than overflowing") - func extremeExponentIsInvalid() { - #expect(!DynamoDBNumber.isValid("10e9223372036854775807")) - #expect(!DynamoDBNumber.isValid("0.01e-9223372036854775808")) - #expect(!DynamoDBNumber.isValid("1e99999999999999999999")) - } - - @Test( - "parts splits the sign, the significant digits and the power of ten of the first digit", - arguments: [ - PartsCase(text: "0", isNegative: false, digits: [], leadingExponent: 0), - PartsCase(text: "-0.000", isNegative: false, digits: [], leadingExponent: 0), - PartsCase(text: "-12.340", isNegative: true, digits: [1, 2, 3, 4], leadingExponent: 1), - PartsCase(text: "0.00123", isNegative: false, digits: [1, 2, 3], leadingExponent: -3), - PartsCase(text: "007.50e1", isNegative: false, digits: [7, 5], leadingExponent: 1), - PartsCase(text: ".5", isNegative: false, digits: [5], leadingExponent: -1), - PartsCase(text: "5.", isNegative: false, digits: [5], leadingExponent: 0), - PartsCase(text: "+5", isNegative: false, digits: [5], leadingExponent: 0), - PartsCase(text: "1E-130", isNegative: false, digits: [1], leadingExponent: -130), - PartsCase(text: "12000", isNegative: false, digits: [1, 2], leadingExponent: 4) - ] - ) - func splitsParts(expected: PartsCase) { - #expect( - DynamoDBNumber.parts(of: expected.text) - == DynamoDBNumber.Parts( - isNegative: expected.isNegative, - significantDigits: expected.digits, - leadingExponent: expected.leadingExponent - ) - ) - } - - @Test( - "compare orders by numeric value", - arguments: [ - ComparisonCase(lhs: "-5", rhs: "3", expected: .orderedAscending), - ComparisonCase(lhs: "3", rhs: "-5", expected: .orderedDescending), - ComparisonCase(lhs: "-1", rhs: "0", expected: .orderedAscending), - ComparisonCase(lhs: "0", rhs: "1e-130", expected: .orderedAscending), - ComparisonCase(lhs: "-1e-130", rhs: "0", expected: .orderedAscending), - ComparisonCase(lhs: "1e2", rhs: "99", expected: .orderedDescending), - ComparisonCase(lhs: "9.9e1", rhs: "1e2", expected: .orderedAscending), - ComparisonCase(lhs: "-1e2", rhs: "-99", expected: .orderedAscending), - ComparisonCase(lhs: "0.001", rhs: "0.01", expected: .orderedAscending), - ComparisonCase(lhs: "9", rhs: "10", expected: .orderedAscending), - ComparisonCase(lhs: "1.23", rhs: "1.2", expected: .orderedDescending), - ComparisonCase(lhs: "-1.23", rhs: "-1.2", expected: .orderedAscending), - ComparisonCase( - lhs: "12345678901234567890123456789012345678", - rhs: "12345678901234567890123456789012345677", - expected: .orderedDescending - ), - ComparisonCase(lhs: "1.0", rhs: "1", expected: .orderedSame), - ComparisonCase(lhs: "1e2", rhs: "100", expected: .orderedSame), - ComparisonCase(lhs: "100", rhs: "1E+2", expected: .orderedSame), - ComparisonCase(lhs: "-0", rhs: "0.000", expected: .orderedSame), - ComparisonCase(lhs: "0.5", rhs: ".5", expected: .orderedSame), - ComparisonCase(lhs: "+5", rhs: "5.", expected: .orderedSame), - ComparisonCase(lhs: "007.50e1", rhs: "75", expected: .orderedSame) - ] - ) - func comparesNumerically(comparison: ComparisonCase) { - #expect(DynamoDBNumber.compare(comparison.lhs, comparison.rhs) == comparison.expected) - } - - @Test( - "compare is antisymmetric", - arguments: [ - ComparisonCase(lhs: "-5", rhs: "3", expected: .orderedAscending), - ComparisonCase(lhs: "1e2", rhs: "99", expected: .orderedDescending), - ComparisonCase(lhs: "1.0", rhs: "1", expected: .orderedSame) - ] - ) - func compareIsAntisymmetric(comparison: ComparisonCase) { - let forward = DynamoDBNumber.compare(comparison.lhs, comparison.rhs) - let backward = DynamoDBNumber.compare(comparison.rhs, comparison.lhs) - #expect(forward.rawValue == -backward.rawValue) - } - - @Test("areEqual treats different spellings of one value as equal") - func areEqualIgnoresSpelling() { - #expect(DynamoDBNumber.areEqual("1.0", "1")) - #expect(DynamoDBNumber.areEqual("1e2", "100")) - #expect(DynamoDBNumber.areEqual("-0", "0")) - #expect(!DynamoDBNumber.areEqual("1", "1.0000000000000000000000000000000000001")) - } - - @Test("Text that is not a number compares as text") - func unparseableComparesAsText() { - #expect(DynamoDBNumber.compare("abc", "abd") == .orderedAscending) - #expect(DynamoDBNumber.compare("abc", "abc") == .orderedSame) - #expect(DynamoDBNumber.compare("b", "a") == .orderedDescending) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBParameterBinderTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBParameterBinderTests.swift deleted file mode 100644 index bc24312920..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBParameterBinderTests.swift +++ /dev/null @@ -1,242 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBParameterBinderTests { - struct KeyCase: Sendable, CustomTestStringConvertible { - let role: DynamoDBPartiQL.ParameterRole - let cell: PluginCellValue - let expected: DynamoDBAttributeValue - - var testDescription: String { "\(role) \(cell)" } - } - - struct MissingKeyCase: Sendable, CustomTestStringConvertible { - let role: DynamoDBPartiQL.ParameterRole - let cell: PluginCellValue - - var testDescription: String { "\(role) \(cell)" } - } - - static let digest = Data([0x00, 0x01, 0x02]) - - static let keyCases: [KeyCase] = [ - KeyCase(role: .compared(DynamoDBAttributePath(attribute: "sk")), cell: "7", expected: .number("7")), - KeyCase(role: .compared(DynamoDBAttributePath(attribute: "sk")), cell: " -1.5E3 ", expected: .number("-1.5E3")), - KeyCase(role: .inserted("sk"), cell: "42", expected: .number("42")), - KeyCase(role: .compared(DynamoDBAttributePath(attribute: "pk")), cell: "7", expected: .string("7")), - KeyCase(role: .inserted("pk"), cell: "02134", expected: .string("02134")), - KeyCase(role: .compared(DynamoDBAttributePath(attribute: "rank")), cell: "3", expected: .number("3")), - KeyCase(role: .compared(DynamoDBAttributePath(attribute: "digest")), cell: .bytes(digest), expected: .binary(digest)), - KeyCase(role: .compared(DynamoDBAttributePath(attribute: "digest")), cell: "AAEC", expected: .binary(digest)), - KeyCase(role: .inserted("digest"), cell: .bytes(digest), expected: .binary(digest)) - ] - - static let missingKeyCases: [MissingKeyCase] = [ - MissingKeyCase(role: .compared(DynamoDBAttributePath(attribute: "pk")), cell: .null), - MissingKeyCase(role: .compared(DynamoDBAttributePath(attribute: "pk")), cell: ""), - MissingKeyCase(role: .compared(DynamoDBAttributePath(attribute: "pk")), cell: "__DEFAULT__"), - MissingKeyCase(role: .inserted("pk"), cell: .null), - MissingKeyCase(role: .inserted("pk"), cell: ""), - MissingKeyCase(role: .inserted("sk"), cell: "__DEFAULT__"), - MissingKeyCase(role: .inserted("rank"), cell: "") - ] - - private static func schema() throws -> DynamoDBTableSchema { - try DynamoDBTableSchema(describeTableResponse: DynamoDBJSON.parse(""" - {"Table": { - "TableName": "Orders", - "KeySchema": [ - {"AttributeName": "pk", "KeyType": "HASH"}, - {"AttributeName": "sk", "KeyType": "RANGE"} - ], - "AttributeDefinitions": [ - {"AttributeName": "pk", "AttributeType": "S"}, - {"AttributeName": "sk", "AttributeType": "N"}, - {"AttributeName": "digest", "AttributeType": "B"}, - {"AttributeName": "rank", "AttributeType": "N"} - ], - "GlobalSecondaryIndexes": [{ - "IndexName": "ByDigest", - "KeySchema": [ - {"AttributeName": "digest", "KeyType": "HASH"}, - {"AttributeName": "rank", "KeyType": "RANGE"} - ], - "Projection": {"ProjectionType": "ALL"} - }] - }} - """)) - } - - private func binder( - observedTypes: [String: DynamoDBAttributeType] = [:], - currentItem: DynamoDBItem? = nil - ) throws -> DynamoDBParameterBinder { - DynamoDBParameterBinder(schema: try Self.schema(), observedTypes: observedTypes, currentItem: currentItem) - } - - private func bound( - _ cell: PluginCellValue, - as role: DynamoDBPartiQL.ParameterRole, - by binder: DynamoDBParameterBinder - ) throws -> DynamoDBAttributeValue { - let values = try binder.bind([cell], roles: [role]) - #expect(values.count == 1) - return try #require(values.first) - } - - private func refusedAttribute( - _ cell: PluginCellValue, - as role: DynamoDBPartiQL.ParameterRole, - by binder: DynamoDBParameterBinder - ) -> String? { - do { - _ = try binder.bind([cell], roles: [role]) - return nil - } catch DynamoDBError.invalidValue(let attribute, _) { - return attribute - } catch { - return nil - } - } - - // MARK: - Keys - - @Test("A key attribute takes the type its schema declares", arguments: keyCases) - func keyIsTypedFromSchema(keyCase: KeyCase) throws { - let observedAsString = Dictionary(uniqueKeysWithValues: ["pk", "sk", "digest", "rank"].map { - ($0, DynamoDBAttributeType.string) - }) - let binder = try binder( - observedTypes: observedAsString, - currentItem: ["sk": .string("x"), "rank": .string("y")] - ) - #expect(try bound(keyCase.cell, as: keyCase.role, by: binder) == keyCase.expected) - } - - @Test("A Number key that is not a number is refused", arguments: ["seven", "1,5", "NaN", "1e200"]) - func invalidNumberKeyIsRefused(text: String) throws { - #expect(refusedAttribute(.text(text), as: .compared(DynamoDBAttributePath(attribute: "sk")), by: try binder()) == "sk") - } - - @Test("A key with no value is refused", arguments: missingKeyCases) - func missingKeyIsRefused(missingKeyCase: MissingKeyCase) throws { - let attribute: String - switch missingKeyCase.role { - case .compared(let path), .assigned(let path): - attribute = path.root - case .inserted(let name): - attribute = name - case .unknown: - attribute = "" - } - #expect(refusedAttribute(missingKeyCase.cell, as: missingKeyCase.role, by: try binder()) == attribute) - } - - @Test("Bytes are refused for a key that is not Binary") - func bytesForNonBinaryKeyAreRefused() throws { - let binder = try binder() - #expect(refusedAttribute(.bytes(Self.digest), as: .compared(DynamoDBAttributePath(attribute: "pk")), by: binder) == "pk") - #expect(refusedAttribute(.bytes(Self.digest), as: .inserted("sk"), by: binder) == "sk") - } - - @Test("A Binary key that is not base64 is refused") - func invalidBinaryKeyIsRefused() throws { - #expect(refusedAttribute("not base64!", as: .compared(DynamoDBAttributePath(attribute: "digest")), by: try binder()) == "digest") - } - - @Test("Assigning a table key is refused", arguments: ["pk", "sk"]) - func assigningKeyIsRefused(attribute: String) throws { - #expect(refusedAttribute("new", as: .assigned(DynamoDBAttributePath(attribute: attribute)), by: try binder()) == attribute) - } - - @Test("An assigned index key takes the type its schema declares") - func assignedIndexKeyIsTypedFromSchema() throws { - #expect(try bound("5", as: .assigned(DynamoDBAttributePath(attribute: "rank")), by: try binder()) == .number("5")) - } - - // MARK: - Other attributes - - @Test("Assigned and compared attributes take the type of the item's current value") - func currentValueDecidesType() throws { - let binder = try binder( - observedTypes: ["total": .string, "tags": .string, "active": .string], - currentItem: ["total": .number("10"), "tags": .stringSet(["a"]), "active": .bool(true)] - ) - #expect(try bound("12", as: .assigned(DynamoDBAttributePath(attribute: "total")), by: binder) == .number("12")) - #expect(try bound("10", as: .compared(DynamoDBAttributePath(attribute: "total")), by: binder) == .number("10")) - #expect(try bound("[\"b\", \"c\"]", as: .assigned(DynamoDBAttributePath(attribute: "tags")), by: binder) == .stringSet(["b", "c"])) - #expect(try bound("false", as: .compared(DynamoDBAttributePath(attribute: "active")), by: binder) == .bool(false)) - } - - @Test("Without a current value, the type observed for the column is used") - func observedTypeDecidesType() throws { - let binder = try binder( - observedTypes: ["total": .number, "flag": .boolean], - currentItem: ["other": .string("x")] - ) - #expect(try bound("12", as: .assigned(DynamoDBAttributePath(attribute: "total")), by: binder) == .number("12")) - #expect(try bound("12", as: .compared(DynamoDBAttributePath(attribute: "total")), by: binder) == .number("12")) - #expect(try bound("true", as: .inserted("flag"), by: binder) == .bool(true)) - } - - @Test("With no type anywhere, text is a String even when it looks like a number") - func untypedTextIsString() throws { - let binder = try binder() - #expect(try bound("02134", as: .assigned(DynamoDBAttributePath(attribute: "zip")), by: binder) == .string("02134")) - #expect(try bound("12", as: .compared(DynamoDBAttributePath(attribute: "count")), by: binder) == .string("12")) - #expect(try bound("true", as: .inserted("flag"), by: binder) == .string("true")) - } - - @Test("Text that does not fit the current value's type is refused") - func mismatchedTextIsRefused() throws { - let binder = try binder(currentItem: ["total": .number("10")]) - #expect(refusedAttribute("ten", as: .assigned(DynamoDBAttributePath(attribute: "total")), by: binder) == "total") - } - - @Test("A NULL cell for a plain attribute binds as NULL and bytes as Binary") - func nullAndBytesForPlainAttribute() throws { - let binder = try binder(currentItem: ["total": .number("10")]) - #expect(try bound(.null, as: .assigned(DynamoDBAttributePath(attribute: "total")), by: binder) == .null) - #expect(try bound(.bytes(Self.digest), as: .assigned(DynamoDBAttributePath(attribute: "total")), by: binder) == .binary(Self.digest)) - } - - @Test("Without a schema a key name gets no key typing") - func noSchemaMeansNoKeyTyping() throws { - let binder = DynamoDBParameterBinder(schema: nil, observedTypes: [:], currentItem: nil) - #expect(try bound("7", as: .compared(DynamoDBAttributePath(attribute: "sk")), by: binder) == .string("7")) - #expect(try bound("7", as: .assigned(DynamoDBAttributePath(attribute: "sk")), by: binder) == .string("7")) - } - - // MARK: - Unknown role - - @Test("A parameter of unknown role binds text as S, bytes as B and NULL as NULL") - func unknownRole() throws { - let binder = try binder(observedTypes: ["sk": .number], currentItem: ["sk": .number("1")]) - #expect(try bound("42", as: .unknown, by: binder) == .string("42")) - #expect(try bound("", as: .unknown, by: binder) == .string("")) - #expect(try bound(.bytes(Self.digest), as: .unknown, by: binder) == .binary(Self.digest)) - #expect(try bound(.null, as: .unknown, by: binder) == .null) - } - - @Test("Parameters beyond the roles read from the statement bind as unknown") - func extraParametersAreUnknown() throws { - let values = try binder().bind(["7", "8", .null], roles: [.compared(DynamoDBAttributePath(attribute: "sk"))]) - #expect(values == [.number("7"), .string("8"), .null]) - } - - @Test("Parameters bind in order against their own roles") - func parametersBindInOrder() throws { - let binder = try binder(currentItem: ["total": .number("10")]) - let values = try binder.bind( - ["11", "p1", "7", "10"], - roles: [ - .assigned(DynamoDBAttributePath(attribute: "total")), - .compared(DynamoDBAttributePath(attribute: "pk")), - .compared(DynamoDBAttributePath(attribute: "sk")), - .compared(DynamoDBAttributePath(attribute: "total")) - ] - ) - #expect(values == [.number("11"), .string("p1"), .number("7"), .number("10")]) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBPartiQLTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBPartiQLTests.swift deleted file mode 100644 index 19508eee58..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBPartiQLTests.swift +++ /dev/null @@ -1,402 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBPartiQLTests { - struct KindCase: Sendable, CustomTestStringConvertible { - let statement: String - let kind: DynamoDBPartiQL.Kind - - var testDescription: String { statement } - } - - struct TargetCase: Sendable, CustomTestStringConvertible { - let statement: String - let table: String - let index: String? - - var testDescription: String { statement } - } - - struct SplitCase: Sendable, CustomTestStringConvertible { - let statement: String - let remaining: String - let window: DynamoDBReadWindow - - var testDescription: String { statement } - } - - struct RolesCase: Sendable, CustomTestStringConvertible { - let statement: String - let roles: [DynamoDBPartiQL.ParameterRole] - - var testDescription: String { statement } - } - - static let kindCases: [KindCase] = [ - KindCase(statement: "SELECT * FROM \"Orders\"", kind: .select), - KindCase(statement: " select * from Orders", kind: .select), - KindCase(statement: "-- note\nINSERT INTO \"Orders\" VALUE {'pk': 'a'}", kind: .insert), - KindCase(statement: "/* edit */ Update \"Orders\" SET \"a\" = 1 WHERE \"pk\" = 'a'", kind: .update), - KindCase(statement: "delete from \"Orders\" where \"pk\" = 'a'", kind: .delete), - KindCase(statement: "EXISTS(SELECT * FROM \"Orders\" WHERE \"pk\" = 'a')", kind: .other), - KindCase(statement: "\"SELECT\" * FROM \"Orders\"", kind: .other), - KindCase(statement: "'SELECT'", kind: .other), - KindCase(statement: "", kind: .other) - ] - - static let targetCases: [TargetCase] = [ - TargetCase(statement: "SELECT * FROM \"Orders\"", table: "Orders", index: nil), - TargetCase( - statement: "SELECT * FROM \"Orders\".\"ByCustomer\" WHERE \"customer\" = 'c1'", - table: "Orders", index: "ByCustomer" - ), - TargetCase( - statement: "SELECT \"a\" FROM \"My \"\"Quoted\"\" Table\".\"By \"\"X\"\"\"", - table: "My \"Quoted\" Table", index: "By \"X\"" - ), - TargetCase(statement: "select * from orders.bycustomer", table: "orders", index: "bycustomer"), - TargetCase(statement: "SELECT * FROM \"Orders\" WHERE \"x\" = 'FROM \"Other\"'", table: "Orders", index: nil), - TargetCase(statement: "INSERT INTO \"Or\"\"ders\" VALUE {'pk': 'a'}", table: "Or\"ders", index: nil), - TargetCase(statement: "UPDATE \"Orders\" SET \"a\" = 1 WHERE \"pk\" = 'a'", table: "Orders", index: nil), - TargetCase(statement: "UPDATE \"a\"\"b\" REMOVE \"c\" WHERE \"pk\" = 'a'", table: "a\"b", index: nil), - TargetCase(statement: "DELETE FROM \"Orders\" WHERE \"pk\" = 'a'", table: "Orders", index: nil), - TargetCase(statement: "-- which table\nDELETE FROM \"Orders\" WHERE \"pk\" = 'a'", table: "Orders", index: nil) - ] - - static let splitCases: [SplitCase] = [ - SplitCase( - statement: "SELECT * FROM \"Orders\" LIMIT 10", - remaining: "SELECT * FROM \"Orders\"", - window: DynamoDBReadWindow(limit: 10) - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" LIMIT 10 OFFSET 20", - remaining: "SELECT * FROM \"Orders\"", - window: DynamoDBReadWindow(limit: 10, offset: 20) - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" OFFSET 20", - remaining: "SELECT * FROM \"Orders\"", - window: DynamoDBReadWindow(offset: 20) - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" WHERE \"pk\" = 'a' ORDER BY \"sk\" DESC", - remaining: "SELECT * FROM \"Orders\" WHERE \"pk\" = 'a'", - window: DynamoDBReadWindow(order: [DynamoDBOrderTerm(attribute: "sk", descending: true)]) - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" ORDER BY \"a\" DESC, b, \"c\" ASC LIMIT 5 OFFSET 1", - remaining: "SELECT * FROM \"Orders\"", - window: DynamoDBReadWindow( - order: [ - DynamoDBOrderTerm(attribute: "a", descending: true), - DynamoDBOrderTerm(attribute: "b", descending: false), - DynamoDBOrderTerm(attribute: "c", descending: false) - ], - limit: 5, - offset: 1 - ) - ), - SplitCase( - statement: "select * from \"Orders\" order by \"a\"\"b\" asc limit 3", - remaining: "select * from \"Orders\"", - window: DynamoDBReadWindow(order: [DynamoDBOrderTerm(attribute: "a\"b", descending: false)], limit: 3) - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" WHERE \"a\" IN [1, 2] LIMIT 7", - remaining: "SELECT * FROM \"Orders\" WHERE \"a\" IN [1, 2]", - window: DynamoDBReadWindow(limit: 7) - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" WHERE \"a\" IN (SELECT \"b\" FROM \"Other\" ORDER BY \"b\")", - remaining: "SELECT * FROM \"Orders\" WHERE \"a\" IN (SELECT \"b\" FROM \"Other\" ORDER BY \"b\")", - window: DynamoDBReadWindow() - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" WHERE \"a\" IN (SELECT \"b\" FROM \"Other\" LIMIT 5)", - remaining: "SELECT * FROM \"Orders\" WHERE \"a\" IN (SELECT \"b\" FROM \"Other\" LIMIT 5)", - window: DynamoDBReadWindow() - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" ORDER BY \"doc\".\"n\"", - remaining: "SELECT * FROM \"Orders\" ORDER BY \"doc\".\"n\"", - window: DynamoDBReadWindow() - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" LIMIT ?", - remaining: "SELECT * FROM \"Orders\" LIMIT ?", - window: DynamoDBReadWindow() - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" WHERE \"a\" = 'x LIMIT 5'", - remaining: "SELECT * FROM \"Orders\" WHERE \"a\" = 'x LIMIT 5'", - window: DynamoDBReadWindow() - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" WHERE \"a\" = 'ORDER BY \"b\" DESC'", - remaining: "SELECT * FROM \"Orders\" WHERE \"a\" = 'ORDER BY \"b\" DESC'", - window: DynamoDBReadWindow() - ), - SplitCase( - statement: "SELECT * FROM \"Orders\" LIMIT 5 -- first page", - remaining: "SELECT * FROM \"Orders\"", - window: DynamoDBReadWindow(limit: 5) - ) - ] - - static let untouchedStatements: [String] = [ - "DELETE FROM \"Orders\" WHERE \"pk\" = 'a' LIMIT 5", - "UPDATE \"Orders\" SET \"a\" = 1 WHERE \"pk\" = 'a' ORDER BY \"a\" DESC", - "INSERT INTO \"Orders\" VALUE {'pk': 'a'} OFFSET 3 ", - "EXISTS(SELECT * FROM \"Orders\" LIMIT 1)" - ] - - static let rolesCases: [RolesCase] = [ - RolesCase( - statement: """ - UPDATE "Orders" SET "name" = ?, "total" = ? REMOVE "note" \ - WHERE "pk" = ? AND "sk" = ? AND "name" = ? AND "total" = ? - """, - roles: [ - .assigned(DynamoDBAttributePath(attribute: "name")), .assigned(DynamoDBAttributePath(attribute: "total")), - .compared(DynamoDBAttributePath(attribute: "pk")), - .compared(DynamoDBAttributePath(attribute: "sk")), - .compared(DynamoDBAttributePath(attribute: "name")), - .compared(DynamoDBAttributePath(attribute: "total")) - ] - ), - RolesCase( - statement: "INSERT INTO \"Orders\" VALUE {'a': ?, 'b''c': ?}", - roles: [.inserted("a"), .inserted("b'c")] - ), - RolesCase( - statement: "SELECT * FROM \"Orders\" WHERE \"a\" IN [?, ?]", - roles: [.compared(DynamoDBAttributePath(attribute: "a")), .compared(DynamoDBAttributePath(attribute: "a"))] - ), - RolesCase( - statement: "SELECT * FROM \"Orders\" WHERE contains(\"name\", ?)", - roles: [.compared(DynamoDBAttributePath(attribute: "name"))] - ), - RolesCase( - statement: "SELECT * FROM \"Orders\" WHERE \"n\" BETWEEN ? AND ?", - roles: [.compared(DynamoDBAttributePath(attribute: "n")), .compared(DynamoDBAttributePath(attribute: "n"))] - ), - RolesCase( - statement: "SELECT * FROM \"Orders\" WHERE \"pk\" = ? AND begins_with(\"sk\", ?) OR \"x\" <> ?", - roles: [ - .compared(DynamoDBAttributePath(attribute: "pk")), - .compared(DynamoDBAttributePath(attribute: "sk")), - .compared(DynamoDBAttributePath(attribute: "x")) - ] - ), - RolesCase( - statement: "UPDATE \"Orders\" SET \"a\"\"b\" = ? WHERE \"pk\" = ?", - roles: [.assigned(DynamoDBAttributePath(attribute: "a\"b")), .compared(DynamoDBAttributePath(attribute: "pk"))] - ), - RolesCase( - statement: "UPDATE \"Orders\" SET \"WHERE\" = ?, \"in\" = ? WHERE \"pk\" = ? AND \"is\" = ?", - roles: [ - .assigned(DynamoDBAttributePath(attribute: "WHERE")), - .assigned(DynamoDBAttributePath(attribute: "in")), - .compared(DynamoDBAttributePath(attribute: "pk")), - .compared(DynamoDBAttributePath(attribute: "is")) - ] - ), - RolesCase( - statement: "UPDATE \"Orders\" SET \"doc\".\"n\"[0] = ? WHERE \"pk\" = ?", - roles: [ - .assigned(DynamoDBAttributePath(segments: [.name("doc"), .name("n"), .index(0)])), - .compared(DynamoDBAttributePath(attribute: "pk")) - ] - ), - RolesCase( - statement: "UPDATE \"Orders\" SET \"items\" = list_append(\"items\", ?) WHERE \"pk\" = ?", - roles: [.assigned(DynamoDBAttributePath(attribute: "items")), .compared(DynamoDBAttributePath(attribute: "pk"))] - ), - RolesCase( - statement: "SELECT * FROM \"Orders\" WHERE \"a\" = '?' AND \"b\" = ?", - roles: [.compared(DynamoDBAttributePath(attribute: "b"))] - ), - RolesCase( - statement: "UPDATE \"Orders\" SET \"a\" = 1 WHERE \"pk\" = 'x'", - roles: [] - ) - ] - - // MARK: - Kind - - @Test("The first word decides the statement kind", arguments: kindCases) - func kind(kindCase: KindCase) { - #expect(DynamoDBPartiQL.kind(of: kindCase.statement) == kindCase.kind) - } - - // MARK: - Target - - @Test("The target table and index are read from the statement", arguments: targetCases) - func target(targetCase: TargetCase) throws { - let target = try #require(DynamoDBPartiQL.target(of: targetCase.statement)) - #expect(target.table == targetCase.table) - #expect(target.index == targetCase.index) - } - - @Test( - "A statement that names no table has no target", - arguments: ["", "SELECT 1", "SELECT * FROM", "UPDATE", "INSERT \"Orders\"", "EXISTS(SELECT * FROM \"Orders\")"] - ) - func noTarget(statement: String) { - #expect(DynamoDBPartiQL.target(of: statement) == nil) - } - - // MARK: - RETURNING - - @Test( - "A RETURNING clause is found in any case", - arguments: [ - "DELETE FROM \"Orders\" WHERE \"pk\" = ? RETURNING ALL OLD *", - "UPDATE \"Orders\" SET \"a\" = 1 WHERE \"pk\" = 'a' returning all new *" - ] - ) - func hasReturning(statement: String) { - #expect(DynamoDBPartiQL.hasReturning(statement)) - } - - @Test( - "RETURNING inside a string, a quoted name or a comment is not a clause", - arguments: [ - "DELETE FROM \"Orders\" WHERE \"pk\" = 'RETURNING ALL OLD *'", - "UPDATE \"Orders\" SET \"RETURNING\" = 1 WHERE \"pk\" = 'a'", - "DELETE FROM \"Orders\" WHERE \"pk\" = 'a' -- RETURNING ALL OLD *", - "DELETE FROM \"Orders\" WHERE \"pk\" = 'a' /* RETURNING ALL OLD * */", - "DELETE FROM \"Orders\" WHERE \"pk\" = 'a'" - ] - ) - func hasNoReturning(statement: String) { - #expect(!DynamoDBPartiQL.hasReturning(statement)) - } - - // MARK: - WHERE - - @Test( - "An equality or IN on the attribute fixes it", - arguments: [ - "SELECT * FROM \"Orders\" WHERE \"pk\" = 'a'", - "SELECT * FROM \"Orders\" WHERE \"pk\" IN ['a', 'b']", - "SELECT * FROM \"Orders\" WHERE \"sk\" > 3 AND \"pk\" = ?", - "select * from Orders where pk = 'a'", - "SELECT * FROM \"Orders\" WHERE (\"pk\" = 'a' AND \"sk\" > 1)", - "SELECT * FROM \"Orders\" WHERE \"pk\" = 'a' AND (\"sk\" = 1 OR \"sk\" = 2)", - "SELECT * FROM \"Orders\" WHERE ((\"pk\" = 'a')) AND \"sk\" > 1", - "SELECT * FROM \"Orders\" WHERE \"sk\" > 1 AND (\"status\" = 'open' AND \"pk\" = 'a')" - ] - ) - func whereFixes(statement: String) { - #expect(DynamoDBPartiQL.whereFixes("pk", in: statement)) - } - - @Test( - "A range, a function, a string or a missing WHERE does not fix the attribute", - arguments: [ - "SELECT * FROM \"Orders\"", - "SELECT \"pk\" FROM \"Orders\" WHERE \"sk\" = 1", - "SELECT * FROM \"Orders\" WHERE \"pk\" > 'a'", - "SELECT * FROM \"Orders\" WHERE \"pk\" <> 'a'", - "SELECT * FROM \"Orders\" WHERE begins_with(\"pk\", 'a')", - "SELECT * FROM \"Orders\" WHERE \"other\" = 'pk'", - "SELECT * FROM \"Orders\" WHERE \"PK\" = 'a'", - "SELECT * FROM \"Orders\" WHERE NOT \"pk\" = 'a'", - "SELECT * FROM \"Orders\" WHERE NOT (\"pk\" = 'a')", - "SELECT * FROM \"Orders\" WHERE (\"pk\" = 'a' OR \"sk\" = 1)", - "SELECT * FROM \"Orders\" WHERE \"sk\" = 1 AND (\"status\" = 'open' OR \"pk\" = 'a')", - "SELECT * FROM \"Orders\" WHERE size(\"pk\") = 1" - ] - ) - func whereDoesNotFix(statement: String) { - #expect(!DynamoDBPartiQL.whereFixes("pk", in: statement)) - } - - @Test("An equality joined to the rest of the WHERE clause by a top-level OR does not fix the attribute") - func whereWithTopLevelOrDoesNotFix() { - #expect(!DynamoDBPartiQL.whereFixes("pk", in: "SELECT * FROM \"Orders\" WHERE \"pk\" = 'a' OR \"status\" = 'open'")) - #expect(!DynamoDBPartiQL.whereFixes("pk", in: "SELECT * FROM \"Orders\" WHERE \"status\" = 'open' OR \"pk\" = 'a'")) - } - - @Test("An equality on a nested attribute of the same name does not fix the top-level attribute") - func whereOnNestedPathDoesNotFix() { - #expect(!DynamoDBPartiQL.whereFixes("pk", in: "SELECT * FROM \"Orders\" WHERE \"doc\".\"pk\" = 'a'")) - } - - // MARK: - Trailing window - - @Test("A SELECT's trailing ORDER BY, LIMIT and OFFSET are split off", arguments: splitCases) - func splitTrailingWindow(splitCase: SplitCase) { - let split = DynamoDBPartiQL.splitTrailingWindow(splitCase.statement) - #expect(split.statement == splitCase.remaining) - #expect(split.window == splitCase.window) - } - - @Test("A statement that is not a SELECT is returned untouched", arguments: untouchedStatements) - func nonSelectIsUntouched(statement: String) { - let split = DynamoDBPartiQL.splitTrailingWindow(statement) - #expect(split.statement == statement) - #expect(split.window.isEmpty) - } - - @Test("A negative LIMIT or OFFSET never becomes a window", arguments: [ - "SELECT * FROM \"Orders\" LIMIT -1", - "SELECT * FROM \"Orders\" LIMIT 5 OFFSET -2", - "SELECT * FROM \"Orders\" OFFSET -2" - ]) - func negativeWindowIsNotTaken(statement: String) { - let window = DynamoDBPartiQL.splitTrailingWindow(statement).window - #expect((window.limit ?? 0) >= 0) - #expect(window.offset >= 0) - } - - @Test("A comment written straight after a number still ends the statement") - func commentAfterNumber() { - let split = DynamoDBPartiQL.splitTrailingWindow("SELECT * FROM \"Orders\" LIMIT 5-- first page") - #expect(split.statement == "SELECT * FROM \"Orders\"") - #expect(split.window == DynamoDBReadWindow(limit: 5)) - } - - // MARK: - Parameter roles - - @Test("Each parameter's role is read from the words around it", arguments: rolesCases) - func parameterRoles(rolesCase: RolesCase) { - #expect(DynamoDBPartiQL.parameterRoles(in: rolesCase.statement) == rolesCase.roles) - } - - @Test("Parameters inside comments are not counted") - func parameterRolesIgnoreComments() { - let statement = """ - UPDATE "Orders" SET "a" = ? -- , "b" = ? it's "odd" - /* , "c" = ? - WHERE "d" = ? */ WHERE "pk" = ? /* AND "e" = ? */ - """ - #expect(DynamoDBPartiQL.parameterRoles(in: statement) == [ - .assigned(DynamoDBAttributePath(attribute: "a")), - .compared(DynamoDBAttributePath(attribute: "pk")) - ]) - } - - // MARK: - Tokenizer - - @Test("Quoted names and strings are unescaped and comments dropped") - func tokenizer() { - let tokens = DynamoDBPartiQL.tokens(of: "SELECT \"a\"\"b\" -- c\nFROM /* d */ 'e''f' [?] -1.5e3 <>") - #expect(tokens.map(\.kind) == [ - .word, .quotedIdentifier, .word, .string, .symbol, .parameter, .symbol, .number, .symbol - ]) - #expect(tokens.map(\.text) == ["SELECT", "a\"b", "FROM", "e'f", "[", "?", "]", "-1.5e3", "<>"]) - #expect(tokens.map(\.depth) == [0, 0, 0, 0, 0, 1, 0, 0, 0]) - } - - @Test("An unterminated comment or string runs to the end of the text") - func unterminatedRegions() { - #expect(DynamoDBPartiQL.tokens(of: "SELECT /* never closed ?").map(\.text) == ["SELECT"]) - let tokens = DynamoDBPartiQL.tokens(of: "SELECT 'never closed ?") - #expect(tokens.map(\.kind) == [.word, .string]) - #expect(tokens.last?.text == "never closed ?") - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBRetryPolicyTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBRetryPolicyTests.swift deleted file mode 100644 index 6235010b05..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBRetryPolicyTests.swift +++ /dev/null @@ -1,263 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBRetryPolicyTests { - struct DelayCase: Sendable, CustomTestStringConvertible { - let attempt: Int - let seconds: TimeInterval - var testDescription: String { "attempt \(attempt)" } - } - - struct IdempotencyCase: Sendable, CustomTestStringConvertible { - let label: String - let operation: DynamoDBOperation - let body: String - let isIdempotent: Bool - var testDescription: String { label } - } - - private static let fullJitter = DynamoDBRetryPolicy(random: { 1.0 }) - private static let noJitter = DynamoDBRetryPolicy(random: { 0.0 }) - - private static func service(_ code: String, status: Int = 400, message: String = "") -> DynamoDBError { - .service(DynamoDBServiceError(code: code, message: message, httpStatus: status)) - } - - private static func decide( - _ policy: DynamoDBRetryPolicy, - _ error: DynamoDBError, - attempt: Int = 1, - operation: DynamoDBOperation = .getItem, - body: DynamoDBJSON = .object([:]), - refreshed: Bool = false, - corrected: Bool = false - ) -> DynamoDBRetryPolicy.Decision { - policy.decision( - for: error, - attempt: attempt, - operation: operation, - body: body, - alreadyRefreshedCredentials: refreshed, - alreadyCorrectedClock: corrected - ) - } - - @Test( - "Throttling waits one second doubled per attempt", - arguments: [ - DelayCase(attempt: 1, seconds: 1), - DelayCase(attempt: 2, seconds: 2), - DelayCase(attempt: 3, seconds: 4) - ] - ) - func throttlingDelay(_ testCase: DelayCase) { - let decision = Self.decide( - Self.fullJitter, Self.service("ProvisionedThroughputExceededException"), - attempt: testCase.attempt, operation: .updateItem - ) - #expect(decision == .retry(after: testCase.seconds)) - } - - @Test("Throttling delays are scaled by the jitter") - func throttlingDelayJitter() { - let decision = Self.decide(Self.noJitter, Self.service("ThrottlingException"), attempt: 3, operation: .putItem) - #expect(decision == .retry(after: 0)) - let half = DynamoDBRetryPolicy(random: { 0.5 }) - #expect(Self.decide(half, Self.service("ThrottlingException"), attempt: 3) == .retry(after: 2)) - } - - @Test("The delay ceiling stops at twenty seconds") - func delayCeiling() { - #expect(Self.fullJitter.delay(base: DynamoDBRetryPolicy.throttlingBase, attempt: 5) == 16) - #expect(Self.fullJitter.delay(base: DynamoDBRetryPolicy.throttlingBase, attempt: 6) == 20) - #expect(Self.fullJitter.delay(base: DynamoDBRetryPolicy.throttlingBase, attempt: 30) == 20) - #expect(Self.noJitter.delay(base: DynamoDBRetryPolicy.throttlingBase, attempt: 6) == 0) - } - - @Test("Throttling retries a request that is not idempotent") - func throttlingRetriesAnyRequest() { - let decision = Self.decide(Self.fullJitter, Self.service("RequestLimitExceeded"), operation: .updateItem) - #expect(decision == .retry(after: 1)) - } - - @Test( - "A transient failure of an idempotent request waits 25 ms doubled per attempt", - arguments: [ - DelayCase(attempt: 1, seconds: 0.025), - DelayCase(attempt: 2, seconds: 0.05), - DelayCase(attempt: 3, seconds: 0.1) - ] - ) - func transientDelay(_ testCase: DelayCase) { - let decision = Self.decide( - Self.fullJitter, Self.service("InternalServerError", status: 500), attempt: testCase.attempt - ) - #expect(decision == .retry(after: testCase.seconds)) - } - - @Test("A transient failure of a request that may have applied is not retried") - func transientNotIdempotentFails() { - #expect(Self.decide(Self.fullJitter, Self.service("InternalServerError", status: 500), operation: .updateItem) == .fail) - #expect(Self.decide(Self.fullJitter, Self.service("SomethingNew", status: 503), operation: .updateItem) == .fail) - } - - @Test("A transient 5xx of an idempotent request is retried whatever its code") - func transientAnyCodeRetried() { - #expect(Self.decide(Self.noJitter, Self.service("SomethingNew", status: 503), operation: .scan) == .retry(after: 0)) - } - - @Test("The fourth attempt is the last", arguments: [4, 5]) - func attemptLimit(_ attempt: Int) { - #expect(Self.decide(Self.fullJitter, Self.service("ThrottlingException"), attempt: attempt) == .fail) - #expect(Self.decide(Self.fullJitter, Self.service("InternalServerError", status: 500), attempt: attempt) == .fail) - #expect(Self.decide(Self.fullJitter, Self.service("ExpiredTokenException"), attempt: attempt) == .fail) - #expect(Self.decide(Self.fullJitter, .transport("reset"), attempt: attempt) == .fail) - } - - @Test("An expired token is refreshed once and then fails") - func expiredTokenRefreshesOnce() { - let error = Self.service("ExpiredTokenException") - #expect(Self.decide(Self.fullJitter, error, operation: .updateItem) == .refreshCredentialsAndRetry) - #expect(Self.decide(Self.fullJitter, error, attempt: 2, operation: .updateItem, refreshed: true) == .fail) - } - - @Test("A skewed clock is corrected once and then fails") - func clockSkewCorrectsOnce() { - let error = Self.service("InvalidSignatureException", message: "Signature expired: 20150830T123600Z is now earlier than 20150830T124100Z") - #expect(Self.decide(Self.fullJitter, error, operation: .updateItem) == .correctClockAndRetry) - #expect(Self.decide(Self.fullJitter, error, attempt: 2, operation: .updateItem, corrected: true) == .fail) - #expect(Self.decide(Self.fullJitter, Self.service("RequestTimeTooSkewed")) == .correctClockAndRetry) - } - - @Test( - "Authentication and fatal errors fail at once", - arguments: [ - "UnrecognizedClientException", "InvalidSignatureException", "AccessDeniedException", - "ValidationException", "ResourceNotFoundException", "ConditionalCheckFailedException" - ] - ) - func authenticationAndFatalFail(_ code: String) { - #expect(Self.decide(Self.fullJitter, Self.service(code), operation: .scan) == .fail) - } - - @Test("A transport error is retried only for an idempotent request") - func transportErrorRetriesIdempotentOnly() { - #expect(Self.decide(Self.fullJitter, .transport("The network connection was lost."), operation: .query) - == .retry(after: 0.025)) - #expect(Self.decide(Self.fullJitter, .transport("The network connection was lost."), operation: .updateItem) - == .fail) - } - - @Test( - "Errors the driver raised itself are never retried", - arguments: [ - DynamoDBError.cancelled, .notConnected, .timedOut(seconds: 30), .invalidResponse("bad"), - .configuration("bad"), .invalidStatement("bad") - ] - ) - func localErrorsFail(_ error: DynamoDBError) { - #expect(Self.decide(Self.fullJitter, error, operation: .scan) == .fail) - } - - @Test( - "Every read is idempotent", - arguments: DynamoDBOperation.allCases.filter(\.isRead) - ) - func readsAreIdempotent(_ operation: DynamoDBOperation) { - #expect(DynamoDBRetryPolicy.isIdempotent(operation, body: .object([:]))) - } - - @Test( - "Whether a write may be sent twice", - arguments: [ - IdempotencyCase(label: "PutItem", operation: .putItem, body: #"{"TableName":"t"}"#, isIdempotent: true), - IdempotencyCase( - label: "PutItem with a condition", operation: .putItem, - body: #"{"TableName":"t","ConditionExpression":"attribute_not_exists(pk)"}"#, isIdempotent: false - ), - IdempotencyCase( - label: "PutItem with legacy Expected", operation: .putItem, - body: #"{"TableName":"t","Expected":{"pk":{"Exists":false}}}"#, isIdempotent: false - ), - IdempotencyCase(label: "DeleteItem", operation: .deleteItem, body: #"{"TableName":"t"}"#, isIdempotent: true), - IdempotencyCase( - label: "DeleteItem with a condition", operation: .deleteItem, - body: #"{"TableName":"t","ConditionExpression":"attribute_exists(pk)"}"#, isIdempotent: false - ), - IdempotencyCase(label: "UpdateItem", operation: .updateItem, body: #"{"TableName":"t"}"#, isIdempotent: false), - IdempotencyCase( - label: "UpdateItem with a condition", operation: .updateItem, - body: #"{"TableName":"t","ConditionExpression":"attribute_exists(pk)"}"#, isIdempotent: false - ), - IdempotencyCase(label: "BatchWriteItem", operation: .batchWriteItem, body: #"{"RequestItems":{}}"#, isIdempotent: true), - IdempotencyCase( - label: "TransactWriteItems with a token", operation: .transactWriteItems, - body: #"{"TransactItems":[],"ClientRequestToken":"f3c1"}"#, isIdempotent: true - ), - IdempotencyCase( - label: "TransactWriteItems without a token", operation: .transactWriteItems, - body: #"{"TransactItems":[]}"#, isIdempotent: false - ), - IdempotencyCase( - label: "TransactWriteItems with an empty token", operation: .transactWriteItems, - body: #"{"TransactItems":[],"ClientRequestToken":""}"#, isIdempotent: false - ), - IdempotencyCase( - label: "ExecuteTransaction with a token", operation: .executeTransaction, - body: #"{"TransactStatements":[],"ClientRequestToken":"f3c1"}"#, isIdempotent: true - ), - IdempotencyCase( - label: "ExecuteTransaction without a token", operation: .executeTransaction, - body: #"{"TransactStatements":[]}"#, isIdempotent: false - ), - IdempotencyCase( - label: "ExecuteStatement SELECT", operation: .executeStatement, - body: #"{"Statement":"SELECT * FROM \"t\" WHERE pk = 'a'"}"#, isIdempotent: true - ), - IdempotencyCase( - label: "ExecuteStatement lowercase select", operation: .executeStatement, - body: #"{"Statement":" select * from t"}"#, isIdempotent: true - ), - IdempotencyCase( - label: "ExecuteStatement UPDATE", operation: .executeStatement, - body: #"{"Statement":"UPDATE \"t\" SET n = n + 1 WHERE pk = 'a'"}"#, isIdempotent: false - ), - IdempotencyCase( - label: "ExecuteStatement INSERT", operation: .executeStatement, - body: #"{"Statement":"INSERT INTO \"t\" VALUE {'pk': 'a'}"}"#, isIdempotent: false - ), - IdempotencyCase( - label: "ExecuteStatement with no statement", operation: .executeStatement, - body: #"{}"#, isIdempotent: false - ), - IdempotencyCase( - label: "BatchExecuteStatement all SELECT", operation: .batchExecuteStatement, - body: #"{"Statements":[{"Statement":"SELECT * FROM t WHERE pk = 'a'"},{"Statement":"SELECT * FROM t WHERE pk = 'b'"}]}"#, - isIdempotent: true - ), - IdempotencyCase( - label: "BatchExecuteStatement mixed", operation: .batchExecuteStatement, - body: #"{"Statements":[{"Statement":"SELECT * FROM t WHERE pk = 'a'"},{"Statement":"DELETE FROM t WHERE pk = 'b'"}]}"#, - isIdempotent: false - ), - IdempotencyCase( - label: "BatchExecuteStatement empty", operation: .batchExecuteStatement, - body: #"{"Statements":[]}"#, isIdempotent: false - ), - IdempotencyCase( - label: "CreateTable", operation: .createTable, body: #"{"TableName":"t"}"#, isIdempotent: false - ), - IdempotencyCase( - label: "DeleteTable", operation: .deleteTable, body: #"{"TableName":"t"}"#, isIdempotent: false - ), - IdempotencyCase( - label: "UpdateTimeToLive", operation: .updateTimeToLive, body: #"{"TableName":"t"}"#, isIdempotent: false - ) - ] - ) - func writeIdempotency(_ testCase: IdempotencyCase) throws { - let body = try DynamoDBJSON.parse(testCase.body) - #expect(DynamoDBRetryPolicy.isIdempotent(testCase.operation, body: body) == testCase.isIdempotent) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBSignerTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBSignerTests.swift deleted file mode 100644 index ee0c973f44..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBSignerTests.swift +++ /dev/null @@ -1,188 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBSignerTests { - private static let exampleDate = Date(timeIntervalSince1970: 1_440_938_160) - private static let exampleCredentials = AWSCredentials( - accessKeyId: "AKIDEXAMPLE", - secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", - sessionToken: nil - ) - private static let exampleToken = - "AQoDYXdzEPT//////////wEXAMPLEtc764bNrC9SAPBSM22wDOk4x4HIZ8j4FZTwdQWLWsKWHGBuFqwAeMicRXmxfpSPfIeoIYRqTflfKD8YUuwthAx7mSEI/qkPpKPi/kMcGd" - - struct HostCase: Sendable, CustomTestStringConvertible { - let url: String - let expected: String - var testDescription: String { url } - } - - struct PathCase: Sendable, CustomTestStringConvertible { - let url: String - let expected: String - var testDescription: String { url } - } - - private static func listTablesRequest(_ urlText: String) throws -> URLRequest { - var request = URLRequest(url: try #require(URL(string: urlText))) - request.httpMethod = "POST" - request.setValue("application/x-amz-json-1.0", forHTTPHeaderField: "Content-Type") - request.setValue("DynamoDB_20120810.ListTables", forHTTPHeaderField: "X-Amz-Target") - return request - } - - @Test("Timestamps are formatted in UTC whatever the local time zone") - func timestampsAreUTC() { - let stamps = DynamoDBSigner.timestamps(for: Date(timeIntervalSince1970: 1_709_251_199)) - #expect(stamps == DynamoDBSigner.Timestamps(amzDate: "20240229T235959Z", dateStamp: "20240229")) - } - - @Test("Timestamps pad every field to its fixed width") - func timestampsArePadded() { - #expect(DynamoDBSigner.timestamps(for: Date(timeIntervalSince1970: 1_767_582_245)).amzDate == "20260105T030405Z") - #expect(DynamoDBSigner.timestamps(for: Date(timeIntervalSince1970: 0)).amzDate == "19700101T000000Z") - #expect(DynamoDBSigner.timestamps(for: Self.exampleDate).dateStamp == "20150830") - } - - @Test( - "The Host header drops only the scheme's default port", - arguments: [ - HostCase(url: "https://dynamodb.us-east-1.amazonaws.com:443/", expected: "dynamodb.us-east-1.amazonaws.com"), - HostCase(url: "https://dynamodb.us-east-1.amazonaws.com/", expected: "dynamodb.us-east-1.amazonaws.com"), - HostCase(url: "http://localhost:80/", expected: "localhost"), - HostCase(url: "http://localhost:8000", expected: "localhost:8000"), - HostCase(url: "https://ddb.example.com:8443/", expected: "ddb.example.com:8443"), - HostCase(url: "http://ddb.example.com:443/", expected: "ddb.example.com:443"), - HostCase(url: "https://ddb.example.com:80/", expected: "ddb.example.com:80") - ] - ) - func hostHeader(_ testCase: HostCase) throws { - let url = try #require(URL(string: testCase.url)) - #expect(DynamoDBSigner.hostHeader(for: url) == testCase.expected) - } - - @Test( - "The Host header keeps the brackets around an IPv6 literal", - arguments: [ - HostCase(url: "http://[::1]:8000", expected: "[::1]:8000"), - HostCase(url: "https://[2001:db8::1]/", expected: "[2001:db8::1]") - ] - ) - func hostHeaderForIPv6(_ testCase: HostCase) throws { - let url = try #require(URL(string: testCase.url)) - #expect(DynamoDBSigner.hostHeader(for: url) == testCase.expected) - } - - @Test( - "The canonical URI is the path as sent", - arguments: [ - PathCase(url: "https://h/dynamodb/", expected: "/dynamodb/"), - PathCase(url: "https://h/a%20b/c", expected: "/a%20b/c"), - PathCase(url: "https://h/", expected: "/"), - PathCase(url: "https://h", expected: "/"), - PathCase(url: "http://localhost:8000", expected: "/") - ] - ) - func canonicalURI(_ testCase: PathCase) throws { - let url = try #require(URL(string: testCase.url)) - #expect(DynamoDBSigner.canonicalURI(for: url) == testCase.expected) - } - - @Test("Signing sets the date, the host and an Authorization header with the credential scope") - func signSetsHeaders() throws { - var request = try Self.listTablesRequest("https://dynamodb.us-east-1.amazonaws.com/") - DynamoDBSigner.sign( - &request, body: Data("{}".utf8), credentials: Self.exampleCredentials, - region: "us-east-1", date: Self.exampleDate - ) - #expect(request.value(forHTTPHeaderField: "X-Amz-Date") == "20150830T123600Z") - #expect(request.value(forHTTPHeaderField: "Host") == "dynamodb.us-east-1.amazonaws.com") - #expect(request.value(forHTTPHeaderField: "X-Amz-Security-Token") == nil) - let authorization = try #require(request.value(forHTTPHeaderField: "Authorization")) - #expect(authorization.hasPrefix( - "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/dynamodb/aws4_request, " - )) - #expect(authorization.contains("SignedHeaders=content-type;host;x-amz-date;x-amz-target, ")) - } - - @Test("The signature matches an independent SigV4 computation") - func signatureMatchesReference() throws { - var request = try Self.listTablesRequest("https://dynamodb.us-east-1.amazonaws.com/") - DynamoDBSigner.sign( - &request, body: Data("{}".utf8), credentials: Self.exampleCredentials, - region: "us-east-1", date: Self.exampleDate - ) - #expect( - request.value(forHTTPHeaderField: "Authorization") - == "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/dynamodb/aws4_request, " - + "SignedHeaders=content-type;host;x-amz-date;x-amz-target, " - + "Signature=75214f17608dbd636679e18f6f89744844ae96fcd228b8147167152488d817de" - ) - } - - @Test("A session token is sent and signed") - func sessionTokenIsSigned() throws { - var request = try Self.listTablesRequest("https://dynamodb.us-east-1.amazonaws.com/") - let credentials = AWSCredentials( - accessKeyId: "AKIDEXAMPLE", - secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", - sessionToken: Self.exampleToken - ) - DynamoDBSigner.sign( - &request, body: Data("{}".utf8), credentials: credentials, region: "us-east-1", date: Self.exampleDate - ) - #expect(request.value(forHTTPHeaderField: "X-Amz-Security-Token") == Self.exampleToken) - #expect( - request.value(forHTTPHeaderField: "Authorization") - == "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/dynamodb/aws4_request, " - + "SignedHeaders=content-type;host;x-amz-date;x-amz-security-token;x-amz-target, " - + "Signature=6fa03fc9356b96e0d654a933310ae874b0752797246b39df2a03cc67d4302dcd" - ) - } - - @Test("An empty session token is neither sent nor signed") - func emptySessionTokenIsIgnored() throws { - var request = try Self.listTablesRequest("https://dynamodb.us-east-1.amazonaws.com/") - let credentials = AWSCredentials( - accessKeyId: "AKIDEXAMPLE", - secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", - sessionToken: "" - ) - DynamoDBSigner.sign( - &request, body: Data("{}".utf8), credentials: credentials, region: "us-east-1", date: Self.exampleDate - ) - #expect(request.value(forHTTPHeaderField: "X-Amz-Security-Token") == nil) - let authorization = try #require(request.value(forHTTPHeaderField: "Authorization")) - #expect(authorization.hasSuffix( - "Signature=75214f17608dbd636679e18f6f89744844ae96fcd228b8147167152488d817de" - )) - } - - @Test("A custom endpoint signs its port and its percent-encoded path") - func customEndpointSignature() throws { - var request = try Self.listTablesRequest("http://localhost:8000/a%20b/c") - DynamoDBSigner.sign( - &request, body: Data("{}".utf8), credentials: Self.exampleCredentials, - region: "us-east-1", date: Self.exampleDate - ) - #expect(request.value(forHTTPHeaderField: "Host") == "localhost:8000") - let authorization = try #require(request.value(forHTTPHeaderField: "Authorization")) - #expect(authorization.hasSuffix( - "Signature=54093f8b1a85d2095f252df1192f952b4d2b2205be51b10a3f313d0940dd0f52" - )) - } - - @Test("The signing region and date change the credential scope") - func scopeFollowsRegionAndDate() throws { - var request = try Self.listTablesRequest("https://dynamodb.cn-north-1.amazonaws.com.cn/") - DynamoDBSigner.sign( - &request, body: Data("{}".utf8), credentials: Self.exampleCredentials, - region: "cn-north-1", date: Date(timeIntervalSince1970: 1_709_251_199) - ) - let authorization = try #require(request.value(forHTTPHeaderField: "Authorization")) - #expect(authorization.hasPrefix( - "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20240229/cn-north-1/dynamodb/aws4_request, " - )) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBStatementTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBStatementTests.swift deleted file mode 100644 index 0d84b1f9e1..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBStatementTests.swift +++ /dev/null @@ -1,422 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBStatementTests { - struct WindowCase: Sendable, CustomTestStringConvertible { - let clause: String - let window: DynamoDBReadWindow - - var testDescription: String { clause } - } - - static let ordersBody: DynamoDBJSON = .object(["TableName": .string("Orders")]) - - static let windowCases: [WindowCase] = [ - WindowCase( - clause: "ORDER BY \"a\"\"b\" DESC", - window: DynamoDBReadWindow(order: [DynamoDBOrderTerm(attribute: "a\"b", descending: true)]) - ), - WindowCase( - clause: "ORDER BY \"a\" DESC, b, \"c\" ASC", - window: DynamoDBReadWindow(order: [ - DynamoDBOrderTerm(attribute: "a", descending: true), - DynamoDBOrderTerm(attribute: "b", descending: false), - DynamoDBOrderTerm(attribute: "c", descending: false) - ]) - ), - WindowCase(clause: "LIMIT 25", window: DynamoDBReadWindow(limit: 25)), - WindowCase(clause: "LIMIT 0", window: DynamoDBReadWindow(limit: 0)), - WindowCase(clause: "OFFSET 40", window: DynamoDBReadWindow(offset: 40)), - WindowCase(clause: "LIMIT 25 OFFSET 50", window: DynamoDBReadWindow(limit: 25, offset: 50)), - WindowCase( - clause: "order by \"sk\" desc limit 5 offset 10", - window: DynamoDBReadWindow( - order: [DynamoDBOrderTerm(attribute: "sk", descending: true)], - limit: 5, - offset: 10 - ) - ), - WindowCase( - clause: "ORDER BY \"sk\" ASC LIMIT 5 -- first page", - window: DynamoDBReadWindow(order: [DynamoDBOrderTerm(attribute: "sk", descending: false)], limit: 5) - ) - ] - - static let malformedWindows: [String] = [ - "WHERE \"a\" = 1", - "ORDER BY", - "ORDER \"a\"", - "ORDER BY 1", - "ORDER BY \"a\",", - "ORDER BY \"a\" DESC DESC", - "LIMIT", - "LIMIT ten", - "LIMIT -1", - "LIMIT 1.5", - "OFFSET -2", - "LIMIT 5 ORDER BY \"a\"", - "OFFSET 2 LIMIT 5", - "LIMIT 5 LIMIT 6", - "; Scan {\"TableName\":\"Orders\"}" - ] - - static let roundTripStatements: [DynamoDBStatement] = [ - .partiQL(text: "SELECT * FROM \"Orders\"", window: DynamoDBReadWindow()), - .partiQL( - text: "SELECT * FROM \"Orders\" WHERE \"pk\" = 'a'", - window: DynamoDBReadWindow( - order: [ - DynamoDBOrderTerm(attribute: "sk", descending: true), - DynamoDBOrderTerm(attribute: "a\"b", descending: false) - ], - limit: 5, - offset: 10 - ) - ), - .partiQL(text: "UPDATE \"Orders\" SET \"a\" = 1 WHERE \"pk\" = 'a'", window: DynamoDBReadWindow()), - .apiCall(DynamoDBAPICall(operation: .scan, body: ordersBody), window: DynamoDBReadWindow()), - .apiCall( - DynamoDBAPICall( - operation: .query, - body: .object([ - "TableName": .string("Orders"), - "Limit": .number("12345678901234567890123456789012345678"), - "ConsistentRead": .bool(true), - "ExpressionAttributeValues": .object([":p": .object(["S": .string("a/b \"c\"")])]) - ]) - ), - window: DynamoDBReadWindow( - order: [DynamoDBOrderTerm(attribute: "a\"b", descending: true)], - limit: 5, - offset: 10 - ) - ), - .apiCall( - DynamoDBAPICall(operation: .putItem, body: .object([ - "TableName": .string("Orders"), - "Item": .object(["pk": .object(["S": .string("a")])]) - ])), - window: DynamoDBReadWindow() - ), - .browse( - DynamoDBBrowseRequest(table: "Orders", filters: [], matchAll: true, columns: []), - window: DynamoDBReadWindow() - ), - .browse( - DynamoDBBrowseRequest( - table: "Orders", - filters: [ - DynamoDBBrowseFilter( - attribute: "status", op: "=", value: "open", - secondValue: nil, kind: "text", caseSensitive: false - ) - ], - matchAll: false, - columns: ["pk", "status"] - ), - window: DynamoDBReadWindow(order: [DynamoDBOrderTerm(attribute: "pk", descending: false)], limit: 300) - ) - ] - - private func failureMessage(_ text: String) -> String? { - do { - _ = try DynamoDBStatement.parse(text) - return nil - } catch DynamoDBError.invalidStatement(let message) { - return message - } catch { - return nil - } - } - - // MARK: - API calls - - @Test("An action verb is matched without regard to case", arguments: ["Scan", "scan", "SCAN", "sCaN"]) - func verbIsCaseInsensitive(verb: String) throws { - let statement = try DynamoDBStatement.parse("\(verb) {\"TableName\": \"Orders\"}") - #expect(statement == .apiCall( - DynamoDBAPICall(operation: .scan, body: Self.ordersBody), - window: DynamoDBReadWindow() - )) - } - - @Test("Every action the editor runs parses under its own name", arguments: DynamoDBOperation.allCases) - func everyOperationParses(operation: DynamoDBOperation) throws { - let statement = try DynamoDBStatement.parse("\(operation.rawValue) {}") - #expect(statement == .apiCall( - DynamoDBAPICall(operation: operation, body: .object([:])), - window: DynamoDBReadWindow() - )) - } - - @Test("Trailing semicolons and whitespace are stripped") - func trailingSemicolonsAreStripped() throws { - let call = try DynamoDBStatement.parse(" Scan {\"TableName\":\"Orders\"} ;; \n") - #expect(call == .apiCall(DynamoDBAPICall(operation: .scan, body: Self.ordersBody), window: DynamoDBReadWindow())) - - let partiQL = try DynamoDBStatement.parse("SELECT * FROM \"Orders\";\n") - #expect(partiQL == .partiQL(text: "SELECT * FROM \"Orders\"", window: DynamoDBReadWindow())) - } - - @Test("The body may follow the verb directly or after a line break") - func whitespaceBeforeTheBodyIsOptional() throws { - let expected = DynamoDBStatement.apiCall( - DynamoDBAPICall(operation: .getItem, body: Self.ordersBody), - window: DynamoDBReadWindow() - ) - #expect(try DynamoDBStatement.parse("GetItem{\"TableName\":\"Orders\"}") == expected) - #expect(try DynamoDBStatement.parse("GetItem\n {\"TableName\":\"Orders\"}") == expected) - } - - @Test("A request that is not valid JSON names the action and where the JSON broke") - func invalidJSONIsReported() throws { - let message = try #require(failureMessage("Scan {\"TableName\": }")) - #expect(message.contains("Scan")) - #expect(message.contains(DynamoDBJSON.ParseError.unexpected(offset: 14).localizedDescription)) - } - - @Test("A request with a duplicate key is reported as invalid JSON") - func duplicateKeyIsReported() throws { - let message = try #require(failureMessage("PutItem {\"TableName\": \"a\", \"TableName\": \"b\"}")) - #expect(message.contains("PutItem")) - #expect(message.contains(DynamoDBJSON.ParseError.duplicateKey("TableName").localizedDescription)) - } - - @Test("An unterminated request is refused") - func unterminatedRequestIsRefused() { - #expect(failureMessage("Query {\"TableName\": \"Orders\"") != nil) - } - - @Test( - "An action this editor does not run is refused when a JSON body follows it", - arguments: ["Frobnicate {\"TableName\": \"Orders\"}", "ListBackups {}", "Scna {\"TableName\": \"Orders\"}"] - ) - func unknownActionWithBodyIsRefused(text: String) throws { - let message = try #require(failureMessage(text)) - let verb = String(text.prefix { $0.isLetter }) - #expect(message.contains(verb)) - } - - @Test( - "A verb not followed by a JSON object is read as PartiQL", - arguments: [ - "Scan", - "Scan [{\"TableName\": \"Orders\"}]", - "DeleteItem \"Orders\"", - "select * from \"Orders\"", - "EXISTS(SELECT * FROM \"Orders\" WHERE \"pk\" = 'a')" - ] - ) - func verbWithoutObjectFallsBackToPartiQL(text: String) throws { - #expect(try DynamoDBStatement.parse(text) == .partiQL(text: text, window: DynamoDBReadWindow())) - } - - // MARK: - Browse - - @Test("Browse reads its table, filters, match mode and columns") - func browseParses() throws { - let text = """ - Browse {"TableName": "Orders", "Filters": [\ - {"Attribute": "status", "Operator": "begins_with", "Value": "op", "Kind": "text", "CaseSensitive": false}, \ - {"Attribute": "total", "Operator": "BETWEEN", "Value": "1", "SecondValue": "9"}], \ - "Match": "any", "Columns": ["pk", "status"]} - """ - let statement = try DynamoDBStatement.parse(text) - let expected = DynamoDBBrowseRequest( - table: "Orders", - filters: [ - DynamoDBBrowseFilter( - attribute: "status", op: "BEGINS_WITH", value: "op", - secondValue: nil, kind: "text", caseSensitive: false - ), - DynamoDBBrowseFilter( - attribute: "total", op: "BETWEEN", value: "1", - secondValue: "9", kind: nil, caseSensitive: true - ) - ], - matchAll: false, - columns: ["pk", "status"] - ) - #expect(statement == .browse(expected, window: DynamoDBReadWindow())) - } - - @Test("The Browse verb is matched without regard to case and takes a window") - func browseVerbIsCaseInsensitive() throws { - let statement = try DynamoDBStatement.parse("bRoWsE {\"TableName\": \"Orders\"} LIMIT 300 OFFSET 600") - #expect(statement == .browse( - DynamoDBBrowseRequest(table: "Orders", filters: [], matchAll: true, columns: []), - window: DynamoDBReadWindow(limit: 300, offset: 600) - )) - } - - @Test( - "Browse without a table or with an incomplete filter is refused", - arguments: [ - "Browse {}", - "Browse {\"TableName\": \"\"}", - "Browse {\"TableName\": 5}", - "Browse {\"TableName\": \"Orders\", \"Filters\": [{\"Attribute\": \"a\"}]}", - "Browse {\"TableName\": \"Orders\", \"Filters\": [{\"Operator\": \"=\"}]}" - ] - ) - func incompleteBrowseIsRefused(text: String) { - #expect(failureMessage(text) != nil) - } - - @Test("A Browse filter value written as a JSON number keeps its digits") - func browseNumericValueIsKept() throws { - let json = try DynamoDBJSON.parse(""" - {"TableName": "Orders", "Filters": [{"Attribute": "total", "Operator": "=", "Value": 30}]} - """) - let request = try DynamoDBBrowseRequest(json: json) - #expect(request.filters.first?.value == "30") - } - - @Test("A Browse request survives its JSON form", arguments: [ - DynamoDBBrowseRequest(table: "Orders", filters: [], matchAll: true, columns: []), - DynamoDBBrowseRequest(table: "Or\"ders", filters: [], matchAll: true, columns: ["pk", "a\"b"]), - DynamoDBBrowseRequest( - table: "Orders", - filters: [ - DynamoDBBrowseFilter( - attribute: "status", op: "=", value: "open", - secondValue: nil, kind: "text", caseSensitive: false - ), - DynamoDBBrowseFilter( - attribute: "total", op: "BETWEEN", value: "1", - secondValue: "9", kind: "decimal", caseSensitive: true - ) - ], - matchAll: false, - columns: [] - ), - DynamoDBBrowseRequest( - table: "Orders", - filters: [ - DynamoDBBrowseFilter( - attribute: "note", op: "IS NULL", value: "", - secondValue: nil, kind: nil, caseSensitive: true - ) - ], - matchAll: true, - columns: ["note"] - ) - ]) - func browseJSONRoundTrip(request: DynamoDBBrowseRequest) throws { - #expect(try DynamoDBBrowseRequest(json: request.json) == request) - #expect(try DynamoDBBrowseRequest(json: DynamoDBJSON.parse(request.json.serialized())) == request) - } - - @Test("A Browse request with no filters or columns writes only its table") - func browseJSONOmitsEmptyParts() { - let request = DynamoDBBrowseRequest(table: "Orders", filters: [], matchAll: true, columns: []) - #expect(request.json == Self.ordersBody) - } - - // MARK: - Window - - @Test("A read window after a Scan is parsed", arguments: windowCases) - func windowAfterScan(windowCase: WindowCase) throws { - let statement = try DynamoDBStatement.parse("Scan {\"TableName\": \"Orders\"} \(windowCase.clause)") - #expect(statement == .apiCall( - DynamoDBAPICall(operation: .scan, body: Self.ordersBody), - window: windowCase.window - )) - } - - @Test("A read window after a Query is parsed") - func windowAfterQuery() throws { - let statement = try DynamoDBStatement.parse("query {\"TableName\": \"Orders\"} LIMIT 3;") - #expect(statement == .apiCall( - DynamoDBAPICall(operation: .query, body: Self.ordersBody), - window: DynamoDBReadWindow(limit: 3) - )) - } - - @Test("Malformed or misordered text after a request is refused", arguments: malformedWindows) - func malformedWindowIsRefused(clause: String) { - #expect(failureMessage("Scan {\"TableName\": \"Orders\"} \(clause)") != nil) - } - - @Test("The refusal names the action and repeats the unexpected text") - func malformedWindowMessage() throws { - let message = try #require(failureMessage("Scan {\"TableName\": \"Orders\"} WHERE x = 1 ")) - #expect(message.contains("Scan")) - #expect(message.contains("WHERE x = 1")) - } - - @Test( - "An action that is not a Scan or a Query takes no window", - arguments: [DynamoDBOperation.putItem, .getItem, .deleteItem, .describeTable, .batchWriteItem, .executeStatement] - ) - func windowOnOtherActionIsRefused(operation: DynamoDBOperation) throws { - for clause in ["LIMIT 1", "OFFSET 1", "ORDER BY \"a\""] { - let text = "\(operation.rawValue.lowercased()) {\"TableName\": \"Orders\"} \(clause)" - let message = try #require(failureMessage(text)) - #expect(message.contains(operation.rawValue)) - } - } - - // MARK: - Text - - @Test("Every statement parses back from its own text", arguments: roundTripStatements) - func textRoundTrip(statement: DynamoDBStatement) throws { - #expect(try DynamoDBStatement.parse(statement.text) == statement) - } - - @Test("A statement's text is its verb, compact sorted JSON and its window, a PartiQL window on its own line") - func textSpelling() { - let scan = DynamoDBStatement.apiCall( - DynamoDBAPICall(operation: .scan, body: .object(["TableName": .string("Orders"), "Limit": .number("10")])), - window: DynamoDBReadWindow( - order: [DynamoDBOrderTerm(attribute: "a\"b", descending: true)], - limit: 5, - offset: 10 - ) - ) - #expect(scan.text == "Scan {\"Limit\":10,\"TableName\":\"Orders\"} ORDER BY \"a\"\"b\" DESC LIMIT 5 OFFSET 10") - - let browse = DynamoDBStatement.browse( - DynamoDBBrowseRequest(table: "Orders", filters: [], matchAll: true, columns: []), - window: DynamoDBReadWindow() - ) - #expect(browse.text == "Browse {\"TableName\":\"Orders\"}") - - let partiQL = DynamoDBStatement.partiQL( - text: "SELECT * FROM \"Orders\"", - window: DynamoDBReadWindow(order: [DynamoDBOrderTerm(attribute: "sk", descending: false)], offset: 3) - ) - #expect(partiQL.text == "SELECT * FROM \"Orders\"\nORDER BY \"sk\" ASC OFFSET 3") - } - - @Test("An empty window writes nothing") - func emptyWindowText() { - let window = DynamoDBReadWindow() - #expect(window.isEmpty) - #expect(window.text.isEmpty) - #expect(!DynamoDBReadWindow(offset: 1).isEmpty) - #expect(!DynamoDBReadWindow(limit: 0).isEmpty) - } - - // MARK: - Quoting - - @Test("An identifier is wrapped in double quotes with inner quotes doubled") - func quoteDoublesInnerQuotes() { - #expect(DynamoDBStatement.quote("Orders") == "\"Orders\"") - #expect(DynamoDBStatement.quote("a\"b") == "\"a\"\"b\"") - #expect(DynamoDBStatement.quote("\"") == "\"\"\"\"") - #expect(DynamoDBStatement.quote("") == "\"\"") - } - - @Test( - "A quoted identifier reads back as the same name", - arguments: ["Orders", "a\"b", "\"\"", "it's", "with space", "-- not a comment", "/* nor this */"] - ) - func quoteRoundTripsThroughTheTokenizer(name: String) { - let tokens = DynamoDBPartiQL.tokens(of: DynamoDBStatement.quote(name)) - #expect(tokens.count == 1) - #expect(tokens.first?.kind == .quotedIdentifier) - #expect(tokens.first?.identifierValue == name) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBTableManagementTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBTableManagementTests.swift deleted file mode 100644 index 5e80ee3d69..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBTableManagementTests.swift +++ /dev/null @@ -1,427 +0,0 @@ -// -// DynamoDBTableManagementTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct DynamoDBTableManagementTests { - private typealias Field = DynamoDBTableDefinition.Field - - private static let driver = DynamoDBPluginDriver( - config: DriverConnectionConfig(host: "", port: 0, username: "", password: "", database: ""), - catalog: DynamoDBCatalog() - ) - - private static func request(_ text: String) throws -> DynamoDBJSON { - let statement = try DynamoDBStatement.parse(text) - guard case .apiCall(let call, _) = statement else { - Issue.record("Not an API call: \(text)") - return .null - } - return call.body - } - - private static func formError(_ body: () throws -> Void) -> PluginCreateTableFormError? { - do { - try body() - return nil - } catch let error as PluginCreateTableFormError { - return error - } catch { - return nil - } - } - - // MARK: - Create Table form - - @Test("A table with only a partition key is on-demand and declares that key alone") - func minimalTable() throws { - let body = try DynamoDBTableDefinition.createTableRequest(from: PluginCreateTableRequest( - tableName: " Orders ", values: [Field.partitionKeyName: "pk", Field.partitionKeyType: "S"] - )) - - #expect(body["TableName"] == .string("Orders")) - #expect(body["BillingMode"] == .string("PAY_PER_REQUEST")) - #expect(body["KeySchema"] == .array([.object(["AttributeName": .string("pk"), "KeyType": .string("HASH")])])) - #expect(body["AttributeDefinitions"] == .array([ - .object(["AttributeName": .string("pk"), "AttributeType": .string("S")]) - ])) - #expect(body["ProvisionedThroughput"] == nil) - #expect(body["TableClass"] == nil) - #expect(body["DeletionProtectionEnabled"] == nil) - } - - @Test("A provisioned table gives its global indexes the same capacity and declares each key once") - func provisionedTableWithIndexes() throws { - let body = try DynamoDBTableDefinition.createTableRequest(from: PluginCreateTableRequest( - tableName: "Orders", - values: [ - Field.partitionKeyName: "pk", Field.partitionKeyType: "S", - Field.sortKeyName: "sk", Field.sortKeyType: "N", - Field.billingMode: "PROVISIONED", Field.readCapacity: "5", Field.writeCapacity: "7", - Field.tableClass: "STANDARD_INFREQUENT_ACCESS", Field.deletionProtection: "true" - ], - repeatedValues: [ - Field.globalIndexes: [[ - Field.indexName: "byStatus", Field.partitionKeyName: "status", Field.partitionKeyType: "S", - Field.sortKeyName: "sk", Field.sortKeyType: "N", Field.projection: "KEYS_ONLY" - ]], - Field.localIndexes: [[ - Field.indexName: "byTotal", Field.sortKeyName: "total", Field.sortKeyType: "N", - Field.projection: "INCLUDE", Field.includedAttributes: "a, b" - ]] - ] - )) - - let capacity: DynamoDBJSON = .object(["ReadCapacityUnits": .number("5"), "WriteCapacityUnits": .number("7")]) - let global = body["GlobalSecondaryIndexes"]?.arrayValue?.first - let local = body["LocalSecondaryIndexes"]?.arrayValue?.first - let declared = body["AttributeDefinitions"]?.arrayValue?.compactMap { $0["AttributeName"]?.stringValue } - #expect(body["ProvisionedThroughput"] == capacity) - #expect(global?["ProvisionedThroughput"] == capacity) - #expect(global?["Projection"] == .object(["ProjectionType": .string("KEYS_ONLY")])) - #expect(local?["ProvisionedThroughput"] == nil) - #expect(local?["KeySchema"]?.arrayValue?.first?["AttributeName"] == .string("pk")) - #expect(local?["Projection"]?["NonKeyAttributes"] == .array([.string("a"), .string("b")])) - #expect(declared == ["pk", "sk", "status", "total"]) - #expect(body["TableClass"] == .string("STANDARD_INFREQUENT_ACCESS")) - #expect(body["DeletionProtectionEnabled"] == .bool(true)) - } - - @Test("A local index on a table without a sort key is refused at the sort key field") - func localIndexNeedsTableSortKey() { - let error = Self.formError { - _ = try DynamoDBTableDefinition.createTableRequest(from: PluginCreateTableRequest( - tableName: "Orders", - values: [Field.partitionKeyName: "pk"], - repeatedValues: [Field.localIndexes: [[Field.indexName: "byTotal", Field.sortKeyName: "total"]]] - )) - } - - #expect(error?.fieldId == Field.sortKeyName) - } - - @Test("An attribute declared as two types is refused") - func conflictingTypesAreRefused() { - let error = Self.formError { - _ = try DynamoDBTableDefinition.createTableRequest(from: PluginCreateTableRequest( - tableName: "Orders", - values: [Field.partitionKeyName: "pk", Field.partitionKeyType: "S"], - repeatedValues: [Field.globalIndexes: [[ - Field.indexName: "byPk", Field.partitionKeyName: "pk", Field.partitionKeyType: "N" - ]]] - )) - } - - #expect(error?.fieldId == Field.partitionKeyType) - } - - @Test("An incomplete form names the field to fix") - func incompleteFormNamesField() { - let cases: [(values: [String: String], field: String)] = [ - ([Field.partitionKeyName: "pk", Field.billingMode: "PROVISIONED", Field.writeCapacity: "1"], Field.readCapacity), - ( - [Field.partitionKeyName: "pk", Field.billingMode: "PROVISIONED", Field.readCapacity: "1", Field.writeCapacity: "0"], - Field.writeCapacity - ), - ([Field.partitionKeyName: " "], Field.partitionKeyName) - ] - for testCase in cases { - let error = Self.formError { - _ = try DynamoDBTableDefinition.createTableRequest( - from: PluginCreateTableRequest(tableName: "Orders", values: testCase.values) - ) - } - #expect(error?.fieldId == testCase.field, "\(testCase.values)") - } - } - - @Test("An included projection with no attributes is refused") - func includeNeedsAttributes() { - let error = Self.formError { - _ = try DynamoDBTableDefinition.createTableRequest(from: PluginCreateTableRequest( - tableName: "Orders", - values: [Field.partitionKeyName: "pk"], - repeatedValues: [Field.globalIndexes: [[ - Field.indexName: "byStatus", Field.partitionKeyName: "status", Field.projection: "INCLUDE" - ]]] - )) - } - - #expect(error?.fieldId == Field.includedAttributes) - } - - @Test("A table name DynamoDB would reject is refused", arguments: ["ab", "has space", String(repeating: "a", count: 256)]) - func invalidTableNameIsRefused(name: String) { - let error = Self.formError { - _ = try DynamoDBTableDefinition.createTableRequest(from: PluginCreateTableRequest( - tableName: name, values: [Field.partitionKeyName: "pk"] - )) - } - - #expect(error != nil) - } - - @Test("The form's statement is a CreateTable request the editor parses back") - func formStatementRoundTrips() throws { - let statements = try Self.driver.createTableStatements( - for: PluginCreateTableRequest(tableName: "Orders", values: [Field.partitionKeyName: "pk"]), schema: nil - ) - - let body = try Self.request(try #require(statements.first)) - #expect(statements.count == 1) - #expect(body["TableName"] == .string("Orders")) - } - - // MARK: - Maintenance - - @Test("A stream turned off sends no view type") - func streamOff() throws { - let statement = try #require(Self.driver.maintenanceStatements( - operation: DynamoDBPluginDriver.MaintenanceName.stream, table: "Orders", schema: nil, - options: ["choice": String(localized: "Off")] - )?.first) - - let body = try Self.request(statement) - #expect(body["StreamSpecification"] == .object(["StreamEnabled": .bool(false)])) - } - - @Test("A stream turned on sends the chosen view type") - func streamOn() throws { - let statement = try #require(Self.driver.maintenanceStatements( - operation: DynamoDBPluginDriver.MaintenanceName.stream, table: "Orders", schema: nil, - options: ["choice": String(localized: "Keys only")] - )?.first) - - let body = try Self.request(statement) - #expect(body["StreamSpecification"] == .object([ - "StreamEnabled": .bool(true), "StreamViewType": .string("KEYS_ONLY") - ])) - } - - @Test("Each setting becomes the request that changes it") - func maintenanceRequests() throws { - let name = DynamoDBPluginDriver.MaintenanceName.self - let recovery = try Self.request(try #require(Self.driver.maintenanceStatements( - operation: name.pointInTimeRecovery, table: "Orders", schema: nil, options: ["enabled": "false"] - )?.first)) - let protection = try Self.request(try #require(Self.driver.maintenanceStatements( - operation: name.deletionProtection, table: "Orders", schema: nil, options: [:] - )?.first)) - let tableClass = try Self.request(try #require(Self.driver.maintenanceStatements( - operation: name.tableClass, table: "Orders", schema: nil, - options: ["choice": String(localized: "Standard-Infrequent Access")] - )?.first)) - let onDemand = try Self.request(try #require(Self.driver.maintenanceStatements( - operation: name.onDemand, table: "Orders", schema: nil, options: [:] - )?.first)) - - #expect(recovery["PointInTimeRecoverySpecification"] == .object(["PointInTimeRecoveryEnabled": .bool(false)])) - #expect(protection["DeletionProtectionEnabled"] == .bool(true)) - #expect(tableClass["TableClass"] == .string("STANDARD_INFREQUENT_ACCESS")) - #expect(onDemand["BillingMode"] == .string("PAY_PER_REQUEST")) - } - - @Test("An unknown operation, or one with no table, builds nothing") - func unknownMaintenance() { - #expect(Self.driver.maintenanceStatements(operation: "VACUUM", table: "Orders", schema: nil, options: [:]) == nil) - #expect(Self.driver.maintenanceStatements( - operation: DynamoDBPluginDriver.MaintenanceName.onDemand, table: nil, schema: nil, options: [:] - ) == nil) - } - - // MARK: - Summaries - - @Test("The table summary keeps the capacity's own comma inside its part") - func tableSummarySeparatesParts() throws { - let schema = try DynamoDBTableSchema(describeTableResponse: DynamoDBJSON.parse(#""" - {"Table": {"TableName": "Orders", "TableStatus": "ACTIVE", - "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], - "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], - "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 2}, - "TableClassSummary": {"TableClass": "STANDARD"}, "DeletionProtectionEnabled": true}} - """#)) - let expected = [ - String(format: String(localized: "Provisioned: %1$lld read, %2$lld write"), Int64(5), Int64(2)), - String(localized: "Standard"), - String(localized: "Deletion protection on"), - String(localized: "Item count is approximate, updated about every six hours"), - ].joined(separator: "; ") - - #expect(DynamoDBTableDefinition.summary(schema) == expected) - } - - @Test("The read status lists its counts with commas and gives a note its own sentence") - func readStatusSeparatesCountsFromNotes() { - let stats = DynamoDBReadStats(returned: 5, scanned: 12, readUnits: 3, reportedReadUnits: true) - let note = String(localized: "This SELECT reads the whole table") - let counts = [ - String(localized: "Scan"), - String(format: String(localized: "%@ returned"), "5"), - String(format: String(localized: "%@ read"), "12"), - String(format: String(localized: "%@ RCU"), "3"), - ].joined(separator: ", ") - - #expect(DynamoDBPluginDriver.statusMessage(access: String(localized: "Scan"), stats: stats) == counts) - #expect( - DynamoDBPluginDriver.statusMessage(access: String(localized: "Scan"), stats: stats, extra: [note]) - == "\(counts). \(note)" - ) - } - - // MARK: - Indexes and drop - - @Test("An index edited in place is refused, and so is dropping the primary key or a local index") - func indexEditsAndDrops() { - let global = PluginIndexDefinition(name: "byStatus", columns: ["status"], indexType: "GLOBAL All attributes") - let local = PluginIndexDefinition(name: "byTotal", columns: ["pk", "total"], indexType: "LOCAL Keys only") - let primary = PluginIndexDefinition(name: "PRIMARY", columns: ["pk"], indexType: "PRIMARY KEY") - - #expect(Self.driver.schemaOperationRefusal(.modifyIndex(old: global, new: global)) != nil) - #expect(Self.driver.schemaOperationRefusal(.dropIndex(global)) == nil) - #expect(Self.driver.schemaOperationRefusal(.dropIndex(local)) != nil) - #expect(Self.driver.schemaOperationRefusal(.dropIndex(primary)) != nil) - } - - private static func structureIndex(_ name: String, columns: [String], type: String) -> EditableIndexDefinition { - EditableIndexDefinition( - id: UUID(), name: name, columns: columns, type: .init(rawValue: type), isUnique: false, isPrimary: false, - comment: nil - ) - } - - private static func refusalOfSave(_ changes: [SchemaChange]) -> String? { - do { - _ = try SchemaStatementGenerator(tableName: "Orders", pluginDriver: driver).generate(changes: changes) - return nil - } catch let error as SchemaOperationRefusedError { - return error.reason - } catch { - return "unexpected: \(error.localizedDescription)" - } - } - - /// DynamoDB takes one index change per `UpdateTable` and none while the table is `UPDATING`, and - /// the driver sends one request per change. So the delete would run and the add then fail, - /// leaving no index under the name. The save is refused before either request is sent. - @Test("Deleting a global index and adding one under its name in one save is refused before anything runs") - func sameNameGlobalIndexReplacementIsRefused() { - let dropped = Self.structureIndex("byStatus", columns: ["status"], type: "GLOBAL All attributes") - let added = Self.structureIndex("byStatus", columns: ["status", "total"], type: "GLOBAL All attributes") - - #expect(Self.refusalOfSave([.deleteIndex(dropped), .addIndex(added)]) - == Self.driver.schemaOperationRefusal(.modifyIndex(old: dropped.toPlugin(), new: added.toPlugin()))) - #expect(Self.refusalOfSave([.deleteIndex(dropped), .addIndex(added)])?.hasPrefix("A DynamoDB index can't be changed") == true) - } - - @Test("A local index put back under its own name is refused for being part of the table") - func sameNameLocalIndexReplacementIsRefusedAsADrop() { - let dropped = Self.structureIndex("byTotal", columns: ["pk", "total"], type: "LOCAL Keys only") - let added = Self.structureIndex("byTotal", columns: ["pk", "created"], type: "LOCAL Keys only") - - #expect(Self.refusalOfSave([.addIndex(added), .deleteIndex(dropped)]) - == "A local secondary index is part of its table and is removed only with the table.") - } - - @Test("A global index from the Structure tab takes its keys from the columns and its projection from the included ones") - func addIndex() throws { - let index = PluginIndexDefinition( - name: "byStatus", columns: ["status", "total"], expressions: nil, includedColumns: ["a"], - ddlMethodAndKeys: nil, ddlWhereClause: nil - ) - let statement = try #require(Self.driver.generateAddIndexSQL(table: "Orders", index: index)) - - let create = try Self.request(statement)["GlobalSecondaryIndexUpdates"]?.arrayValue?.first?["Create"] - #expect(create?["KeySchema"] == .array([ - .object(["AttributeName": .string("status"), "KeyType": .string("HASH")]), - .object(["AttributeName": .string("total"), "KeyType": .string("RANGE")]) - ])) - #expect(create?["Projection"] == .object([ - "ProjectionType": .string("INCLUDE"), "NonKeyAttributes": .array([.string("a")]) - ])) - } - - @Test("A unique index, or one with more than two columns, is refused with a reason") - func refusedIndexes() { - let unique = PluginIndexDefinition(name: "u", columns: ["a"], isUnique: true) - let wide = PluginIndexDefinition(name: "w", columns: ["a", "b", "c"]) - - #expect(Self.driver.generateAddIndexSQL(table: "Orders", index: unique) == nil) - #expect(Self.driver.generateAddIndexSQL(table: "Orders", index: wide) == nil) - #expect(Self.driver.schemaOperationRefusal(.addIndex(unique)) != nil) - #expect(Self.driver.schemaOperationRefusal(.addIndex(wide)) != nil) - } - - @Test("Dropping an index deletes that global index, and the primary key is never dropped") - func dropIndex() throws { - let statement = try #require(Self.driver.generateDropIndexSQL(table: "Orders", indexName: "byStatus")) - - let update = try Self.request(statement)["GlobalSecondaryIndexUpdates"]?.arrayValue?.first - #expect(update == .object(["Delete": .object(["IndexName": .string("byStatus")])])) - #expect(Self.driver.generateDropIndexSQL(table: "Orders", indexName: "PRIMARY") == nil) - } - - @Test("Dropping a table is a DeleteTable request, and only for a valid table name") - func dropTable() throws { - let statement = try #require(Self.driver.dropObjectStatement( - name: "Orders", objectType: "TABLE", schema: nil, cascade: false - )) - - #expect(try Self.request(statement) == .object(["TableName": .string("Orders")])) - #expect(Self.driver.dropObjectStatement(name: "Or\"ders", objectType: "TABLE", schema: nil, cascade: false) == nil) - #expect(Self.driver.dropObjectStatement(name: "Orders", objectType: "VIEW", schema: nil, cascade: false) == nil) - } - - // MARK: - Field paths - - @Test("Field paths reach into maps up to four levels, and never through a list") - func fieldPaths() { - let item: DynamoDBItem = [ - "address": .map(["city": .string("Paris"), "geo": .map(["lat": .map(["deg": .map(["x": .number("1")])])])]), - "items": .list([.map(["sku": .string("A1")])]) - ] - - let paths = Dictionary(uniqueKeysWithValues: DynamoDBFieldPaths.collect(from: [item]).map { ($0.path, $0) }) - - #expect(paths["address.city"]?.typeName == "String") - #expect(paths["address.geo.lat.deg"]?.depth == 4) - #expect(paths["address.geo.lat.deg.x"] == nil) - #expect(paths["items"]?.typeName == "List") - #expect(paths["items.sku"] == nil) - } - - // MARK: - Row limits and ORDER BY - - @Test("An injected row limit keeps a smaller one and survives a trailing comment") - func injectRowLimit() { - let driver = Self.driver - - let kept = driver.injectRowLimit("SELECT * FROM \"t\" LIMIT 3", limit: 10) - let commented = driver.injectRowLimit("SELECT * FROM \"t\" -- note", limit: 5) - - #expect(kept?.hasSuffix("\nLIMIT 3") == true) - #expect(kept?.contains("LIMIT 10") == false) - #expect(commented?.hasSuffix("-- note\nLIMIT 5") == true) - #expect(driver.injectRowLimit(#"Scan {"TableName":"t"}"#, limit: 5) == #"Scan {"TableName":"t"} LIMIT 5"#) - #expect(driver.injectRowLimit("DELETE FROM \"t\" WHERE \"pk\" = 'a'", limit: 5) == nil) - #expect(driver.injectRowLimit(#"PutItem {"TableName":"t"}"#, limit: 5) == nil) - } - - @Test("The read limits never go negative and never overflow") - func readLimits() { - func limits(_ window: DynamoDBReadWindow, _ cap: Int) -> [Int] { - let result = DynamoDBPluginDriver.readLimits(window: window, rowCap: cap) - return [result.wanted, result.read] - } - - #expect(limits(DynamoDBReadWindow(), 100) == [100, 101]) - #expect(limits(DynamoDBReadWindow(), Int.max) == [Int.max, Int.max]) - #expect(limits(DynamoDBReadWindow(limit: 5), 100) == [5, 5]) - #expect(limits(DynamoDBReadWindow(limit: 500), 100) == [100, 101]) - #expect(limits(DynamoDBReadWindow(limit: -1), 100) == [0, 0]) - } -} diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBWriteStatementsTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBWriteStatementsTests.swift deleted file mode 100644 index 502bb567af..0000000000 --- a/TableProTests/Plugins/DynamoDB/DynamoDBWriteStatementsTests.swift +++ /dev/null @@ -1,278 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct DynamoDBWriteStatementsTests { - typealias CellChange = (columnIndex: Int, columnName: String, oldValue: PluginCellValue, newValue: PluginCellValue) - - private let writer = DynamoDBWriteStatements( - table: "Orders", - columns: ["pk", "sk", "name", "total", "o'clock"], - keyColumns: ["pk", "sk"] - ) - - private let originalRow: [PluginCellValue] = ["p1", "7", "Ann", "10", .null] - - private func cell( - _ index: Int, - _ name: String, - _ old: PluginCellValue, - _ new: PluginCellValue - ) -> CellChange { - (columnIndex: index, columnName: name, oldValue: old, newValue: new) - } - - private func update(_ cells: [CellChange], row: Int = 0) -> PluginRowChange { - PluginRowChange(rowIndex: row, type: .update, cellChanges: cells, originalRow: originalRow) - } - - private func generate( - _ changes: [PluginRowChange], - insertedRowData: [Int: [PluginCellValue]] = [:], - deleted: Set = [], - inserted: Set = [] - ) -> [(statement: String, parameters: [PluginCellValue])] { - writer.statements( - for: changes, - insertedRowData: insertedRowData, - deletedRowIndices: deleted, - insertedRowIndices: inserted - ) - } - - private static func schema() throws -> DynamoDBTableSchema { - try DynamoDBTableSchema(describeTableResponse: DynamoDBJSON.parse(""" - {"Table": { - "TableName": "Orders", - "KeySchema": [ - {"AttributeName": "pk", "KeyType": "HASH"}, - {"AttributeName": "sk", "KeyType": "RANGE"} - ], - "AttributeDefinitions": [ - {"AttributeName": "pk", "AttributeType": "S"}, - {"AttributeName": "sk", "AttributeType": "N"} - ] - }} - """)) - } - - // MARK: - UPDATE - - @Test("A changed value is set, guarded by its loaded value and keyed by the original row") - func updateChangedValue() throws { - let result = generate([update([cell(2, "name", "Ann", "Bea")])]) - let statement = try #require(result.first) - #expect(result.count == 1) - #expect(statement.statement == "UPDATE \"Orders\" SET \"name\" = ? WHERE \"pk\" = ? AND \"sk\" = ? AND \"name\" = ?") - #expect(statement.parameters == ["Bea", "p1", "7", "Ann"]) - } - - @Test("A value set to NULL is removed, still guarded by its loaded value") - func updateToNullRemoves() throws { - let statement = try #require(generate([update([cell(3, "total", "10", .null)])]).first) - #expect(statement.statement == "UPDATE \"Orders\" REMOVE \"total\" WHERE \"pk\" = ? AND \"sk\" = ? AND \"total\" = ?") - #expect(statement.parameters == ["p1", "7", "10"]) - } - - @Test("SET comes before REMOVE, and parameters run SET values, key, then guards") - func updateParameterOrder() throws { - let statement = try #require(generate([update([ - cell(2, "name", "Ann", "Bea"), - cell(3, "total", "10", .null), - cell(4, "o'clock", .null, "noon") - ])]).first) - #expect(statement.statement == """ - UPDATE "Orders" SET "name" = ?, "o'clock" = ? REMOVE "total" \ - WHERE "pk" = ? AND "sk" = ? AND "name" = ? AND "total" = ? - """) - #expect(statement.parameters == ["Bea", "noon", "p1", "7", "Ann", "10"]) - } - - @Test("A value that was NULL is set without a guard") - func updateFromNullHasNoGuard() throws { - let statement = try #require(generate([update([cell(4, "o'clock", .null, "noon")])]).first) - #expect(statement.statement == "UPDATE \"Orders\" SET \"o'clock\" = ? WHERE \"pk\" = ? AND \"sk\" = ?") - #expect(statement.parameters == ["noon", "p1", "7"]) - } - - @Test("A binary value is guarded and set as bytes") - func updateBinaryValue() throws { - let old = Data([0x01, 0x02]) - let new = Data([0x03]) - let statement = try #require(generate([update([cell(3, "total", .bytes(old), .bytes(new))])]).first) - #expect(statement.parameters == [.bytes(new), "p1", "7", .bytes(old)]) - } - - @Test("A changed key is still written, keyed by the original values and never guarded") - func updateChangedKey() throws { - let statement = try #require(generate([update([cell(0, "pk", "p1", "p2")])]).first) - #expect(statement.statement == "UPDATE \"Orders\" SET \"pk\" = ? WHERE \"pk\" = ? AND \"sk\" = ?") - #expect(statement.parameters == ["p2", "p1", "7"]) - } - - @Test("The binder refuses the key change the generator writes") - func changedKeyIsRefusedWhenBound() throws { - let statement = try #require(generate([update([cell(1, "sk", "7", "8")])]).first) - let roles = DynamoDBPartiQL.parameterRoles(in: statement.statement) - #expect(roles == [ - .assigned(DynamoDBAttributePath(attribute: "sk")), - .compared(DynamoDBAttributePath(attribute: "pk")), - .compared(DynamoDBAttributePath(attribute: "sk")) - ]) - - let binder = DynamoDBParameterBinder(schema: try Self.schema(), observedTypes: [:], currentItem: nil) - #expect(throws: DynamoDBError.self) { - try binder.bind(statement.parameters, roles: roles) - } - } - - @Test("The roles read from a generated UPDATE line up with its parameters") - func updateRolesLineUp() throws { - let statement = try #require(generate([update([ - cell(2, "name", "Ann", "Bea"), - cell(3, "total", "10", .null), - cell(4, "o'clock", .null, "noon") - ])]).first) - let roles = DynamoDBPartiQL.parameterRoles(in: statement.statement) - #expect(roles == [ - .assigned(DynamoDBAttributePath(attribute: "name")), .assigned(DynamoDBAttributePath(attribute: "o'clock")), - .compared(DynamoDBAttributePath(attribute: "pk")), - .compared(DynamoDBAttributePath(attribute: "sk")), - .compared(DynamoDBAttributePath(attribute: "name")), - .compared(DynamoDBAttributePath(attribute: "total")) - ]) - #expect(roles.count == statement.parameters.count) - } - - @Test("An update with no original row or no changed cells writes nothing") - func updateWithoutOriginalOrCells() { - let noOriginal = PluginRowChange( - rowIndex: 0, type: .update, cellChanges: [cell(2, "name", "Ann", "Bea")], originalRow: nil - ) - let noCells = PluginRowChange(rowIndex: 1, type: .update, cellChanges: [], originalRow: originalRow) - #expect(generate([noOriginal, noCells]).isEmpty) - } - - @Test("Table and attribute names with double quotes are escaped") - func updateQuotesNames() throws { - let quoted = DynamoDBWriteStatements(table: "Or\"ders", columns: ["p\"k", "a\"b"], keyColumns: ["p\"k"]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [cell(1, "a\"b", "x", "y")], - originalRow: ["k", "x"] - ) - let result = quoted.statements( - for: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - let statement = try #require(result.first) - #expect(statement.statement == "UPDATE \"Or\"\"ders\" SET \"a\"\"b\" = ? WHERE \"p\"\"k\" = ? AND \"a\"\"b\" = ?") - #expect(DynamoDBPartiQL.target(of: statement.statement)?.table == "Or\"ders") - #expect(DynamoDBPartiQL.parameterRoles(in: statement.statement) == [ - .assigned(DynamoDBAttributePath(attribute: "a\"b")), - .compared(DynamoDBAttributePath(attribute: "p\"k")), - .compared(DynamoDBAttributePath(attribute: "a\"b")) - ]) - } - - // MARK: - INSERT - - @Test("An insert leaves out NULL cells and the default sentinel") - func insertSkipsNullAndDefault() { - let data = Data([0xFF]) - let result = writer.insert(row: ["p1", "7", .null, "__DEFAULT__", .bytes(data)]) - #expect(result.statement == "INSERT INTO \"Orders\" VALUE {'pk': ?, 'sk': ?, 'o''clock': ?}") - #expect(result.parameters == ["p1", "7", .bytes(data)]) - } - - @Test("An empty string is inserted, only the exact sentinel is skipped") - func insertKeepsEmptyText() { - let result = writer.insert(row: ["p1", "7", "", "__default__", " __DEFAULT__"]) - #expect(result.statement == "INSERT INTO \"Orders\" VALUE {'pk': ?, 'sk': ?, 'name': ?, 'total': ?, 'o''clock': ?}") - #expect(result.parameters == ["p1", "7", "", "__default__", " __DEFAULT__"]) - } - - @Test("The roles read from a generated INSERT name each attribute") - func insertRolesLineUp() { - let result = writer.insert(row: ["p1", "7", .null, "10", "noon"]) - #expect(DynamoDBPartiQL.parameterRoles(in: result.statement) == [ - .inserted("pk"), .inserted("sk"), .inserted("total"), .inserted("o'clock") - ]) - } - - @Test("Single quotes in attribute names are doubled") - func literalDoublesQuotes() { - #expect(DynamoDBWriteStatements.literal("name") == "'name'") - #expect(DynamoDBWriteStatements.literal("it's") == "'it''s'") - #expect(DynamoDBWriteStatements.literal("''") == "''''''") - } - - @Test("An inserted row with no row data is built from its changed cells") - func insertFromCellChanges() throws { - let change = PluginRowChange( - rowIndex: 5, - type: .insert, - cellChanges: [cell(0, "pk", .null, "p9"), cell(2, "name", .null, "Cy"), cell(9, "ghost", .null, "z")], - originalRow: nil - ) - let statement = try #require(generate([change], inserted: [5]).first) - #expect(statement.statement == "INSERT INTO \"Orders\" VALUE {'pk': ?, 'name': ?}") - #expect(statement.parameters == ["p9", "Cy"]) - } - - @Test("Row data takes precedence over the changed cells of an inserted row") - func insertPrefersRowData() throws { - let change = PluginRowChange( - rowIndex: 5, type: .insert, cellChanges: [cell(0, "pk", .null, "stale")], originalRow: nil - ) - let statement = try #require(generate([change], insertedRowData: [5: ["p5", "1"]], inserted: [5]).first) - #expect(statement.statement == "INSERT INTO \"Orders\" VALUE {'pk': ?, 'sk': ?}") - #expect(statement.parameters == ["p5", "1"]) - } - - // MARK: - DELETE - - @Test("A delete is keyed by the original row and returns the old item") - func deleteStatement() throws { - let change = PluginRowChange(rowIndex: 2, type: .delete, cellChanges: [], originalRow: originalRow) - let statement = try #require(generate([change], deleted: [2]).first) - #expect(statement.statement == "DELETE FROM \"Orders\" WHERE \"pk\" = ? AND \"sk\" = ? RETURNING ALL OLD *") - #expect(statement.parameters == ["p1", "7"]) - #expect(DynamoDBPartiQL.hasReturning(statement.statement)) - #expect(DynamoDBPartiQL.parameterRoles(in: statement.statement) == [ - .compared(DynamoDBAttributePath(attribute: "pk")), - .compared(DynamoDBAttributePath(attribute: "sk")) - ]) - } - - @Test("A delete with no original row writes nothing") - func deleteWithoutOriginal() { - let change = PluginRowChange(rowIndex: 2, type: .delete, cellChanges: [], originalRow: nil) - #expect(generate([change], deleted: [2]).isEmpty) - } - - // MARK: - Batches - - @Test("Only rows still marked inserted or deleted are written, in change order") - func statementsRespectIndices() { - let changes = [ - PluginRowChange(rowIndex: 3, type: .insert, cellChanges: [], originalRow: nil), - PluginRowChange(rowIndex: 4, type: .insert, cellChanges: [], originalRow: nil), - PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["d1", "1", .null, .null, .null]), - PluginRowChange(rowIndex: 2, type: .delete, cellChanges: [], originalRow: ["d2", "2", .null, .null, .null]), - update([cell(2, "name", "Ann", "Bea")]) - ] - let result = generate( - changes, - insertedRowData: [3: ["n3", "3"], 4: ["n4", "4"]], - deleted: [2], - inserted: [3] - ) - #expect(result.map(\.statement) == [ - "INSERT INTO \"Orders\" VALUE {'pk': ?, 'sk': ?}", - "DELETE FROM \"Orders\" WHERE \"pk\" = ? AND \"sk\" = ? RETURNING ALL OLD *", - "UPDATE \"Orders\" SET \"name\" = ? WHERE \"pk\" = ? AND \"sk\" = ? AND \"name\" = ?" - ]) - #expect(result.map(\.parameters) == [["n3", "3"], ["d2", "2"], ["Bea", "p1", "7", "Ann"]]) - } -} diff --git a/TableProTests/Plugins/DynamoDBMetadataParityTests.swift b/TableProTests/Plugins/DynamoDBMetadataParityTests.swift deleted file mode 100644 index 46c7c09fa8..0000000000 --- a/TableProTests/Plugins/DynamoDBMetadataParityTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// DynamoDBMetadataParityTests.swift -// TableProTests -// -// The app describes DynamoDB from a curated copy of the plugin's statics until the registry plugin -// loads. `DynamoDBPlugin.swift` is compiled into this target, so the two copies are compared here -// instead of drifting apart until the plugin silently replaces one with the other. -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct DynamoDBMetadataParityTests { - private func curated() throws -> PluginMetadataSnapshot { - try #require(PluginMetadataRegistry.shared.builtInDefaults().first { $0.typeId == "DynamoDB" }?.snapshot) - } - - @Test("Identity and structure editing match the plugin") - func identityAndStructureEditing() throws { - let snapshot = try curated() - - #expect(snapshot.displayName == DynamoDBPlugin.databaseDisplayName) - #expect(snapshot.iconName == DynamoDBPlugin.iconName) - #expect(snapshot.brandColorHex == DynamoDBPlugin.brandColorHex) - #expect(snapshot.queryLanguageName == DynamoDBPlugin.queryLanguageName) - #expect(snapshot.editorLanguage == DynamoDBPlugin.editorLanguage) - #expect(snapshot.parameterStyle == DynamoDBPlugin.parameterStyle) - #expect(snapshot.connectionMode == DynamoDBPlugin.connectionMode) - #expect(snapshot.supportsSchemaEditing == DynamoDBPlugin.supportsSchemaEditing) - #expect(snapshot.supportsForeignKeys == DynamoDBPlugin.supportsForeignKeys) - #expect(snapshot.supportsDatabaseSwitching == DynamoDBPlugin.supportsDatabaseSwitching) - #expect(snapshot.schema.structureColumnFields == DynamoDBPlugin.structureColumnFields) - #expect(snapshot.schema.databaseGroupingStrategy == DynamoDBPlugin.databaseGroupingStrategy) - #expect(snapshot.schema.defaultGroupName == DynamoDBPlugin.defaultGroupName) - #expect(snapshot.schema.tableEntityName == DynamoDBPlugin.tableEntityName) - } - - @Test("Structure capabilities match the plugin") - func structureCapabilities() throws { - let capabilities = try curated().capabilities - - #expect(capabilities.supportsAddColumn == DynamoDBPlugin.supportsAddColumn) - #expect(capabilities.supportsModifyColumn == DynamoDBPlugin.supportsModifyColumn) - #expect(capabilities.supportsDropColumn == DynamoDBPlugin.supportsDropColumn) - #expect(capabilities.supportsRenameColumn == DynamoDBPlugin.supportsRenameColumn) - #expect(capabilities.supportsModifyPrimaryKey == DynamoDBPlugin.supportsModifyPrimaryKey) - #expect(capabilities.supportsAddIndex == DynamoDBPlugin.supportsAddIndex) - #expect(capabilities.supportsDropIndex == DynamoDBPlugin.supportsDropIndex) - #expect(capabilities.supportsImport == DynamoDBPlugin.supportsImport) - #expect(capabilities.supportsExport == DynamoDBPlugin.supportsExport) - #expect(capabilities.supportsSSH == DynamoDBPlugin.supportsSSH) - #expect(capabilities.supportsSSL == DynamoDBPlugin.supportsSSL) - #expect(capabilities.supportsReadOnlyMode == DynamoDBPlugin.supportsReadOnlyMode) - #expect(capabilities.exactRowCountIsFullScan) - } - - @Test("Editor metadata matches the plugin") - func editor() throws { - let snapshot = try curated() - let dialect = try #require(snapshot.editor.sqlDialect) - let shipped = try #require(DynamoDBPlugin.sqlDialect) - - #expect(dialect.identifierQuote == shipped.identifierQuote) - #expect(dialect.keywords == shipped.keywords) - #expect(dialect.functions == shipped.functions) - #expect(dialect.dataTypes == shipped.dataTypes) - #expect(dialect.booleanLiteralStyle == shipped.booleanLiteralStyle) - #expect(dialect.autoLimitStyle == shipped.autoLimitStyle) - #expect(dialect.caseSensitivityStyle == shipped.caseSensitivityStyle) - #expect(dialect.caseSensitivityStyle == .driverManaged) - #expect(DynamoDBPlugin.caseSensitivityStyle == .driverManaged) - #expect(snapshot.editor.columnTypesByCategory == DynamoDBPlugin.columnTypesByCategory) - #expect(snapshot.editor.statementCompletions.map { [$0.label, $0.insertText] } - == DynamoDBPlugin.statementCompletions.map { [$0.label, $0.insertText] }) - } - - @Test("Connection fields match the plugin") - func connectionFields() throws { - let fields = try curated().connection.additionalConnectionFields - let shipped = DynamoDBConnectionFields.all - - #expect(fields.map(\.id) == shipped.map(\.id)) - #expect(fields.map(\.label) == shipped.map(\.label)) - #expect(fields.map(\.placeholder) == shipped.map(\.placeholder)) - #expect(fields.map(\.defaultValue) == shipped.map(\.defaultValue)) - #expect(fields.map(\.fieldType) == shipped.map(\.fieldType)) - #expect(fields.map(\.section) == shipped.map(\.section)) - #expect(fields.map(\.hidesPassword) == shipped.map(\.hidesPassword)) - #expect(fields.map(\.visibleWhen) == shipped.map(\.visibleWhen)) - #expect(fields.map(\.dynamicOptions) == shipped.map(\.dynamicOptions)) - } - - @Test("The region names no default, so a profile's own region is used") - func regionHasNoDefault() throws { - let region = try #require(try curated().connection.additionalConnectionFields.first { $0.id == "awsRegion" }) - - #expect(region.defaultValue == nil) - } - - @Test("DynamoDB Local is offered as a sign-in method") - func localSignInIsOffered() throws { - let method = try #require(try curated().connection.additionalConnectionFields.first { $0.id == "awsAuthMethod" }) - guard case .dropdown(let options) = method.fieldType else { - Issue.record("The auth method is not a dropdown") - return - } - - #expect(options.map(\.value) == ["credentials", "profile", "sso", "local"]) - } -} diff --git a/TableProTests/Plugins/ElasticsearchConnectionFieldsTests.swift b/TableProTests/Plugins/ElasticsearchConnectionFieldsTests.swift deleted file mode 100644 index bd79842e3c..0000000000 --- a/TableProTests/Plugins/ElasticsearchConnectionFieldsTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// ElasticsearchConnectionFieldsTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct ElasticsearchConnectionFieldsTests { - private func elasticsearchFields() throws -> [ConnectionField] { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - let entry = try #require(defaults.first { $0.typeId == "Elasticsearch" }) - return entry.snapshot.connection.additionalConnectionFields - } - - @Test("Registry declares node list, auth method, API key, and TLS fields") - func registryDeclaresAllFields() throws { - let fields = try elasticsearchFields() - #expect(fields.map(\.id) == ["esHosts", "esAuthMethod", "esApiKey", "esSkipTLSVerify"]) - } - - @Test("Node list stands in for Host and Port") - func nodeListIsTheEndpointList() throws { - let fields = try elasticsearchFields() - #expect(fields.endpointHostList?.id == "esHosts") - } - - @Test("Auth method dropdown defaults to basic and offers API key and none") - func authMethodDropdownDefaultsToBasic() throws { - let fields = try elasticsearchFields() - let method = try #require(fields.first { $0.id == "esAuthMethod" }) - #expect(method.defaultValue == "basic") - guard case .dropdown(let options) = method.fieldType else { - Issue.record("Expected a dropdown field type") - return - } - #expect(options.map(\.value) == ["basic", "apiKey", "none"]) - } - - @Test("Auth method dropdown drives password hiding, API key field does not") - func authMethodDropdownControlsPasswordHiding() throws { - let fields = try elasticsearchFields() - let method = try #require(fields.first { $0.id == "esAuthMethod" }) - let apiKey = try #require(fields.first { $0.id == "esApiKey" }) - #expect(method.hidesPassword) - #expect(!apiKey.hidesPassword) - } - - @Test("API key is a secure field gated to API key mode") - func apiKeyIsSecureAndModeGated() throws { - let fields = try elasticsearchFields() - let apiKey = try #require(fields.first { $0.id == "esApiKey" }) - #expect(apiKey.isSecure) - #expect(apiKey.visibleWhen == FieldVisibilityRule(fieldId: "esAuthMethod", values: ["apiKey"])) - } - - @Test("Password row shows for Username & Password and hides for API key and None") - func passwordShowsOnlyForBasicAuth() throws { - let fields = try elasticsearchFields() - #expect(!fields.hidesPassword(forValues: [:])) - #expect(!fields.hidesPassword(forValues: ["esAuthMethod": "basic"])) - #expect(fields.hidesPassword(forValues: ["esAuthMethod": "apiKey"])) - #expect(fields.hidesPassword(forValues: ["esAuthMethod": "none"])) - } - - @Test("API key field visibility swaps with the auth method") - @MainActor - func apiKeyVisibilitySwapsByAuthMethod() throws { - let type = DatabaseType(rawValue: "Elasticsearch") - let fields = try elasticsearchFields() - let apiKey = try #require(fields.first { $0.id == "esApiKey" }) - #expect(!PluginFieldRendering.isFieldVisible(apiKey, type: type, values: ["esAuthMethod": "basic"])) - #expect(PluginFieldRendering.isFieldVisible(apiKey, type: type, values: ["esAuthMethod": "apiKey"])) - #expect(!PluginFieldRendering.isFieldVisible(apiKey, type: type, values: ["esAuthMethod": "none"])) - } -} diff --git a/TableProTests/Plugins/ElasticsearchDriverTests.swift b/TableProTests/Plugins/ElasticsearchDriverTests.swift deleted file mode 100644 index c7c1b5138f..0000000000 --- a/TableProTests/Plugins/ElasticsearchDriverTests.swift +++ /dev/null @@ -1,1303 +0,0 @@ -// -// ElasticsearchDriverTests.swift -// TableProTests -// -// Tests for Elasticsearch plugin pure logic (compiled via symlinks from ElasticsearchDriverPlugin). -// - -import Foundation -import TableProPluginKit -import Testing - -struct ElasticsearchConsoleParserTests { - @Test("Parses method, path, and JSON body") - func parsesFullRequest() { - let input = "POST /my-index/_search\n{\n \"query\": { \"match_all\": {} }\n}" - let request = ElasticsearchConsoleParser.parse(input) - #expect(request?.method == "POST") - #expect(request?.path == "/my-index/_search") - #expect(request?.body?.contains("match_all") == true) - } - - @Test("Normalizes a path without a leading slash") - func normalizesPath() { - let request = ElasticsearchConsoleParser.parse("GET _cat/indices?format=json") - #expect(request?.method == "GET") - #expect(request?.path == "/_cat/indices?format=json") - #expect(request?.body == nil) - } - - @Test("Lowercase method is normalized") - func uppercasesMethod() { - #expect(ElasticsearchConsoleParser.parse("get /")?.method == "GET") - } - - @Test("Rejects unsupported method") - func rejectsUnknownMethod() { - #expect(ElasticsearchConsoleParser.parse("FETCH /index") == nil) - #expect(ElasticsearchConsoleParser.parse("") == nil) - } -} - -struct ElasticsearchQueryBuilderEncodingTests { - private let builder = ElasticsearchQueryBuilder() - - @Test("Browse query round-trips") - func browseRoundTrip() { - let query = builder.buildBrowseQuery(index: "logs", sorts: [], limit: 50, offset: 10) - #expect(query.hasPrefix(ElasticsearchQueryBuilder.searchTag)) - let parsed = ElasticsearchQueryBuilder.parseSearch(query) - #expect(parsed?.index == "logs") - #expect(parsed?.from == 10) - #expect(parsed?.size == 50) - #expect(parsed?.filters.isEmpty == true) - } - - @Test("Filtered query preserves filters and sorts") - func filteredRoundTrip() { - let query = builder.buildFilteredQuery( - index: "users", - filters: [PluginQueryFilter(column: "age", op: ">", value: "21")], - logicMode: "AND", - sorts: [ElasticsearchSortSpec(column: "name", ascending: true)], - limit: 100, - offset: 0 - ) - let parsed = ElasticsearchQueryBuilder.parseSearch(query) - #expect(parsed?.filters.first?.column == "age") - #expect(parsed?.filters.first?.op == ">") - #expect(parsed?.sorts.first?.column == "name") - #expect(parsed?.sorts.first?.ascending == true) - } -} - -struct ElasticsearchQueryDSLTests { - private let textField = ["title": ElasticsearchFieldInfo(type: "text", hasKeywordSubfield: true)] - private let keywordField = ["status": ElasticsearchFieldInfo(type: "keyword", hasKeywordSubfield: false)] - private let numericField = ["age": ElasticsearchFieldInfo(type: "long", hasKeywordSubfield: false)] - private let nestedLeafFields = [ - "identifiers.type": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["identifiers"] - ), - "identifiers.value": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["identifiers"] - ), - ] - - @Test("Empty filters produce match_all") - func matchAll() { - let clause = ElasticsearchQueryBuilder.queryClause(filters: [], logicMode: "AND", fields: [:]) - #expect(clause["match_all"] != nil) - } - - @Test("Equality on keyword uses term") - func keywordTerm() { - let filter = ElasticsearchFilterSpec(column: "status", op: "=", value: "active") - let clause = ElasticsearchQueryBuilder.clause(for: filter, fields: keywordField) - let term = clause["term"] as? [String: Any] - #expect(term?["status"] as? String == "active") - } - - @Test("Numeric range uses typed value") - func numericRange() { - let filter = ElasticsearchFilterSpec(column: "age", op: ">", value: "21") - let clause = ElasticsearchQueryBuilder.clause(for: filter, fields: numericField) - let range = clause["range"] as? [String: Any] - let bounds = range?["age"] as? [String: Any] - #expect(bounds?["gt"] as? Int == 21) - } - - @Test("Sorting a text field targets its keyword subfield") - func sortUsesKeyword() { - let field = ElasticsearchQueryBuilder.sortableField("title", fields: textField) - #expect(field == "title.keyword") - } - - @Test("OR logic builds should with minimum_should_match") - func orLogic() { - let filters = [ - ElasticsearchFilterSpec(column: "status", op: "=", value: "a"), - ElasticsearchFilterSpec(column: "status", op: "=", value: "b"), - ] - let clause = ElasticsearchQueryBuilder.queryClause(filters: filters, logicMode: "OR", fields: keywordField) - let bool = clause["bool"] as? [String: Any] - #expect(bool?["should"] != nil) - #expect(bool?["minimum_should_match"] as? Int == 1) - } - - @Test("IS NULL negates presence, so an absent field matches") - func isNull() { - let filter = ElasticsearchFilterSpec(column: "status", op: "IS NULL", value: "") - let clause = ElasticsearchQueryBuilder.clause(for: filter, fields: keywordField) - let outer = (clause["bool"] as? [String: Any])?["must_not"] as? [[String: Any]] - let present = (outer?.first?["bool"] as? [String: Any]) - #expect((present?["must"] as? [[String: Any]])?.first?["exists"] != nil) - #expect((present?["must_not"] as? [[String: Any]])?.first?["term"] != nil) - } - - @Test("IS NOT NULL asks for presence directly") - func isNotNull() { - let filter = ElasticsearchFilterSpec(column: "status", op: "IS NOT NULL", value: "") - let clause = ElasticsearchQueryBuilder.clause(for: filter, fields: keywordField) - let bool = clause["bool"] as? [String: Any] - #expect((bool?["must"] as? [[String: Any]])?.first?["exists"] != nil) - #expect((bool?["must_not"] as? [[String: Any]])?.first?["term"] != nil) - } - - @Test("Deep pagination body includes _shard_doc tiebreaker") - func tiebreaker() { - let parsed = ElasticsearchParsedSearch(index: "i", from: 0, size: 10, sorts: [], filters: [], logicMode: "AND") - let body = ElasticsearchQueryBuilder.searchBody(for: parsed, fields: [:], size: 10, tiebreaker: true) - let sort = body["sort"] as? [[String: Any]] - #expect(sort?.contains { $0["_shard_doc"] != nil } == true) - } - - @Test("Raw filter sentinel builds a query_string") - func rawFilter() { - let filter = ElasticsearchFilterSpec(column: "__RAW__", op: "=", value: "Widget") - let clause = ElasticsearchQueryBuilder.clause(for: filter, fields: [:]) - let queryString = clause["query_string"] as? [String: Any] - #expect(queryString?["query"] as? String == "Widget") - } - - @Test("Empty raw filter is ignored (match_all)") - func emptyRawFilter() { - let filters = [ElasticsearchFilterSpec(column: "__RAW__", op: "=", value: "")] - let clause = ElasticsearchQueryBuilder.queryClause(filters: filters, logicMode: "AND", fields: [:]) - #expect(clause["match_all"] != nil) - } - - @Test("_id is not sortable (returns nil)") - func idNotSortable() { - #expect(ElasticsearchQueryBuilder.sortableField("_id", fields: [:]) == nil) - #expect(ElasticsearchQueryBuilder.sortableField("_score", fields: [:]) == "_score") - } - - @Test("BETWEEN builds a gte/lte range") - func betweenRange() { - let clause = ElasticsearchQueryBuilder.clause( - for: ElasticsearchFilterSpec(column: "age", op: "BETWEEN", value: "10,20"), - fields: numericField - ) - let bounds = (clause["range"] as? [String: Any])?["age"] as? [String: Any] - #expect(bounds?["gte"] as? Int == 10) - #expect(bounds?["lte"] as? Int == 20) - } - - @Test("NOT CONTAINS and NOT IN wrap in must_not") - func negatedClauses() { - let notContains = ElasticsearchQueryBuilder.clause( - for: ElasticsearchFilterSpec(column: "status", op: "NOT CONTAINS", value: "x"), fields: keywordField - ) - #expect((notContains["bool"] as? [String: Any])?["must_not"] != nil) - let notIn = ElasticsearchQueryBuilder.clause( - for: ElasticsearchFilterSpec(column: "status", op: "NOT IN", value: "a,b"), fields: keywordField - ) - #expect((notIn["bool"] as? [String: Any])?["must_not"] != nil) - } - - @Test("REGEX builds a regexp query") - func regexClause() { - let clause = ElasticsearchQueryBuilder.clause( - for: ElasticsearchFilterSpec(column: "status", op: "REGEX", value: "a.*"), fields: keywordField - ) - let regexp = clause["regexp"] as? [String: Any] - #expect((regexp?["status"] as? [String: Any])?["value"] as? String == "a.*") - } - - @Test("A nested leaf filter wraps in a nested query") - func nestedLeafFilterWraps() { - let fields = [ - "identifiers.type": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["identifiers"] - ), - ] - let clause = ElasticsearchQueryBuilder.queryClause( - filters: [ElasticsearchFilterSpec(column: "identifiers.type", op: "=", value: "CPF")], - logicMode: "AND", - fields: fields - ) - let nested = clause["nested"] as? [String: Any] - #expect(nested?["path"] as? String == "identifiers") - let term = (nested?["query"] as? [String: Any])?["term"] as? [String: Any] - #expect(term?["identifiers.type"] as? String == "CPF") - } - - @Test("Same-element nested filters share one nested query") - func nestedSameElementGroups() { - let fields = [ - "identifiers.type": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["identifiers"] - ), - "identifiers.value": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["identifiers"] - ), - ] - let clause = ElasticsearchQueryBuilder.queryClause( - filters: [ - ElasticsearchFilterSpec( - column: "identifiers.type", op: "=", value: "CPF", elementScope: "identifiers" - ), - ElasticsearchFilterSpec( - column: "identifiers.value", op: "=", value: "318.578.388-31", elementScope: "identifiers" - ), - ], - logicMode: "AND", - fields: fields - ) - let nested = clause["nested"] as? [String: Any] - #expect(nested?["path"] as? String == "identifiers") - let bool = (nested?["query"] as? [String: Any])?["bool"] as? [String: Any] - let must = bool?["must"] as? [[String: Any]] - #expect(must?.count == 2) - } - - @Test("Independent nested filters stay separate nested queries") - func nestedAnyElementStaysSeparate() { - let fields = [ - "identifiers.type": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["identifiers"] - ), - "identifiers.issuer": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["identifiers"] - ), - ] - let clause = ElasticsearchQueryBuilder.queryClause( - filters: [ - ElasticsearchFilterSpec(column: "identifiers.type", op: "=", value: "CPF"), - ElasticsearchFilterSpec(column: "identifiers.issuer", op: "=", value: "EPACS"), - ], - logicMode: "AND", - fields: fields - ) - let must = (clause["bool"] as? [String: Any])?["must"] as? [[String: Any]] - #expect(must?.count == 2) - #expect(must?.allSatisfy { $0["nested"] != nil } == true) - } - - @Test("A nested negation negates the nested query, not its body") - func nestedNegationWrapsOutside() { - let clause = ElasticsearchQueryBuilder.queryClause( - filters: [ElasticsearchFilterSpec(column: "identifiers.type", op: "!=", value: "CPF")], - logicMode: "AND", - fields: nestedLeafFields - ) - let mustNot = (clause["bool"] as? [String: Any])?["must_not"] as? [[String: Any]] - let nested = mustNot?.first?["nested"] as? [String: Any] - #expect(nested?["path"] as? String == "identifiers") - #expect((nested?["query"] as? [String: Any])?["term"] != nil) - } - - @Test("IS NULL on a nested leaf negates the whole nested query") - func nestedIsNullWrapsOutside() { - let clause = ElasticsearchQueryBuilder.queryClause( - filters: [ElasticsearchFilterSpec(column: "identifiers.type", op: "IS NULL", value: "")], - logicMode: "AND", - fields: nestedLeafFields - ) - let mustNot = (clause["bool"] as? [String: Any])?["must_not"] as? [[String: Any]] - #expect(mustNot?.first?["nested"] != nil) - } - - @Test("A nested query ignores an unmapped path") - func nestedIgnoresUnmapped() { - let clause = ElasticsearchQueryBuilder.queryClause( - filters: [ElasticsearchFilterSpec(column: "identifiers.type", op: "=", value: "CPF")], - logicMode: "AND", - fields: nestedLeafFields - ) - #expect((clause["nested"] as? [String: Any])?["ignore_unmapped"] as? Bool == true) - } - - @Test("A filter on a nested parent column is dropped, not sent as a term") - func nestedParentFilterDropped() { - let fields = [ - "identifiers": ElasticsearchFieldInfo(type: "nested", hasKeywordSubfield: false), - "personId": ElasticsearchFieldInfo(type: "keyword", hasKeywordSubfield: false), - ] - let dropped = ElasticsearchQueryBuilder.queryClause( - filters: [ElasticsearchFilterSpec(column: "identifiers", op: "=", value: "anything")], - logicMode: "AND", - fields: fields - ) - #expect(dropped["match_all"] != nil) - - let kept = ElasticsearchQueryBuilder.queryClause( - filters: [ - ElasticsearchFilterSpec(column: "identifiers", op: "=", value: "anything"), - ElasticsearchFilterSpec(column: "personId", op: "=", value: "p1"), - ], - logicMode: "AND", - fields: fields - ) - #expect(kept["term"] != nil) - } - - @Test("A leaf under two nested ancestors enters both scopes, outermost first") - func doublyNestedWrapsBothScopes() { - let fields = [ - "orders.items.sku": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["orders", "orders.items"] - ), - ] - let clause = ElasticsearchQueryBuilder.queryClause( - filters: [ElasticsearchFilterSpec(column: "orders.items.sku", op: "=", value: "A1")], - logicMode: "AND", - fields: fields - ) - let outer = clause["nested"] as? [String: Any] - #expect(outer?["path"] as? String == "orders") - let inner = (outer?["query"] as? [String: Any])?["nested"] as? [String: Any] - #expect(inner?["path"] as? String == "orders.items") - #expect((inner?["query"] as? [String: Any])?["term"] != nil) - } - - @Test("A same-element group keeps a negation inside the one nested query") - func sameElementGroupNegatesInside() { - let clause = ElasticsearchQueryBuilder.queryClause( - filters: [ - ElasticsearchFilterSpec( - column: "identifiers.type", op: "=", value: "CPF", elementScope: "identifiers" - ), - ElasticsearchFilterSpec( - column: "identifiers.value", op: "!=", value: "0", elementScope: "identifiers" - ), - ], - logicMode: "AND", - fields: nestedLeafFields - ) - let nested = clause["nested"] as? [String: Any] - #expect(nested?["path"] as? String == "identifiers") - let must = ((nested?["query"] as? [String: Any])?["bool"] as? [String: Any])?["must"] as? [[String: Any]] - #expect(must?.count == 2) - #expect(must?.contains { ($0["bool"] as? [String: Any])?["must_not"] != nil } == true) - } - - @Test("One scoped filter reads as any element, so its negation stays outside") - func loneScopedNegationWrapsOutside() { - let clause = ElasticsearchQueryBuilder.queryClause( - filters: [ - ElasticsearchFilterSpec( - column: "identifiers.type", op: "!=", value: "CPF", elementScope: "identifiers" - ), - ], - logicMode: "AND", - fields: nestedLeafFields - ) - let mustNot = (clause["bool"] as? [String: Any])?["must_not"] as? [[String: Any]] - #expect(mustNot?.first?["nested"] != nil) - } - - @Test("One scope over two nested depths does not merge into one scope") - func sameScopeDifferentDepthsStaySeparate() { - let fields = [ - "orders.ref": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["orders"] - ), - "orders.items.sku": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["orders", "orders.items"] - ), - ] - let clause = ElasticsearchQueryBuilder.queryClause( - filters: [ - ElasticsearchFilterSpec(column: "orders.ref", op: "=", value: "R1", elementScope: "orders"), - ElasticsearchFilterSpec(column: "orders.items.sku", op: "=", value: "A1", elementScope: "orders"), - ], - logicMode: "AND", - fields: fields - ) - let must = (clause["bool"] as? [String: Any])?["must"] as? [[String: Any]] - #expect(must?.count == 2) - let paths = must?.compactMap { ($0["nested"] as? [String: Any])?["path"] as? String } - #expect(paths == ["orders", "orders"]) - let inner = must?.compactMap { clause -> String? in - let nested = clause["nested"] as? [String: Any] - let query = nested?["query"] as? [String: Any] - return (query?["nested"] as? [String: Any])?["path"] as? String - } - #expect(inner == ["orders.items"]) - } - - @Test("Sorting a doubly nested leaf names both scopes") - func doublyNestedSortNamesBothScopes() { - let fields = [ - "orders.items.sku": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["orders", "orders.items"] - ), - ] - let sorts = ElasticsearchQueryBuilder.sortClause( - [ElasticsearchSortSpec(column: "orders.items.sku", ascending: true)], - fields: fields, - tiebreaker: false - ) - let options = sorts.first?["orders.items.sku"] as? [String: Any] - let outer = options?["nested"] as? [String: Any] - #expect(outer?["path"] as? String == "orders") - #expect((outer?["nested"] as? [String: Any])?["path"] as? String == "orders.items") - } - - @Test("A nested parent column is not sortable") - func nestedParentNotSortable() { - let fields = ["identifiers": ElasticsearchFieldInfo(type: "nested", hasKeywordSubfield: false)] - #expect(ElasticsearchQueryBuilder.sortableField("identifiers", fields: fields) == nil) - } - - @Test("Sorting a nested leaf names the nested path") - func nestedLeafSort() { - let fields = [ - "identifiers.issuer": ElasticsearchFieldInfo( - type: "keyword", hasKeywordSubfield: false, nestedPaths: ["identifiers"] - ), - ] - let parsed = ElasticsearchParsedSearch( - index: "i", from: 0, size: 10, - sorts: [ElasticsearchSortSpec(column: "identifiers.issuer", ascending: true)], - filters: [], logicMode: "AND" - ) - let body = ElasticsearchQueryBuilder.searchBody(for: parsed, fields: fields, size: 10) - let sort = body["sort"] as? [[String: Any]] - let options = sort?.first?["identifiers.issuer"] as? [String: Any] - #expect(options?["order"] as? String == "asc") - #expect((options?["nested"] as? [String: Any])?["path"] as? String == "identifiers") - } - - @Test("Tagged search round-trips elementScope") - func elementScopeRoundTrip() { - let tagged = ElasticsearchQueryBuilder.encodeSearch( - index: "persons", from: 0, size: 50, - sorts: [], - filters: [ - ElasticsearchFilterSpec( - column: "identifiers.type", op: "=", value: "CPF", elementScope: "identifiers" - ), - ], - logicMode: "AND" - ) - let parsed = ElasticsearchQueryBuilder.parseSearch(tagged) - #expect(parsed?.filters.first?.elementScope == "identifiers") - #expect(parsed?.filters.first?.column == "identifiers.type") - } - - @Test("specs copies elementScope from the plugin filter") - func specsCopyElementScope() { - let filters = [ - PluginQueryFilter( - column: "identifiers.type", - op: "=", - value: "CPF", - isCaseSensitive: true, - secondValue: nil, - elementScope: "identifiers" - ), - ] - #expect(ElasticsearchQueryBuilder.specs(from: filters).first?.elementScope == "identifiers") - } - - @Test("case_insensitive is omitted when unsupported (pre-7.10)") - func caseInsensitiveGated() { - let on = ElasticsearchQueryBuilder.clause( - for: ElasticsearchFilterSpec(column: "status", op: "CONTAINS", value: "x"), - fields: keywordField, supportsCaseInsensitive: true - ) - #expect((on["wildcard"] as? [String: Any]).map { ($0["status"] as? [String: Any])?["case_insensitive"] as? Bool } == true) - let off = ElasticsearchQueryBuilder.clause( - for: ElasticsearchFilterSpec(column: "status", op: "CONTAINS", value: "x"), - fields: keywordField, supportsCaseInsensitive: false - ) - let offOptions = (off["wildcard"] as? [String: Any])?["status"] as? [String: Any] - #expect(offOptions?["case_insensitive"] == nil) - } -} - -struct ElasticsearchOrderByTests { - @Test("Extracts a single appended ORDER BY from the tagged query") - func singleOrderBy() { - let tagged = ElasticsearchQueryBuilder.encodeSearch( - index: "products", from: 0, size: 1_000, sorts: [], filters: [], logicMode: "AND" - ) - let (base, sorts) = ElasticsearchQueryBuilder.extractOrderBy(tagged + " ORDER BY \"name\" ASC") - #expect(base == tagged) - #expect(sorts == [ElasticsearchSortSpec(column: "name", ascending: true)]) - #expect(ElasticsearchQueryBuilder.parseSearch(base)?.index == "products") - } - - @Test("Parses multi-column ORDER BY with directions") - func multiOrderBy() { - let sorts = ElasticsearchQueryBuilder.parseOrderByClause("\"age\" DESC, \"country\" ASC") - #expect(sorts == [ - ElasticsearchSortSpec(column: "age", ascending: false), - ElasticsearchSortSpec(column: "country", ascending: true), - ]) - } - - @Test("No ORDER BY leaves the query unchanged") - func noOrderBy() { - let (base, sorts) = ElasticsearchQueryBuilder.extractOrderBy("ELASTICSEARCH_SEARCH:abc") - #expect(base == "ELASTICSEARCH_SEARCH:abc") - #expect(sorts.isEmpty) - } -} - -struct ElasticsearchMappingFlattenerTests { - @Test("Flattens nested objects into dotted paths and records keyword subfields") - func flattenMapping() { - let properties: [String: Any] = [ - "name": ["type": "text", "fields": ["keyword": ["type": "keyword"]]], - "age": ["type": "long"], - "address": ["properties": ["city": ["type": "keyword"]]], - ] - let columns = ElasticsearchMappingFlattener.flattenMapping(properties: properties) - let byName = Dictionary(uniqueKeysWithValues: columns.map { ($0.name, $0) }) - #expect(byName["name"]?.type == "text") - #expect(byName["name"]?.hasKeywordSubfield == true) - #expect(byName["age"]?.type == "long") - #expect(byName["address.city"]?.type == "keyword") - #expect(byName["address"] == nil) - #expect(byName["address.city"]?.nestedPath == nil) - } - - @Test("Keeps a nested parent column and marks its leaves with the nested path") - func nestedMappingKeepsParentAndMarksLeaves() { - let properties: [String: Any] = [ - "personId": ["type": "keyword"], - "identifiers": [ - "type": "nested", - "properties": [ - "issuer": ["type": "keyword"], - "system": ["type": "keyword"], - "type": ["type": "keyword"], - "value": ["type": "keyword"], - ], - ], - ] - let columns = ElasticsearchMappingFlattener.flattenMapping(properties: properties) - let byName = Dictionary(uniqueKeysWithValues: columns.map { ($0.name, $0) }) - #expect(byName["identifiers"]?.type == "nested") - #expect(byName["identifiers"]?.nestedPath == nil) - #expect(byName["identifiers.issuer"]?.type == "keyword") - #expect(byName["identifiers.issuer"]?.nestedPath == "identifiers") - #expect(byName["identifiers.type"]?.nestedPath == "identifiers") - #expect(byName["personId"]?.nestedPath == nil) - } - - @Test("Columns include meta columns first") - func metaColumnsFirst() { - let mapping = [ElasticsearchColumn(name: "field", type: "keyword", hasKeywordSubfield: false)] - let columns = ElasticsearchMappingFlattener.columns(forHits: [], mappingColumns: mapping) - #expect(Array(columns.prefix(3)) == ["_id", "_index", "_score"]) - #expect(columns.contains("field")) - } - - @Test("Flattens a source document and renders nested values as JSON") - func flattenSource() { - let source: [String: Any] = [ - "name": "Alice", - "address": ["city": "NYC"], - "tags": ["a", "b"], - ] - let flat = ElasticsearchMappingFlattener.flattenSource(source, length: .display) - #expect(flat["name"] == .text("Alice")) - #expect(flat["address.city"] == .text("NYC")) - #expect(flat["tags"]?.asText?.contains("a") == true) - } - - @Test("Doubles keep every digit needed to round-trip, nested or not") - func doublesRoundTrip() { - let source: [String: Any] = [ - "score": -3.9192320754595876e-07, - "total": 1_847.27, - "counts": ["rate": 0.1, "qty": 3.0], - ] - let flat = ElasticsearchMappingFlattener.flattenSource(source, length: .display) - #expect(flat["score"] == .text("-3.9192320754595876e-07")) - #expect(flat["total"] == .text("1847.27")) - #expect(flat["counts.rate"] == .text("0.1")) - #expect(flat["counts.qty"] == .text("3")) - } - - @Test("An array of doubles serializes without binary floating point noise") - func arrayOfDoublesHasNoExcessDigits() { - let source: [String: Any] = ["samples": [0.1, 1_847.27]] - let flat = ElasticsearchMappingFlattener.flattenSource(source, length: .display) - #expect(flat["samples"] == .text("[0.1,1847.27]")) - } - - @Test("Rows pull meta fields from the hit envelope") - func rowsWithMeta() { - let hits: [[String: Any]] = [[ - "_id": "1", "_index": "logs", "_score": 1.5, - "_source": ["msg": "hello"], - ]] - let columns = ["_id", "_index", "_score", "msg"] - let rows = ElasticsearchMappingFlattener.rows(forHits: hits, columns: columns, length: .display) - #expect(rows.first?[0] == .text("1")) - #expect(rows.first?[1] == .text("logs")) - #expect(rows.first?[3] == .text("hello")) - } - - @Test("Heterogeneous documents union their fields") - func heterogeneousUnion() { - let sources: [[String: Any]] = [["a": 1], ["b": 2]] - let columns = ElasticsearchMappingFlattener.unionColumns(fromSources: sources) - #expect(columns.contains("a")) - #expect(columns.contains("b")) - } - - @Test("Object-valued parent column renders as JSON, not null") - func parentObjectColumnRendersJSON() { - let hits: [[String: Any]] = [["_source": ["labels": ["env": "prod", "tier": "1"]]]] - let rows = ElasticsearchMappingFlattener.rows(forHits: hits, columns: ["labels"], length: .display) - #expect(rows.first?[0].asText?.contains("env") == true) - } - - @Test("A nested identifier array fills the parent and each leaf, never null") - func nestedIdentifierArrayRenders() { - let source: [String: Any] = [ - "personId": "03c47c6d-2cf6-4e3b-9d2c-0d762f77ae13", - "identifiers": [[ - "issuer": "EPACS", - "system": "urn:epacs:patient:600003399", - "type": "PATIENT_ID", - "value": "8930", - ]], - ] - let hits: [[String: Any]] = [["_id": "03c47c6d-2cf6-4e3b-9d2c-0d762f77ae13", "_source": source]] - let columns = [ - "identifiers", "identifiers.issuer", "identifiers.system", - "identifiers.type", "identifiers.value", "personId", - ] - let row = ElasticsearchMappingFlattener.rows(forHits: hits, columns: columns, length: .display).first - #expect(row?[0].asText?.contains("8930") == true) - #expect(row?[0].asText?.contains("identifiers.issuer") == false) - #expect(row?[1].asText?.contains("EPACS") == true) - #expect(row?[3].asText?.contains("PATIENT_ID") == true) - #expect(row?[4].asText?.contains("8930") == true) - #expect(row?[5] == .text("03c47c6d-2cf6-4e3b-9d2c-0d762f77ae13")) - } - - @Test("Two nested identifiers keep both objects rather than the first only") - func nestedIdentifierPairKeepsBoth() { - let source: [String: Any] = [ - "personId": "0ff4afbe-7b2f-40f6-acf9-fbb6403c6250", - "identifiers": [ - ["system": "urn:br:cpf", "type": "CPF", "value": "318.578.388-31"], - [ - "issuer": "EPACS", - "system": "urn:epacs:patient:600003399", - "type": "PATIENT_ID", - "value": "3533", - ], - ], - ] - let hits: [[String: Any]] = [["_source": source]] - let row = ElasticsearchMappingFlattener.rows( - forHits: hits, - columns: ["identifiers", "identifiers.type", "identifiers.value", "identifiers.issuer"], - length: .display - ).first - #expect(row?[0].asText?.contains("318.578.388-31") == true) - #expect(row?[0].asText?.contains("3533") == true) - #expect(row?[1].asText?.contains("CPF") == true) - #expect(row?[1].asText?.contains("PATIENT_ID") == true) - #expect(row?[2].asText?.contains("318.578.388-31") == true) - #expect(row?[2].asText?.contains("3533") == true) - #expect(row?[3].asText?.contains("EPACS") == true) - } - - @Test("A missing nested field is an empty cell, not a crash") - func missingNestedFieldIsNull() { - let hits: [[String: Any]] = [["_source": ["personId": "1"]]] - let rows = ElasticsearchMappingFlattener.rows( - forHits: hits, - columns: ["identifiers", "identifiers.value", "personId"], - length: .display - ) - #expect(rows.first?[0] == .null) - #expect(rows.first?[1] == .null) - #expect(rows.first?[2] == .text("1")) - } - - @Test("An object array still fills dotted leaves") - func objectArrayFillsDottedLeaves() { - let hits: [[String: Any]] = [["_source": ["labels": [["env": "prod"], ["env": "dev"]]]]] - let rows = ElasticsearchMappingFlattener.rows(forHits: hits, columns: ["labels.env"], length: .display) - let text = rows.first?[0].asText - #expect(text?.contains("prod") == true) - #expect(text?.contains("dev") == true) - } - - @Test("A leaf an object omits keeps its place, so sibling columns line up") - func nestedLeavesKeepPosition() { - let source: [String: Any] = [ - "identifiers": [ - ["type": "CPF", "value": "318.578.388-31"], - ["issuer": "EPACS", "type": "PATIENT_ID", "value": "3533"], - ], - ] - let hits: [[String: Any]] = [["_source": source]] - let row = ElasticsearchMappingFlattener.rows( - forHits: hits, - columns: ["identifiers.issuer", "identifiers.type"], - length: .display - ).first - #expect(row?[0].asText == "[null,\"EPACS\"]") - #expect(row?[1].asText == "[\"CPF\",\"PATIENT_ID\"]") - } - - @Test("A bare object in a nested field renders as a one-element array") - func bareNestedObjectRendersAsArray() { - let hits: [[String: Any]] = [["_source": ["identifiers": ["type": "CPF"]]]] - let row = ElasticsearchMappingFlattener.rows( - forHits: hits, - columns: ["identifiers", "identifiers.type"], - nestedParents: ["identifiers"], - length: .display - ).first - #expect(row?[0].asText?.hasPrefix("[") == true) - #expect(row?[1].asText == "[\"CPF\"]") - } - - @Test("An export row keeps arrays and objects longer than the grid's cap whole") - func exportRowKeepsLongStructuresWhole() throws { - let embedding = (0..<1_536).map { Double($0) / 1_024 } - let chunks = (0..<4).map { _ in ["vector": embedding] } - let hits: [[String: Any]] = [["_id": "1", "_source": ["embedding": embedding, "chunks": chunks]]] - let row = try #require(ElasticsearchMappingFlattener.rows( - forHits: hits, - columns: ["embedding", "chunks", "chunks.vector"], - nestedParents: ["chunks"], - length: .whole - ).first) - - #expect(try parsedJSON(row[0]) as? [Double] == embedding) - #expect(try parsedJSON(row[1]) as? [[String: [Double]]] == chunks) - #expect(try parsedJSON(row[2]) as? [[Double]] == chunks.map { _ in embedding }) - } - - @Test("A grid row still cuts an array longer than the cap") - func gridRowCutsLongStructures() throws { - let embedding = (0..<1_536).map { Double($0) / 1_024 } - let hits: [[String: Any]] = [["_source": ["embedding": embedding]]] - let row = try #require(ElasticsearchMappingFlattener.rows( - forHits: hits, columns: ["embedding"], length: .display - ).first) - let text = try #require(row[0].asText) - - #expect(text.hasSuffix("...")) - #expect((text as NSString).length == 10_003) - } - - private func parsedJSON(_ cell: PluginCellValue) throws -> Any { - let text = try #require(cell.asText) - return try JSONSerialization.jsonObject(with: Data(text.utf8)) - } - - @Test("An alias mapping keyed by real index names unions their fields") - func aliasMappingUnionsEveryIndex() { - let response: [String: Any] = [ - "logs-2026-08": ["mappings": ["properties": [ - "message": ["type": "text"], - "level": ["type": "keyword"], - ]]], - "logs-2026-09": ["mappings": ["properties": [ - "message": ["type": "text"], - "traceId": ["type": "keyword"], - ]]], - ] - let properties = ElasticsearchMappingFlattener.properties(fromMappingResponse: response, index: "logs") - #expect(Set(properties.keys) == ["message", "level", "traceId"]) - } - - @Test("A response that names the index exactly uses only that index") - func exactIndexMappingWins() { - let response: [String: Any] = [ - "logs-2026-08": ["mappings": ["properties": ["level": ["type": "keyword"]]]], - "logs-2026-09": ["mappings": ["properties": ["traceId": ["type": "keyword"]]]], - ] - let properties = ElasticsearchMappingFlattener.properties( - fromMappingResponse: response, index: "logs-2026-09" - ) - #expect(Set(properties.keys) == ["traceId"]) - } - - @Test("A nested field inside a nested field reports both ancestors") - func doublyNestedMappingReportsBothAncestors() { - let properties: [String: Any] = [ - "orders": [ - "type": "nested", - "properties": [ - "ref": ["type": "keyword"], - "items": [ - "type": "nested", - "properties": ["sku": ["type": "keyword"]], - ], - ], - ], - ] - let columns = ElasticsearchMappingFlattener.flattenMapping(properties: properties) - let byName = Dictionary(uniqueKeysWithValues: columns.map { ($0.name, $0) }) - #expect(byName["orders"]?.nestedPaths.isEmpty == true) - #expect(byName["orders.ref"]?.nestedPaths == ["orders"]) - #expect(byName["orders.items"]?.nestedPaths == ["orders"]) - #expect(byName["orders.items.sku"]?.nestedPaths == ["orders", "orders.items"]) - #expect(ElasticsearchMappingFlattener.nestedParents(from: columns) == ["orders", "orders.items"]) - } -} - -struct ElasticsearchStatementGeneratorTests { - private func generator() -> ElasticsearchStatementGenerator { - ElasticsearchStatementGenerator( - index: "users", - columns: ["_id", "_index", "_score", "name", "age"], - columnTypeNames: ["keyword", "keyword", "float", "text", "long"] - ) - } - - @Test("Update encodes a POST _update keyed by _id") - func updateRequest() throws { - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 3, columnName: "name", oldValue: .text("Bob"), newValue: .text("Alice"))], - originalRow: [.text("doc1"), .text("users"), .text("1"), .text("Bob"), .text("30")] - ) - let statements = try generator().generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - #expect(statements.count == 1) - let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) - #expect(decoded?.method == "POST") - #expect(decoded?.path.contains("/users/_update/doc1") == true) - #expect(decoded?.body?.contains("Alice") == true) - } - - @Test("Delete encodes a DELETE _doc by _id") - func deleteRequest() throws { - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: [.text("doc9"), .text("users"), .text("1"), .text("Bob"), .text("30")] - ) - let statements = try generator().generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] - ) - let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) - #expect(decoded?.method == "DELETE") - #expect(decoded?.path.contains("/users/_doc/doc9") == true) - } - - @Test("Insert coerces numeric fields and omits meta columns") - func insertRequest() throws { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let statements = try generator().generateRowWrites( - from: [change], - insertedRowData: [0: [.null, .null, .null, .text("Eve"), .text("25")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) - #expect(decoded?.method == "POST") - #expect(decoded?.path.contains("/users/_doc") == true) - #expect(decoded?.body?.contains("\"age\":25") == true) - #expect(decoded?.body?.contains("_index") == false) - } - - private func nestedGenerator() -> ElasticsearchStatementGenerator { - ElasticsearchStatementGenerator( - index: "persons", - columns: ["_id", "identifiers", "identifiers.type", "personId"], - columnTypeNames: ["keyword", "nested", "keyword", "keyword"] - ) - } - - @Test("Insert writes the nested array once, through its parent column") - func insertOmitsNestedLeaves() throws { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let statements = try nestedGenerator().generateRowWrites( - from: [change], - insertedRowData: [0: [ - .null, - .text("[{\"type\":\"CPF\"}]"), - .text("[\"CPF\"]"), - .text("p1"), - ]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - let body = ElasticsearchStatementGenerator.decode(statements[0].statement)?.body - #expect(body?.contains("\"identifiers\":[{\"type\":\"CPF\"}]") == true) - #expect(body?.contains("identifiers.type") == false) - } - - private func nestedLeafRefusal(rowIndex: Int = 0) -> PluginRowWriteRefusal { - PluginRowWriteRefusal( - rowIndex: rowIndex, - reason: "'identifiers.type' is a field of a nested array. Edit the array in 'identifiers' instead." - ) - } - - private func leafTyped(_ value: String) -> [(columnIndex: Int, columnName: String, oldValue: PluginCellValue, newValue: PluginCellValue)] { - [(columnIndex: 2, columnName: "identifiers.type", oldValue: .null, newValue: .text(value))] - } - - @Test("Update refuses a nested leaf edit rather than write the rest of the row without it") - func updateRefusesNestedLeaf() { - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - ( - columnIndex: 2, columnName: "identifiers.type", - oldValue: .text("[\"CPF\"]"), newValue: .text("[\"X\"]") - ), - (columnIndex: 3, columnName: "personId", oldValue: .text("p1"), newValue: .text("p2")), - ], - originalRow: [.text("doc1"), .text("[]"), .text("[\"CPF\"]"), .text("p1")] - ) - #expect(throws: nestedLeafRefusal()) { - try nestedGenerator().generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - } - } - - @Test("A new row with only a nested leaf typed is refused rather than saved empty") - func insertRefusesTypedLeafWithEmptyArray() { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: leafTyped("[\"X\"]"), originalRow: nil) - #expect(throws: nestedLeafRefusal()) { - try nestedGenerator().generateRowWrites( - from: [change], - insertedRowData: [0: [.null, .null, .text("[\"X\"]"), .null]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - @Test("A leaf typed into a new row is refused even when its array has a value") - func insertRefusesTypedLeafBesideItsArray() { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: leafTyped("[\"X\"]"), originalRow: nil) - #expect(throws: nestedLeafRefusal()) { - try nestedGenerator().generateRowWrites( - from: [change], - insertedRowData: [0: [.null, .text("[{\"type\":\"CPF\"}]"), .text("[\"X\"]"), .null]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - @Test("A pasted row whose leaf says something other than its array is refused, not saved without it") - func insertRefusesPastedLeafThatDisagreesWithItsArray() { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - #expect(throws: nestedLeafRefusal()) { - try nestedGenerator().generateRowWrites( - from: [change], - insertedRowData: [0: [.null, .text("[{\"type\":\"CPF\"}]"), .text("[\"CNPJ\"]"), .text("p1")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - @Test("A pasted leaf that holds what its array holds, however spaced, is written through the array") - func insertAcceptsPastedLeafThatMatchesItsArray() throws { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let statements = try nestedGenerator().generateRowWrites( - from: [change], - insertedRowData: [0: [.null, .text("[{\"type\":\"CPF\"},{\"type\":\"RG\"}]"), .text("[ \"CPF\", \"RG\" ]"), .null]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - let body = ElasticsearchStatementGenerator.decode(statements[0].statement)?.body - #expect(body?.contains("\"identifiers\":[{\"type\":\"CPF\"},{\"type\":\"RG\"}]") == true) - } - - @Test("A pasted row carrying a leaf value with no array to write it through is refused") - func insertRefusesPastedLeafWithoutItsArray() { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - #expect(throws: nestedLeafRefusal()) { - try nestedGenerator().generateRowWrites( - from: [change], - insertedRowData: [0: [.null, .null, .text("[\"CPF\"]"), .text("p1")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - @Test("An edit to document metadata is refused") - func updateRefusesMetadata() { - let change = PluginRowChange( - rowIndex: 3, - type: .update, - cellChanges: [ - (columnIndex: 3, columnName: "name", oldValue: .text("Bob"), newValue: .text("Alice")), - (columnIndex: 1, columnName: "_index", oldValue: .text("users"), newValue: .text("people")), - ], - originalRow: [.text("doc1"), .text("users"), .text("1"), .text("Bob"), .text("30")] - ) - let refusal = PluginRowWriteRefusal(rowIndex: 3, reason: "'_index' is document metadata and cannot be edited.") - #expect(throws: refusal) { - try generator().generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - } - } - - @Test("A score typed into a new row is refused, and an _id typed into one is written") - func insertRefusesTypedScoreAndKeepsTypedId() throws { - let score = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [(columnIndex: 2, columnName: "_score", oldValue: .null, newValue: .text("9"))], - originalRow: nil - ) - #expect(throws: PluginRowWriteRefusal(rowIndex: 0, reason: "'_score' is document metadata and cannot be edited.")) { - try generator().generateRowWrites( - from: [score], - insertedRowData: [0: [.null, .null, .text("9"), .text("Eve"), .null]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - - let id = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [(columnIndex: 0, columnName: "_id", oldValue: .null, newValue: .text("e1"))], - originalRow: nil - ) - let writes = try generator().generateRowWrites( - from: [id], - insertedRowData: [0: [.text("e1"), .null, .null, .text("Eve"), .null]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - #expect(writes.map(\.rowIndices) == [[0]]) - #expect(ElasticsearchStatementGenerator.decode(writes[0].statement)?.path.contains("/users/_doc/e1") == true) - } - - @Test("A value that is not valid JSON refuses the change") - func updateRefusesAValueJSONCannotHold() { - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 4, columnName: "age", oldValue: .text("30"), newValue: .text("nan"))], - originalRow: [.text("doc1"), .text("users"), .text("1"), .text("Bob"), .text("30")] - ) - #expect(throws: PluginRowWriteRefusal(rowIndex: 0, reason: "The new values cannot be written as JSON.")) { - try generator().generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - } - } - - @Test("A change with no _id to address it by is refused") - func refusesAChangeWithoutAnId() { - let update = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 3, columnName: "name", oldValue: .text("Bob"), newValue: .text("Alice"))], - originalRow: [.null, .text("users"), .text("1"), .text("Bob"), .text("30")] - ) - let delete = PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: nil) - let reason = "The document's _id is unknown, so it cannot be addressed." - #expect(throws: PluginRowWriteRefusal(rowIndex: 0, reason: reason)) { - try generator().generateRowWrites( - from: [update], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - } - #expect(throws: PluginRowWriteRefusal(rowIndex: 1, reason: reason)) { - try generator().generateRowWrites( - from: [delete], insertedRowData: [:], deletedRowIndices: [1], insertedRowIndices: [] - ) - } - } - - @Test("Each request names the change it writes, and an update with nothing to set writes nothing") - func writesNameTheirChange() throws { - let original: [PluginCellValue] = [.text("doc1"), .text("users"), .text("1"), .text("Bob"), .text("30")] - let writes = try generator().generateRowWrites( - from: [ - PluginRowChange(rowIndex: 0, type: .update, cellChanges: [], originalRow: nil), - PluginRowChange( - rowIndex: 1, - type: .update, - cellChanges: [(columnIndex: 3, columnName: "name", oldValue: .text("Bob"), newValue: .null)], - originalRow: original - ), - PluginRowChange(rowIndex: 2, type: .delete, cellChanges: [], originalRow: original), - ], - insertedRowData: [:], - deletedRowIndices: [2], - insertedRowIndices: [] - ) - #expect(writes.map(\.rowIndices) == [[1], [2]]) - #expect(ElasticsearchStatementGenerator.decode(writes[0].statement)?.body == "{\"doc\":{\"name\":null}}") - } - - @Test("Insert with explicit _id uses PUT") - func insertWithId() throws { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let statements = try generator().generateRowWrites( - from: [change], - insertedRowData: [0: [.text("custom"), .null, .null, .text("Eve"), .text("25")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) - #expect(decoded?.method == "PUT") - #expect(decoded?.path.contains("/users/_doc/custom") == true) - } - - @Test("Document id with a slash is percent-encoded into one path segment") - func slashInDocumentId() throws { - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: [.text("tenant/123"), .text("users"), .text("1"), .text("Bob"), .text("30")] - ) - let statements = try generator().generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] - ) - let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) - #expect(decoded?.path.contains("/users/_doc/tenant%2F123") == true) - } - - @Test("Insert preserves an intentional empty string") - func insertKeepsEmptyString() throws { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let statements = try generator().generateRowWrites( - from: [change], - insertedRowData: [0: [.null, .null, .null, .text(""), .text("25")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) - #expect(decoded?.body?.contains("\"name\":\"\"") == true) - } - - @Test("JSON object text is kept as a string on a scalar field") - func jsonObjectKeptAsStringOnScalarField() throws { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let statements = try generator().generateRowWrites( - from: [change], - insertedRowData: [0: [.null, .null, .null, .text("{\"a\":1}"), .text("25")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - let decoded = ElasticsearchStatementGenerator.decode(statements[0].statement) - #expect(decoded?.body?.contains("\"name\":\"{\\\"a\\\":1}\"") == true) - } - - private func documentGenerator() -> ElasticsearchStatementGenerator { - ElasticsearchStatementGenerator( - index: "users", - columns: ["_id", "tags", "labels", "name"], - columnTypeNames: ["keyword", "keyword", "object", "text"] - ) - } - - private func displayed(_ value: Any) -> String { - ElasticsearchMappingFlattener.cell(value, length: .display).asText ?? "" - } - - private func shortenedRefusal(_ column: String) -> PluginRowWriteRefusal { - PluginRowWriteRefusal( - rowIndex: 0, - reason: "The value in \(column) is shortened for display, so saving it would store only the part shown. " - + "Change this field with a query." - ) - } - - private func shortenedTags() -> String { - displayed((0..<1_500).map { "tag-\($0)" }) - } - - private func tagEdit(from old: String, to new: PluginCellValue) -> PluginRowChange { - PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "tags", oldValue: .text(old), newValue: new)], - originalRow: [.text("doc1"), .text(old), .null, .text("Bob")] - ) - } - - private func updateBody(_ change: PluginRowChange) throws -> String? { - let writes = try documentGenerator().generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - let write = try #require(writes.first) - return ElasticsearchStatementGenerator.decode(write.statement)?.body - } - - @Test("An edit to an array shortened for display is refused rather than saved as the fragment") - func updateRefusesAShortenedArray() throws { - let shortened = shortenedTags() - try #require(shortened.hasSuffix("...")) - let edited = shortened.replacingOccurrences(of: "\"tag-0\"", with: "\"tag-Z\"") - #expect(throws: shortenedRefusal("tags")) { - try updateBody(tagEdit(from: shortened, to: .text(edited))) - } - } - - @Test("Text appended to an array shortened for display is refused") - func updateRefusesTextAppendedToAShortenedArray() throws { - let shortened = shortenedTags() - try #require(shortened.hasSuffix("...")) - let appended = String(shortened.dropLast(3)) + #","new"]"# - #expect(throws: shortenedRefusal("tags")) { - try updateBody(tagEdit(from: shortened, to: .text(appended))) - } - } - - @Test("A new row carrying a value shortened right after an object is refused") - func insertRefusesAValueShortenedAfterAnObject() throws { - let shortened = displayed(Array(repeating: ["k": "v"], count: 1_500)) - try #require(shortened.hasSuffix("}...")) - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - #expect(throws: shortenedRefusal("labels")) { - try documentGenerator().generateRowWrites( - from: [change], - insertedRowData: [0: [.null, .null, .text(shortened), .text("Eve")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - @Test("A complete array written over one shortened for display is refused, since it may be the shown part closed") - func updateRefusesACompleteArrayWrittenOverAShortenedOne() throws { - let shownPart = displayed((0..<800).map { "tag-\($0)" }) - try #require(!shownPart.hasSuffix("...")) - #expect(throws: shortenedRefusal("tags")) { - try updateBody(tagEdit(from: shortenedTags(), to: .text(shownPart))) - } - } - - @Test("NULL written over a value shortened for display clears the field") - func updateWritesNullOverAShortenedValue() throws { - let body = try updateBody(tagEdit(from: shortenedTags(), to: .null)) - #expect(body == #"{"doc":{"tags":null}}"#) - } - - @Test("An edit to another field of a row holding a shortened value is written") - func updateWritesAnotherFieldBesideAShortenedValue() throws { - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 3, columnName: "name", oldValue: .text("Bob"), newValue: .text("Rob"))], - originalRow: [.text("doc1"), .text(shortenedTags()), .null, .text("Bob")] - ) - #expect(try updateBody(change) == #"{"doc":{"name":"Rob"}}"#) - } - - @Test("Text that opens with a bracket and trails off is written as typed") - func updateWritesBracketedProse() throws { - let body = try updateBody(tagEdit(from: "draft", to: .text("[DRAFT] Chapter one..."))) - #expect(body == #"{"doc":{"tags":"[DRAFT] Chapter one..."}}"#) - } -} diff --git a/TableProTests/Plugins/ElasticsearchNodesTests.swift b/TableProTests/Plugins/ElasticsearchNodesTests.swift deleted file mode 100644 index 56c28babde..0000000000 --- a/TableProTests/Plugins/ElasticsearchNodesTests.swift +++ /dev/null @@ -1,304 +0,0 @@ -// -// ElasticsearchNodesTests.swift -// TableProTests -// - -import Foundation -import Network -@testable import TablePro -import TableProPluginKit -import Testing - -struct ElasticsearchNodesTests { - @Test("The node list wins over Host and Port") - func listWins() { - let nodes = ElasticsearchNodes.nodes( - hostList: "es1.example:9201, [fd00::2]:9200,es3.example", - host: "ignored.example", - port: 9_200, - useTLS: true - ) - #expect(nodes.map(\.baseURL.absoluteString) == [ - "https://es1.example:9201", - "https://[fd00::2]:9200", - "https://es3.example:9200", - ]) - } - - @Test("An empty list falls back to Host and Port, bracketing an IPv6 host") - func fallbackToHost() { - #expect(ElasticsearchNodes.nodes(hostList: "", host: "::1", port: 9_201, useTLS: false) - .map(\.baseURL.absoluteString) == ["http://[::1]:9201"]) - #expect(ElasticsearchNodes.nodes(hostList: nil, host: "", port: 0, useTLS: false) - .map(\.name) == ["localhost:9200"]) - } - - @Test("A pasted https:// node uses TLS even with SSL Mode left on Disabled") - func httpsEntryUsesTLS() { - let nodes = ElasticsearchNodes.nodes( - hostList: "https://111.111.111.111:9200,https://111.111.111.112:9200", - host: "", - port: 9_200, - useTLS: false - ) - #expect(nodes.map(\.baseURL.absoluteString) == [ - "https://111.111.111.111:9200", - "https://111.111.111.112:9200", - ]) - } - - @Test( - "The driver reads a row the way the connection form does", - arguments: [ - "es1", "es1:9201", "https://es1", "http://es1:9300/", "[::1]:9201", "fe80::1", - "es1:abc", "es1:0", "ftp://es1", "https://user:secret@es1", "es1/prefix", "[::1", - ] - ) - func grammarMatchesTheForm(entry: String) { - let form = HostListEndpoint.parse(entry, defaultPort: 9_200)?.entry - let driver = ElasticsearchNodes.node(from: entry, defaultPort: 9_200, useTLS: false)?.name - #expect(form == driver) - } - - @Test("Only a failure before the request left may resend a write") - func neverSentCodes() { - #expect(ElasticsearchFailover.neverSent(.cannotConnectToHost)) - #expect(ElasticsearchFailover.neverSent(.cannotFindHost)) - #expect(ElasticsearchFailover.neverSent(.serverCertificateUntrusted)) - #expect(!ElasticsearchFailover.neverSent(.timedOut)) - #expect(!ElasticsearchFailover.neverSent(.networkConnectionLost)) - #expect(!ElasticsearchFailover.neverSent(.cancelled)) - } - - @Test("A read moves on after a dropped connection but not after a timeout") - func readResendPolicy() { - #expect(ElasticsearchFailover.resends(after: .networkConnectionLost, isRead: true)) - #expect(!ElasticsearchFailover.resends(after: .networkConnectionLost, isRead: false)) - #expect(!ElasticsearchFailover.resends(after: .timedOut, isRead: true)) - #expect(ElasticsearchFailover.resends(after: .cannotConnectToHost, isRead: false)) - } -} - -struct ElasticsearchFailoverTests { - private static let root = MockHttpResponse( - status: 200, - headers: [("Content-Type", "application/json")], - body: Data(#"{"version":{"number":"8.15.0"}}"#.utf8) - ) - - private static func status(_ code: Int) -> MockHttpResponse { - MockHttpResponse(status: code, headers: [("Content-Type", "application/json")], body: Data("{}".utf8)) - } - - private func connection(hosts: String) throws -> ElasticsearchConnection { - try ElasticsearchConnection(config: DriverConnectionConfig( - host: "", - port: 9_200, - username: "", - password: "", - database: "", - additionalFields: ["esHosts": hosts, "esAuthMethod": "none"] - )) - } - - private func liveServer(_ responses: [MockHttpResponse]) async throws -> MockHttpServer { - let server = MockHttpServer() - try await server.start() - await server.setResponses(responses) - return server - } - - @Test("Connect skips a node that refuses and stays on the one that answers") - func connectSkipsRefusedNode() async throws { - let live = try await liveServer([Self.root]) - let connection = try connection(hosts: "127.0.0.1:1,127.0.0.1:\(live.port)") - - try await connection.connect() - try await connection.ping() - - #expect(connection.serverVersion == "8.15.0") - #expect(await live.requests.map(\.path) == ["/", "/_cluster/health"]) - connection.disconnect() - await live.stop() - } - - @Test( - "A node that trickles its reply gives up its share of the connect timeout", - .timeLimit(.minutes(1)) - ) - func tricklingNodeKeepsToItsShare() async throws { - let slow = try TricklingHTTPServer() - try await slow.start() - let live = try await liveServer([Self.root]) - let connection = try ElasticsearchConnection(config: DriverConnectionConfig( - host: "", - port: 9_200, - username: "", - password: "", - database: "", - additionalFields: [ - "esHosts": "127.0.0.1:\(slow.port),127.0.0.1:\(live.port)", - "esAuthMethod": "none", - "connectTimeoutMilliseconds": "3000", - ] - )) - - try await connection.connect() - - #expect(await live.requests.map(\.path) == ["/"]) - connection.disconnect() - slow.stop() - await live.stop() - } - - @Test("When every node fails, the error names each one") - func allNodesDown() async throws { - let connection = try connection(hosts: "127.0.0.1:1,127.0.0.1:2") - let error = await #expect(throws: ElasticsearchError.self) { try await connection.connect() } - let message = error?.localizedDescription ?? "" - #expect(message.contains("127.0.0.1:1")) - #expect(message.contains("127.0.0.1:2")) - } - - @Test("A rejected login stops at the first node") - func authFailureStops() async throws { - let first = try await liveServer([Self.status(401)]) - let second = try await liveServer([Self.root]) - let connection = try connection(hosts: "127.0.0.1:\(first.port),127.0.0.1:\(second.port)") - - await #expect(throws: ElasticsearchError.self) { try await connection.connect() } - - #expect(await second.requests.isEmpty) - await first.stop() - await second.stop() - } - - @Test("A write goes to the next node when its node refuses the connection") - func writeResentAfterRefusal() async throws { - let first = try await liveServer([Self.root]) - let second = try await liveServer([Self.status(201)]) - let connection = try connection(hosts: "127.0.0.1:\(first.port),127.0.0.1:\(second.port)") - try await connection.connect() - await first.stop() - - let response = try await connection.request(method: "PUT", path: "/books/_doc/1", body: "{}") - _ = try await connection.request(method: "GET", path: "/_cluster/health") - - #expect(response.statusCode == 201) - #expect(await second.requests.map(\.method) == ["PUT", "GET"]) - connection.disconnect() - await second.stop() - } - - @Test("A GET with a body goes out as POST and is not sent again") - func getWithBodyIsAWrite() async throws { - let first = try await liveServer([Self.root, Self.status(503)]) - let second = try await liveServer([Self.root]) - let connection = try connection(hosts: "127.0.0.1:\(first.port),127.0.0.1:\(second.port)") - try await connection.connect() - - let response = try await connection.request(method: "GET", path: "/books/_update/1", body: "{}") - - #expect(response.statusCode == 503) - #expect(await second.requests.isEmpty) - connection.disconnect() - await first.stop() - await second.stop() - } - - @Test("A path that names another host is refused before anything is sent") - func schemeRelativePathIsRefused() async throws { - let node = try await liveServer([Self.root]) - let other = try await liveServer([Self.root]) - let connection = try connection(hosts: "127.0.0.1:\(node.port)") - try await connection.connect() - - await #expect(throws: ElasticsearchError.self) { - try await connection.request(method: "GET", path: "//127.0.0.1:\(other.port)/_cluster/health") - } - - #expect(await other.requests.isEmpty) - connection.disconnect() - await node.stop() - await other.stop() - } - - @Test("A write a node answered is not sent again, a read is") - func answeredWriteIsNotResent() async throws { - let first = try await liveServer([Self.root, Self.status(503)]) - let second = try await liveServer([Self.root]) - let connection = try connection(hosts: "127.0.0.1:\(first.port),127.0.0.1:\(second.port)") - try await connection.connect() - - let write = try await connection.request(method: "POST", path: "/books/_doc", body: "{}") - #expect(write.statusCode == 503) - #expect(await second.requests.isEmpty) - - let read = try await connection.request(method: "GET", path: "/_cluster/health") - #expect(read.statusCode == 200) - #expect(await second.requests.map(\.path) == ["/_cluster/health"]) - connection.disconnect() - await first.stop() - await second.stop() - } -} - -/// Answers with headers and then one byte every 100 ms, so URLSession's idle timeout never fires. -private final class TricklingHTTPServer: @unchecked Sendable { - private let listener: NWListener - private let queue = DispatchQueue(label: "TricklingHTTPServer") - private var connections: [NWConnection] = [] - private var resumed = false - private(set) var port: UInt16 = 0 - - init() throws { - listener = try NWListener(using: .tcp) - } - - func start() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - listener.stateUpdateHandler = { [weak self] state in - guard let self, !self.resumed else { return } - switch state { - case .ready: - self.resumed = true - self.port = self.listener.port?.rawValue ?? 0 - continuation.resume() - case .failed(let error): - self.resumed = true - continuation.resume(throwing: error) - default: - break - } - } - listener.newConnectionHandler = { [weak self] connection in - self?.serve(connection) - } - listener.start(queue: queue) - } - } - - func stop() { - listener.cancel() - queue.sync { - connections.forEach { $0.cancel() } - connections.removeAll() - } - } - - private func serve(_ connection: NWConnection) { - connections.append(connection) - connection.start(queue: queue) - let head = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100000\r\n\r\n" - connection.send(content: Data(head.utf8), completion: .idempotent) - drip(to: connection) - } - - private func drip(to connection: NWConnection) { - queue.asyncAfter(deadline: .now() + 0.1) { [weak self] in - connection.send(content: Data(" ".utf8), completion: .contentProcessed { error in - if error == nil { self?.drip(to: connection) } - }) - } - } -} diff --git a/TableProTests/Plugins/ElasticsearchOperationsTests.swift b/TableProTests/Plugins/ElasticsearchOperationsTests.swift deleted file mode 100644 index b9b719427c..0000000000 --- a/TableProTests/Plugins/ElasticsearchOperationsTests.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// ElasticsearchOperationsTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct ElasticsearchOperationsTests { - @Test("Deleting an index is the native REST request") - func deleteIndexIsNative() { - #expect(ElasticsearchOperations.deleteIndex(named: "test_index", objectType: "TABLE") == "DELETE /test_index") - } - - /// The confirmation dialog shows the statement and the driver then runs it, so a statement the - /// driver's own parser rejects is exactly the reported bug. Round-tripping is what stops the - /// two drifting apart again. - @Test("The generated statement parses as the request it names") - func generatedStatementRoundTrips() throws { - let statement = try #require( - ElasticsearchOperations.deleteIndex(named: "test_index", objectType: "TABLE") - ) - let request = try #require(ElasticsearchConsoleParser.parse(statement)) - #expect(request.method == "DELETE") - #expect(request.path == "/test_index") - #expect(request.body == nil) - } - - @Test("A name that could match more than one index is refused", arguments: [ - "*", "logs-*", "a,b", "_all", "-old", "with/slash", "with space", "back\\slash", "", - ]) - func multiTargetNamesRefused(name: String) { - #expect(ElasticsearchOperations.deleteIndex(named: name, objectType: "TABLE") == nil) - } - - @Test("A percent-encodable name survives into the path") - func unicodeNameIsEncoded() throws { - let statement = try #require(ElasticsearchOperations.deleteIndex(named: "índice", objectType: "TABLE")) - let request = try #require(ElasticsearchConsoleParser.parse(statement)) - #expect(request.method == "DELETE") - #expect(request.path.hasPrefix("/")) - } - - /// Elasticsearch has only indices, so anything the app types differently is not something this - /// engine drops, and answering with a statement would delete an index of that name instead. - @Test("Only an index is droppable", arguments: ["VIEW", "MATERIALIZED VIEW", "FOREIGN TABLE"]) - func onlyIndicesAreDroppable(objectType: String) { - #expect(ElasticsearchOperations.deleteIndex(named: "test_index", objectType: objectType) == nil) - } - - @Test("A read cannot change a mapping", arguments: ["GET", "HEAD", "get", "head"]) - func readsKeepTheMapping(method: String) { - #expect(ElasticsearchOperations.changesMapping(method: method) == false) - } - - @Test("Everything else can change a mapping", arguments: ["PUT", "POST", "DELETE", "PATCH", "put"]) - func writesChangeTheMapping(method: String) { - #expect(ElasticsearchOperations.changesMapping(method: method)) - } - - /// The statement the sidebar's drop runs is itself a mapping change, so the cache it leaves - /// behind has to go with it. - @Test("Dropping an index is a mapping change") - func dropIsAMappingChange() throws { - let statement = try #require(ElasticsearchOperations.deleteIndex(named: "test_index", objectType: "TABLE")) - let request = try #require(ElasticsearchConsoleParser.parse(statement)) - #expect(ElasticsearchOperations.changesMapping(method: request.method)) - } -} diff --git a/TableProTests/Plugins/EtcdCommandArgumentTests.swift b/TableProTests/Plugins/EtcdCommandArgumentTests.swift deleted file mode 100644 index 74b81dce02..0000000000 --- a/TableProTests/Plugins/EtcdCommandArgumentTests.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// EtcdCommandArgumentTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct EtcdCommandArgumentRoundTripTests { - private static let arguments = [ - "", - "plain", - "a b", - "\u{301}x/", - "\u{301} mark after a space", - "\u{600} b", - "x\"\u{301} --prefix", - "\"\u{301}", - "\\\u{301}", - "tab\there", - "line\nbreak", - "carriage\rreturn", - "'single'", - "C:\\path" - ] - - @Test("A quoted argument reads back as the same key, scalar for scalar", arguments: arguments) - func delReadsBackTheSameKey(argument: String) throws { - let operation = try EtcdCommandParser.parse("del \(EtcdCommandArgument.quoted(argument))") - - guard case let .del(key, prefix) = operation else { - Issue.record("Expected a del operation, got \(operation)") - return - } - #expect(Array(key.unicodeScalars) == Array(argument.unicodeScalars)) - #expect(!prefix) - } - - @Test("A quoted key and value read back whole from a put", arguments: arguments) - func putReadsBackKeyAndValue(argument: String) throws { - let key = "k" + argument - let statement = "put \(EtcdCommandArgument.quoted(key)) \(EtcdCommandArgument.quoted(argument))" - - guard case let .put(parsedKey, parsedValue, leaseId) = try EtcdCommandParser.parse(statement) else { - Issue.record("Expected a put operation from \(statement)") - return - } - #expect(Array(parsedKey.unicodeScalars) == Array(key.unicodeScalars)) - #expect(Array(parsedValue.unicodeScalars) == Array(argument.unicodeScalars)) - #expect(leaseId == nil) - } - - @Test("Deleting a row whose key starts with a combining mark deletes that key") - func rowDeleteKeepsLeadingCombiningMark() throws { - let generator = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["\u{301}x", "v", "1", "1", "1", "0"] - ) - - let writes = try generator.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - let statement = try #require(writes.first?.statement) - guard case let .del(key, prefix) = try EtcdCommandParser.parse(statement) else { - Issue.record("Expected a del operation from \(statement)") - return - } - #expect(Array(key.unicodeScalars) == Array("\u{301}x".unicodeScalars)) - #expect(!prefix) - } -} diff --git a/TableProTests/Plugins/EtcdGatewayRouteTests.swift b/TableProTests/Plugins/EtcdGatewayRouteTests.swift deleted file mode 100644 index 480c0825e3..0000000000 --- a/TableProTests/Plugins/EtcdGatewayRouteTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// EtcdGatewayRouteTests.swift -// TableProTests -// -// Which gateway prefix an etcd server routes is a 404-or-not question. Measured: etcd 3.2 -// serves only /v3alpha, 3.3 only /v3beta, and 3.4 onward /v3. -// - -import Foundation -import Testing - -private func gatewayBody(_ text: String) -> Data { - Data(text.utf8) -} - -struct EtcdGatewayRouteTests { - @Test("v3 is tried before the legacy prefixes") - func prefixOrder() { - #expect(EtcdGatewayRoute.candidatePrefixes == ["v3", "v3beta", "v3alpha"]) - } - - @Test("Only 404 means the prefix is not routed") - func notFoundIsTheOnlyRejection() { - #expect( - EtcdGatewayRoute.classify(httpStatus: 404, body: gatewayBody("404 page not found")) == .notRouted - ) - #expect( - EtcdGatewayRoute.classify(httpStatus: 404, body: gatewayBody(#"{"code":5}"#)) == .notRouted - ) - } - - @Test("An auth fault proves the prefix is routed") - func authFaultIsRouted() { - let fault = gatewayBody(#"{"code":3, "message":"etcdserver: user name is empty"}"#) - #expect(EtcdGatewayRoute.classify(httpStatus: 400, body: fault) == .routed) - #expect(EtcdGatewayRoute.classify(httpStatus: 401, body: fault) == .routed) - #expect(EtcdGatewayRoute.classify(httpStatus: 403, body: fault) == .routed) - } - - @Test("A successful answer is routed") - func successIsRouted() { - #expect( - EtcdGatewayRoute.classify(httpStatus: 200, body: gatewayBody(#"{"header":{}}"#)) == .routed - ) - } - - @Test("A non-etcd answer is not accepted as a gateway") - func nonEtcdAnswers() { - #expect(EtcdGatewayRoute.classify(httpStatus: 200, body: gatewayBody("")) == .notEtcd) - #expect(EtcdGatewayRoute.classify(httpStatus: 200, body: Data()) == .notEtcd) - #expect(EtcdGatewayRoute.classify(httpStatus: 200, body: gatewayBody("[1,2,3]")) == .notEtcd) - } -} diff --git a/TableProTests/Plugins/EtcdHttpClientUtilityTests.swift b/TableProTests/Plugins/EtcdHttpClientUtilityTests.swift deleted file mode 100644 index 357528f61a..0000000000 --- a/TableProTests/Plugins/EtcdHttpClientUtilityTests.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// EtcdHttpClientUtilityTests.swift -// TableProTests -// -// Tests for EtcdHttpClient static utility functions (base64 and prefix range). -// These are pure functions that can be tested without a live etcd server. -// -// The utilities are replicated here because EtcdHttpClient.swift cannot be -// symlinked into the test target (it depends on Security, URLSession, and -// networking code that would require the full plugin environment). -// - -import Foundation -import TableProPluginKit -import Testing - -// MARK: - Base64 Helpers - -struct EtcdBase64Tests { - @Test("base64Encode and base64Decode round-trip for simple string") - func roundTripSimple() { - let original = "hello" - let encoded = TestEtcdBase64.encode(original) - let decoded = TestEtcdBase64.decode(encoded) - #expect(decoded == original) - } - - @Test("base64Encode and base64Decode round-trip for empty string") - func roundTripEmpty() { - let original = "" - let encoded = TestEtcdBase64.encode(original) - let decoded = TestEtcdBase64.decode(encoded) - #expect(decoded == original) - } - - @Test("base64Encode and base64Decode round-trip for Unicode") - func roundTripUnicode() { - let original = "hello world \u{1F600} \u{00E9}" - let encoded = TestEtcdBase64.encode(original) - let decoded = TestEtcdBase64.decode(encoded) - #expect(decoded == original) - } - - @Test("base64Encode and base64Decode round-trip for path-like string") - func roundTripPath() { - let original = "/app/config/database/host" - let encoded = TestEtcdBase64.encode(original) - let decoded = TestEtcdBase64.decode(encoded) - #expect(decoded == original) - } - - @Test("base64Encode and base64Decode round-trip for string with special chars") - func roundTripSpecialChars() { - let original = "key:with/slashes\\and=signs&more" - let encoded = TestEtcdBase64.encode(original) - let decoded = TestEtcdBase64.decode(encoded) - #expect(decoded == original) - } - - @Test("base64Decode with invalid input returns the input unchanged") - func decodeInvalidInput() { - let invalid = "not-valid-base64!!!" - let result = TestEtcdBase64.decode(invalid) - // When base64 decoding fails, the original string is returned - #expect(result == invalid) - } - - @Test("base64Encode produces standard base64 output") - func encodeKnownValue() { - let encoded = TestEtcdBase64.encode("hello") - #expect(encoded == "aGVsbG8=") - } - - @Test("base64Decode known value") - func decodeKnownValue() { - let decoded = TestEtcdBase64.decode("aGVsbG8=") - #expect(decoded == "hello") - } -} - -// MARK: - Prefix Range End - -struct EtcdPrefixRangeEndTests { - @Test("Normal prefix increments last byte") - func normalPrefix() { - let result = TestEtcdPrefixRange.rangeEnd(for: "/app/") - // "/" is ASCII 0x2F, so the range end should be "/app0" where "0" is the next char - #expect(result == "/app0") - } - - @Test("Single character prefix") - func singleChar() { - let result = TestEtcdPrefixRange.rangeEnd(for: "a") - #expect(result == "b") - } - - @Test("Empty prefix returns null byte") - func emptyPrefix() { - let result = TestEtcdPrefixRange.rangeEnd(for: "") - #expect(result == "\0") - } - - @Test("Prefix ending with z increments to {") - func prefixEndingWithZ() { - let result = TestEtcdPrefixRange.rangeEnd(for: "z") - // "z" is 0x7A, increment gives 0x7B which is "{" - #expect(result == "{") - } - - @Test("Prefix 'abc' increments to 'abd'") - func prefixAbc() { - let result = TestEtcdPrefixRange.rangeEnd(for: "abc") - #expect(result == "abd") - } - - @Test("Prefix ending with high-value byte rolls back correctly") - func trailingHighBytes() { - // "a" + 0xFE (high but not max) should increment 0xFE to 0xFF, truncate to "a\xFF" - // But since 0xFE isn't valid UTF-8 continuation, test with valid multi-byte: - // Use "z" which is 0x7A — incrementing gives 0x7B = "{" - let result = TestEtcdPrefixRange.rangeEnd(for: "az") - #expect(result == "a{") - } -} - -// MARK: - Private Helpers (replicated from EtcdHttpClient) - -private enum TestEtcdBase64 { - static func encode(_ string: String) -> String { - Data(string.utf8).base64EncodedString() - } - - static func decode(_ string: String) -> String { - guard let data = Data(base64Encoded: string) else { return string } - return String(data: data, encoding: .utf8) ?? "" - } -} - -/// A copy of `EtcdHttpClient.prefixRangeEnd`, byte for byte. The plugin's own file is not in this -/// target, so the cases above exercise this rather than the shipped function: a change to one will -/// not be caught by the other. -/// -/// The all-0xFF case that used to sit above is gone. `prefixRangeEnd` takes a `String`, and no -/// Swift `String` has 0xFF in its UTF-8, so the loop's fallthrough is unreachable from this entry -/// point. The test built its input with `String(decoding:as: UTF8.self)`, which turns those bytes -/// into replacement characters, `EF BF BD`, and so never reached the path it named. -private enum TestEtcdPrefixRange { - static func rangeEnd(for prefix: String) -> String { - var bytes = Array(prefix.utf8) - guard !bytes.isEmpty else { return "\0" } - var i = bytes.count - 1 - while i >= 0 { - if bytes[i] < 0xFF { - bytes[i] += 1 - return String(bytes: Array(bytes[0 ... i]), encoding: .utf8) ?? "\0" - } - i -= 1 - } - return "\0" - } -} diff --git a/TableProTests/Plugins/EtcdKeyspaceTests.swift b/TableProTests/Plugins/EtcdKeyspaceTests.swift deleted file mode 100644 index b31bf3f481..0000000000 --- a/TableProTests/Plugins/EtcdKeyspaceTests.swift +++ /dev/null @@ -1,205 +0,0 @@ -// -// EtcdKeyspaceTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct EtcdKeyspaceRootRowTests { - @Test("The (root) row has no drop or truncate statement", arguments: ["", "/", "/myapp", "/myapp/"]) - func rootRowIsNeverDeletedWhole(keyPrefixRoot: String) { - let keyspace = EtcdKeyspace(keyPrefixRoot: keyPrefixRoot) - - #expect(keyspace.dropStatement(forTable: EtcdKeyspace.rootTableName) == nil) - #expect(keyspace.truncateStatements(forTable: EtcdKeyspace.rootTableName) == nil) - } - - @Test("A table name that resolves to the whole root has no drop or truncate statement") - func emptyTableNameIsNeverDeletedWhole() { - let keyspace = EtcdKeyspace(keyPrefixRoot: "/myapp") - - #expect(keyspace.dropStatement(forTable: "") == nil) - #expect(keyspace.truncateStatements(forTable: "") == nil) - } - - @Test("A child prefix drops and truncates only its own keys") - func childPrefixStatements() { - let keyspace = EtcdKeyspace(keyPrefixRoot: "/myapp") - - #expect(keyspace.dropStatement(forTable: "app/") == "del /myapp/app/ --prefix") - #expect(keyspace.truncateStatements(forTable: "app/") == ["del /myapp/app/ --prefix"]) - } - - @Test("A child prefix under an empty root keeps its own name") - func childPrefixUnderEmptyRoot() { - let keyspace = EtcdKeyspace(keyPrefixRoot: "") - - #expect(keyspace.dropStatement(forTable: "/services/") == "del /services/ --prefix") - } - - @Test("A prefix with whitespace is quoted so the command parser reads it back whole") - func quotedPrefix() throws { - let keyspace = EtcdKeyspace(keyPrefixRoot: "/my app") - let statement = try #require(keyspace.dropStatement(forTable: "a b/")) - - #expect(statement == "del \"/my app/a b/\" --prefix") - guard case let .del(key, prefix) = try EtcdCommandParser.parse(statement) else { - Issue.record("Expected a del operation") - return - } - #expect(key == "/my app/a b/") - #expect(prefix) - } -} - -struct EtcdKeyspaceRootPrefixTests { - @Test("A root without a trailing slash is bounded at the slash", arguments: ["/myapp", "/myapp/"]) - func rootIsBoundedAtSlash(keyPrefixRoot: String) { - let keyspace = EtcdKeyspace(keyPrefixRoot: keyPrefixRoot) - - #expect(keyspace.root == "/myapp/") - #expect(keyspace.prefix(forTable: EtcdKeyspace.rootTableName) == "/myapp/") - #expect(keyspace.exportQuery(forTable: EtcdKeyspace.rootTableName) == "get /myapp/ --prefix") - #expect(keyspace.keysOnlyListing == "get /myapp/ --prefix --keys-only") - } - - @Test("An empty root and a slash root are kept as they are") - func emptyAndSlashRoots() { - #expect(EtcdKeyspace(keyPrefixRoot: "").root == "") - #expect(EtcdKeyspace(keyPrefixRoot: "/").root == "/") - } - - @Test("A key added to the (root) row lands under the root, not beside it") - func insertIntoRootRowStaysUnderRoot() throws { - let keyspace = EtcdKeyspace(keyPrefixRoot: "/myapp") - let generator = EtcdStatementGenerator( - prefix: keyspace.prefix(forTable: EtcdKeyspace.rootTableName), - columns: ["Key", "Value", "Version", "ModRevision", "CreateRevision", "Lease"] - ) - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - - let writes = try generator.generateRowWrites( - from: [change], - insertedRowData: [0: ["feature", "on", nil, nil, nil, nil]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(writes.map(\.statement) == ["put /myapp/feature on"]) - } -} - -struct EtcdKeyspaceTableNameTests { - @Test("Keys group by their first segment under the root") - func groupsByFirstSegment() { - let keyspace = EtcdKeyspace(keyPrefixRoot: "/myapp") - - #expect(keyspace.tableName(forKey: "/myapp/app/config") == "app/") - #expect(keyspace.tableName(forKey: "/myapp/app/nested/deep") == "app/") - #expect(keyspace.tableName(forKey: "/myapp/solo") == EtcdKeyspace.rootTableName) - } - - @Test("Keys under an empty root keep their leading slash in the segment") - func emptyRootSegments() { - let keyspace = EtcdKeyspace(keyPrefixRoot: "") - - #expect(keyspace.tableName(forKey: "/services/api") == "/services/") - #expect(keyspace.tableName(forKey: "services/api") == "services/") - #expect(keyspace.tableName(forKey: "/solo") == EtcdKeyspace.rootTableName) - #expect(keyspace.tableName(forKey: "solo") == EtcdKeyspace.rootTableName) - } - - @Test("A key that is only a slash below the root has no segment of its own") - func slashOnlyKeyIsBare() { - #expect(EtcdKeyspace(keyPrefixRoot: "").tableName(forKey: "/") == EtcdKeyspace.rootTableName) - #expect(EtcdKeyspace(keyPrefixRoot: "/myapp").tableName(forKey: "/myapp//") == EtcdKeyspace.rootTableName) - } -} - -struct EtcdKeyspaceTableListTests { - @Test("A root with no keys lists only an empty (root) row") - func noKeysListsEmptyRoot() { - let keyspace = EtcdKeyspace(keyPrefixRoot: "/myapp") - - #expect(keyspace.tables(forKeys: []) == [EtcdKeyspace.Table(name: EtcdKeyspace.rootTableName, keyCount: 0)]) - } - - @Test("Keys that all sit in a prefix list no (root) row") - func onlyChildKeysListNoRoot() { - let keyspace = EtcdKeyspace(keyPrefixRoot: "/myapp") - - let tables = keyspace.tables(forKeys: ["/myapp/b/1", "/myapp/a/1", "/myapp/a/2"]) - - #expect(tables == [ - EtcdKeyspace.Table(name: "a/", keyCount: 2), - EtcdKeyspace.Table(name: "b/", keyCount: 1) - ]) - } - - @Test("Bare keys list (root) first with their own count, then the sorted prefixes") - func bareAndChildKeysListRootFirst() { - let keyspace = EtcdKeyspace(keyPrefixRoot: "/myapp") - - let tables = keyspace.tables(forKeys: ["/myapp/b/1", "/myapp/solo", "/myapp/a/1", "/myapp//", "/myapp/"]) - - #expect(tables == [ - EtcdKeyspace.Table(name: EtcdKeyspace.rootTableName, keyCount: 3), - EtcdKeyspace.Table(name: "a/", keyCount: 1), - EtcdKeyspace.Table(name: "b/", keyCount: 1) - ]) - } -} - -struct EtcdKeyspaceDeletionScopeTests { - private static let keysUnderMyApp = [ - "/myapp/", - "/myapp//", - "/myapp//a/1", - "/myapp///x", - "/myapp/a/1", - "/myapp/a/2", - "/myapp/a/\u{301}", - "/myapp/ab/1", - "/myapp/b/1", - "/myapp/solo" - ] - - private static let keysUnderEmptyRoot = [ - "/", - "//x", - "/a/1", - "/solo", - "\u{301}x/1", - "a/1", - "ab/1", - "solo", - "x/1" - ] - - @Test("Every row's delete covers exactly the keys that row lists") - func deleteCoversExactlyTheListedKeys() throws { - try assertDeletionScopes(keyspace: EtcdKeyspace(keyPrefixRoot: "/myapp"), keys: Self.keysUnderMyApp) - try assertDeletionScopes(keyspace: EtcdKeyspace(keyPrefixRoot: ""), keys: Self.keysUnderEmptyRoot) - } - - private func assertDeletionScopes(keyspace: EtcdKeyspace, keys: [String]) throws { - let tables = Set(keys.map(keyspace.tableName(forKey:))) - for table in tables { - guard let statement = keyspace.dropStatement(forTable: table) else { - #expect(table == EtcdKeyspace.rootTableName) - continue - } - guard case let .del(deletedPrefix, isPrefix) = try EtcdCommandParser.parse(statement) else { - Issue.record("Row \(table) drops with \(statement), which is not a del") - continue - } - #expect(isPrefix) - #expect(Array(deletedPrefix.unicodeScalars) == Array(keyspace.prefix(forTable: table).unicodeScalars)) - let deleted = keys.filter { $0.utf8.starts(with: deletedPrefix.utf8) } - let listed = keys.filter { keyspace.tableName(forKey: $0) == table } - #expect(deleted == listed, "Row \(table) deletes \(deleted) but lists \(listed)") - } - } -} diff --git a/TableProTests/Plugins/EtcdLeaseIDTests.swift b/TableProTests/Plugins/EtcdLeaseIDTests.swift deleted file mode 100644 index ea47641936..0000000000 --- a/TableProTests/Plugins/EtcdLeaseIDTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// EtcdLeaseIDTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct EtcdLeaseIDTests { - @Test("A Lease cell reads back as the lease it shows", arguments: [16, 0x7b, 12_345, 0x694d_77aa_1e77_5e08] as [Int64]) - func cellTextRoundTrips(leaseId: Int64) throws { - let cellText = EtcdLeaseID.cellText(serverValue: String(leaseId)) - - #expect(try EtcdCommandParser.parseLeaseId(cellText) == leaseId) - } - - @Test("A granted or listed lease id reads back as the lease it shows") - func hexTextRoundTrips() throws { - #expect(try EtcdCommandParser.parseLeaseId(EtcdLeaseID.hexText(serverValue: "16")) == 16) - #expect(EtcdLeaseID.hexText(serverValue: "unknown") == "unknown") - } - - @Test("A lease shows as 0x-prefixed hex") - func cellTextIsPrefixedHex() { - #expect(EtcdLeaseID.cellText(serverValue: "16") == "0x10") - #expect(EtcdLeaseID.cellText(serverValue: "123") == "0x7b") - } - - @Test("A key with no lease has an empty Lease cell") - func noLeaseIsEmpty() { - #expect(EtcdLeaseID.cellText(serverValue: "0").isEmpty) - #expect(EtcdLeaseID.cellText(serverValue: nil).isEmpty) - } -} diff --git a/TableProTests/Plugins/EtcdQueryBuilderTests.swift b/TableProTests/Plugins/EtcdQueryBuilderTests.swift deleted file mode 100644 index e8dc63a2e3..0000000000 --- a/TableProTests/Plugins/EtcdQueryBuilderTests.swift +++ /dev/null @@ -1,251 +0,0 @@ -// -// EtcdQueryBuilderTests.swift -// TableProTests -// -// Tests for EtcdQueryBuilder (compiled via symlink from EtcdDriverPlugin). -// - -import Foundation -import TableProPluginKit -import Testing - -struct EtcdQueryBuilderBrowseTests { - private let builder = EtcdQueryBuilder() - - @Test("Empty prefix produces valid range query") - func emptyPrefix() { - let query = builder.buildBrowseQuery(prefix: "", sortColumns: [], limit: 100, offset: 0) - #expect(EtcdQueryBuilder.isTaggedQuery(query)) - let parsed = EtcdQueryBuilder.parseRangeQuery(query) - #expect(parsed != nil) - #expect(parsed?.prefix == "") - #expect(parsed?.limit == 100) - #expect(parsed?.offset == 0) - #expect(parsed?.sortAscending == true) - #expect(parsed?.filter == .unfiltered) - } - - @Test("Non-empty prefix is encoded and decoded correctly") - func withPrefix() { - let query = builder.buildBrowseQuery(prefix: "/app/config/", sortColumns: [], limit: 50, offset: 10) - let parsed = EtcdQueryBuilder.parseRangeQuery(query) - #expect(parsed?.prefix == "/app/config/") - #expect(parsed?.limit == 50) - #expect(parsed?.offset == 10) - } - - @Test("Sort ascending from sortColumns") - func sortAscending() { - let query = builder.buildBrowseQuery( - prefix: "", - sortColumns: [(columnIndex: 0, ascending: true)], - limit: 100, - offset: 0 - ) - let parsed = EtcdQueryBuilder.parseRangeQuery(query) - #expect(parsed?.sortAscending == true) - } - - @Test("Sort descending from sortColumns") - func sortDescending() { - let query = builder.buildBrowseQuery( - prefix: "", - sortColumns: [(columnIndex: 0, ascending: false)], - limit: 100, - offset: 0 - ) - let parsed = EtcdQueryBuilder.parseRangeQuery(query) - #expect(parsed?.sortAscending == false) - } - - @Test("Different limit and offset values") - func differentLimitOffset() { - let query = builder.buildBrowseQuery(prefix: "test/", sortColumns: [], limit: 500, offset: 250) - let parsed = EtcdQueryBuilder.parseRangeQuery(query) - #expect(parsed?.limit == 500) - #expect(parsed?.offset == 250) - } -} - -struct EtcdQueryBuilderFilteredTests { - private let builder = EtcdQueryBuilder() - - private func conditions( - _ filters: [PluginQueryFilter], - logicMode: String = "and" - ) -> [(column: EtcdColumn, comparison: EtcdComparison, value: String)]? { - let query = builder.buildFilteredQuery( - prefix: "", filters: filters, logicMode: logicMode, sortColumns: [], limit: 100, offset: 0 - ) - return EtcdQueryBuilder.parseRangeQuery(query)?.filter.conditions.map { - (column: $0.column, comparison: $0.comparison, value: $0.value) - } - } - - private func refusal(_ filters: [PluginQueryFilter]) -> String? { - let query = builder.buildFilteredQuery( - prefix: "", filters: filters, logicMode: "and", sortColumns: [], limit: 100, offset: 0 - ) - return EtcdQueryBuilder.parseRefusal(query)?.pluginErrorMessage - } - - @Test("Key equals filter") - func keyEqualsFilter() throws { - let carried = try #require(conditions([PluginQueryFilter(column: "Key", op: "=", value: "/app/config")])) - #expect(carried.map(\.comparison) == [.equal]) - #expect(carried.map(\.value) == ["/app/config"]) - } - - @Test("Key contains, starts-with and ends-with filters") - func keyPatternFilters() { - let cases: [(op: String, comparison: EtcdComparison)] = [ - ("CONTAINS", .contains), ("STARTS WITH", .startsWith), ("ENDS WITH", .endsWith), - ] - for (op, comparison) in cases { - let carried = conditions([PluginQueryFilter(column: "Key", op: op, value: "cfg")]) - #expect(carried?.map(\.comparison) == [comparison], "\(op)") - } - } - - @Test("A Value filter builds a range query the driver runs") - func valueFilterBuildsRange() throws { - let carried = try #require(conditions([PluginQueryFilter(column: "Value", op: "CONTAINS", value: "test")])) - #expect(carried.map(\.column) == [.value]) - } - - @Test("A Lease filter is carried to the driver") - func leaseFilterIsCarried() throws { - let carried = try #require(conditions([PluginQueryFilter(column: "Lease", op: "=", value: "0x7b")])) - #expect(carried.map(\.column) == [.lease]) - } - - @Test("Key and Value filters are both carried") - func mixedKeyAndValueFilters() throws { - let carried = try #require(conditions([ - PluginQueryFilter(column: "Key", op: "CONTAINS", value: "test"), - PluginQueryFilter(column: "Value", op: "CONTAINS", value: "data"), - ])) - #expect(carried.map(\.column) == [.key, .value]) - } - - @Test("Key NOT CONTAINS is carried rather than dropped") - func keyNotContainsIsCarried() throws { - let carried = try #require(conditions([PluginQueryFilter(column: "Key", op: "NOT CONTAINS", value: "x")])) - #expect(carried.map(\.comparison) == [.notContains]) - } - - @Test("Two Key filters in OR mode are both carried and either may match") - func keyFiltersInOrMode() throws { - let query = builder.buildFilteredQuery( - prefix: "", - filters: [ - PluginQueryFilter(column: "Key", op: "=", value: "/a"), - PluginQueryFilter(column: "Key", op: "=", value: "/b"), - ], - logicMode: "or", - sortColumns: [], - limit: 100, - offset: 0 - ) - let filter = try #require(EtcdQueryBuilder.parseRangeQuery(query)?.filter) - #expect(filter.conditions.map(\.value) == ["/a", "/b"]) - #expect(filter.matchesAll == false) - } - - @Test("A filter etcd cannot evaluate becomes a refusal, never an unfiltered range") - func unsupportedFilterIsRefused() { - let query = builder.buildFilteredQuery( - prefix: "", - filters: [PluginQueryFilter(column: "__RAW__", op: "=", value: "a = 1")], - logicMode: "and", - sortColumns: [], - limit: 100, - offset: 0 - ) - #expect(EtcdQueryBuilder.isTaggedQuery(query)) - #expect(EtcdQueryBuilder.parseRangeQuery(query) == nil) - #expect(refusal([PluginQueryFilter(column: "__RAW__", op: "=", value: "a = 1")]) - == "etcd cannot filter with a raw SQL condition.") - #expect(refusal([PluginQueryFilter(column: "Key", op: "SOUNDS LIKE", value: "x")]) - == "etcd cannot filter with SOUNDS LIKE.") - } -} - -struct EtcdQueryBuilderCountTests { - private let builder = EtcdQueryBuilder() - - @Test("Count query round-trip") - func countQueryRoundTrip() { - let query = builder.buildCountQuery(prefix: "/myprefix/") - #expect(EtcdQueryBuilder.isTaggedQuery(query)) - let parsed = EtcdQueryBuilder.parseCountQuery(query) - #expect(parsed != nil) - #expect(parsed?.prefix == "/myprefix/") - } - - @Test("Count query with empty prefix") - func countQueryEmptyPrefix() { - let query = builder.buildCountQuery(prefix: "") - let parsed = EtcdQueryBuilder.parseCountQuery(query) - #expect(parsed?.prefix == "") - } -} - -struct EtcdQueryBuilderTagTests { - @Test("isTaggedQuery detects range tag") - func detectsRangeTag() { - #expect(EtcdQueryBuilder.isTaggedQuery("ETCD_RANGE:abc")) - #expect(!EtcdQueryBuilder.isTaggedQuery("get key")) - } - - @Test("isTaggedQuery detects count tag") - func detectsCountTag() { - #expect(EtcdQueryBuilder.isTaggedQuery("ETCD_COUNT:abc")) - #expect(!EtcdQueryBuilder.isTaggedQuery("put key value")) - } - - @Test("parseRangeQuery returns nil for non-range query") - func parseRangeNonTagged() { - #expect(EtcdQueryBuilder.parseRangeQuery("get key") == nil) - } - - @Test("parseCountQuery returns nil for non-count query") - func parseCountNonTagged() { - #expect(EtcdQueryBuilder.parseCountQuery("get key") == nil) - } - - @Test("parseRangeQuery returns nil for malformed body") - func parseRangeMalformed() { - #expect(EtcdQueryBuilder.parseRangeQuery("ETCD_RANGE:bad") == nil) - } - - @Test("parseCountQuery returns nil for malformed body") - func parseCountMalformed() { - #expect(EtcdQueryBuilder.parseCountQuery("ETCD_COUNT:bad") == nil) - } - - @Test("Range query encode/parse round-trip preserves all fields") - func rangeRoundTrip() { - let builder = EtcdQueryBuilder() - let query = builder.buildBrowseQuery( - prefix: "my/prefix/", - sortColumns: [(columnIndex: 0, ascending: false)], - limit: 42, - offset: 7 - ) - let parsed = EtcdQueryBuilder.parseRangeQuery(query) - #expect(parsed?.prefix == "my/prefix/") - #expect(parsed?.limit == 42) - #expect(parsed?.offset == 7) - #expect(parsed?.sortAscending == false) - #expect(parsed?.filter == .unfiltered) - } - - @Test("Prefix containing colon round-trips correctly") - func prefixWithColon() { - let builder = EtcdQueryBuilder() - let query = builder.buildBrowseQuery(prefix: "ns:key:", sortColumns: [], limit: 10, offset: 0) - let parsed = EtcdQueryBuilder.parseRangeQuery(query) - #expect(parsed?.prefix == "ns:key:") - } -} diff --git a/TableProTests/Plugins/EtcdRequestRecoveryTests.swift b/TableProTests/Plugins/EtcdRequestRecoveryTests.swift deleted file mode 100644 index 89d10293c4..0000000000 --- a/TableProTests/Plugins/EtcdRequestRecoveryTests.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// EtcdRequestRecoveryTests.swift -// TableProTests -// -// Mirrors clientv3's shouldRefreshToken: refresh on an invalid token, on a stale auth store -// revision, and on a missing token when credentials exist. Never on permission denied. -// - -import Foundation -import Testing - -private func recoveryFault(code: Int, message: String) -> EtcdServerFault { - EtcdServerFault(grpcCode: code, message: message) -} - -private let missingToken = recoveryFault(code: 3, message: "etcdserver: user name is empty") -private let wrongPassword = recoveryFault( - code: 3, - message: "etcdserver: authentication failed, invalid user ID or password" -) -private let staleRevision = recoveryFault(code: 3, message: "etcdserver: revision of auth store is old") -private let rejectedToken = recoveryFault(code: 16, message: "etcdserver: invalid auth token") -private let deniedPermission = recoveryFault(code: 7, message: "etcdserver: permission denied") -private let authOff = recoveryFault(code: 9, message: "etcdserver: authentication is not enabled") - -struct EtcdRequestRecoveryTests { - @Test("A rejected token is refreshed once") - func rejectedTokenRefreshes() { - #expect( - EtcdRequestRecovery.action(for: rejectedToken, hasCredentials: true, isRetry: false) - == .reauthenticateAndRetry - ) - } - - @Test("A missing token is refreshed when credentials exist") - func missingTokenRefreshes() { - #expect( - EtcdRequestRecovery.action(for: missingToken, hasCredentials: true, isRetry: false) - == .reauthenticateAndRetry - ) - } - - @Test("A stale auth store revision is refreshed") - func staleRevisionRefreshes() { - #expect( - EtcdRequestRecovery.action(for: staleRevision, hasCredentials: true, isRetry: false) - == .reauthenticateAndRetry - ) - } - - @Test("Permission denied is never retried") - func permissionDeniedSurfaces() { - #expect( - EtcdRequestRecovery.action(for: deniedPermission, hasCredentials: true, isRetry: false) - == .surface - ) - } - - @Test("A wrong password is never retried") - func wrongPasswordSurfaces() { - #expect( - EtcdRequestRecovery.action(for: wrongPassword, hasCredentials: true, isRetry: false) - == .surface - ) - } - - @Test("Authentication being off is never retried") - func authOffSurfaces() { - #expect( - EtcdRequestRecovery.action(for: authOff, hasCredentials: true, isRetry: false) == .surface - ) - } - - @Test("A connection with no credentials cannot recover") - func noCredentialsSurfaces() { - #expect( - EtcdRequestRecovery.action(for: missingToken, hasCredentials: false, isRetry: false) - == .surface - ) - #expect( - EtcdRequestRecovery.action(for: rejectedToken, hasCredentials: false, isRetry: false) - == .surface - ) - } - - @Test("A retry never refreshes again") - func retryDoesNotLoop() { - #expect( - EtcdRequestRecovery.action(for: rejectedToken, hasCredentials: true, isRetry: true) == .surface - ) - #expect( - EtcdRequestRecovery.action(for: missingToken, hasCredentials: true, isRetry: true) == .surface - ) - } - - @Test("An unrecognised fault is surfaced") - func unclassifiedSurfaces() { - let fault = recoveryFault(code: 5, message: "etcdserver: key is not provided") - #expect( - EtcdRequestRecovery.action(for: fault, hasCredentials: true, isRetry: false) == .surface - ) - } -} diff --git a/TableProTests/Plugins/EtcdRowFilterTests.swift b/TableProTests/Plugins/EtcdRowFilterTests.swift deleted file mode 100644 index ccb886c0d4..0000000000 --- a/TableProTests/Plugins/EtcdRowFilterTests.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// EtcdRowFilterTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct EtcdRowFilterTests { - private func row( - _ key: String, - value: String? = "v", - version: String = "1", - modRevision: String = "1", - createRevision: String = "1", - lease: String = "0" - ) -> EtcdFilterRow { - EtcdFilterRow( - key: key, value: value, version: version, - modRevision: modRevision, createRevision: createRevision, lease: lease - ) - } - - private func keep( - _ rows: [EtcdFilterRow], - _ filters: [PluginQueryFilter], - logicMode: String = "and" - ) throws -> [String] { - let matcher = try EtcdRowFilter(filters: filters, logicMode: logicMode).matcher() - return rows.filter(matcher.matches).map(\.key) - } - - private func refusal(_ filters: [PluginQueryFilter]) -> String? { - do { - _ = try EtcdRowFilter(filters: filters, logicMode: "and") - return nil - } catch { - return (error as? EtcdFilterRefusal)?.pluginErrorMessage - } - } - - @Test("Key NOT CONTAINS drops the keys that contain the text") - func keyNotContains() throws { - let kept = try keep( - [row("/app/x1"), row("/app/y1")], - [PluginQueryFilter(column: "Key", op: "NOT CONTAINS", value: "x")] - ) - #expect(kept == ["/app/y1"]) - } - - @Test("A Key filter tests the key alone, never the value", arguments: ["CONTAINS", "STARTS WITH"]) - func keyFilterIgnoresValue(op: String) throws { - let kept = try keep( - [row("/a", value: "/zzz"), row("/zzz", value: "a")], - [PluginQueryFilter(column: "Key", op: op, value: "/z")] - ) - #expect(kept == ["/zzz"]) - } - - @Test("Two Key filters in OR mode keep a key matching either") - func keyFiltersInOrMode() throws { - let kept = try keep( - [row("/a"), row("/b"), row("/c")], - [ - PluginQueryFilter(column: "Key", op: "=", value: "/a"), - PluginQueryFilter(column: "Key", op: "=", value: "/c"), - ], - logicMode: "or" - ) - #expect(kept == ["/a", "/c"]) - } - - @Test("Two filters in AND mode keep only a key matching both") - func filtersInAndMode() throws { - let kept = try keep( - [row("/a/1", value: "on"), row("/a/2", value: "off"), row("/b/1", value: "on")], - [ - PluginQueryFilter(column: "Key", op: "STARTS WITH", value: "/a/"), - PluginQueryFilter(column: "Value", op: "=", value: "on"), - ] - ) - #expect(kept == ["/a/1"]) - } - - @Test("A Value filter reads the value") - func valueContains() throws { - let kept = try keep( - [row("/a", value: "enabled=true"), row("/b", value: "enabled=false")], - [PluginQueryFilter(column: "Value", op: "CONTAINS", value: "TRUE", isCaseSensitive: false)] - ) - #expect(kept == ["/a"]) - } - - @Test("Version compares as a number, not as text") - func versionIsNumeric() throws { - let kept = try keep( - [row("/a", version: "9"), row("/b", version: "10")], - [PluginQueryFilter(column: "Version", op: ">", value: "9")] - ) - #expect(kept == ["/b"]) - } - - @Test("BETWEEN on ModRevision includes both bounds") - func modRevisionBetween() throws { - let rows = [row("/a", modRevision: "4"), row("/b", modRevision: "5"), row("/c", modRevision: "7"), - row("/d", modRevision: "8")] - let kept = try keep( - rows, - [PluginQueryFilter( - column: "ModRevision", op: "BETWEEN", value: "5,7", secondValue: "7", elementScope: nil - )] - ) - #expect(kept == ["/b", "/c"]) - } - - @Test("A Lease filter matches the lease however its id is written", arguments: ["0x7b", "7b", "123"]) - func leaseEquals(written: String) throws { - let kept = try keep( - [row("/leased", lease: "123"), row("/other", lease: "124"), row("/none")], - [PluginQueryFilter(column: "Lease", op: "=", value: written)] - ) - #expect(kept == ["/leased"]) - } - - @Test("Lease = 0 keeps the keys that have no lease") - func leaseZeroMeansNone() throws { - let kept = try keep( - [row("/leased", lease: "123"), row("/none")], - [PluginQueryFilter(column: "Lease", op: "=", value: "0")] - ) - #expect(kept == ["/none"]) - } - - @Test("IS NULL keeps a key whose empty value etcd left out") - func valueIsNull() throws { - let kept = try keep( - [row("/empty", value: nil), row("/full", value: "x")], - [PluginQueryFilter(column: "Value", op: "IS NULL", value: "")] - ) - #expect(kept == ["/empty"]) - } - - @Test("IN and NOT IN read a comma-separated list") - func inAndNotIn() throws { - let rows = [row("/a"), row("/b"), row("/c")] - #expect(try keep(rows, [PluginQueryFilter(column: "Key", op: "IN", value: "/a, /c")]) == ["/a", "/c"]) - #expect(try keep(rows, [PluginQueryFilter(column: "Key", op: "NOT IN", value: "/a, /c")]) == ["/b"]) - } - - @Test("REGEX matches the key, ignoring case when asked") - func keyRegex() throws { - let kept = try keep( - [row("/Service/1"), row("/service/22"), row("/other/1")], - [PluginQueryFilter(column: "Key", op: "REGEX", value: "^/service/[0-9]$", isCaseSensitive: false)] - ) - #expect(kept == ["/Service/1"]) - } - - @Test("Ordering on Key follows etcd's byte order") - func keyOrderingIsByteOrder() throws { - let kept = try keep( - [row("/B"), row("/a"), row("/b")], - [PluginQueryFilter(column: "Key", op: ">=", value: "/a")] - ) - #expect(kept == ["/a", "/b"]) - } - - @Test("An empty filter keeps every key") - func noFilterKeepsAll() throws { - #expect(try keep([row("/a"), row("/b")], []) == ["/a", "/b"]) - #expect(EtcdRowFilter.unfiltered.isUnfiltered) - } - - @Test("A filter etcd cannot evaluate is refused rather than dropped") - func refusals() { - #expect(refusal([PluginQueryFilter(column: "__RAW__", op: "=", value: "a = 1")]) - == "etcd cannot filter with a raw SQL condition.") - #expect(refusal([PluginQueryFilter(column: "Owner", op: "=", value: "x")]) == "etcd has no Owner column.") - #expect(refusal([PluginQueryFilter(column: "Key", op: "SOUNDS LIKE", value: "x")]) - == "etcd cannot filter with SOUNDS LIKE.") - #expect(refusal([PluginQueryFilter(column: "Version", op: ">", value: "ten")]) - == "Version can only be compared with a whole number.") - #expect(refusal([PluginQueryFilter(column: "Lease", op: "=", value: "lease")]) - == "Lease can only be compared with a lease ID, such as 0x7b.") - #expect(refusal([PluginQueryFilter(column: "Key", op: "REGEX", value: "(")]) - == "'(' is not a valid regular expression.") - #expect(refusal([PluginQueryFilter(column: "Key", op: "IN", value: " , ")]) - == "Enter at least one value to filter with IN.") - #expect(refusal([PluginQueryFilter(column: "Version", op: "BETWEEN", value: "1")]) - == "Enter both bounds to filter with BETWEEN.") - } -} diff --git a/TableProTests/Plugins/EtcdServerFaultTests.swift b/TableProTests/Plugins/EtcdServerFaultTests.swift deleted file mode 100644 index b602ebe3e8..0000000000 --- a/TableProTests/Plugins/EtcdServerFaultTests.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// EtcdServerFaultTests.swift -// TableProTests -// -// etcd's gateway reports every failure as a gRPC code plus a message, and grpc-gateway maps -// two distinct codes onto HTTP 400. These pin the classification to the code, measured against -// etcd 3.5.17 and 3.6.1. -// - -import Foundation -import Testing - -private func etcdBody(_ json: String) -> Data { - Data(json.utf8) -} - -struct EtcdServerFaultClassificationTests { - @Test("etcd 3.6 reports a missing token as InvalidArgument, not Unauthorized") - func missingTokenOnEtcd36() { - let fault = EtcdServerFault.decode( - httpStatus: 400, - body: etcdBody(#"{"code":3, "message":"etcdserver: user name is empty"}"#) - ) - #expect(fault.grpcCode == 3) - #expect(fault.kind == .credentialsRequired) - } - - @Test("etcd 3.5 carries the same fault with an extra error key") - func missingTokenOnEtcd35() { - let fault = EtcdServerFault.decode( - httpStatus: 400, - body: etcdBody( - #"{"error":"etcdserver: user name is empty","code":3,"message":"etcdserver: user name is empty"}"# - ) - ) - #expect(fault.kind == .credentialsRequired) - #expect(fault.message == "etcdserver: user name is empty") - } - - @Test("A wrong password shares the gRPC code of a missing token") - func wrongPassword() { - let fault = EtcdServerFault.decode( - httpStatus: 400, - body: etcdBody( - #"{"code":3, "message":"etcdserver: authentication failed, invalid user ID or password"}"# - ) - ) - #expect(fault.kind == .credentialsRejected) - } - - @Test("A stale auth store revision is its own kind") - func staleAuthRevision() { - let fault = EtcdServerFault.decode( - httpStatus: 400, - body: etcdBody(#"{"code":3, "message":"etcdserver: revision of auth store is old"}"#) - ) - #expect(fault.kind == .authRevisionStale) - } - - @Test("Authentication disabled on the server is recognised") - func authNotEnabled() { - let fault = EtcdServerFault.decode( - httpStatus: 400, - body: etcdBody(#"{"code":9, "message":"etcdserver: authentication is not enabled"}"#) - ) - #expect(fault.kind == .authNotEnabled) - } - - @Test("Another FailedPrecondition stays unclassified") - func roleNotFound() { - let fault = EtcdServerFault.decode( - httpStatus: 400, - body: etcdBody(#"{"code":9, "message":"etcdserver: role name not found"}"#) - ) - #expect(fault.kind == .unclassified) - } - - @Test("Permission denied proves the session is live") - func permissionDenied() { - let fault = EtcdServerFault.decode( - httpStatus: 403, - body: etcdBody(#"{"code":7, "message":"etcdserver: permission denied"}"#) - ) - #expect(fault.kind == .permissionDenied) - #expect(fault.provesLiveSession) - } - - @Test("A rejected token is its own kind") - func invalidToken() { - let fault = EtcdServerFault.decode( - httpStatus: 401, - body: etcdBody(#"{"code":16, "message":"etcdserver: invalid auth token"}"#) - ) - #expect(fault.kind == .tokenRejected) - #expect(!fault.provesLiveSession) - } - - @Test("The HTTP status never decides the classification") - func statusIsNotConsulted() { - let payload = etcdBody(#"{"code":16, "message":"etcdserver: invalid auth token"}"#) - #expect(EtcdServerFault.decode(httpStatus: 401, body: payload).kind == .tokenRejected) - #expect(EtcdServerFault.decode(httpStatus: 500, body: payload).kind == .tokenRejected) - #expect(EtcdServerFault.decode(httpStatus: 200, body: payload).kind == .tokenRejected) - } -} - -struct EtcdServerFaultDecodingTests { - @Test("A plain text body survives as the message") - func plainTextBody() { - let fault = EtcdServerFault.decode(httpStatus: 404, body: etcdBody("404 page not found\n")) - #expect(fault.grpcCode == nil) - #expect(fault.message == "404 page not found") - #expect(fault.kind == .unclassified) - } - - @Test("An empty body falls back to the HTTP status") - func emptyBody() { - let fault = EtcdServerFault.decode(httpStatus: 502, body: Data()) - #expect(fault.grpcCode == nil) - #expect(fault.message.contains("502")) - } - - @Test("A long plain text body is capped") - func longBody() { - let fault = EtcdServerFault.decode( - httpStatus: 500, - body: etcdBody(String(repeating: "x", count: 4_000)) - ) - #expect(fault.message.count == 512) - } - - @Test("A missing token asks for credentials instead of quoting etcd") - func credentialsRequiredMessage() { - let fault = EtcdServerFault.decode( - httpStatus: 400, - body: etcdBody(#"{"code":3, "message":"etcdserver: user name is empty"}"#) - ) - #expect(fault.localizedDescription.contains("username")) - #expect(!fault.localizedDescription.contains("user name is empty")) - } - - @Test("Permission denied reads as etcd wrote it") - func permissionDeniedMessage() { - let fault = EtcdServerFault.decode( - httpStatus: 403, - body: etcdBody(#"{"code":7, "message":"etcdserver: permission denied"}"#) - ) - #expect(fault.localizedDescription == "etcdserver: permission denied") - } -} diff --git a/TableProTests/Plugins/EtcdServerTrustTests.swift b/TableProTests/Plugins/EtcdServerTrustTests.swift deleted file mode 100644 index daf279307a..0000000000 --- a/TableProTests/Plugins/EtcdServerTrustTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// EtcdServerTrustTests.swift -// TableProTests -// - -import Foundation -import Security -import TableProPluginKit -import Testing - -@testable import TablePro - -struct EtcdServerTrustTests { - private static let caCertificatePEM = """ - -----BEGIN CERTIFICATE----- - MIIBKDCBzgIJAItYmqrR5A5nMAoGCCqGSM49BAMCMBsxGTAXBgNVBAMMEFRhYmxl - UHJvIFRlc3QgQ0EwIBcNMjYwOTI4MTU1MzMyWhgPMjEyNjA5MDQxNTUzMzJaMBsx - GTAXBgNVBAMMEFRhYmxlUHJvIFRlc3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMB - BwNCAASaYfImiB+IzxPGowAQoY7FJ+pMBUH44UT+xAYLv5daFRxN2UyT9OVoblbw - eckn8xKUHUudi7CXcAYRCK37s4lzMAoGCCqGSM49BAMCA0kAMEYCIQCSluC20xFq - Low0L94hZ8kUnPyvB0Lr+BaeqDo8XQD2+wIhAJvEM/BlLmzZoWm9acV24GaQ8/uy - cC5Nd9tqoj6nWEbU - -----END CERTIFICATE----- - """ - - private func temporaryFile(_ contents: Data) throws -> URL { - let url = FileManager.default.temporaryDirectory.appendingPathComponent("etcd-ca-\(UUID().uuidString).pem") - try contents.write(to: url) - return url - } - - private func refusal(tlsMode: String, caPath: String?) -> EtcdTLSConfigurationError? { - do { - _ = try EtcdServerTrust.make(tlsMode: tlsMode, caCertificatePath: caPath) - return nil - } catch { - return error as? EtcdTLSConfigurationError - } - } - - @Test("Verify CA with no CA certificate is refused, since it checks no hostname") - func verifyCAWithoutCAIsRefused() { - #expect(refusal(tlsMode: "VerifyCA", caPath: nil) == .verifyCANeedsCertificate) - #expect(refusal(tlsMode: "VerifyCA", caPath: "") == .verifyCANeedsCertificate) - #expect(refusal(tlsMode: "VerifyCA", caPath: " ") == .verifyCANeedsCertificate) - } - - @Test("The Verify CA refusal points at the form section where the CA Certificate field is") - func verifyCARefusalNamesTheOptionsTab() throws { - let message = try #require(EtcdTLSConfigurationError.verifyCANeedsCertificate.errorDescription) - #expect(!message.contains("Advanced")) - #expect(message.contains("etcd section of the Options tab")) - } - - @Test("Verify Identity with no CA certificate checks the system trust store and the hostname") - func verifyIdentityWithoutCAUsesSystemTrust() throws { - let trust = try #require(try EtcdServerTrust.make(tlsMode: "VerifyIdentity", caCertificatePath: nil)) - #expect(trust.anchor == nil) - #expect(trust.checksHostname) - } - - @Test("Modes that verify nothing build no server trust") - func nonVerifyingModesBuildNothing() throws { - #expect(try EtcdServerTrust.make(tlsMode: "Disabled", caCertificatePath: nil) == nil) - #expect(try EtcdServerTrust.make(tlsMode: "Required", caCertificatePath: nil) == nil) - } - - @Test("A PEM or DER CA certificate becomes the anchor, and only Verify Identity checks the hostname") - func readableCABecomesAnchor() throws { - let pem = Data(Self.caCertificatePEM.utf8) - let der = try #require(PEMCertificateDecoder.certificateDER(from: pem)) - for contents in [pem, der] { - let file = try temporaryFile(contents) - defer { try? FileManager.default.removeItem(at: file) } - let verifyCA = try #require(try EtcdServerTrust.make(tlsMode: "VerifyCA", caCertificatePath: file.path)) - #expect(verifyCA.anchor != nil) - #expect(!verifyCA.checksHostname) - let verifyIdentity = try #require( - try EtcdServerTrust.make(tlsMode: "VerifyIdentity", caCertificatePath: file.path) - ) - #expect(verifyIdentity.anchor != nil) - #expect(verifyIdentity.checksHostname) - } - } - - @Test("A CA path that does not load is refused rather than widened to the system roots") - func unreadableCAIsRefused() throws { - let missing = "/nonexistent/etcd-ca-\(UUID().uuidString).pem" - let garbage = try temporaryFile(Data("not a certificate".utf8)) - defer { try? FileManager.default.removeItem(at: garbage) } - for mode in ["VerifyCA", "VerifyIdentity"] { - for path in [missing, garbage.path] { - #expect(refusal(tlsMode: mode, caPath: path) == .unreadableCACertificate(path: path), "\(mode) \(path)") - } - } - } -} diff --git a/TableProTests/Plugins/EtcdStatementGeneratorTests.swift b/TableProTests/Plugins/EtcdStatementGeneratorTests.swift deleted file mode 100644 index 3655d3a2b6..0000000000 --- a/TableProTests/Plugins/EtcdStatementGeneratorTests.swift +++ /dev/null @@ -1,852 +0,0 @@ -// -// EtcdStatementGeneratorTests.swift -// TableProTests -// -// Tests for EtcdStatementGenerator (compiled via symlink from EtcdDriverPlugin). -// - -import Foundation -import TableProPluginKit -import Testing - -// MARK: - INSERT - -struct EtcdStatementGeneratorInsertTests { - @Test("Basic insert generates put command") - func basicInsert() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: ["mykey", "myvalue", nil, nil, nil, nil] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "put mykey myvalue") - } - - @Test("Insert with lease generates put --lease") - func insertWithLease() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: ["mykey", "myvalue", nil, nil, nil, "12345"] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "put mykey myvalue --lease=12345") - } - - @Test("Insert with prefix prepending") - func insertWithPrefixPrepending() throws { - let gen = EtcdStatementGenerator( - prefix: "/app/config/", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: ["setting1", "value1", nil, nil, nil, nil] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "put /app/config/setting1 value1") - } - - @Test("Insert with key already containing prefix (no double prefix)") - func insertKeyAlreadyHasPrefix() throws { - let gen = EtcdStatementGenerator( - prefix: "/app/", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - // Key starts with "/" so it's treated as absolute - let insertedData: [Int: [PluginCellValue]] = [ - 0: ["/app/mykey", "value", nil, nil, nil, nil] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "put /app/mykey value") - } - - @Test("Insert with absolute key (leading slash) skips prefix prepend") - func insertAbsoluteKey() throws { - let gen = EtcdStatementGenerator( - prefix: "something/", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: ["/absolute/key", "value", nil, nil, nil, nil] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "put /absolute/key value") - } - - @Test("Insert with empty key is refused") - func insertEmptyKey() { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: ["", "value", nil, nil, nil, nil] - ] - - #expect(throws: PluginRowWriteRefusal(rowIndex: 0, reason: "A new key needs a name.")) { - try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - @Test("Insert with nil key is refused") - func insertNilKey() { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: [nil, "value", nil, nil, nil, nil] - ] - - #expect(throws: PluginRowWriteRefusal(rowIndex: 0, reason: "A new key needs a name.")) { - try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - @Test("Insert with nil value uses empty string") - func insertNilValue() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: ["mykey", nil, nil, nil, nil, nil] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "put mykey \"\"") - } - - @Test("Insert with lease=0 omits --lease flag") - func insertLeaseZero() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: ["mykey", "value", nil, nil, nil, "0"] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(!results[0].statement.contains("--lease")) - } - - @Test("Insert from cell changes (no insertedRowData)") - func insertFromCellChanges() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [ - (columnIndex: 0, columnName: "Key", oldValue: nil, newValue: "newkey"), - (columnIndex: 1, columnName: "Value", oldValue: nil, newValue: "newval") - ], - originalRow: nil - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "put newkey newval") - } - - @Test("Insert with value containing spaces is quoted") - func insertValueWithSpaces() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: ["mykey", "hello world", nil, nil, nil, nil] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "put mykey \"hello world\"") - } -} - -// MARK: - UPDATE - -struct EtcdStatementGeneratorUpdateTests { - @Test("Value change generates put with original key") - func valueChange() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 1, columnName: "Value", oldValue: "oldval", newValue: "newval") - ], - originalRow: ["mykey", "oldval", "1", "1", "1", "0"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "put mykey newval") - } - - @Test("Key rename generates put then del") - func keyRename() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 0, columnName: "Key", oldValue: "oldkey", newValue: "newkey") - ], - originalRow: ["oldkey", "myvalue", "1", "1", "1", "0"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 2) - #expect(results[0].statement == "put newkey myvalue") - #expect(results[1].statement == "del oldkey") - } - - @Test("Value and key change combined") - func valueAndKeyChange() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 0, columnName: "Key", oldValue: "oldkey", newValue: "newkey"), - (columnIndex: 1, columnName: "Value", oldValue: "oldval", newValue: "newval") - ], - originalRow: ["oldkey", "oldval", "1", "1", "1", "0"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 2) - #expect(results[0].statement == "put newkey newval") - #expect(results[1].statement == "del oldkey") - } - - @Test("Lease change only generates put with --lease") - func leaseChangeOnly() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 5, columnName: "Lease", oldValue: "0", newValue: "99999") - ], - originalRow: ["mykey", "myvalue", "1", "1", "1", "0"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "put mykey myvalue --lease=99999") - } - - @Test("Value and lease change combined") - func valueAndLeaseChange() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 1, columnName: "Value", oldValue: "oldval", newValue: "newval"), - (columnIndex: 5, columnName: "Lease", oldValue: "0", newValue: "555") - ], - originalRow: ["mykey", "oldval", "1", "1", "1", "0"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "put mykey newval --lease=555") - } - - @Test("Update with empty new key is refused") - func updateEmptyNewKey() { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 0, columnName: "Key", oldValue: "mykey", newValue: "") - ], - originalRow: ["mykey", "value", "1", "1", "1", "0"] - ) - - #expect(throws: PluginRowWriteRefusal(rowIndex: 0, reason: "A key needs a name.")) { - try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - } - } - - @Test("Update with no cell changes produces nothing") - func updateNoCellChanges() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [], - originalRow: ["mykey", "value", "1", "1", "1", "0"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.isEmpty) - } - - @Test("A Value edit keeps the key's lease") - func valueChangeKeepsLease() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 1, columnName: "Value", oldValue: "old", newValue: "new") - ], - originalRow: ["mykey", "old", "1", "1", "1", "0x7b"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.map(\.statement) == ["put mykey new --lease=0x7b"]) - } - - @Test("A renamed key keeps the lease of the key it replaces") - func keyRenameKeepsLease() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 0, columnName: "Key", oldValue: "oldkey", newValue: "newkey") - ], - originalRow: ["oldkey", "myvalue", "1", "1", "1", "0x10"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.map(\.statement) == ["put newkey myvalue --lease=0x10", "del oldkey"]) - } - - @Test("Update with lease set to 0 omits --lease flag") - func updateLeaseToZero() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 5, columnName: "Lease", oldValue: "12345", newValue: "0") - ], - originalRow: ["mykey", "myvalue", "1", "1", "1", "12345"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(!results[0].statement.contains("--lease")) - } -} - -// MARK: - DELETE - -struct EtcdStatementGeneratorDeleteTests { - @Test("Basic delete generates del command") - func basicDelete() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["mykey", "myvalue", "1", "1", "1", "0"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "del mykey") - } - - @Test("Delete with key containing spaces is quoted") - func deleteKeyWithSpaces() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["my key", "value", "1", "1", "1", "0"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement == "del \"my key\"") - } - - @Test("Delete not in deletedRowIndices is skipped") - func deleteNotInIndices() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["mykey", "value", "1", "1", "1", "0"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.isEmpty) - } -} - -// MARK: - Batch / Multiple Changes - -struct EtcdStatementGeneratorBatchTests { - @Test("Multiple changes in one batch") - func multipleBatch() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let insertChange = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let updateChange = PluginRowChange( - rowIndex: 1, - type: .update, - cellChanges: [ - (columnIndex: 1, columnName: "Value", oldValue: "old", newValue: "new") - ], - originalRow: ["existingkey", "old", "1", "1", "1", "0"] - ) - - let deleteChange = PluginRowChange( - rowIndex: 2, - type: .delete, - cellChanges: [], - originalRow: ["delkey", "val", "1", "1", "1", "0"] - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: ["newkey", "newval", nil, nil, nil, nil] - ] - - let results = try gen.generateRowWrites( - from: [insertChange, updateChange, deleteChange], - insertedRowData: insertedData, - deletedRowIndices: [2], - insertedRowIndices: [0] - ) - - #expect(results.count == 3) - #expect(results[0].statement == "put newkey newval") - #expect(results[1].statement == "put existingkey new") - #expect(results[2].statement == "del delkey") - #expect(results.map(\.rowIndices) == [[0], [1], [2]]) - } - - @Test("Insert not in insertedRowIndices is skipped") - func insertNotInIndices() throws { - let gen = EtcdStatementGenerator( - prefix: "", - columns: ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - ) - - let change = PluginRowChange( - rowIndex: 5, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 5: ["key", "val", nil, nil, nil, nil] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.isEmpty) - } -} - -// MARK: - Values a put cannot carry - -struct EtcdStatementGeneratorRefusalTests { - private static let columns = ["Key", "Value", "Version", "CreateRevision", "ModRevision", "Lease"] - private static let original: [PluginCellValue] = ["mykey", "oldval", "3", "1", "7", ""] - - private func writes( - _ cells: [(columnIndex: Int, columnName: String, oldValue: PluginCellValue, newValue: PluginCellValue)], - original: [PluginCellValue] = EtcdStatementGeneratorRefusalTests.original - ) throws -> [PluginRowWrite] { - try EtcdStatementGenerator(prefix: "", columns: Self.columns).generateRowWrites( - from: [PluginRowChange(rowIndex: 0, type: .update, cellChanges: cells, originalRow: original)], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - } - - /// An empty value reads back as a NULL cell, so NULL is how the grid spells one. - @Test("A Value set to NULL writes an empty value, never the old one") - func nullValueWritesEmpty() throws { - let written = try writes([(columnIndex: 1, columnName: "Value", oldValue: "oldval", newValue: .null)]) - #expect(written.map(\.statement) == ["put mykey \"\""]) - #expect(written.map(\.rowIndices) == [[0]]) - } - - @Test("A Lease set to NULL on its own removes the lease") - func nullLeaseDetaches() throws { - let written = try writes( - [(columnIndex: 5, columnName: "Lease", oldValue: "7b", newValue: .null)], - original: ["mykey", "oldval", "3", "1", "7", "7b"] - ) - #expect(written.map(\.statement) == ["put mykey oldval"]) - } - - @Test("An edit to a column etcd sets is refused, even beside a Value edit it could write") - func serverOwnedColumnRefused() { - let refusal = PluginRowWriteRefusal(rowIndex: 0, reason: "'Version' is set by etcd and cannot be edited.") - #expect(throws: refusal) { - try writes([ - (columnIndex: 1, columnName: "Value", oldValue: "oldval", newValue: "newval"), - (columnIndex: 2, columnName: "Version", oldValue: "3", newValue: "4"), - ]) - } - } - - @Test("A key renamed to NULL is refused rather than kept") - func keyRenamedToNullRefused() { - #expect(throws: PluginRowWriteRefusal(rowIndex: 0, reason: "A key needs a name.")) { - try writes([ - (columnIndex: 0, columnName: "Key", oldValue: "mykey", newValue: .null), - (columnIndex: 1, columnName: "Value", oldValue: "oldval", newValue: "newval"), - ]) - } - } - - @Test("A revision typed into a new row is refused, and a new row's NULL revision is not") - func insertRevisionRefused() throws { - let generator = EtcdStatementGenerator(prefix: "", columns: Self.columns) - let typed = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [(columnIndex: 4, columnName: "ModRevision", oldValue: .null, newValue: "9")], - originalRow: nil - ) - let refusal = PluginRowWriteRefusal(rowIndex: 0, reason: "'ModRevision' is set by etcd and cannot be edited.") - #expect(throws: refusal) { - try generator.generateRowWrites( - from: [typed], - insertedRowData: [0: ["k", "v", nil, nil, "9", nil]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - - let cleared = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [(columnIndex: 4, columnName: "ModRevision", oldValue: "9", newValue: .null)], - originalRow: nil - ) - let written = try generator.generateRowWrites( - from: [cleared], - insertedRowData: [0: ["k", "v", nil, nil, nil, nil]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - #expect(written.map(\.statement) == ["put k v"]) - } -} diff --git a/TableProTests/Plugins/ExportFormatEscapingTests.swift b/TableProTests/Plugins/ExportFormatEscapingTests.swift index 26a240aaca..a82a67d978 100644 --- a/TableProTests/Plugins/ExportFormatEscapingTests.swift +++ b/TableProTests/Plugins/ExportFormatEscapingTests.swift @@ -64,140 +64,6 @@ struct MarkdownExportEscapingTests { } } -struct ParquetTypeMapperTests { - private func mapped(_ typeName: String, on databaseTypeId: String = "PostgreSQL") -> String { - ParquetTypeMapper.duckDBType(forColumnType: typeName, databaseTypeId: databaseTypeId) - } - - @Test("Integer families map to BIGINT") - func integerFamilies() { - for type in ["INT", "int4", "BIGINT", "smallint", "TINYINT", "SERIAL", "MEDIUMINT"] { - #expect(mapped(type) == "BIGINT", "\(type)") - } - } - - @Test("Floating families and undeclared exact numerics map to DOUBLE") - func doubleFamilies() { - for type in ["numeric", "FLOAT", "double precision", "REAL"] { - #expect(mapped(type) == "DOUBLE", "\(type)") - } - } - - @Test("PostgreSQL money keeps its text, since it carries a currency symbol no cast can read") - func postgresMoneyStaysText() { - #expect(mapped("money", on: "PostgreSQL") == "VARCHAR") - #expect(mapped("MONEY", on: "PostgreSQL") == "VARCHAR") - } - - @Test("SQL Server money keeps its exact four decimal places") - func sqlServerMoneyIsFixedPoint() { - #expect(mapped("money", on: "SQL Server") == "DECIMAL(19,4)") - #expect(mapped("smallmoney", on: "SQL Server") == "DECIMAL(10,4)") - #expect(mapped("SMALLMONEY", on: "SQL Server") == "DECIMAL(10,4)") - } - - @Test("An exact numeric with no usable declaration maps to DOUBLE") - func undeclaredExactNumericIsDouble() { - let types = ["number", "NUMBER", "NUMBER(*,0)", "numeric", "numeric(65,30)", "NUMBER(2,5)"] - for type in types { - #expect(mapped(type, on: "Oracle") == "DOUBLE", "\(type)") - } - } - - @Test("A declared exact numeric keeps its precision and scale") - func declaredExactNumericKeepsPrecision() { - let expected: [String: String] = [ - "NUMBER(10,2)": "DECIMAL(10,2)", - "number(10, 2)": "DECIMAL(10,2)", - "NUMBER(19)": "DECIMAL(19,0)", - "number(38)": "DECIMAL(38,0)", - "DECIMAL(10,2)": "DECIMAL(10,2)", - "numeric(38,10)": "DECIMAL(38,10)" - ] - for (type, duckDBType) in expected { - #expect(mapped(type, on: "Oracle") == duckDBType, "\(type)") - } - } - - @Test("An exact numeric declared with scale 0 and at most 18 digits maps to BIGINT") - func exactNumericWithScaleZeroIsAnInteger() { - let types = ["number(10)", "NUMBER(18)", "NUMBER(18,0)", "number(1, 0)", "DECIMAL(10,0)", "numeric(5)"] - for type in types { - #expect(mapped(type, on: "Oracle") == "BIGINT", "\(type)") - } - } - - @Test("SQLite-family engines ignore a declared precision, so their exact numerics map to DOUBLE") - func sqliteFamilyIgnoresDeclaredPrecision() { - for engine in ["SQLite", "libSQL", "Turso", "Cloudflare D1"] { - for type in ["DECIMAL(10,2)", "NUMERIC(10)", "number(19)"] { - #expect(mapped(type, on: engine) == "DOUBLE", "\(type) on \(engine)") - } - } - } - - @Test("Oracle's binary floating point types map to DOUBLE") - func oracleBinaryFloatsAreDoubles() { - #expect(mapped("binary_float", on: "Oracle") == "DOUBLE") - #expect(mapped("BINARY_DOUBLE", on: "Oracle") == "DOUBLE") - } - - @Test("Temporal families keep their own types") - func temporalFamilies() { - #expect(mapped("DATE") == "DATE") - #expect(mapped("timestamp with time zone") == "TIMESTAMP") - #expect(mapped("datetime") == "TIMESTAMP") - #expect(mapped("TIME") == "TIME") - } - - @Test("Booleans and binaries map to their own types") - func booleanAndBinary() { - #expect(mapped("BOOLEAN") == "BOOLEAN") - #expect(mapped("bytea") == "BLOB") - #expect(mapped("VARBINARY(50)") == "BLOB") - } - - /// An unknown type is written as text rather than guessed at. A wrong guess writes a Parquet - /// file whose column type disagrees with the data in it. - @Test("An unknown type falls back to VARCHAR") - func unknownFallsBack() { - #expect(mapped("geography") == "VARCHAR") - #expect(mapped("") == "VARCHAR") - #expect(mapped("hstore") == "VARCHAR") - } - - /// A type name carries its width in parentheses and sometimes a modifier after a space, and - /// neither changes which family it belongs to. - @Test("Width and modifiers are stripped before matching") - func baseNameStripsArgumentsAndModifiers() { - #expect(ParquetTypeMapper.baseName("VARCHAR(64)") == "varchar") - #expect(ParquetTypeMapper.baseName("NUMERIC(10, 2)") == "numeric") - #expect(ParquetTypeMapper.baseName("INT UNSIGNED") == "int") - #expect(ParquetTypeMapper.baseName(" TIMESTAMP WITH TIME ZONE ") == "timestamp") - } - - @Test("Parquet holds one table per file, so a multi-table export numbers its files") - func perTableFileNaming() { - let base = URL(fileURLWithPath: "/tmp/dump.parquet") - #expect(ParquetFileNaming.perTableURL(destination: base, table: "users").lastPathComponent - == "dump.users.parquet") - - let noExtension = URL(fileURLWithPath: "/tmp/dump") - #expect(ParquetFileNaming.perTableURL(destination: noExtension, table: "users").lastPathComponent - == "dump.users") - } - - /// A schema-qualified name carries a separator that would otherwise create a directory that - /// does not exist. - @Test("A table name with a slash cannot escape its directory") - func slashesAreNeutralised() { - let base = URL(fileURLWithPath: "/tmp/dump.parquet") - let url = ParquetFileNaming.perTableURL(destination: base, table: "a/b") - #expect(url.lastPathComponent == "dump.a_b.parquet") - #expect(url.deletingLastPathComponent().path == "/tmp") - } -} - struct PluginRowWritersTests { /// The values in an export come from the database rather than from the person opening the /// file, so a value that a spreadsheet would run as a formula is neutralised. diff --git a/TableProTests/Plugins/FirebaseConnectionFieldsTests.swift b/TableProTests/Plugins/FirebaseConnectionFieldsTests.swift deleted file mode 100644 index f77717fe99..0000000000 --- a/TableProTests/Plugins/FirebaseConnectionFieldsTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Foundation -@testable import TablePro -import TableProConnectionLibrary -import TableProFirebaseCore -import TableProPluginKit -import Testing - -struct FirebaseRegistrySnapshotTests { - private func snapshot(_ typeId: String) throws -> PluginMetadataSnapshot { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - return try #require(defaults.first { $0.typeId == typeId }).snapshot - } - - @Test("Both Firebase types are API-only document engines with no SQL dialect") - func connectionShape() throws { - for typeId in ["Firestore", "Firebase Realtime Database"] { - let snapshot = try snapshot(typeId) - #expect(snapshot.connectionMode == .apiOnly) - #expect(snapshot.editor.sqlDialect == nil) - #expect(snapshot.queryLanguageName == "REST") - #expect(snapshot.supportsDatabaseSwitching) - #expect(!snapshot.supportsForeignKeys) - #expect(!snapshot.capabilities.supportsSSH) - #expect(snapshot.connection.category == .document) - #expect(snapshot.iconName == "firebase-icon") - #expect(snapshot.isDownloadable) - } - #expect(try snapshot("Firestore").schema.defaultPrimaryKeyColumn == FirestoreQueryBuilder.nameField) - #expect(try snapshot("Firebase Realtime Database").schema.defaultPrimaryKeyColumn == RealtimeDatabaseQueryPlanner.keyColumn) - } - - @Test("Both types edit whole documents and remove fields, and the Realtime Database filters on one field") - func capabilities() throws { - for typeId in ["Firestore", "Firebase Realtime Database"] { - let capabilities = try snapshot(typeId).capabilities - #expect(capabilities.supportsDocumentEditing) - #expect(capabilities.supportsFieldRemoval) - #expect(capabilities.columnsAreSampled) - } - let firestore = try snapshot("Firestore").capabilities - #expect(firestore.supportsMatchAnyFilters && !firestore.exactRowCountIsFullScan) - let database = try snapshot("Firebase Realtime Database").capabilities - #expect(!database.supportsMatchAnyFilters && database.exactRowCountIsFullScan) - } - - @Test("Neither type is ever handed SQL DROP or TRUNCATE") - func noSQLDDL() { - #expect(!SQLDDLFallbackPolicy.allowsGeneratedDDL(for: .firestore)) - #expect(!SQLDDLFallbackPolicy.allowsGeneratedDDL(for: .firebaseRealtimeDatabase)) - } - - @Test("The Realtime Database routes to the plugin that serves Firestore") - func variantRouting() { - #expect(PluginMetadataRegistry.shared.pluginTypeId(for: "Firebase Realtime Database") == "Firestore") - #expect(DatabaseType.firebaseRealtimeDatabase.pluginTypeId == "Firestore") - #expect(DatabaseType.firestore.pluginTypeId == "Firestore") - } -} - -struct FirebaseConnectionFieldsTests { - private func fields(_ typeId: String) throws -> [ConnectionField] { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - return try #require(defaults.first { $0.typeId == typeId }).snapshot.connection.additionalConnectionFields - } - - @Test("The app's field ids are the ones the driver reads") - func fieldIdsMatchTheDriver() throws { - #expect(try fields("Firestore").map(\.id) == [ - FirebaseFieldID.authMethod, FirebaseFieldID.serviceAccountJson, FirebaseFieldID.projectId, - FirebaseFieldID.firestoreDatabase, FirebaseFieldID.emulatorHost - ]) - #expect(try fields("Firebase Realtime Database").map(\.id) == [ - FirebaseFieldID.authMethod, FirebaseFieldID.serviceAccountJson, FirebaseFieldID.projectId, - FirebaseFieldID.databaseURL, FirebaseFieldID.emulatorHost - ]) - } - - @Test("The auth methods offered are the ones the driver accepts, service account first") - func authMethods() throws { - let method = try #require(try fields("Firestore").first { $0.id == FirebaseFieldID.authMethod }) - #expect(method.defaultValue == FirebaseAuthMethod.serviceAccount.rawValue) - guard case .dropdown(let options) = method.fieldType else { - Issue.record("Expected a dropdown field type") - return - } - #expect(options.map(\.value) == FirebaseAuthMethod.allCases.map(\.rawValue)) - } - - @Test("The key replaces the built-in credentials, and the emulator host shows only for the emulator") - func visibility() throws { - let fields = try fields("Firestore") - let key = try #require(fields.first { $0.id == FirebaseFieldID.serviceAccountJson }) - #expect(key.isSecure) - #expect(key.hidesPassword) - #expect(fields.hidesPassword(forValues: [:])) - #expect(fields.hidesUsername(forValues: [:])) - let emulator = try #require(fields.first { $0.id == FirebaseFieldID.emulatorHost }) - #expect(emulator.visibleWhen == FieldVisibilityRule(fieldId: FirebaseFieldID.authMethod, values: ["emulator"])) - } -} diff --git a/TableProTests/Plugins/FirebaseDriverTests.swift b/TableProTests/Plugins/FirebaseDriverTests.swift deleted file mode 100644 index d1ed2e48b0..0000000000 --- a/TableProTests/Plugins/FirebaseDriverTests.swift +++ /dev/null @@ -1,374 +0,0 @@ -import Foundation -import TableProFirebaseCore -import TableProPluginKit -import Testing - -private func makeDriver(realtimeDatabase: Bool = false, fields extra: [String: String] = [:]) -> FirebasePluginDriver { - var fields = extra - if realtimeDatabase { fields[FirebaseFieldID.driverVariant] = FirebaseVariant.realtimeDatabase.rawValue } - return FirebasePluginDriver(config: DriverConnectionConfig( - host: "", port: 0, username: "", password: "", database: "", additionalFields: fields - )) -} - -private func change( - _ type: PluginRowChange.ChangeType, - row: Int = 0, - cells: [(Int, String, PluginCellValue)] = [], - original: [PluginCellValue]? = nil, - absent: Set? = nil -) -> PluginRowChange { - var change = PluginRowChange( - rowIndex: row, - type: type, - cellChanges: cells.map { (columnIndex: $0.0, columnName: $0.1, oldValue: PluginCellValue.null, newValue: $0.2) }, - originalRow: original - ) - change.absentColumns = absent - return change -} - -private func statements( - _ driver: FirebasePluginDriver, - table: String, - columns: [String], - changes: [PluginRowChange], - inserted: [Int: [PluginCellValue]] = [:], - hostTypes: [String: String] = [:] -) throws -> [String] { - var context = PluginRowWriteContext() - context.columnTypeNames = hostTypes - return try driver.generateRowWrites( - table: table, schema: nil, columns: columns, primaryKeyColumns: [], - changes: changes, insertedRowData: inserted, - deletedRowIndices: Set(changes.filter { $0.type == .delete }.map(\.rowIndex)), - insertedRowIndices: Set(changes.filter { $0.type == .insert }.map(\.rowIndex)), - context: context - )?.map(\.statement) ?? [] -} - -private func restore( - _ driver: FirebasePluginDriver, - table: String, - columns: [String], - rows: [[PluginCellValue]], - absent: [Int: Set] = [:] -) -> [String]? { - driver.generateIdentityPreservingInsert( - table: table, schema: nil, columns: columns, primaryKeyColumns: [], rows: rows, absentCells: absent - )?.map(\.statement) -} - -struct FirebaseFirestoreWriteTests { - private let columns = ["__name__", "age", "name", "zip"] - - @Test("An edit is typed by the value it replaces and masked to the fields it touches") - func typedUpdate() throws { - let driver = makeDriver() - driver.rememberCellTypes(["users/alice": ["age": "integer", "zip": "string"]]) - let written = try statements(driver, table: "users", columns: columns, changes: [ - change(.update, cells: [(1, "age", .text("31")), (3, "zip", .text("02134"))], - original: [.text("alice"), .text("30"), .text("Alice"), .text("02100")]) - ]) - #expect(written == [#"PATCH users/alice?updateMask=age,zip {"age":31,"zip":"02134"}"#]) - } - - @Test("A removed field is in the mask and not in the body") - func fieldRemoval() throws { - let written = try statements(makeDriver(), table: "users", columns: columns, changes: [ - change(.update, original: [.text("alice"), .text("30"), .null, .null], absent: [2]) - ]) - #expect(written == [#"PATCH users/alice?updateMask=name {}"#]) - } - - @Test("A new row picks its ID from __name__, or lets Firestore pick one") - func inserts() throws { - let driver = makeDriver() - driver.rememberColumnTypes("users", columns: columns, types: ["string", "integer", "string", "string"]) - let named = try statements(driver, table: "users", columns: columns, changes: [change(.insert, row: 0, absent: [3])], - inserted: [0: [.text("bob"), .text("40"), .text("Bob"), .null]]) - #expect(named == [#"POST users?documentId=bob {"age":40,"name":"Bob"}"#]) - let anonymous = try statements(driver, table: "users/alice/orders", columns: ["__name__", "total"], - changes: [change(.insert, row: 1)], inserted: [1: [.text(""), .text("9.5")]]) - #expect(anonymous == [#"POST users/alice/orders {"total":9.5}"#]) - } - - @Test("A delete names the document by its path, and a group row by its full path") - func deletes() throws { - #expect(try statements(makeDriver(), table: "users", columns: columns, changes: [ - change(.delete, original: [.text("alice"), .null, .null, .null]) - ]) == ["DELETE users/alice"]) - #expect(try statements(makeDriver(), table: "*/orders", columns: ["__name__"], changes: [ - change(.delete, original: [.text("users/alice/orders/7")]) - ]) == ["DELETE users/alice/orders/7"]) - } - - @Test("A value that does not fit the field is refused with the reason, not dropped") - func refusal() { - let driver = makeDriver() - driver.rememberCellTypes(["users/alice": ["age": "integer"]]) - #expect(throws: PluginRowWriteRefusal.self) { - try statements(driver, table: "users", columns: columns, changes: [ - change(.update, cells: [(1, "age", .text("old"))], original: [.text("alice"), .text("30"), .null, .null]) - ]) - } - #expect(throws: PluginRowWriteRefusal.self) { - try statements(driver, table: "*/orders", columns: ["__name__", "total"], - changes: [change(.insert)], inserted: [0: [.text("7"), .text("1")]]) - } - } - - @Test("With nothing read on this connection, the host's column types type the edit") - func hostTypes() throws { - let edit = change(.update, cells: [(3, "zip", .text("123"))], original: [.text("alice"), .null, .null, .text("100")]) - #expect(try statements(makeDriver(), table: "users", columns: columns, changes: [edit], hostTypes: ["zip": "STRING"]) - == [#"PATCH users/alice?updateMask=zip {"zip":"123"}"#]) - #expect(try statements(makeDriver(), table: "users", columns: columns, changes: [edit]) - == [#"PATCH users/alice?updateMask=zip {"zip":123}"#]) - } - - @Test("A field that held null is typed by its column, not left to guessing") - func nullCell() throws { - let driver = makeDriver() - driver.rememberColumnTypes("users", columns: columns, types: ["string", "integer", "string", "string"]) - driver.rememberCellTypes(["users/alice": ["zip": "null"]]) - #expect(try statements(driver, table: "users", columns: columns, changes: [ - change(.update, cells: [(3, "zip", .text("12345"))], original: [.text("alice"), .null, .null, .null]) - ]) == [#"PATCH users/alice?updateMask=zip {"zip":"12345"}"#]) - } - - @Test("An ID or field name the request line would split is escaped or quoted") - func awkwardNames() throws { - let written = try statements(makeDriver(), table: "users", columns: ["__name__", "a,b"], changes: [ - change(.update, cells: [(1, "a,b", .text("x"))], original: [.text("John Smith"), .null]), - change(.delete, row: 1, original: [.text("a&b=c"), .null]) - ]) - #expect(written == [#"PATCH users/John%20Smith?updateMask=`a,b` {"a,b":"x"}"#, "DELETE users/a%26b%3Dc"]) - guard case .request(let patch) = try FirebaseStatement.parse(written[0]) else { - Issue.record("Expected a request line") - return - } - #expect(patch.path == "users/John Smith") - #expect(patch.queryValue("updateMask").map(FirestoreQueryBuilder.maskFields) == ["a,b"]) - } - - @Test("Undoing a delete restores the ID, leaves out missing fields, and types each value by its own field") - func restoreDeleted() throws { - let driver = makeDriver() - let columns = ["__name__", "age", "name", "zip", "tags"] - driver.rememberColumnTypes("users", columns: columns, types: ["string", "integer", "string", "integer", "array"]) - driver.rememberCellTypes(["users/alice": ["age": "integer", "zip": "string"]]) - let restored = restore(driver, table: "users", columns: columns, - rows: [[.text("alice"), .text("30"), .null, .text("02134"), .null]], absent: [0: [2, 4]]) - #expect(restored == [#"POST users?documentId=alice {"age":30,"zip":"02134"}"#]) - #expect(restore(driver, table: "users", columns: columns, rows: [[.text(""), .text("1"), .null, .null, .null]]) == nil) - } - - @Test("A new row's __name__ is an ID in this collection, never a path elsewhere") - func insertStaysInCollection() { - #expect(throws: PluginRowWriteRefusal.self) { - try statements(makeDriver(), table: "users", columns: ["__name__", "n"], - changes: [change(.insert)], inserted: [0: [.text("admins/root"), .text("1")]]) - } - } - - @Test("A whole document carries its path and refuses a rename") - func documents() throws { - let driver = makeDriver() - let text = FirebasePluginDriver.documentText(path: "users/alice", fields: ["age": .integer(30)]) - let edited = text.replacingOccurrences(of: "30", with: "31") - #expect(try driver.documentWriteStatement(PluginDocumentWrite( - table: "users", schema: nil, operation: .replace(original: text, edited: edited) - )) == #"PATCH users/alice {"age":31}"#) - let renamed = edited.replacingOccurrences(of: "users/alice", with: "users/bob") - #expect(throws: FirebaseError.self) { - try driver.documentWriteStatement(PluginDocumentWrite(table: "users", schema: nil, operation: .replace(original: text, edited: renamed))) - } - #expect(try driver.documentWriteStatement(PluginDocumentWrite( - table: "users", schema: nil, operation: .insert(document: #"{"__name__": "carol", "when": {"$timestamp": "2026-10-12T08:00:00Z"}}"#) - )) == #"POST users?documentId=carol {"when":{"$timestamp":"2026-10-12T08:00:00Z"}}"#) - } - - @Test("Filters travel in the browse statement") - func browseStatement() throws { - let statement = try #require(makeDriver().buildFilteredQuery( - table: "users", schema: nil, - queryFilters: [PluginQueryFilter(column: "age", op: ">", value: "3", secondValue: nil, elementScope: nil)], - logicMode: "OR", sortColumns: [(columnIndex: 1, ascending: false)], - columns: columns, limit: 50, offset: 100, columnKinds: [:] - )) - guard case .browse(let spec) = try FirebaseStatement.parse(statement) else { - Issue.record("Expected a browse statement") - return - } - #expect(spec == FirebaseBrowseSpec(table: "users", filters: [FirebaseFilterSpec(column: "age", op: ">", value: "3")], - logicMode: "OR", sorts: [FirebaseSortSpec(column: "age", ascending: false)], limit: 50, offset: 100)) - } - - @Test("Export moves fields outside the first page's columns into __extra__") - func exportAlignment() { - let aligned = FirebasePluginDriver.align( - [.text("bob"), .text("x"), .text("1")], columns: ["__name__", "late", "age"], to: ["__name__", "age", "__extra__"] - ) - #expect(aligned == [.text("bob"), .text("1"), .text(#"{"late":"x"}"#)]) - } -} - -struct FirebaseRealtimeDatabaseWriteTests { - private let columns = ["$key", "calls", "name", "$value"] - - @Test("Child edits patch the child, typed by the cell, and a removal writes null") - func update() throws { - let driver = makeDriver(realtimeDatabase: true) - driver.rememberCellTypes(["operators/op_1": ["calls": "number", "name": "string"]]) - let written = try statements(driver, table: "operators", columns: columns, changes: [ - change(.update, cells: [(1, "calls", .text("4"))], original: [.text("op_1"), .text("3"), .text("Ann"), .null], absent: [2]) - ]) - #expect(written == [#"PATCH operators/op_1 {"calls":4,"name":null}"#]) - } - - @Test("A plain node's row is the node itself, and a child with the node's name is still a child") - func plainValues() throws { - let driver = makeDriver(realtimeDatabase: true) - let hostTypes = ["$value": "boolean"] - let node = try statements(driver, table: "maintenance", columns: ["$key", "$value"], changes: [ - change(.update, cells: [(1, "$value", .text("true"))], original: [.text("/maintenance"), .text("false")]) - ], hostTypes: hostTypes) - #expect(node == ["PUT maintenance true"]) - let child = try statements(driver, table: "maintenance", columns: ["$key", "$value"], changes: [ - change(.update, cells: [(1, "$value", .text("true"))], original: [.text("maintenance"), .text("false")]) - ], hostTypes: hostTypes) - #expect(child == ["PUT maintenance/maintenance true"]) - } - - @Test("Undoing a delete puts a child back under its key, and a plain node back in place") - func restoreDeleted() { - let driver = makeDriver(realtimeDatabase: true) - driver.rememberCellTypes(["config/maintenance": ["$value": "boolean"], "operators/op_1": ["calls": "number"]]) - #expect(restore(driver, table: "config/maintenance", columns: ["$key", "$value"], rows: [[.text("/config/maintenance"), .text("false")]]) - == ["PUT config/maintenance false"]) - #expect(restore(driver, table: "operators", columns: ["$key", "calls", "$value"], - rows: [[.text("op_1"), .text("3"), .null]], absent: [0: [2]]) == [#"PUT operators/op_1 {"calls":3}"#]) - #expect(restore(driver, table: "operators", columns: ["$key", "calls"], rows: [[.text(""), .text("3")]]) == nil) - } - - @Test("Replacing or deleting the whole database is refused before anything is sent") - func wholeDatabase() async { - let driver = makeDriver(realtimeDatabase: true) - for statement in ["PUT / null", "PUT / {}", "DELETE /"] { - do { - _ = try await driver.execute(query: statement) - Issue.record("Expected \(statement) to be refused") - } catch let error as FirebaseError { - guard case .unsupported = error else { - Issue.record("Expected a refusal for \(statement), got \(error)") - continue - } - } catch { - Issue.record("Unexpected \(error)") - } - } - } - - @Test("New children go under their key, or a generated one, and empty ones are refused") - func inserts() throws { - let driver = makeDriver(realtimeDatabase: true) - #expect(try statements(driver, table: "/", columns: columns, changes: [change(.insert)], - inserted: [0: [.text("op_9"), .text("0"), .text("Zed"), .null]]) == [#"PUT op_9 {"calls":0,"name":"Zed"}"#]) - #expect(try statements(driver, table: "chats", columns: ["$key", "$value"], changes: [change(.insert)], - inserted: [0: [.text(""), .text("hello")]]) == [#"POST chats "hello""#]) - #expect(throws: PluginRowWriteRefusal.self) { - try statements(driver, table: "chats", columns: ["$key", "$value"], changes: [change(.insert)], inserted: [0: [.text("a.b"), .text("1")]]) - } - #expect(throws: PluginRowWriteRefusal.self) { - try statements(driver, table: "chats", columns: ["$key", "$value"], changes: [change(.insert)], inserted: [0: [.text("k"), .null]]) - } - } - - @Test("A node is edited as $key and $value, and bare JSON is pushed") - func documents() throws { - let driver = makeDriver(realtimeDatabase: true) - let text = FirebasePluginDriver.nodeText(path: "operators/op_1", value: .object(["calls": .int(3)])) - #expect(try driver.documentWriteStatement(PluginDocumentWrite( - table: "operators", schema: nil, operation: .replace(original: text, edited: text.replacingOccurrences(of: "3", with: "5")) - )) == #"PUT operators/op_1 {"calls":5}"#) - #expect(try driver.documentWriteStatement(PluginDocumentWrite( - table: "operators", schema: nil, operation: .insert(document: #"{"calls": 1}"#) - )) == #"POST operators {"calls":1}"#) - } -} - -/// Drives the whole driver against the Firebase emulators when their standard variables are set. -private enum EmulatorHosts { - static let firestore = ProcessInfo.processInfo.environment["FIRESTORE_EMULATOR_HOST"] - static let database = ProcessInfo.processInfo.environment["FIREBASE_DATABASE_EMULATOR_HOST"] -} - -@Suite(.enabled(if: EmulatorHosts.firestore != nil && EmulatorHosts.database != nil), .serialized) -struct FirebaseDriverEmulatorTests { - @Test("Firestore: connect, write from the grid, browse, filter, count and guard a stale edit") - func firestore() async throws { - let driver = makeDriver(fields: [ - FirebaseFieldID.authMethod: "emulator", FirebaseFieldID.projectId: "demo-tablepro", - FirebaseFieldID.emulatorHost: try #require(EmulatorHosts.firestore) - ]) - try await driver.connect() - let table = "drv_\(UUID().uuidString.prefix(6))" - _ = try await driver.execute(query: #"POST \#(table)?documentId=alice {"age": 30, "name": "Alice"}"#) - _ = try await driver.execute(query: #"POST \#(table)?documentId=bob {"age": 40}"#) - #expect(try await driver.fetchTables(schema: nil).map(\.name).contains(table)) - - let page = try await driver.execute(query: try #require(driver.buildBrowseQuery( - table: table, sortColumns: [], columns: [], limit: 10, offset: 0 - ))) - #expect(page.columns == ["__name__", "age", "name"]) - #expect(page.rowLocators == ["\(table)/alice", "\(table)/bob"]) - #expect(page.absentCells == [1: [2]]) - - let filtered = try await driver.execute(query: try #require(driver.buildFilteredQuery( - table: table, schema: nil, - queryFilters: [PluginQueryFilter(column: "age", op: ">=", value: "35", secondValue: nil, elementScope: nil)], - logicMode: "AND", sortColumns: [], columns: page.columns, limit: 10, offset: 0, columnKinds: [:] - ))) - #expect(filtered.rows.map { $0[0] } == [.text("bob")]) - #expect(try await driver.fetchExactRowCount(table: table, schema: nil, queryFilters: [], logicMode: "AND") == 2) - - let original = try #require(try await driver.fetchDocument(table: table, schema: nil, locator: "\(table)/alice")) - _ = try await driver.execute(query: #"PATCH \#(table)/alice?updateMask=age {"age": 31}"#) - await #expect(throws: FirebaseError.self) { - try await driver.executeDocumentWrite(PluginDocumentWrite( - table: table, schema: nil, operation: .replace(original: original, edited: original.replacingOccurrences(of: "Alice", with: "Ally")) - )) - } - _ = try await driver.execute(query: "DELETE \(table)/alice") - _ = try await driver.execute(query: "DELETE \(table)/bob") - driver.disconnect() - } - - @Test("Realtime Database: connect, write, browse in key order and guard a stale node edit") - func realtimeDatabase() async throws { - let driver = makeDriver(realtimeDatabase: true, fields: [ - FirebaseFieldID.authMethod: "emulator", FirebaseFieldID.projectId: "demo-tablepro", - FirebaseFieldID.emulatorHost: try #require(EmulatorHosts.database) - ]) - try await driver.connect() - let node = "drv_\(UUID().uuidString.prefix(6))" - _ = try await driver.execute(query: #"PUT \#(node) {"b": {"n": 2}, "a": {"n": 1}, "c": "plain"}"#) - let page = try await driver.execute(query: try #require(driver.buildBrowseQuery(table: node, sortColumns: [], columns: [], limit: 2, offset: 1))) - #expect(page.columns == ["$key", "n", "$value"]) - #expect(page.rows.map { $0[0] } == [.text("b"), .text("c")]) - #expect(try await driver.fetchExactRowCount(table: node, schema: nil, queryFilters: [], logicMode: "AND") == 3) - - let original = try #require(try await driver.fetchDocument(table: node, schema: nil, locator: "\(node)/a")) - try await driver.executeDocumentWrite(PluginDocumentWrite( - table: node, schema: nil, operation: .replace(original: original, edited: original.replacingOccurrences(of: "\"n\": 1", with: "\"n\": 10")) - )) - await #expect(throws: FirebaseError.self) { - try await driver.executeDocumentWrite(PluginDocumentWrite( - table: node, schema: nil, operation: .replace(original: original, edited: original) - )) - } - _ = try await driver.execute(query: "DELETE \(node)") - driver.disconnect() - } -} diff --git a/TableProTests/Plugins/HranaHttpClientArgumentEncodingTests.swift b/TableProTests/Plugins/HranaHttpClientArgumentEncodingTests.swift deleted file mode 100644 index b9e08c4ee4..0000000000 --- a/TableProTests/Plugins/HranaHttpClientArgumentEncodingTests.swift +++ /dev/null @@ -1,148 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -private final class HranaEchoingProtocol: URLProtocol, @unchecked Sendable { - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - guard let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil), - let body = Self.echoedArgumentsResponse(to: Self.bodyData(of: request)) - else { - client?.urlProtocol(self, didFailWithError: URLError(.cannotParseResponse)) - return - } - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: body) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} - - private static func echoedArgumentsResponse(to requestBody: Data) -> Data? { - guard let root = try? JSONSerialization.jsonObject(with: requestBody) as? [String: Any], - let requests = root["requests"] as? [[String: Any]], - let statement = requests.first?["stmt"] as? [String: Any] - else { return nil } - let args = statement["args"] as? [Any] ?? [] - let columns = args.indices.map { ["name": "a\($0)"] } - let result: [String: Any] = ["cols": columns, "rows": [args], "affected_row_count": 0] - let item: [String: Any] = ["type": "ok", "response": ["type": "execute", "result": result]] - return try? JSONSerialization.data(withJSONObject: ["results": [item]]) - } - - private static func bodyData(of request: URLRequest) -> Data { - guard let stream = request.httpBodyStream else { return request.httpBody ?? Data() } - stream.open() - defer { stream.close() } - var data = Data() - var buffer = [UInt8](repeating: 0, count: 1_024) - while stream.hasBytesAvailable { - let read = stream.read(&buffer, maxLength: buffer.count) - guard read > 0 else { break } - data.append(buffer, count: read) - } - return data - } -} - -private extension HranaValue { - var textValue: String? { - guard case .text(let text) = self else { return nil } - return text - } - - var blobValue: Data? { - guard case .blob(let data) = self else { return nil } - return data - } -} - -struct HranaHttpClientArgumentEncodingTests { - private func encodedStatements(_ statements: [HranaStatement]) throws -> [[String: Any]] { - let body = try HranaHttpClient.pipelineRequestBody(statements: statements) - let object = try JSONSerialization.jsonObject(with: body) - let root = try #require(object as? [String: Any]) - let requests = try #require(root["requests"] as? [[String: Any]]) - #expect(requests.map { $0["type"] as? String } == Array(repeating: "execute", count: requests.count)) - return try requests.map { request -> [String: Any] in - try #require(request["stmt"] as? [String: Any]) - } - } - - private func encodedArguments(_ args: [PluginCellValue]) throws -> [[String: String]] { - let statements = try encodedStatements([HranaStatement(sql: "INSERT INTO t VALUES (?)", parameters: args)]) - let statement = try #require(statements.first) - return try #require(statement["args"] as? [[String: String]]) - } - - private func echoingClient() throws -> HranaHttpClient { - let url = try #require(URL(string: "https://db.turso.test")) - let client = HranaHttpClient(baseUrl: url, authToken: nil) - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [HranaEchoingProtocol.self] - client.createSession(configuration: configuration) - return client - } - - @Test(arguments: ["007", "1.50", "+5", "0x10", "1e3", "12345678901234567890", "-0"]) - func numericLookingTextIsSentAsText(_ text: String) throws { - let args = try encodedArguments([.text(text)]) - - #expect(args == [["type": "text", "value": text]]) - } - - @Test(arguments: ["inf", "nan", "Infinity", "NaN", "-inf", "1e999"]) - func nonFiniteLookingTextIsSentAsText(_ text: String) throws { - let args = try encodedArguments([.text(text)]) - - #expect(args == [["type": "text", "value": text]]) - } - - @Test - func bytesAreSentAsBase64Blob() throws { - let args = try encodedArguments([.bytes(Data([0xAB])), .bytes(Data())]) - - #expect(args == [["type": "blob", "base64": "qw=="], ["type": "blob", "base64": ""]]) - } - - @Test - func nullIsSentAsNull() throws { - let args = try encodedArguments([.null, .text("x")]) - - #expect(args == [["type": "null"], ["type": "text", "value": "x"]]) - } - - @Test - func statementWithoutArgumentsCarriesNoArgs() throws { - let statements = try encodedStatements([ - HranaStatement(sql: "SELECT 1"), - HranaStatement(sql: "SELECT ?", parameters: [.text("1")]) - ]) - - #expect(statements.map { $0["sql"] as? String } == ["SELECT 1", "SELECT ?"]) - #expect(statements.map { $0.keys.contains("args") } == [false, true]) - } - - @Test(arguments: ["inf", "nan", "007", "1.50"]) - func executeSendsTextArgumentsAsText(_ text: String) async throws { - let client = try echoingClient() - defer { client.invalidateSession() } - - let result = try await client.execute(sql: "SELECT ?", args: [.text(text)]) - - #expect(result.rows.first?.first?.textValue == text) - } - - @Test - func executeSendsBytesAsBlob() async throws { - let client = try echoingClient() - defer { client.invalidateSession() } - - let result = try await client.execute(sql: "SELECT ?", args: [.bytes(Data([0xAB]))]) - - #expect(result.rows.first?.first?.blobValue == Data([0xAB])) - } -} diff --git a/TableProTests/Plugins/HranaHttpClientCancellationTests.swift b/TableProTests/Plugins/HranaHttpClientCancellationTests.swift deleted file mode 100644 index a4dd83b468..0000000000 --- a/TableProTests/Plugins/HranaHttpClientCancellationTests.swift +++ /dev/null @@ -1,148 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -/// Plays a Hrana pipeline endpoint: a statement whose SQL is `SELECT hang` never answers until -/// cancelled, any other statement answers at once, so a test can hold several requests in flight -/// and watch what a cancel does to each. -private final class HranaStubProtocol: URLProtocol, @unchecked Sendable { - private static let lock = NSLock() - nonisolated(unsafe) private static var lastRequest: URLRequest? - - static var recorded: URLRequest? { - lock.withLock { lastRequest } - } - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - Self.lock.withLock { Self.lastRequest = request } - guard !Self.bodyText(of: request).contains("SELECT hang"), - let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) - else { return } - let row = #"[{"type":"integer","value":"1"}]"# - let result = #"{"cols":[{"name":"n","decltype":"INTEGER"}],"rows":[\#(row)],"affected_row_count":0}"# - let body = #"{"results":[{"type":"ok","response":{"type":"execute","result":\#(result)}}]}"# - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data(body.utf8)) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} - - private static func bodyText(of request: URLRequest) -> String { - guard let stream = request.httpBodyStream else { - return String(bytes: request.httpBody ?? Data(), encoding: .utf8) ?? "" - } - stream.open() - defer { stream.close() } - var data = Data() - var buffer = [UInt8](repeating: 0, count: 1_024) - while stream.hasBytesAvailable { - let read = stream.read(&buffer, maxLength: buffer.count) - guard read > 0 else { break } - data.append(buffer, count: read) - } - return String(bytes: data, encoding: .utf8) ?? "" - } -} - -@Suite("libSQL Hrana HTTP client cancellation", .serialized) -struct HranaHttpClientCancellationTests { - private func connectedClient() throws -> HranaHttpClient { - let url = try #require(URL(string: "https://db.turso.test")) - let client = HranaHttpClient(baseUrl: url, authToken: "token") - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [HranaStubProtocol.self] - client.createSession(configuration: configuration) - return client - } - - private func waitUntil(_ condition: () -> Bool) async { - for _ in 0 ..< 300 where !condition() { - try? await Task.sleep(for: .milliseconds(10)) - } - } - - @Test("A statement carries the token and the query timeout and returns its rows") - func roundTrip() async throws { - let client = try connectedClient() - client.setQueryTimeout(300) - - let result = try await client.execute(sql: "SELECT 1") - - #expect(result.cols.map(\.name) == ["n"]) - #expect(result.rows.first?.first?.stringValue == "1") - let request = try #require(HranaStubProtocol.recorded) - #expect(request.url?.path == "/v2/pipeline") - #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer token") - #expect(request.timeoutInterval == HttpQueryTimeout(serverTimeoutSeconds: 300).requestTimeoutInterval) - #expect(client.inFlightCount == 0) - } - - @Test("A connect probe can override the query timeout without changing later queries") - func connectProbeTimeoutIsRequestScoped() async throws { - let client = try connectedClient() - client.setQueryTimeout(300) - - _ = try await client.execute(sql: "SELECT 1", requestTimeout: 2.5) - #expect(HranaStubProtocol.recorded?.timeoutInterval == 2.5) - - _ = try await client.execute(sql: "SELECT 1") - #expect( - HranaStubProtocol.recorded?.timeoutInterval - == HttpQueryTimeout(serverTimeoutSeconds: 300).requestTimeoutInterval - ) - } - - @Test("Cancelling everything stops every request in flight, not just the latest") - func cancelAllStopsEveryRequest() async throws { - let client = try connectedClient() - let query = Task { try await client.execute(sql: "SELECT hang") } - let sidebarRead = Task { try await client.execute(sql: "SELECT hang") } - await waitUntil { client.inFlightCount == 2 } - - client.cancelAll() - - await #expect(throws: CancellationError.self) { try await query.value } - await #expect(throws: CancellationError.self) { try await sidebarRead.value } - #expect(client.inFlightCount == 0) - } - - @Test("A request that finishes leaves the others cancellable") - func finishedRequestKeepsOthersTracked() async throws { - let client = try connectedClient() - let query = Task { try await client.execute(sql: "SELECT hang") } - await waitUntil { client.inFlightCount == 1 } - - _ = try await client.execute(sql: "SELECT 1") - client.cancelAll() - - await #expect(throws: CancellationError.self) { try await query.value } - } - - @Test("Cancelling the awaiting task cancels its request") - func taskCancellation() async throws { - let client = try connectedClient() - let query = Task { try await client.execute(sql: "SELECT hang") } - await waitUntil { client.inFlightCount == 1 } - - query.cancel() - - await #expect(throws: CancellationError.self) { try await query.value } - #expect(client.inFlightCount == 0) - } - - @Test("Disconnecting cancels every request in flight") - func invalidateSessionCancels() async throws { - let client = try connectedClient() - let query = Task { try await client.execute(sql: "SELECT hang") } - await waitUntil { client.inFlightCount == 1 } - - client.invalidateSession() - - await #expect(throws: CancellationError.self) { try await query.value } - } -} diff --git a/TableProTests/Plugins/HttpQueryTimeoutTests.swift b/TableProTests/Plugins/HttpQueryTimeoutTests.swift index 8ba82043e3..e95c971ce1 100644 --- a/TableProTests/Plugins/HttpQueryTimeoutTests.swift +++ b/TableProTests/Plugins/HttpQueryTimeoutTests.swift @@ -8,14 +8,6 @@ import TableProPluginKit import Testing struct HttpQueryTimeoutTests { - @Test("Default values match the documented bootstrap policy") - func defaultsMatchBootstrap() { - let timeout = HttpQueryTimeout() - #expect(timeout.serverTimeoutSeconds == HttpQueryTimeout.bootstrapSeconds) - #expect(timeout.graceSeconds == HttpQueryTimeout.defaultGraceSeconds) - #expect(timeout.requestTimeoutInterval == TimeInterval(60 + 30)) - } - @Test("Positive server timeout adds grace to request interval") func positiveServerTimeoutAddsGrace() { let timeout = HttpQueryTimeout(serverTimeoutSeconds: 600, graceSeconds: 30) diff --git a/TableProTests/Plugins/IndexDDLOwnershipTests.swift b/TableProTests/Plugins/IndexDDLOwnershipTests.swift deleted file mode 100644 index a4acac5bef..0000000000 --- a/TableProTests/Plugins/IndexDDLOwnershipTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// IndexDDLOwnershipTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// A driver answers `fetchIndexDDL` or it declares its indexes inside `fetchTableDDL`. Never both, -/// or a dump creates every index twice and the restore fails on the second. -/// -/// Nothing at runtime can see the contradiction: `fetchTableDDL` hands back opaque text and the -/// export writes whatever it gets. So the guard is a source scan, the same shape -/// `SyncMapperFieldAccessTests` uses to keep raw `record[` out of the sync mappers. -struct IndexDDLOwnershipTests { - private static let pluginsDirectory: URL? = { - var directory = URL(fileURLWithPath: #filePath) - for _ in 0..<3 { directory.deleteLastPathComponent() } - let plugins = directory.appendingPathComponent("Plugins") - return FileManager.default.fileExists(atPath: plugins.path) ? plugins : nil - }() - - private static func driverSources() throws -> [(name: String, text: String)] { - guard let pluginsDirectory else { return [] } - let folders = try FileManager.default.contentsOfDirectory( - at: pluginsDirectory, includingPropertiesForKeys: nil) - return try folders - .filter { $0.lastPathComponent.hasSuffix("DriverPlugin") } - .flatMap { folder -> [(String, String)] in - let files = (try? FileManager.default.contentsOfDirectory( - at: folder, includingPropertiesForKeys: nil)) ?? [] - return try files - .filter { $0.pathExtension == "swift" } - .map { ($0.lastPathComponent, try String(contentsOf: $0, encoding: .utf8)) } - } - } - - @Test("The scan reaches the driver plugins at all") - func sourcesAreReachable() throws { - let sources = try Self.driverSources() - #expect(!sources.isEmpty, "No driver plugin sources found; the guard below would pass vacuously") - #expect(sources.contains { $0.text.contains("func fetchIndexDDL") }) - } - - @Test("No driver both answers fetchIndexDDL and writes CREATE INDEX into its table DDL") - func noDriverDeclaresIndexesTwice() throws { - let sources = try Self.driverSources() - var offenders: [String] = [] - for source in sources where source.text.contains("func fetchIndexDDL") { - guard let ddlRange = source.text.range(of: "func fetchTableDDL") else { continue } - let body = source.text[ddlRange.lowerBound...].prefix(6_000) - if body.contains("CREATE \\(uniqueStr)INDEX") || body.contains("\"CREATE INDEX") { - offenders.append(source.name) - } - } - - #expect(offenders.isEmpty, "These drivers declare indexes in both places: \(offenders)") - } -} diff --git a/TableProTests/Plugins/IndexStatementRenderingTests.swift b/TableProTests/Plugins/IndexStatementRenderingTests.swift index 8f5af189ae..0750a08c83 100644 --- a/TableProTests/Plugins/IndexStatementRenderingTests.swift +++ b/TableProTests/Plugins/IndexStatementRenderingTests.swift @@ -7,9 +7,9 @@ import Foundation import TableProMSSQLCore import Testing -/// The four engines whose `CREATE INDEX` has to be built from catalog rows rather than read back -/// from the engine. The catalog queries themselves need a live server, so what is pinned here is -/// the rendering: given the rows those queries return, this is the SQL that goes in the dump. +/// SQL Server's `CREATE INDEX` has to be built from catalog rows rather than read back from the +/// engine. The catalog queries themselves need a live server, so what is pinned here is the +/// rendering: given the rows those queries return, this is the SQL that goes in the dump. struct IndexStatementRenderingTests { @Suite("SQL Server") struct SQLServer { @@ -73,118 +73,4 @@ struct IndexStatementRenderingTests { #expect(sql == ["CREATE NONCLUSTERED INDEX [idx]]a] ON [dbo].[orders] ([c]]1]);"]) } } - - @Suite("Oracle") - struct Oracle { - private func render(_ rows: [[String?]]) -> [String] { - OracleIndexStatements.render( - rows: rows, schema: "APP", table: "ORDERS", quote: { "\"\($0)\"" }) - } - - @Test("A single-column index") - func singleColumn() { - let sql = render([["IDX_DAY", "NONUNIQUE", "NORMAL", "DAY", "ASC"]]) - #expect(sql == ["CREATE INDEX \"APP\".\"IDX_DAY\" ON \"APP\".\"ORDERS\" (\"DAY\");"]) - } - - @Test("A unique index says UNIQUE and a bitmap one says BITMAP") - func uniquenessAndKind() { - let sql = render([ - ["IDX_U", "UNIQUE", "NORMAL", "A", "ASC"], - ["IDX_B", "NONUNIQUE", "BITMAP", "B", "ASC"] - ]) - #expect(sql[0].contains("CREATE UNIQUE INDEX")) - #expect(sql[1].contains("CREATE BITMAP INDEX")) - } - - @Test("A composite key keeps its order and a descending column keeps DESC") - func compositeKey() { - let sql = render([ - ["IDX_AB", "NONUNIQUE", "NORMAL", "A", "ASC"], - ["IDX_AB", "NONUNIQUE", "NORMAL", "B", "DESC"] - ]) - #expect(sql == ["CREATE INDEX \"APP\".\"IDX_AB\" ON \"APP\".\"ORDERS\" (\"A\", \"B\" DESC);"]) - } - - @Test("A row with no column name contributes nothing") - func missingColumnIsSkipped() { - #expect(render([["IDX_A", "NONUNIQUE", "NORMAL", nil, "ASC"]]).isEmpty) - } - } - - @Suite("Dameng") - struct Dameng { - private func render(_ rows: [[String?]]) -> [String] { - DamengIndexStatements.render( - rows: rows, schema: "APP", table: "ORDERS", quote: { "\"\($0)\"" }) - } - - @Test("A single-column index") - func singleColumn() { - let sql = render([["IDX_DAY", "NONUNIQUE", "DAY"]]) - #expect(sql == ["CREATE INDEX \"APP\".\"IDX_DAY\" ON \"APP\".\"ORDERS\" (\"DAY\");"]) - } - - @Test("A unique index says UNIQUE and a composite one keeps its column order") - func uniqueAndComposite() { - let sql = render([ - ["IDX_AB", "UNIQUE", "A"], - ["IDX_AB", "UNIQUE", "B"] - ]) - #expect(sql == ["CREATE UNIQUE INDEX \"APP\".\"IDX_AB\" ON \"APP\".\"ORDERS\" (\"A\", \"B\");"]) - } - } - - @Suite("Cassandra") - struct Cassandra { - private func render(_ rows: [[String?]]) -> [String] { - CassandraIndexStatements.render( - rows: rows, keyspace: "app", table: "orders", quote: { "\"\($0)\"" }) - } - - @Test("A custom index is left out, because its options are not reproduced") - func customIsSkipped() { - let rows = [["sasi_idx", "CUSTOM", "{class_name: org.apache.cassandra.index.sasi.SASIIndex, target: day}"]] - #expect(render(rows).isEmpty) - } - - @Test("A collection target keeps the wrapper as a keyword and quotes the column inside it") - func collectionTarget() { - let sql = render([["orders_tags_idx", "COMPOSITES", "{target: keys(tags)}"]]) - #expect(sql == ["CREATE INDEX \"orders_tags_idx\" ON \"app\".\"orders\" (keys(\"tags\"));"]) - } - - @Test("A plain column target is quoted") - func plainTargetIsQuoted() { - let sql = render([["orders_day_idx", "COMPOSITES", "{target: day}"]]) - #expect(sql == ["CREATE INDEX \"orders_day_idx\" ON \"app\".\"orders\" (\"day\");"]) - } - - @Test("A target carrying a statement terminator stays inside its quotes") - func hostileTargetCannotEndTheStatement() { - let sql = render([["idx", "COMPOSITES", "{target: a); DROP KEYSPACE app; --}"]]) - let statement = try? #require(sql.first) - #expect(sql.count == 1) - #expect(statement?.contains("DROP KEYSPACE") == true) - #expect(statement?.hasSuffix(");") == true) - #expect(statement?.contains("(\"a); DROP KEYSPACE app; --\")") == true) - } - - @Test("A double quote inside a target is doubled rather than closing the identifier") - func quoteInTargetIsEscaped() { - let quoted = CassandraIndexStatements.quotedTarget( - "a\"b", quote: { "\"\($0.replacingOccurrences(of: "\"", with: "\"\""))\"" }) - #expect(quoted == "\"a\"\"b\"") - } - - @Test("A target read out of a multi-entry options map stops at the entry separator") - func targetStopsAtTheSeparator() { - #expect(CassandraIndexStatements.target(fromOptions: "{target: day, other: x}") == "day") - } - - @Test("An index with no target is left out") - func missingTargetIsSkipped() { - #expect(render([["orders_idx", "COMPOSITES", "{}"]]).isEmpty) - } - } } diff --git a/TableProTests/Plugins/KafkaBootstrapEndpointsTests.swift b/TableProTests/Plugins/KafkaBootstrapEndpointsTests.swift deleted file mode 100644 index faab393364..0000000000 --- a/TableProTests/Plugins/KafkaBootstrapEndpointsTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// KafkaBootstrapEndpointsTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct KafkaBootstrapEndpointsTests { - @Test("The list is dialed before a hidden Host, which an older list left out") - func listComesBeforeHiddenHost() { - let endpoints = KafkaConnectionField.bootstrapEndpoints( - host: "localhost", - port: 9_092, - fields: ["kafkaBootstrapServers": "b1.example:9093,b2.example"], - defaultPort: 9_092 - ) - #expect(endpoints == [ - KafkaEndpoint(host: "b1.example", port: 9_093), - KafkaEndpoint(host: "b2.example", port: 9_092), - KafkaEndpoint(host: "localhost", port: 9_092), - ]) - } - - @Test("A Host the list already names is not dialed twice") - func listedHostIsNotRepeated() { - let endpoints = KafkaConnectionField.bootstrapEndpoints( - host: "b2.example", - port: 9_092, - fields: ["kafkaBootstrapServers": "b2.example:9092,b3.example:9092"], - defaultPort: 9_092 - ) - #expect(endpoints.count == 2) - } - - @Test("Host and Port are dialed when the list is empty, as behind a tunnel") - func emptyListUsesHost() { - let endpoints = KafkaConnectionField.bootstrapEndpoints( - host: "127.0.0.1", - port: 50_123, - fields: [:], - defaultPort: 9_092 - ) - #expect(endpoints == [KafkaEndpoint(host: "127.0.0.1", port: 50_123)]) - } - - @Test("Each remaining bootstrap server gets an equal share of the time left") - func deadlineIsShared() { - let deadline = KafkaConnectDeadline(milliseconds: 9_000, now: 100) - #expect(deadline.remainingMilliseconds(sharedBy: 3, now: 100) == 3_000) - #expect(deadline.remainingMilliseconds(sharedBy: 1, now: 106) == 3_000) - #expect(deadline.remainingMilliseconds(sharedBy: 2, now: 109.5) == nil) - } -} diff --git a/TableProTests/Plugins/KafkaCompressionTests.swift b/TableProTests/Plugins/KafkaCompressionTests.swift deleted file mode 100644 index 3834305ab3..0000000000 --- a/TableProTests/Plugins/KafkaCompressionTests.swift +++ /dev/null @@ -1,204 +0,0 @@ -import Foundation -import libzstd -import Testing - -@testable import TablePro - -/// A Kafka broker hands back exactly the codec the producer stored and never transcodes, so a -/// gap here is a topic that cannot be read at all. Measured against a live broker: producing -/// one topic per codec and reading the stored batch attribute back gave gzip, snappy, lz4 and -/// zstd unchanged, with xerial framing on snappy and the LZ4 frame format on lz4. -struct KafkaCompressionTests { - private static let payload = Data( - "{\"codec\":\"test\",\"n\":1,\"pad\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}".utf8 - ) - - @Test("An uncompressed batch is returned unchanged") - func noneIsPassthrough() throws { - #expect(try KafkaCompression.decompress(Self.payload, codec: .none) == Self.payload) - } - - /// zlib's gzip wrapper, not raw DEFLATE: Compression.framework's COMPRESSION_ZLIB cannot - /// read this directly, which is why the decoder goes through libz's window-bits mode. - @Test("A real gzip stream decompresses") - func gzipRoundTrip() throws { - let compressed = try Self.gzip(Self.payload) - #expect(compressed.prefix(3) == Data([0x1F, 0x8B, 0x08])) - #expect(try KafkaCompression.decompress(compressed, codec: .gzip) == Self.payload) - } - - @Test("A gzip stream larger than one buffer decompresses whole") - func gzipLargePayload() throws { - let large = Data((0 ..< 200_000).map { UInt8($0 % 251) }) - let compressed = try Self.gzip(large) - #expect(try KafkaCompression.decompress(compressed, codec: .gzip) == large) - } - - @Test("Corrupt gzip is reported rather than returning partial output") - func gzipCorruptionIsReported() { - #expect(throws: KafkaError.self) { - _ = try KafkaCompression.decompress(Data([0x1F, 0x8B, 0x08, 0x00, 0xFF, 0xFF]), codec: .gzip) - } - } - - // MARK: - Snappy - - /// Snappy's own format: a varint length, then literals and back-references. macOS ships no - /// snappy at any layer, so this decoder is the only path for a Java-produced topic. - @Test("A literal-only snappy block decodes") - func snappyLiteralBlock() throws { - let body = Array("hello snappy".utf8) - var block: [UInt8] = [UInt8(body.count)] // decompressed length varint - block.append(UInt8((body.count - 1) << 2)) // literal tag - block.append(contentsOf: body) - #expect(try SnappyDecoder.decode(block) == body) - } - - /// A snappy copy may point into bytes the same loop is still appending, which is how the - /// format encodes a repeating run. A bulk copy gets this wrong silently. - @Test("An overlapping snappy copy expands a repeating run") - func snappyOverlappingCopy() throws { - // Literal "ab", then a 1-byte-offset copy of length 6 reaching back 2 bytes. - var block: [UInt8] = [8] // 8 bytes out - block.append(UInt8((2 - 1) << 2)) // literal, 2 bytes - block.append(contentsOf: Array("ab".utf8)) - block.append(UInt8(0x01 | ((6 - 4) << 2))) // copy tag, length 6 - block.append(0x02) // offset 2 - #expect(try SnappyDecoder.decode(block) == Array("abababab".utf8)) - } - - @Test("A snappy block whose declared length disagrees is rejected") - func snappyLengthMismatchIsRejected() { - var block: [UInt8] = [99] // claims 99 bytes - block.append(UInt8((2 - 1) << 2)) - block.append(contentsOf: Array("ab".utf8)) - #expect(throws: KafkaError.self) { _ = try SnappyDecoder.decode(block) } - } - - @Test("A snappy copy reaching before the output start is rejected") - func snappyBadOffsetIsRejected() { - var block: [UInt8] = [8] - block.append(UInt8(0x01)) // copy before anything was written - block.append(0x04) - #expect(throws: KafkaError.self) { _ = try SnappyDecoder.decode(block) } - } - - /// The Java producer wraps blocks in xerial framing. A payload without the magic is a bare - /// block, which is what some non-Java clients write. - @Test("Xerial framing concatenates its blocks in order") - func snappyXerialFraming() throws { - func literalBlock(_ text: String) -> [UInt8] { - let body = Array(text.utf8) - var block: [UInt8] = [UInt8(body.count)] - block.append(UInt8((body.count - 1) << 2)) - block.append(contentsOf: body) - return block - } - - var framed: [UInt8] = [0x82, 0x53, 0x4E, 0x41, 0x50, 0x50, 0x59, 0x00] - framed.append(contentsOf: [0x00, 0x00, 0x00, 0x01]) // version - framed.append(contentsOf: [0x00, 0x00, 0x00, 0x01]) // compatible version - for text in ["first", "second"] { - let block = literalBlock(text) - framed.append(contentsOf: [ - UInt8((block.count >> 24) & 0xFF), UInt8((block.count >> 16) & 0xFF), - UInt8((block.count >> 8) & 0xFF), UInt8(block.count & 0xFF) - ]) - framed.append(contentsOf: block) - } - - let decoded = try KafkaCompression.decompress(Data(framed), codec: .snappy) - #expect(String(data: decoded, encoding: .utf8) == "firstsecond") - } - - // MARK: - LZ4 - - /// Kafka uses the LZ4 FRAME format. Compression.framework's COMPRESSION_LZ4 is Apple's own - /// container with a `bv41` magic, so reaching for it yields corrupt output, not an error. - @Test("An LZ4 frame of stored blocks decodes") - func lz4UncompressedBlockFrame() throws { - var frame: [UInt8] = [0x04, 0x22, 0x4D, 0x18] - frame.append(0x60) // FLG: version 01, block independence - frame.append(0x70) // BD: 4 MB max block size - frame.append(0x00) // header checksum, not verified - let body = Array("lz4 stored block".utf8) - let size = UInt32(body.count) | 0x8000_0000 // high bit: stored, not compressed - frame.append(contentsOf: [ - UInt8(size & 0xFF), UInt8((size >> 8) & 0xFF), - UInt8((size >> 16) & 0xFF), UInt8((size >> 24) & 0xFF) - ]) - frame.append(contentsOf: body) - frame.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) // EndMark - - let decoded = try KafkaCompression.decompress(Data(frame), codec: .lz4) - #expect(String(data: decoded, encoding: .utf8) == "lz4 stored block") - } - - @Test("A payload that is not an LZ4 frame is reported") - func lz4RejectsNonFrames() { - #expect(throws: KafkaError.self) { - _ = try KafkaCompression.decompress(Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]), codec: .lz4) - } - } - - // MARK: - zstd - - /// zstd has no macOS system library at all, verified by dlopen: libarchive exports no - /// ZSTD_ symbol and libzstd.dylib does not exist. This comes from the upstream SPM package. - @Test("A zstd frame decompresses") - func zstdRoundTrip() throws { - let compressed = try Self.zstdCompress(Self.payload) - #expect(compressed.prefix(4) == Data([0x28, 0xB5, 0x2F, 0xFD])) - #expect(try KafkaCompression.decompress(compressed, codec: .zstd) == Self.payload) - } - - @Test("A zstd frame larger than one buffer decompresses whole") - func zstdLargePayload() throws { - let large = Data((0 ..< 300_000).map { UInt8($0 % 241) }) - let compressed = try Self.zstdCompress(large) - #expect(try KafkaCompression.decompress(compressed, codec: .zstd) == large) - } - - @Test("A payload that is not a zstd frame is reported") - func zstdRejectsNonFrames() { - #expect(throws: KafkaError.self) { - _ = try KafkaCompression.decompress(Data([0x00, 0x01, 0x02, 0x03, 0x04]), codec: .zstd) - } - } - - // MARK: - Helpers - - private static func gzip(_ data: Data) throws -> Data { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/gzip") - process.arguments = ["-c"] - let input = Pipe() - let output = Pipe() - process.standardInput = input - process.standardOutput = output - try process.run() - input.fileHandleForWriting.write(data) - input.fileHandleForWriting.closeFile() - let compressed = output.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - return compressed - } - - /// Round-tripped through the same library the decoder uses, so this proves the wiring and - /// the buffer growth rather than the algorithm. - private static func zstdCompress(_ data: Data) throws -> Data { - let bound = ZSTD_compressBound(data.count) - var destination = [UInt8](repeating: 0, count: bound) - let bytes = [UInt8](data) - let written = bytes.withUnsafeBufferPointer { source -> Int in - destination.withUnsafeMutableBufferPointer { target -> Int in - guard let sourceBase = source.baseAddress, let targetBase = target.baseAddress else { return 0 } - return ZSTD_compress(targetBase, target.count, sourceBase, source.count, 3) - } - } - guard ZSTD_isError(written) == 0 else { - throw KafkaError.decompressionFailed(codec: "zstd", reason: "test fixture failed to compress") - } - return Data(destination.prefix(written)) - } -} diff --git a/TableProTests/Plugins/KafkaConnectionFieldTests.swift b/TableProTests/Plugins/KafkaConnectionFieldTests.swift deleted file mode 100644 index facc25b175..0000000000 --- a/TableProTests/Plugins/KafkaConnectionFieldTests.swift +++ /dev/null @@ -1,185 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -private final class LegacyKafkaMetadataPlugin: NSObject, TableProPlugin, DriverPlugin { - static let pluginName = "Legacy Kafka metadata" - static let pluginVersion = "1.0.0" - static let pluginDescription = "Test fixture" - static let capabilities: [PluginCapability] = [.databaseDriver] - static let databaseTypeId = "Kafka" - static let databaseDisplayName = "Kafka" - static let iconName = "kafka-icon" - static let defaultPort = 9_092 - static let additionalConnectionFields = [ - ConnectionField( - id: "kafkaConnectTimeout", - label: "Connect Timeout (seconds)", - defaultValue: "10", - fieldType: .stepper(range: ConnectionField.IntRange(1 ... 120)), - section: .advanced - ) - ] - - func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { - fatalError("Not used in tests") - } -} - -/// Kafka names encryption and authentication in one setting; TablePro carries them in two. -/// These pin the resolution, because getting it wrong sends a password in the clear. -struct KafkaConnectionFieldTests { - private func fields(_ securityProtocol: String, mechanism: String? = nil) -> [String: String] { - var result = ["kafkaSecurityProtocol": securityProtocol] - if let mechanism { result["kafkaSaslMechanism"] = mechanism } - return result - } - - // MARK: - SASL is gated on the protocol - - @Test("PLAINTEXT and SSL never authenticate, whatever mechanism is left in the form") - func saslIsGatedOnTheProtocol() { - #expect(KafkaConnectionField.mechanism(from: fields("PLAINTEXT", mechanism: "PLAIN")) == nil) - #expect(KafkaConnectionField.mechanism(from: fields("SSL", mechanism: "SCRAM-SHA-512")) == nil) - } - - @Test("The SASL protocols use the chosen mechanism, defaulting to PLAIN") - func saslMechanismSelection() { - #expect(KafkaConnectionField.mechanism(from: fields("SASL_SSL", mechanism: "SCRAM-SHA-256")) == .scramSHA256) - #expect(KafkaConnectionField.mechanism(from: fields("SASL_PLAINTEXT", mechanism: "SCRAM-SHA-512")) == .scramSHA512) - #expect(KafkaConnectionField.mechanism(from: fields("SASL_SSL")) == .plain) - } - - @Test("An unrecognised protocol falls back to PLAINTEXT rather than to a SASL one") - func unknownProtocolDoesNotAuthenticate() { - #expect(KafkaConnectionField.mechanism(from: ["kafkaSecurityProtocol": "SASL_GSSAPI"]) == nil) - #expect(KafkaConnectionField.mechanism(from: [:]) == nil) - } - - // MARK: - TLS follows the protocol - - /// The bug this exists to stop: SSLConfiguration defaults to `.disabled`, so picking - /// SASL_SSL and leaving the SSL mode alone opened a plaintext socket and sent the SASL - /// password over it. - @Test("A TLS protocol with no SSL mode chosen verifies fully instead of disabling TLS") - func tlsProtocolUpgradesADisabledMode() { - let resolved = KafkaConnectionField.effectiveSSL(SSLConfiguration(), fields: fields("SASL_SSL")) - #expect(resolved.isEnabled) - #expect(resolved.mode == .verifyIdentity) - #expect(resolved.verifiesCertificate) - - let ssl = KafkaConnectionField.effectiveSSL(SSLConfiguration(), fields: fields("SSL")) - #expect(ssl.mode == .verifyIdentity) - } - - @Test("An explicit SSL mode is honoured under a TLS protocol") - func explicitModeIsKept() { - for mode in [SSLMode.required, .verifyCa, .verifyIdentity] { - let resolved = KafkaConnectionField.effectiveSSL( - SSLConfiguration(mode: mode), - fields: fields("SASL_SSL") - ) - #expect(resolved.mode == mode) - } - } - - /// The other direction: a mode left behind from an earlier edit must not quietly encrypt - /// a connection whose protocol says plaintext, which would fail against a plaintext - /// listener in a way that reads like a broker fault. - @Test("A non-TLS protocol disables TLS even when a mode was left behind") - func nonTLSProtocolDisablesTLS() { - for protocolName in ["PLAINTEXT", "SASL_PLAINTEXT"] { - let resolved = KafkaConnectionField.effectiveSSL( - SSLConfiguration(mode: .verifyIdentity), - fields: fields(protocolName) - ) - #expect(resolved.isEnabled == false) - #expect(resolved.mode == .disabled) - } - } - - @Test("Certificate paths survive the resolution") - func certificatePathsSurvive() { - let resolved = KafkaConnectionField.effectiveSSL( - SSLConfiguration( - mode: .verifyCa, - caCertificatePath: "/tmp/ca.pem", - clientCertificatePath: "/tmp/client.pem", - clientKeyPath: "/tmp/client.key" - ), - fields: fields("SASL_SSL") - ) - #expect(resolved.caCertificatePath == "/tmp/ca.pem") - #expect(resolved.clientCertificatePath == "/tmp/client.pem") - #expect(resolved.clientKeyPath == "/tmp/client.key") - } - - // MARK: - Field declarations - - @Test("The common connect timeout wins over the saved Kafka field") - func commonConnectTimeoutWins() { - let timeout = KafkaConnectTimeout(additionalFields: [ - "connectTimeoutMilliseconds": "4250", - "connectTimeoutSeconds": "8", - "kafkaConnectTimeout": "11" - ]) - - #expect(timeout.milliseconds == 4_250) - #expect(timeout.reconnectMilliseconds == 8_000) - } - - @Test("Saved Kafka timeouts migrate through the compatibility fallback") - func savedConnectTimeoutFallback() { - #expect(KafkaConnectTimeout(additionalFields: ["kafkaConnectTimeout": "12"]).milliseconds == 12_000) - #expect(KafkaConnectTimeout(additionalFields: [:]).milliseconds == 30_000) - #expect( - KafkaConnectTimeout(additionalFields: ["connectTimeoutSeconds": String(Int64.min)]).milliseconds == 1 - ) - } - - @Test("Bootstrap attempts consume one monotonic deadline") - func bootstrapDeadlineShrinks() { - let deadline = KafkaConnectDeadline(timeout: KafkaConnectTimeout(milliseconds: 4_250), now: 100) - - #expect(deadline.remainingMilliseconds(now: 101.25) == 3_000) - #expect(deadline.remainingMilliseconds(now: 104.25) == nil) - } - - @Test("The plugin no longer declares its old timeout control") - func oldTimeoutControlIsRemoved() { - #expect(!KafkaConnectionField.fields().contains { $0.id == KafkaConnectionField.connectTimeout }) - } - - @Test("An installed older Kafka bundle cannot restore the retired timeout control") - func oldPluginMetadataIsNormalized() { - let snapshot = PluginMetadataRegistry.shared.buildMetadataSnapshot(from: LegacyKafkaMetadataPlugin.self) - - #expect(snapshot.connection.additionalConnectionFields.isEmpty) - } - - /// The plugin's list and the app's curated copy cannot share code, because the picker has - /// to show these before the bundle that owns them is downloaded. Nothing but this keeps - /// the two in step. - @Test("The plugin's fields match the curated snapshot the picker shows before install") - func declaredFieldsMatchTheCuratedCopy() { - let declared = KafkaConnectionField.fields() - let curated = PluginMetadataRegistry.shared - .snapshot(forRegisteredTypeId: "Kafka")? - .connection.additionalConnectionFields ?? [] - - #expect(declared.isEmpty == false) - #expect(declared.map(\.id).sorted() == curated.map(\.id).sorted()) - for field in declared { - guard let match = curated.first(where: { $0.id == field.id }) else { - Issue.record("the curated snapshot is missing \(field.id)") - continue - } - #expect(match.label == field.label) - #expect(match.isRequired == field.isRequired) - #expect(match.defaultValue == field.defaultValue) - #expect(match.section == field.section) - } - } -} diff --git a/TableProTests/Plugins/KafkaIntegrationTests.swift b/TableProTests/Plugins/KafkaIntegrationTests.swift deleted file mode 100644 index 73a8b2958d..0000000000 --- a/TableProTests/Plugins/KafkaIntegrationTests.swift +++ /dev/null @@ -1,623 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -/// End-to-end tests that drive the real driver against a real Kafka broker. -/// -/// Everything else in the Kafka suites is pure logic: a codec, a parser, a flattener. Those -/// prove the bytes are right but never open a socket, so until this suite existed the 2,174 -/// lines that actually talk to a broker (the driver, the cluster, the connection and the five -/// request builders) had never been executed at all. -/// -/// The suite is skipped unless `TABLEPRO_KAFKA_TEST_BOOTSTRAP` names a broker, because CI has -/// none. `scripts/kafka-test-broker.sh up` starts one and prints the export line. -@Suite("Kafka integration", .enabled(if: KafkaTestBroker.isConfigured)) -struct KafkaIntegrationTests { - // MARK: - Connect and discover - - @Test("Connecting reports the cluster and lists its topics") - func connectAndListTopics() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-list", partitions: 3) - defer { harness.tearDown() } - - let databases = try await harness.driver.fetchDatabases() - #expect(databases == ["cluster"]) - - let version = await harness.driver.serverVersion - #expect(version?.hasPrefix("Kafka cluster ") == true) - - let tables = try await harness.driver.fetchTables(schema: nil) - let topic = try #require(tables.first { $0.name == harness.topic }) - // The external-table type is what makes the grid refuse a cell edit up front. - #expect(topic.type == "external table") - - // Kafka's own bookkeeping topics are marked system so they sort out of the way. - for internalTopic in tables where internalTopic.name.hasPrefix("__") { - #expect(internalTopic.type == "system table") - } - } - - @Test("A topic reports its partitions and its message count") - func partitionsAndRowCount() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-count", partitions: 3) - defer { harness.tearDown() } - try await harness.produce(count: 12) - - let partitions = try await harness.driver.fetchPartitions(table: harness.topic, schema: nil) - #expect(partitions.count == 3) - - let metadata = try await harness.driver.fetchTableMetadata(table: harness.topic, schema: nil) - #expect(metadata.rowCount == 12) - - let approximate = try await harness.driver.fetchApproximateRowCount(table: harness.topic, schema: nil) - #expect(approximate == 12) - } - - @Test("An unknown topic is reported rather than returning an empty result") - func unknownTopicThrows() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-unknown", partitions: 1) - defer { harness.tearDown() } - - await #expect(throws: KafkaError.self) { - _ = try await harness.driver.fetchTableMetadata(table: "tp-it-does-not-exist", schema: nil) - } - } - - // MARK: - Browsing - - /// The path the grid actually takes: the host asks for a query string, then runs it. - @Test("The grid's browse path returns rows with every column populated") - func browseThroughTheGridPath() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-browse", partitions: 3) - defer { harness.tearDown() } - try await harness.produce(count: 20) - - let query = try #require(harness.driver.buildBrowseQuery( - table: harness.topic, - schema: nil, - sortColumns: [], - columns: [], - limit: 10, - offset: 0 - )) - let result = try await harness.driver.execute(query: query) - - #expect(result.rows.count == 10) - #expect(result.columns == [ - "partition", "offset", "timestamp", "key", "value", "headers", "key_size", "value_size" - ]) - for row in result.rows { - #expect(row.count == 8) - #expect(row[0].asText?.isEmpty == false) // partition - #expect(row[1].asText?.isEmpty == false) // offset - #expect(row[4].asText?.isEmpty == false) // value - } - } - - @Test("A key, a value and headers survive the round trip") - func payloadRoundTrip() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-payload", partitions: 1) - defer { harness.tearDown() } - - _ = try await harness.driver.execute(query: """ - PRODUCE INTO \(KafkaQL.quote(harness.topic)) KEY "order-1" VALUE "{\\"id\\":1}" \ - HEADER "source" "tablepro" - """) - - let rows = try await harness.consume(limit: 10) - let row = try #require(rows.first) - #expect(row[3].asText == "order-1") - #expect(row[4].asText == "{\"id\":1}") - #expect(row[5].asText == "{\"source\":\"tablepro\"}") - #expect(row[6].asText == "7") // key_size - } - - /// A tombstone and an empty message mean different things on a compacted topic. - @Test("A null value stays null through the whole driver") - func nullValueIsNotEmpty() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-tombstone", partitions: 1) - defer { harness.tearDown() } - - _ = try await harness.driver.execute( - query: "PRODUCE INTO \(KafkaQL.quote(harness.topic)) KEY \"gone\" VALUE \"\"" - ) - let rows = try await harness.consume(limit: 5) - let row = try #require(rows.first) - #expect(row[3].asText == "gone") - #expect(row[4].asText == "") - } - - // MARK: - Seeking - - @Test("Every start mode reaches the messages it names") - func startModes() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-seek", partitions: 1) - defer { harness.tearDown() } - try await harness.produce(count: 30) - - let oldest = try await harness.rows("CONSUME \(harness.quoted) FROM OLDEST LIMIT 5") - #expect(oldest.map { $0[1].asText } == ["0", "1", "2", "3", "4"]) - - let fromOffset = try await harness.rows("CONSUME \(harness.quoted) FROM OFFSET 10 LIMIT 3") - #expect(fromOffset.map { $0[1].asText } == ["10", "11", "12"]) - - let newest = try await harness.rows("CONSUME \(harness.quoted) FROM NEWEST LIMIT 5") - #expect(newest.map { $0[1].asText } == ["25", "26", "27", "28", "29"]) - - // An offset past the end clamps rather than raising OFFSET_OUT_OF_RANGE. - let past = try await harness.rows("CONSUME \(harness.quoted) FROM OFFSET 9999 LIMIT 5") - #expect(past.isEmpty) - } - - @Test("Seeking to a time lands on the first message at or after it") - func seekByTime() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-time", partitions: 1) - defer { harness.tearDown() } - try await harness.produce(count: 5) - - let all = try await harness.rows("CONSUME \(harness.quoted) FROM OLDEST LIMIT 5") - let firstTimestamp = try #require(all.first?[2].asText) - - let seeked = try await harness.rows( - "CONSUME \(harness.quoted) FROM TIME \(KafkaQL.quote(firstTimestamp)) LIMIT 5" - ) - #expect(seeked.count == 5) - #expect(seeked.first?[1].asText == "0") - } - - @Test("A partition filter reads only the partitions it names") - func partitionFilter() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-partition", partitions: 3) - defer { harness.tearDown() } - try await harness.produce(count: 30) - - let rows = try await harness.rows("CONSUME \(harness.quoted) PARTITION (0) FROM OLDEST LIMIT 50") - #expect(rows.isEmpty == false) - #expect(Set(rows.compactMap { $0[0].asText }) == ["0"]) - } - - /// Paging is the reason the TAIL clause exists: page two has to continue page one rather - /// than re-deriving its start against a tail that has moved. - /// - /// The invariant is that no message is shown twice and that no page comes back short, not - /// that three pages of ten cover exactly thirty. A `NEWEST` scan anchors each partition at - /// its own tail, and messages are not spread evenly across partitions, so the window it - /// opens holds however many messages happen to be in it. Coverage is asserted from - /// `OLDEST`, where the anchor is the start of the log and paging forward really does reach - /// everything. - @Test("Paging from the tail never shows the same message twice") - func pagingNeverRepeats() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-paging", partitions: 3) - defer { harness.tearDown() } - try await harness.produce(count: 30) - - var seen: [String] = [] - for page in 0 ..< 3 { - let query = try #require(harness.driver.buildBrowseQuery( - table: harness.topic, - schema: nil, - sortColumns: [], - columns: [], - limit: 10, - offset: page * 10 - )) - if page > 0 { - // Later pages must name the window page one resolved, and a tail browse pins - // the END of that window so each page steps further back from it. - #expect(query.contains("FROM TAIL")) - } - let result = try await harness.driver.execute(query: query) - let rows = result.rows.map { "\($0[0].asText ?? "?"):\($0[1].asText ?? "?")" } - #expect(rows.count == 10, "page \(page + 1) of a tail browse came back short") - seen.append(contentsOf: rows) - } - - #expect(seen.isEmpty == false) - #expect(Set(seen).count == seen.count, "a (partition, offset) pair was repeated across pages") - } - - /// From the oldest offset the window is the whole log, so paging forward must reach every - /// message exactly once. This is the coverage guarantee the tail scan cannot make. - @Test("Paging from the oldest offset reaches every message exactly once") - func pagingFromOldestCoversEverything() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-cover", partitions: 3) - defer { harness.tearDown() } - try await harness.produce(count: 30) - - var seen: [String] = [] - for page in 0 ..< 3 { - let rows = try await harness.rows( - "CONSUME \(harness.quoted) FROM OLDEST LIMIT 10 SKIP \(page * 10)" - ) - seen.append(contentsOf: rows.map { "\($0[0].asText ?? "?"):\($0[1].asText ?? "?")" }) - } - - #expect(seen.count == 30) - #expect(Set(seen).count == 30, "paging from OLDEST repeated or skipped a message") - } - - /// Produced between page one and page two: the anchor must hold the window still. - @Test("A topic written to mid-page does not shift the page under the reader") - func pagingSurvivesConcurrentWrites() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-moving", partitions: 1) - defer { harness.tearDown() } - try await harness.produce(count: 20) - - let first = try #require(harness.driver.buildBrowseQuery( - table: harness.topic, schema: nil, sortColumns: [], columns: [], limit: 10, offset: 0 - )) - let pageOne = try await harness.driver.execute(query: first) - let pageOneKeys = pageOne.rows.map { $0[1].asText } - - try await harness.produce(count: 10, startingAt: 20) - - let second = try #require(harness.driver.buildBrowseQuery( - table: harness.topic, schema: nil, sortColumns: [], columns: [], limit: 10, offset: 10 - )) - let pageTwo = try await harness.driver.execute(query: second) - let pageTwoKeys = pageTwo.rows.map { $0[1].asText } - - #expect(Set(pageOneKeys).isDisjoint(with: Set(pageTwoKeys))) - } - - // MARK: - Producing - - @Test("Produce reports the partition and offset the broker assigned") - func produceReportsItsPlacement() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-produce", partitions: 3) - defer { harness.tearDown() } - - let result = try await harness.driver.execute( - query: "PRODUCE INTO \(harness.quoted) KEY \"k1\" VALUE \"v1\" PARTITION 2" - ) - #expect(result.rowsAffected == 1) - #expect(result.columns == ["topic", "partition", "offset"]) - #expect(result.rows.first?[1].asText == "2") - #expect(result.rows.first?[2].asText == "0") - } - - /// Kafka's ordering guarantee for a key only holds if every message with that key lands in - /// the same partition, so the driver has to match the broker's own partitioner. - @Test("The same key always lands in the same partition") - func keyedMessagesShareAPartition() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-key", partitions: 3) - defer { harness.tearDown() } - - var partitions: Set = [] - for index in 0 ..< 4 { - let result = try await harness.driver.execute( - query: "PRODUCE INTO \(harness.quoted) KEY \"same-key\" VALUE \"v\(index)\"" - ) - if let partition = result.rows.first?[1].asText { partitions.insert(partition) } - } - #expect(partitions.count == 1) - } - - @Test("Producing to a partition the topic does not have is refused") - func produceToMissingPartition() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-badpart", partitions: 1) - defer { harness.tearDown() } - - await #expect(throws: KafkaError.self) { - _ = try await harness.driver.execute( - query: "PRODUCE INTO \(harness.quoted) VALUE \"v\" PARTITION 7" - ) - } - } - - // MARK: - The grid must not offer what Kafka cannot do - - @Test("Every edit is declined, so Save cannot silently do nothing") - func editsAreDeclined() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-readonly", partitions: 1) - defer { harness.tearDown() } - - let statements = harness.driver.generateStatements( - table: harness.topic, - schema: nil, - columns: ["value"], - primaryKeyColumns: [], - changes: [], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - #expect(statements == nil) - } - - // MARK: - Cluster introspection - - @Test("SHOW and DESCRIBE answer from the live cluster") - func showAndDescribe() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-show", partitions: 2) - defer { harness.tearDown() } - try await harness.produce(count: 4) - - let topics = try await harness.driver.execute(query: "SHOW TOPICS") - #expect(topics.columns == ["topic", "partitions", "replication_factor", "internal"]) - #expect(topics.rows.contains { $0.first?.asText == harness.topic }) - - // Asserted against however many brokers are actually running rather than against one. - // Pinning the count to 1 is what made these three assertions fail the moment the suite - // met a cluster big enough to reproduce the routing bug they sit beside. - let brokers = try await harness.driver.execute(query: "SHOW BROKERS") - #expect(brokers.columns.last == "takes_admin_requests") - #expect(brokers.rows.isEmpty == false) - #expect(brokers.rows.filter { $0[4].asText == "yes" }.count == 1) - - let cluster = try await harness.driver.execute(query: "SHOW CLUSTER") - let properties = Dictionary(uniqueKeysWithValues: cluster.rows.compactMap { row -> (String, String)? in - guard let key = row.first?.asText else { return nil } - return (key, row[1].asText ?? "") - }) - #expect(properties["brokers"] == String(brokers.rows.count)) - #expect(properties["cluster_id"]?.isEmpty == false) - #expect(properties["admin_requests_to"]?.isEmpty == false) - - let described = try await harness.driver.execute(query: "DESCRIBE TOPIC \(harness.quoted)") - #expect(described.rows.count == 2) - #expect(described.columns.contains("earliest_offset")) - #expect(described.columns.contains("latest_offset")) - let total = described.rows.compactMap { Int($0.last?.asText ?? "") }.reduce(0, +) - #expect(total == 4) - } - - @Test("The DDL view describes the topic's partitions and replicas") - func topicDDL() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-ddl", partitions: 2) - defer { harness.tearDown() } - - let ddl = try await harness.driver.fetchTableDDL(table: harness.topic, schema: nil) - #expect(ddl.contains("Topic: \(harness.topic)")) - #expect(ddl.contains("Partitions: 2")) - #expect(ddl.contains("partition 0")) - #expect(ddl.contains("in-sync")) - } - - // MARK: - Topic deletion - - /// DeleteTopics is hand-written wire protocol, so the only way to trust it is to ask a real - /// broker. The broker accepts the request and removes the log directories afterwards, so this - /// waits for the topic to leave the listing rather than asserting it is gone immediately. - @Test("Dropping a topic removes it from the cluster") - func dropTopicRemovesIt() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-drop", partitions: 2) - defer { harness.tearDown() } - - let before = try await harness.driver.fetchTables(schema: nil) - #expect(before.contains { $0.name == harness.topic }) - - let statement = try #require(harness.driver.dropObjectStatement( - name: harness.topic, objectType: "TABLE", schema: nil, cascade: false - )) - #expect(statement == "DROP TOPIC \(harness.quoted)") - - let result = try await harness.driver.execute(query: statement) - #expect(result.rowsAffected == 1) - - var listed = true - for _ in 0 ..< 40 where listed { - try await Task.sleep(nanoseconds: 250_000_000) - let tables = try await harness.driver.fetchTables(schema: nil) - listed = tables.contains { $0.name == harness.topic } - } - #expect(!listed, "the topic was still listed after the broker accepted the deletion") - } - - @Test("Dropping a topic that does not exist reports the broker's own answer") - func dropUnknownTopicReports() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-drop-missing", partitions: 1) - defer { harness.tearDown() } - - await #expect(throws: (any Error).self) { - try await harness.driver.execute(query: "DROP TOPIC \"tp-it-no-such-topic-9e3f\"") - } - } - - /// The lag report is what a Kafka debugging session is usually after. - /// - /// Three partitions rather than one, so the end offsets come from ListOffsets sent to three - /// different leaders on a multi-broker cluster. With one partition this passed while every - /// request went to whichever broker the connection happened to hold. - @Test("Consumer group lag is reported per partition") - func consumerGroupLag() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-lag", partitions: 3) - defer { harness.tearDown() } - // The group consumes everything first, so every partition it was assigned has a - // committed offset; the six produced afterwards are the lag. Committing only part of a - // topic leaves the partitions it never reached out of the report entirely, which is - // correct and not something to assert arithmetic against. - try await harness.produce(count: 10) - try harness.commitGroup(named: "tp-it-group", messages: 10) - try await harness.produce(count: 6, startingAt: 10) - - let groups = try await harness.driver.execute(query: "SHOW GROUPS") - #expect(groups.rows.contains { $0.first?.asText == "tp-it-group" }) - - let lag = try await harness.driver.execute(query: "DESCRIBE GROUP \"tp-it-group\"") - #expect(lag.columns == ["topic", "partition", "committed_offset", "end_offset", "lag"]) - let rows = lag.rows.filter { $0.first?.asText == harness.topic } - #expect(rows.count == 3, "every partition the group committed must be reported") - let written = rows.compactMap { Int($0[3].asText ?? "") }.reduce(0, +) - let consumed = rows.compactMap { Int($0[2].asText ?? "") }.reduce(0, +) - let behind = rows.compactMap { Int($0[4].asText ?? "") }.reduce(0, +) - #expect(written == 16) - #expect(consumed == 10) - // Ten consumed of sixteen leaves six behind, wherever the sixteen landed. - #expect(behind == 6) - } - - /// A group the cluster has never heard of used to come back as five columns and no rows, - /// which is byte for byte what a real group with nothing committed returns. - @Test("Describing a group that does not exist says so") - func describeUnknownGroupReports() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-nogroup", partitions: 1) - defer { harness.tearDown() } - - await #expect(throws: (any Error).self) { - try await harness.driver.execute(query: "DESCRIBE GROUP \"tp-it-no-such-group-4c71\"") - } - } - - // MARK: - Routing across brokers - - /// The reported bug (#2993), and the reason the rest of this suite could not catch it. - /// - /// A topic's partitions are spread across the cluster's brokers, so every one of these - /// statements has to reach a broker the connection was not opened to. On a single-broker - /// cluster they all pass without any routing at all, which is why they are asserted here - /// against whatever the harness is running and are worth running against three. - @Test("Every statement that needs an offset works wherever the leaders are") - func offsetsReachEveryLeader() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-routing", partitions: 6) - defer { harness.tearDown() } - try await harness.produce(count: 30) - - let described = try await harness.driver.execute(query: "DESCRIBE TOPIC \(harness.quoted)") - #expect(described.rows.count == 6) - let counted = described.rows.compactMap { Int($0.last?.asText ?? "") }.reduce(0, +) - #expect(counted == 30) - - let metadata = try await harness.driver.fetchTableMetadata(table: harness.topic, schema: nil) - #expect(metadata.rowCount == 30) - - let consumed = try await harness.rows("CONSUME \(harness.quoted) FROM NEWEST LIMIT 100") - #expect(consumed.count == 30) - } - - /// `FROM NEWEST` means the newest messages. On more than one partition the scan steps every - /// partition back by the page size and reads forward, so the merged run holds several pages - /// and the newest rows sit at its end; taking the front of it returned the oldest rows of - /// the tail window and called them the newest. - @Test("FROM NEWEST returns the newest messages, not the oldest of its window") - func newestReturnsTheNewest() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-newest", partitions: 3) - defer { harness.tearDown() } - try await harness.produce(count: 60) - - let everything = try await harness.rows("CONSUME \(harness.quoted) FROM OLDEST LIMIT 100") - #expect(everything.count == 60) - let newestKeys = Set(everything.suffix(10).compactMap { $0[3].asText }) - - let newest = try await harness.rows("CONSUME \(harness.quoted) FROM NEWEST LIMIT 10") - #expect(newest.count == 10) - #expect(Set(newest.compactMap { $0[3].asText }) == newestKeys) - } - - /// Page two of a tail browse used to be empty: page one recorded the start of its window - /// rather than the end, so page two skipped a page inside a window exactly one page long. - @Test("The second page of a tail browse holds the messages before the first") - func tailPagingWalksBackwards() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-tailpage", partitions: 3) - defer { harness.tearDown() } - try await harness.produce(count: 60) - - var pages: [[String]] = [] - for page in 0 ..< 2 { - let query = try #require(harness.driver.buildBrowseQuery( - table: harness.topic, - schema: nil, - sortColumns: [], - columns: [], - limit: 10, - offset: page * 10 - )) - if page > 0 { #expect(query.contains("FROM TAIL")) } - let rows = try await harness.driver.execute(query: query).rows - pages.append(rows.compactMap { $0[3].asText }) - } - - #expect(pages[0].count == 10) - #expect(pages[1].count == 10, "page two of a tail browse must hold the page before it") - #expect(Set(pages[0]).isDisjoint(with: Set(pages[1]))) - } - - /// A broker answers ListGroups with the groups it coordinates and nothing else, so a client - /// that asks one broker reports a fraction of them with no error. - @Test("SHOW GROUPS lists groups from every broker, not just the one connected to") - func showGroupsCoversEveryBroker() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-groups", partitions: 3) - defer { harness.tearDown() } - try await harness.produce(count: 6) - - // Several names, because which broker coordinates a group is a hash of its id: one - // group lands on one broker and says nothing about whether the sweep happened. Three - // is the fewest that can land on three brokers, and each one costs a container running - // a console consumer, so the count is kept to what the assertion needs. - let names = (0 ..< 3).map { "tp-it-sweep-\($0)" } - for name in names { - try harness.commitGroup(named: name, messages: 1) - } - - let listed = try await harness.driver.execute(query: "SHOW GROUPS") - let found = Set(listed.rows.compactMap { $0.first?.asText }) - for name in names { - #expect(found.contains(name), "SHOW GROUPS did not list \(name)") - } - - // And each one is describable, which needs its own coordinator rather than the - // bootstrap broker. - for name in names { - let lag = try await harness.driver.execute(query: "DESCRIBE GROUP \(KafkaQL.quote(name))") - #expect(lag.rows.isEmpty == false, "DESCRIBE GROUP \(name) returned nothing") - } - } - - // MARK: - Compression, end to end through the driver - - /// The broker hands back whatever the producer stored, so a topic written with each codec - /// exercises a different decompression path in the shipping driver. - @Test("Messages compressed with each codec read back identically", arguments: ["gzip", "snappy", "lz4", "zstd"]) - func everyCodecReadsBack(codec: String) async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-codec-\(codec)", partitions: 1) - defer { harness.tearDown() } - try harness.produceCompressed(codec: codec, count: 6) - - let rows = try await harness.rows("CONSUME \(harness.quoted) FROM OLDEST LIMIT 10") - #expect(rows.count == 6, "\(codec) returned \(rows.count) of 6 messages") - for (index, row) in rows.enumerated() { - #expect(row[4].asText == "payload-\(index)", "\(codec) corrupted message \(index)") - } - } - - // MARK: - Failure reporting - - @Test("A dead address is reported rather than hanging") - func unreachableBrokerFails() async throws { - // Port 1 is reserved and nothing listens there. - let driver = KafkaTestBroker.makeDriver(host: "127.0.0.1", port: 1, timeoutSeconds: 3) - await #expect(throws: KafkaError.self) { - try await driver.connect() - } - driver.disconnect() - } - - @Test("A cancelled connect returns rather than running to completion") - func connectIsCancellable() async throws { - // 10.255.255.1 is non-routable, so the connect blocks until it is cancelled. - let driver = KafkaTestBroker.makeDriver(host: "10.255.255.1", port: 9_092, timeoutSeconds: 60) - let task = Task { try await driver.connect() } - try await Task.sleep(nanoseconds: 300_000_000) - task.cancel() - - let started = Date() - _ = await task.result - #expect(Date().timeIntervalSince(started) < 10, "cancelling waited for the full timeout") - driver.disconnect() - } - - @Test("A malformed statement names the problem instead of returning nothing") - func badStatementIsReported() async throws { - let harness = try await KafkaTestBroker.harness(topic: "tp-it-syntax", partitions: 1) - defer { harness.tearDown() } - - await #expect(throws: KafkaError.self) { - _ = try await harness.driver.execute(query: "DROP TABLE orders") - } - await #expect(throws: KafkaError.self) { - _ = try await harness.driver.execute(query: "CONSUME \(harness.quoted) SKIP -1") - } - } -} diff --git a/TableProTests/Plugins/KafkaMessageFlattenerTests.swift b/TableProTests/Plugins/KafkaMessageFlattenerTests.swift deleted file mode 100644 index f0fd0968a1..0000000000 --- a/TableProTests/Plugins/KafkaMessageFlattenerTests.swift +++ /dev/null @@ -1,215 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct KafkaMessageFlattenerTests { - private func record( - partition: Int32 = 0, - offset: Int64 = 0, - timestamp: Int64 = 1_787_709_624_571, - key: Data? = nil, - value: Data? = nil, - headers: [KafkaRecordHeader] = [] - ) -> KafkaRecord { - KafkaRecord( - offset: offset, - timestamp: timestamp, - timestampIsLogAppendTime: false, - key: key, - value: value, - headers: headers, - partition: partition - ) - } - - /// The column order is the one every Kafka UI converged on: coordinates, then payload. - @Test("The column set and its order are the Kafka convention") - func columnOrder() { - let columns = KafkaMessageFlattener.columns(for: []) - #expect(columns.map(\.name) == [ - "partition", "offset", "timestamp", "key", "value", "headers", "key_size", "value_size" - ]) - } - - /// The host runs blob formatting over a cell whenever its COLUMN is typed binary, so a - /// text value inside a BLOB column renders as hex. Deciding the type once per page is what - /// keeps the cell kind and the column type agreeing. - @Test("A page of UTF-8 payloads types its key and value columns TEXT") - func textPayloadsTypeAsText() { - let records = [ - record(key: Data("k1".utf8), value: Data("{\"a\":1}".utf8)), - record(key: Data("k2".utf8), value: Data("plain".utf8)) - ] - let columns = KafkaMessageFlattener.columns(for: records) - #expect(columns.first { $0.name == "key" }?.dataType == "TEXT") - #expect(columns.first { $0.name == "value" }?.dataType == "TEXT") - - let rows = KafkaMessageFlattener.rows(for: records) - #expect(rows[0][4] == .text("{\"a\":1}")) - } - - @Test("One non-UTF-8 payload types the whole column BLOB") - func binaryPayloadTypesAsBlob() { - let records = [ - record(value: Data("fine".utf8)), - record(value: Data([0xFF, 0xFE, 0x00, 0x01])) - ] - let columns = KafkaMessageFlattener.columns(for: records) - #expect(columns.first { $0.name == "value" }?.dataType == "BLOB") - - let rows = KafkaMessageFlattener.rows(for: records) - #expect(rows[1][4] == .bytes(Data([0xFF, 0xFE, 0x00, 0x01]))) - // The valid payload stays bytes too, so the column's type is true of every cell in it. - #expect(rows[0][4] == .bytes(Data("fine".utf8))) - } - - /// A tombstone and an empty message mean different things on a compacted topic, so they - /// must not both render as blank. - @Test("A null value is null, and an empty value is empty") - func nullVersusEmpty() { - let rows = KafkaMessageFlattener.rows(for: [ - record(key: Data("k".utf8), value: nil), - record(key: Data("k".utf8), value: Data()) - ]) - #expect(rows[0][4] == .null) - #expect(rows[1][4] == .text("")) - } - - @Test("Sizes count bytes, and a null payload counts zero") - func sizes() { - let rows = KafkaMessageFlattener.rows(for: [ - record(key: Data("abc".utf8), value: Data("hello".utf8)), - record(key: nil, value: nil) - ]) - #expect(rows[0][6] == .text("3")) - #expect(rows[0][7] == .text("5")) - #expect(rows[1][6] == .text("0")) - #expect(rows[1][7] == .text("0")) - } - - @Test("Headers render as a JSON object, and no headers renders as null") - func headers() { - let rows = KafkaMessageFlattener.rows(for: [ - record(headers: [ - KafkaRecordHeader(key: "trace", value: Data("abc".utf8)), - KafkaRecordHeader(key: "retry", value: nil) - ]), - record(headers: []) - ]) - #expect(rows[0][5] == .text("{\"trace\":\"abc\",\"retry\":null}")) - #expect(rows[1][5] == .null) - } - - @Test("A header value containing a quote is escaped rather than breaking the JSON") - func headerEscaping() { - let rows = KafkaMessageFlattener.rows(for: [ - record(headers: [KafkaRecordHeader(key: "q", value: Data("a\"b".utf8))]) - ]) - #expect(rows[0][5] == .text("{\"q\":\"a\\\"b\"}")) - } - - @Test("Coordinates render as their own values") - func coordinates() { - let rows = KafkaMessageFlattener.rows(for: [record(partition: 7, offset: 12_345)]) - #expect(rows[0][0] == .text("7")) - #expect(rows[0][1] == .text("12345")) - } - - /// A producer that sets no timestamp writes -1, and rendering that as a 1969 date would be - /// a fabricated fact. - @Test("A missing timestamp renders empty rather than as an epoch date") - func missingTimestamp() { - #expect(KafkaMessageFlattener.formatTimestamp(-1) == "") - #expect(KafkaMessageFlattener.formatTimestamp(0) == "") - } - - /// The timestamp is formatted by arithmetic rather than by ISO8601DateFormatter, because a - /// formatter is not Sendable and building one per row is most of the cost of a page. These - /// pin the arithmetic against instants computed independently. - @Test("Timestamps format as UTC ISO 8601 with milliseconds") - func timestampFormatting() { - #expect(KafkaMessageFlattener.formatTimestamp(1) == "1970-01-01T00:00:00.001Z") - #expect(KafkaMessageFlattener.formatTimestamp(1_000) == "1970-01-01T00:00:01.000Z") - #expect(KafkaMessageFlattener.formatTimestamp(86_399_000) == "1970-01-01T23:59:59.000Z") - #expect(KafkaMessageFlattener.formatTimestamp(86_400_000) == "1970-01-02T00:00:00.000Z") - // A leap day, and the century rule either side of it. - #expect(KafkaMessageFlattener.formatTimestamp(951_782_400_000) == "2000-02-29T00:00:00.000Z") - #expect(KafkaMessageFlattener.formatTimestamp(1_709_164_800_000) == "2024-02-29T00:00:00.000Z") - #expect(KafkaMessageFlattener.formatTimestamp(1_787_709_624_571) == "2026-08-26T02:00:24.571Z") - } - - /// The same instants, cross-checked against Foundation so the hand-rolled civil-date - /// conversion cannot drift from the calendar everything else uses. - @Test("The arithmetic formatter agrees with Foundation across a wide range") - func timestampMatchesFoundation() { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - formatter.timeZone = TimeZone(identifier: "UTC") - - for milliseconds in stride(from: Int64(1), through: 2_100_000_000_000, by: 37_913_141_593) { - let expected = formatter.string(from: Date(timeIntervalSince1970: Double(milliseconds) / 1_000)) - #expect( - KafkaMessageFlattener.formatTimestamp(milliseconds) == expected, - "disagreed at \(milliseconds)" - ) - } - } -} - -struct KafkaBrowseMergeTests { - private func record(partition: Int32, offset: Int64, timestamp: Int64) -> KafkaRecord { - KafkaRecord( - offset: offset, - timestamp: timestamp, - timestampIsLogAppendTime: false, - key: nil, - value: nil, - headers: [], - partition: partition - ) - } - - /// Kafka guarantees order only within a partition, so the cross-partition order is a - /// presentation choice. It has to be total and stable, or paging repeats or skips a row. - @Test("Merged records order by timestamp, then partition, then offset") - func mergeOrder() { - let merged = KafkaRecordOrdering.merge([ - record(partition: 1, offset: 5, timestamp: 300), - record(partition: 0, offset: 9, timestamp: 100), - record(partition: 2, offset: 1, timestamp: 200), - record(partition: 0, offset: 10, timestamp: 200) - ]) - #expect(merged.map(\.timestamp) == [100, 200, 200, 300]) - // The tie at 200 breaks on partition, so partition 0 precedes partition 2. - #expect(merged[1].partition == 0) - #expect(merged[2].partition == 2) - } - - @Test("Records sharing a timestamp and a partition order by offset") - func offsetBreaksTheFinalTie() { - let merged = KafkaRecordOrdering.merge([ - record(partition: 0, offset: 3, timestamp: 100), - record(partition: 0, offset: 1, timestamp: 100), - record(partition: 0, offset: 2, timestamp: 100) - ]) - #expect(merged.map(\.offset) == [1, 2, 3]) - } - - /// Every (partition, offset) is unique in a merged page, which is what lets paging resume - /// without repeating or dropping a message. - @Test("A merged page never repeats a (partition, offset) pair") - func coordinatesStayUnique() { - let merged = KafkaRecordOrdering.merge((0 ..< 30).map { index in - record(partition: Int32(index % 3), offset: Int64(index / 3), timestamp: Int64(index % 7)) - }) - var seen = Set() - for item in merged { - let key = "\(item.partition):\(item.offset)" - #expect(seen.contains(key) == false) - seen.insert(key) - } - #expect(seen.count == 30) - } -} diff --git a/TableProTests/Plugins/KafkaProtocolCodecTests.swift b/TableProTests/Plugins/KafkaProtocolCodecTests.swift deleted file mode 100644 index 85d12d54b9..0000000000 --- a/TableProTests/Plugins/KafkaProtocolCodecTests.swift +++ /dev/null @@ -1,274 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -/// Golden-byte tests for the Kafka wire codec. -/// -/// These exist because of how Kafka fails. A malformed request does not come back as an error: -/// the broker consumes what it can and closes the socket, so the only symptom is a dropped -/// connection with no diagnostic. Every expectation below was measured against a live Apache -/// Kafka 4.3.1 broker before it was written down. -struct KafkaProtocolCodecTests { - // MARK: - Varints - - @Test("Unsigned varints round-trip, and match the LEB128 boundary cases") - func unsignedVarintRoundTrip() throws { - let cases: [UInt32] = [0, 1, 127, 128, 300, 16_383, 16_384, 2_097_151, UInt32.max] - for value in cases { - var writer = KafkaProtocolWriter() - writer.unsignedVarint(value) - var reader = KafkaProtocolReader(writer.data) - #expect(try reader.unsignedVarint() == value) - } - - var writer = KafkaProtocolWriter() - writer.unsignedVarint(300) - #expect([UInt8](writer.data) == [0xAC, 0x02]) - } - - /// The records inside a batch use zig-zag signed varints while the protocol frame uses - /// unsigned ones. They are indistinguishable on the wire, so mixing them decodes small - /// values plausibly and corrupts large ones. - @Test("Zig-zag varints round-trip through negative values") - func zigZagVarintRoundTrip() throws { - let cases: [Int32] = [0, -1, 1, -2, 2, 63, -64, 1_000_000, -1_000_000, Int32.min, Int32.max] - for value in cases { - var writer = KafkaProtocolWriter() - writer.varint(value) - var reader = KafkaProtocolReader(writer.data) - #expect(try reader.varint() == value) - } - - var writer = KafkaProtocolWriter() - writer.varint(-1) - #expect([UInt8](writer.data) == [0x01]) - } - - @Test("Zig-zag varlongs round-trip through 64-bit extremes") - func zigZagVarlongRoundTrip() throws { - let cases: [Int64] = [0, -1, 1, Int64.min, Int64.max, 1_787_709_624_571] - for value in cases { - var writer = KafkaProtocolWriter() - writer.varlong(value) - var reader = KafkaProtocolReader(writer.data) - #expect(try reader.varlong() == value) - } - } - - // MARK: - Compact encoding - - /// A compact null array is uvarint 0 and an empty one is uvarint 1. The legacy `0xff` - /// (-1 as an int32) does not apply, and writing it sets the varint continuation bit, so - /// the broker keeps consuming the bytes that follow and then hangs up. - @Test("A compact null array is 0x00 and an empty compact array is 0x01") - func compactNullVersusEmptyArray() throws { - var nullWriter = KafkaProtocolWriter() - nullWriter.compactArrayCount(nil) - #expect([UInt8](nullWriter.data) == [0x00]) - - var emptyWriter = KafkaProtocolWriter() - emptyWriter.compactArrayCount(0) - #expect([UInt8](emptyWriter.data) == [0x01]) - - var reader = KafkaProtocolReader(Data([0x00])) - #expect(try reader.compactArrayCount() == nil) - var emptyReader = KafkaProtocolReader(Data([0x01])) - #expect(try emptyReader.compactArrayCount() == 0) - } - - @Test("A compact string is length+1, and null is distinct from empty") - func compactStringEncoding() throws { - var writer = KafkaProtocolWriter() - writer.compactString("tp-demo") - #expect([UInt8](writer.data) == [0x08] + Array("tp-demo".utf8)) - - var nullWriter = KafkaProtocolWriter() - nullWriter.nullableCompactString(nil) - #expect([UInt8](nullWriter.data) == [0x00]) - - var emptyWriter = KafkaProtocolWriter() - emptyWriter.nullableCompactString("") - #expect([UInt8](emptyWriter.data) == [0x01]) - - var reader = KafkaProtocolReader(Data([0x00])) - #expect(try reader.nullableCompactString() == nil) - var emptyReader = KafkaProtocolReader(Data([0x01])) - #expect(try emptyReader.nullableCompactString() == "") - } - - @Test("A legacy null string is -1 and round-trips distinctly from empty") - func legacyStringEncoding() throws { - var writer = KafkaProtocolWriter() - writer.nullableLegacyString(nil) - #expect([UInt8](writer.data) == [0xFF, 0xFF]) - - var reader = KafkaProtocolReader(writer.data) - #expect(try reader.nullableLegacyString() == nil) - - var emptyWriter = KafkaProtocolWriter() - emptyWriter.nullableLegacyString("") - #expect([UInt8](emptyWriter.data) == [0x00, 0x00]) - } - - @Test("Unknown tagged fields are skipped by their declared size") - func taggedFieldsSkipUnknownTags() throws { - // One field: tag 7, three bytes of payload, then an int16 the caller expects next. - let bytes: [UInt8] = [0x01, 0x07, 0x03, 0xAA, 0xBB, 0xCC, 0x00, 0x2A] - var reader = KafkaProtocolReader(Data(bytes)) - try reader.taggedFields() - #expect(try reader.int16() == 42) - } - - @Test("A truncated buffer reports the shortfall instead of trapping") - func truncationIsReported() { - var reader = KafkaProtocolReader(Data([0x00, 0x01])) - #expect(throws: KafkaError.self) { _ = try reader.int64() } - } - - // MARK: - Request framing - - /// `client_id` is declared `flexibleVersions: none`, so it stays a legacy int16-length - /// string even inside a flexible header, and the empty tagged-field buffer that closes a - /// flexible header is mandatory. - @Test("A flexible request header keeps client_id legacy and closes with a tag buffer") - func flexibleRequestHeaderShape() throws { - let request = KafkaRequest(api: .metadata, version: 12) { writer, _ in - writer.compactArrayCount(nil) - } - let framed = request.framed(correlationId: 7, clientId: "tp") - var reader = KafkaProtocolReader(framed) - - #expect(try reader.int32() == Int32(framed.count - 4)) - #expect(try reader.int16() == KafkaApiKey.metadata.rawValue) - #expect(try reader.int16() == 12) - #expect(try reader.int32() == 7) - #expect(try reader.legacyString() == "tp") - #expect(try reader.uint8() == 0x00) - #expect(try reader.uint8() == 0x00) - } - - @Test("A non-flexible request header carries no tag buffer") - func legacyRequestHeaderShape() throws { - let request = KafkaRequest(api: .saslHandshake, version: 1) { writer, _ in - writer.legacyString("PLAIN") - } - let framed = request.framed(correlationId: 3, clientId: "tp") - var reader = KafkaProtocolReader(framed) - _ = try reader.int32() - _ = try reader.int16() - _ = try reader.int16() - _ = try reader.int32() - _ = try reader.legacyString() - #expect(try reader.legacyString() == "PLAIN") - } - - /// ApiVersions is a documented special case: its response always uses a v0 header with no - /// tagged fields, even at v3, because the client cannot know the version yet. Reading a - /// tag buffer here consumes the first byte of the error code, which surfaces as a - /// plausible but wrong error rather than as a parse failure. Measured: a real broker's - /// reply parsed as error 76 with this wrong, and error 0 with it right. - @Test("The ApiVersions response header is never flexible") - func apiVersionsResponseHeaderIsNeverFlexible() throws { - var payload = KafkaProtocolWriter() - payload.int32(1) // correlation id - payload.int16(0) // error code, immediately after: no tag buffer - payload.unsignedVarint(1) // an empty compact array of api keys - - let (correlationId, body) = try KafkaResponseHeader.strip(payload.data, api: .apiVersions, version: 3) - #expect(correlationId == 1) - var reader = body - #expect(try reader.int16() == 0) - } - - @Test("Every other flexible response strips a tag buffer") - func flexibleResponseHeaderIsStripped() throws { - var payload = KafkaProtocolWriter() - payload.int32(9) - payload.emptyTaggedFields() - payload.int32(0) // throttle time - - let (correlationId, body) = try KafkaResponseHeader.strip(payload.data, api: .metadata, version: 12) - #expect(correlationId == 9) - var reader = body - #expect(try reader.int32() == 0) - } - - // MARK: - Version negotiation - - @Test("Negotiation picks the highest version both sides implement") - func negotiationTakesTheOverlap() throws { - // The ranges a real Kafka 4.3.1 broker advertised. - let table = KafkaApiVersionTable(ranges: [ - KafkaApiKey.fetch.rawValue: 4 ... 18, - KafkaApiKey.metadata.rawValue: 0 ... 13, - KafkaApiKey.listOffsets.rawValue: 1 ... 11 - ]) - // Capped at what this client implements, because v13 swaps topic names for UUIDs. - #expect(try table.negotiated(.fetch) == 12) - #expect(try table.negotiated(.metadata) == 12) - #expect(try table.negotiated(.listOffsets) == 7) - } - - @Test("A broker that caps below our floor is reported, not guessed at") - func negotiationRejectsAnIncompatibleBroker() { - let table = KafkaApiVersionTable(ranges: [KafkaApiKey.fetch.rawValue: 0 ... 2]) - #expect(throws: KafkaError.self) { _ = try table.negotiated(.fetch) } - } - - @Test("An API the broker never mentions is unsupported rather than assumed") - func negotiationRejectsAnAbsentApi() { - let table = KafkaApiVersionTable(ranges: [KafkaApiKey.metadata.rawValue: 0 ... 12]) - #expect(throws: KafkaError.self) { _ = try table.negotiated(.deleteRecords) } - } - - /// SaslHandshake is `flexibleVersions: none` at both v0 and v1, unlike the APIs either - /// side of it. Encoding it compactly is a silent authentication failure. - @Test("SaslHandshake is never flexible; SaslAuthenticate becomes flexible at v2") - func saslFlexibilityBoundaries() { - #expect(KafkaApiKey.saslHandshake.isFlexible(version: 0) == false) - #expect(KafkaApiKey.saslHandshake.isFlexible(version: 1) == false) - #expect(KafkaApiKey.saslAuthenticate.isFlexible(version: 1) == false) - #expect(KafkaApiKey.saslAuthenticate.isFlexible(version: 2)) - #expect(KafkaApiKey.fetch.isFlexible(version: 11) == false) - #expect(KafkaApiKey.fetch.isFlexible(version: 12)) - } - - // MARK: - CRC-32C - - /// The broker rejects a produced batch whose CRC does not match, and macOS has no - /// Castagnoli routine to check this against. These are the published test vectors. - @Test("CRC-32C matches the published Castagnoli vectors") - func crc32cVectors() { - #expect(KafkaCRC32C.checksum([UInt8]()) == 0x0000_0000) - #expect(KafkaCRC32C.checksum(Array("123456789".utf8)) == 0xE306_9283) - #expect(KafkaCRC32C.checksum([UInt8](repeating: 0x00, count: 32)) == 0x8A91_36AA) - #expect(KafkaCRC32C.checksum([UInt8](repeating: 0xFF, count: 32)) == 0x62A8_AB43) - } - - // MARK: - Partitioner - - /// Kafka's default partitioner is murmur2 with seed 0x9747b28c, sign bit masked. Matching - /// it is what keeps a message produced here with the rest of its key's messages. - @Test("murmur2 matches the values Kafka's own client computes") - func murmur2MatchesKafka() { - #expect(KafkaPartitioner.murmur2(Data()) == 0x0000_0000 || KafkaPartitioner.murmur2(Data()) != 0) - // Same key, same partition, every time and independent of partition count ordering. - let key = Data("order-1".utf8) - let first = KafkaPartitioner.partition(forKey: key, count: 3) - #expect(first == KafkaPartitioner.partition(forKey: key, count: 3)) - #expect((0 ..< 3).contains(Int(first))) - } - - @Test("The partitioner spreads keys and never returns an out-of-range partition") - func partitionerStaysInRange() { - var seen = Set() - for index in 0 ..< 200 { - let partition = KafkaPartitioner.partition(forKey: Data("key-\(index)".utf8), count: 5) - #expect((0 ..< 5).contains(Int(partition))) - seen.insert(partition) - } - #expect(seen.count > 1) - } -} diff --git a/TableProTests/Plugins/KafkaQLTests.swift b/TableProTests/Plugins/KafkaQLTests.swift deleted file mode 100644 index 0bbbc6de34..0000000000 --- a/TableProTests/Plugins/KafkaQLTests.swift +++ /dev/null @@ -1,248 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct KafkaQLTests { - private func consume(_ input: String) throws -> KafkaConsumeQuery { - guard case .consume(let query) = try KafkaQL.parse(input) else { - throw KafkaError.syntax("expected a CONSUME statement") - } - return query - } - - private func produce(_ input: String) throws -> KafkaProduceQuery { - guard case .produce(let query) = try KafkaQL.parse(input) else { - throw KafkaError.syntax("expected a PRODUCE statement") - } - return query - } - - @Test("CONSUME defaults to the newest hundred messages") - func consumeDefaults() throws { - let query = try consume("CONSUME \"orders\"") - #expect(query.topic == "orders") - #expect(query.start == .newest) - #expect(query.limit == 100) - #expect(query.skip == 0) - #expect(query.partitions == nil) - } - - @Test("Every start mode parses to its own anchor") - func startModes() throws { - #expect(try consume("CONSUME t FROM NEWEST").start == .newest) - #expect(try consume("CONSUME t FROM OLDEST").start == .oldest) - #expect(try consume("CONSUME t FROM EARLIEST").start == .oldest) - #expect(try consume("CONSUME t FROM LATEST").start == .newest) - #expect(try consume("CONSUME t FROM OFFSET 42").start == .offset(42)) - #expect(try consume("CONSUME t FROM TIME 1787709624571").start == .timestamp(1_787_709_624_571)) - } - - @Test("An ISO 8601 instant resolves to milliseconds since the epoch") - func isoTimestamp() throws { - let query = try consume("CONSUME t FROM TIME \"2026-01-01T00:00:00Z\"") - guard case .timestamp(let milliseconds) = query.start else { - Issue.record("expected a timestamp start") - return - } - #expect(milliseconds == 1_767_225_600_000) - } - - /// The anchor is what makes paging stable: page two reads the offsets page one resolved - /// rather than re-deriving "newest" against a tail that has moved on. - @Test("A resolved anchor round-trips through the statement text") - func anchorRoundTrip() throws { - let query = try consume("CONSUME \"orders\" FROM ANCHOR (0:120,1:80,2:0) LIMIT 50") - #expect(query.start == .resolved([0: 120, 1: 80, 2: 0])) - #expect(query.limit == 50) - } - - @Test("LIMIT, SKIP and PARTITION parse together in any order") - func modifiers() throws { - let query = try consume("CONSUME \"orders\" PARTITION (0,2) FROM OLDEST LIMIT 25 SKIP 50") - #expect(query.partitions == [0, 2]) - #expect(query.limit == 25) - #expect(query.skip == 50) - #expect(query.start == .oldest) - } - - @Test("SELECT * FROM is accepted as sugar for CONSUME") - func selectSugar() throws { - let query = try consume("SELECT * FROM \"orders\" LIMIT 10") - #expect(query.topic == "orders") - #expect(query.limit == 10) - } - - @Test("A topic name survives every quoting style, and an inner space") - func quoting() throws { - #expect(try consume("CONSUME \"my topic\"").topic == "my topic") - #expect(try consume("CONSUME 'my topic'").topic == "my topic") - #expect(try consume("CONSUME `my topic`").topic == "my topic") - #expect(try consume("CONSUME plain").topic == "plain") - } - - @Test("PRODUCE parses a key, a value, a partition and headers") - func produceParsing() throws { - let query = try produce("PRODUCE INTO \"orders\" KEY \"k1\" VALUE \"{}\" PARTITION 2 HEADER \"h\" \"v\"") - #expect(query.topic == "orders") - #expect(query.key == "k1") - #expect(query.value == "{}") - #expect(query.partition == 2) - #expect(query.headers.count == 1) - #expect(query.headers[0].key == "h") - #expect(query.headers[0].value == Data("v".utf8)) - } - - @Test("PRODUCE with no partition leaves the choice to the partitioner") - func produceWithoutPartition() throws { - let query = try produce("PRODUCE INTO orders VALUE \"hello\"") - #expect(query.partition == nil) - #expect(query.key == nil) - #expect(query.value == "hello") - } - - @Test("SHOW and DESCRIBE parse to their own statements") - func showAndDescribe() throws { - #expect(isShowTopics(try KafkaQL.parse("SHOW TOPICS"))) - #expect(isShowGroups(try KafkaQL.parse("SHOW GROUPS"))) - guard case .describeGroup(let group) = try KafkaQL.parse("DESCRIBE GROUP \"order-processor\"") else { - Issue.record("expected DESCRIBE GROUP") - return - } - #expect(group == "order-processor") - guard case .describeTopic(let topic) = try KafkaQL.parse("DESCRIBE TOPIC orders") else { - Issue.record("expected DESCRIBE TOPIC") - return - } - #expect(topic == "orders") - } - - @Test("A trailing semicolon and surrounding whitespace are tolerated") - func trailingSemicolon() throws { - #expect(try consume(" CONSUME \"orders\" LIMIT 5 ; ").limit == 5) - } - - /// An unbounded SKIP is not a rude input, it is a crash: a negative one traps in - /// dropFirst and a huge one overflows the skip+limit addition in the browse engine. - @Test("SKIP and LIMIT are bounded rather than trusted") - func skipAndLimitAreBounded() throws { - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("CONSUME t SKIP -1") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("CONSUME t SKIP 9223372036854775807") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("CONSUME t LIMIT 9223372036854775807") } - #expect(try consume("CONSUME t SKIP 0").skip == 0) - #expect(try consume("CONSUME t SKIP \(KafkaQL.maximumLimit)").skip == KafkaQL.maximumLimit) - } - - /// `quote` writes a backslash escape and the tokenizer keeps it so the delimiters can be - /// found, so `unquote` has to take it back off. Otherwise a produced value carries the - /// backslashes onto the topic. - @Test("A quoted value round-trips through quote and unquote") - func quotingRoundTrips() throws { - let values = [ - "plain", "with space", "say \"hi\"", "a,b", "trailing\\", - "C:\\temp\\app.log", "\\\\server\\\\share", "a\\\"b", "\\", "\\\\" - ] - for value in values { - #expect(KafkaQL.unquote(KafkaQL.quote(value)) == value, "round trip of \(value)") - } - let produced = try produce("PRODUCE INTO t VALUE \"say \\\"hi\\\"\"") - #expect(produced.value == "say \"hi\"") - } - - /// A value carrying a Windows path used to arrive on the topic with its separators gone, - /// because `quote` escaped the delimiter but not the escape character the tokenizer honours. - @Test("A value full of backslashes reaches the topic intact") - func backslashesSurviveTheParser() throws { - let statement = "PRODUCE INTO \"logs\" VALUE \(KafkaQL.quote("C:\\temp\\app.log"))" - #expect(try produce(statement).value == "C:\\temp\\app.log") - } - - /// The same gap let a value close its own quote and have the rest of itself parsed as - /// further clauses, so a string chose the partition it was written to. - @Test("A value cannot escape its quotes and inject a clause") - func aValueCannotInjectAClause() throws { - let hostile = "a\\\" PARTITION 3 VALUE \"b" - let query = try produce("PRODUCE INTO \"orders\" VALUE \(KafkaQL.quote(hostile))") - #expect(query.value == hostile) - #expect(query.partition == nil) - } - - @Test("Bad input is reported with a message rather than silently ignored") - func syntaxErrors() { - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("DROP TABLE orders") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("CONSUME") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("CONSUME t LIMIT abc") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("CONSUME t LIMIT 0") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("CONSUME t FROM SIDEWAYS") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("PRODUCE INTO t") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("CONSUME \"unterminated") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("SHOW EVERYTHING") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("DROP") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("DROP TOPIC") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("DROP TOPIC a b") } - } - - /// Every one of these used to parse. A token the grammar has no use for is a typo or a - /// modifier the driver does not implement, and reading the statement without it reports - /// success for something other than what was asked. - @Test("A token the statement has no use for is refused, not discarded") - func trailingTokensAreRefused() { - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("SHOW TOPICS INTERNAL") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("SHOW BROKERS ALL") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("SHOW GROUPS STABLE") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("SHOW CLUSTER VERBOSE") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("DESCRIBE TOPIC \"orders\" \"payments\"") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("DESCRIBE GROUP \"a\" \"b\"") } - } - - /// A partition number that does not parse used to be dropped before anything checked it, so - /// one typo quietly halved the read and the grid reported a full success. - @Test("A partition list refuses what it cannot read as a number") - func partitionListRefusesNonNumbers() throws { - #expect(try consume("CONSUME t PARTITION (0,2)").partitions == [0, 2]) - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("CONSUME t PARTITION (0,two,2)") } - #expect(throws: KafkaError.self) { _ = try KafkaQL.parse("CONSUME t PARTITION (first)") } - } - - /// A tail scan pins the end it steps back from, so later pages of a NEWEST browse read the - /// window page one measured rather than re-deriving it against a moving tail. - @Test("FROM TAIL parses the exclusive end of each partition") - func parsesTailAnchors() throws { - let query = try consume("CONSUME \"orders\" FROM TAIL (0:400,1:250) LIMIT 10 SKIP 10") - guard case .tail(let ends) = query.start else { - Issue.record("expected a tail start mode") - return - } - #expect(ends == [0: 400, 1: 250]) - #expect(query.skip == 10) - } - - /// A topic delete is KafkaQL's own verb. `DROP TABLE` stays a syntax error above, because a - /// topic is not a table and that text is what the app used to invent for engines with no SQL. - @Test("DROP TOPIC names the topic to delete") - func parsesDropTopic() throws { - guard case .dropTopic(let topic) = try KafkaQL.parse("DROP TOPIC orders") else { - Issue.record("expected a dropTopic statement") - return - } - #expect(topic == "orders") - - guard case .dropTopic(let quoted) = try KafkaQL.parse("DROP TOPIC \"my topic\"") else { - Issue.record("expected a dropTopic statement") - return - } - #expect(quoted == "my topic") - } - - private func isShowTopics(_ statement: KafkaStatement) -> Bool { - if case .showTopics = statement { return true } - return false - } - - private func isShowGroups(_ statement: KafkaStatement) -> Bool { - if case .showGroups = statement { return true } - return false - } -} diff --git a/TableProTests/Plugins/KafkaRecordBatchTests.swift b/TableProTests/Plugins/KafkaRecordBatchTests.swift deleted file mode 100644 index ab422356eb..0000000000 --- a/TableProTests/Plugins/KafkaRecordBatchTests.swift +++ /dev/null @@ -1,281 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -/// Record batch decoding, against batches built the way a broker builds them. -struct KafkaRecordBatchTests { - /// Builds an uncompressed v2 batch the way `KafkaRecordBatchEncoder` does, but with an - /// arbitrary record count, so the decoder is exercised against multi-record batches. - private static func batch( - baseOffset: Int64, - baseTimestamp: Int64, - records: [(key: Data?, value: Data?, headers: [KafkaRecordHeader])], - attributes: Int16 = 0, - producerId: Int64 = -1 - ) -> Data { - var payload = KafkaProtocolWriter() - for (index, record) in records.enumerated() { - var inner = KafkaProtocolWriter() - inner.int8(0) - inner.varlong(Int64(index)) - inner.varint(Int32(index)) - if let key = record.key { - inner.varint(Int32(key.count)) - inner.raw(key) - } else { - inner.varint(-1) - } - if let value = record.value { - inner.varint(Int32(value.count)) - inner.raw(value) - } else { - inner.varint(-1) - } - inner.varint(Int32(record.headers.count)) - for header in record.headers { - let keyBytes = Data(header.key.utf8) - inner.varint(Int32(keyBytes.count)) - inner.raw(keyBytes) - if let value = header.value { - inner.varint(Int32(value.count)) - inner.raw(value) - } else { - inner.varint(-1) - } - } - payload.varint(Int32(inner.count)) - payload.raw(inner.bytes) - } - - var body = KafkaProtocolWriter() - body.int16(attributes) - body.int32(Int32(max(0, records.count - 1))) - body.int64(baseTimestamp) - body.int64(baseTimestamp) - body.int64(producerId) - body.int16(-1) - body.int32(-1) - body.int32(Int32(records.count)) - body.raw(payload.bytes) - - var afterLength = KafkaProtocolWriter() - afterLength.int32(-1) - afterLength.int8(2) - afterLength.uint32(KafkaCRC32C.checksum(body.bytes)) - afterLength.raw(body.bytes) - - var framed = KafkaProtocolWriter() - framed.int64(baseOffset) - framed.int32(Int32(afterLength.count)) - framed.raw(afterLength.bytes) - return framed.data - } - - @Test("Records decode with their offsets, timestamps, keys, values and headers") - func decodesAFullBatch() throws { - let blob = Self.batch( - baseOffset: 100, - baseTimestamp: 1_787_709_624_571, - records: [ - (Data("k1".utf8), Data("v1".utf8), []), - (Data("k2".utf8), Data("v2".utf8), [KafkaRecordHeader(key: "trace", value: Data("abc".utf8))]) - ] - ) - - let decoded = try KafkaRecordBatchDecoder.decode(blob: blob, partition: 3, skipAborted: true) - #expect(decoded.records.count == 2) - #expect(decoded.truncated == false) - - let first = decoded.records[0] - #expect(first.offset == 100) - #expect(first.partition == 3) - #expect(first.timestamp == 1_787_709_624_571) - #expect(first.key == Data("k1".utf8)) - #expect(first.value == Data("v1".utf8)) - - let second = decoded.records[1] - #expect(second.offset == 101) - #expect(second.timestamp == 1_787_709_624_572) - #expect(second.headers.count == 1) - #expect(second.headers[0].key == "trace") - #expect(second.headers[0].value == Data("abc".utf8)) - } - - /// A null value is a tombstone on a compacted topic and a zero-length value is an empty - /// message. Collapsing them loses the distinction the topic exists to express. - @Test("A null key or value stays null and does not become empty") - func nullIsNotEmpty() throws { - let blob = Self.batch( - baseOffset: 0, - baseTimestamp: 1_000, - records: [ - (nil, nil, []), - (Data(), Data(), []) - ] - ) - let decoded = try KafkaRecordBatchDecoder.decode(blob: blob, partition: 0, skipAborted: true) - #expect(decoded.records[0].key == nil) - #expect(decoded.records[0].value == nil) - #expect(decoded.records[1].key == Data()) - #expect(decoded.records[1].value == Data()) - } - - /// A partition's blob holds a SEQUENCE of batches, not one, and the broker truncates it at - /// the byte budget mid-batch. Treating the tail as corruption would drop a whole page. - @Test("Several batches in one blob all decode, in order") - func decodesConsecutiveBatches() throws { - var blob = Self.batch(baseOffset: 0, baseTimestamp: 1_000, records: [(nil, Data("a".utf8), [])]) - blob.append(Self.batch(baseOffset: 1, baseTimestamp: 2_000, records: [(nil, Data("b".utf8), [])])) - blob.append(Self.batch(baseOffset: 2, baseTimestamp: 3_000, records: [(nil, Data("c".utf8), [])])) - - let decoded = try KafkaRecordBatchDecoder.decode(blob: blob, partition: 0, skipAborted: true) - #expect(decoded.records.map(\.offset) == [0, 1, 2]) - #expect(decoded.records.compactMap { $0.value.flatMap { String(data: $0, encoding: .utf8) } } == ["a", "b", "c"]) - #expect(decoded.truncated == false) - } - - @Test("A partial trailing batch is dropped and reported as truncation") - func partialTrailingBatchIsDropped() throws { - var blob = Self.batch(baseOffset: 0, baseTimestamp: 1_000, records: [(nil, Data("a".utf8), [])]) - let second = Self.batch(baseOffset: 1, baseTimestamp: 2_000, records: [(nil, Data("b".utf8), [])]) - blob.append(second.prefix(second.count - 5)) - - let decoded = try KafkaRecordBatchDecoder.decode(blob: blob, partition: 0, skipAborted: true) - #expect(decoded.records.map(\.offset) == [0]) - #expect(decoded.truncated) - } - - /// Control batches carry a transaction's commit and abort markers. They are records, so a - /// decoder that does not know about attribute bit 5 puts marker rows in the user's grid. - /// A partition can carry several producers at once. Matching an aborted run on its offset - /// alone let one producer's commit marker end another producer's run, and the rolled-back - /// records then reached the grid. - @Test("An aborted run ends only for the producer that opened it") - func abortedRunsAreTrackedPerProducer() throws { - // Producer 7 opens an aborted transaction at offset 0. - var blob = Self.batch( - baseOffset: 0, - baseTimestamp: 1_000, - records: [(nil, Data("rolled-back".utf8), [])], - attributes: 0x10, - producerId: 7 - ) - // Producer 9 commits in the middle. This must not release producer 7's run. - blob.append(Self.batch( - baseOffset: 1, - baseTimestamp: 1_100, - records: [(Data([0, 0, 0, 0]), Data([0, 0]), [])], - attributes: 0x20, - producerId: 9 - )) - // Still producer 7, still inside the aborted run. - blob.append(Self.batch( - baseOffset: 2, - baseTimestamp: 1_200, - records: [(nil, Data("also-rolled-back".utf8), [])], - attributes: 0x10, - producerId: 7 - )) - - let decoded = try KafkaRecordBatchDecoder.decode( - blob: blob, - partition: 0, - abortedTransactions: [KafkaAbortedTransaction(producerId: 7, firstOffset: 0)], - skipAborted: true - ) - #expect(decoded.records.isEmpty) - } - - @Test("A batch claiming a length shorter than its own header is dropped, not decoded") - func impossibleBatchLengthIsDropped() throws { - var writer = KafkaProtocolWriter() - writer.int64(0) - writer.int32(0) // a length that ends before the header - writer.raw([UInt8](repeating: 0, count: 80)) - let decoded = try KafkaRecordBatchDecoder.decode(blob: writer.data, partition: 0, skipAborted: true) - #expect(decoded.records.isEmpty) - #expect(decoded.truncated) - } - - @Test("Control batches never produce rows") - func controlBatchesAreSkipped() throws { - var blob = Self.batch(baseOffset: 0, baseTimestamp: 1_000, records: [(nil, Data("real".utf8), [])]) - blob.append(Self.batch( - baseOffset: 1, - baseTimestamp: 2_000, - records: [(Data([0, 0, 0, 0]), Data([0, 0]), [])], - attributes: 0x20, - producerId: 42 - )) - - let decoded = try KafkaRecordBatchDecoder.decode(blob: blob, partition: 0, skipAborted: true) - #expect(decoded.records.count == 1) - #expect(decoded.records[0].value == Data("real".utf8)) - } - - @Test("An unknown magic byte is reported rather than guessed at") - func rejectsLegacyMagic() { - var writer = KafkaProtocolWriter() - writer.int64(0) - writer.int32(49) - writer.int32(-1) - writer.int8(1) // v1 message set, not supported - writer.raw([UInt8](repeating: 0, count: 60)) - - #expect(throws: KafkaError.self) { - _ = try KafkaRecordBatchDecoder.decode(blob: writer.data, partition: 0, skipAborted: true) - } - } - - @Test("An empty blob decodes to nothing rather than failing") - func emptyBlobIsNotAnError() throws { - let decoded = try KafkaRecordBatchDecoder.decode(blob: Data(), partition: 0, skipAborted: true) - #expect(decoded.records.isEmpty) - #expect(decoded.truncated == false) - } - - /// The produced batch is the one path where the CRC matters: a broker rejects a mismatch - /// with CORRUPT_MESSAGE, so the encoder has to agree with the decoder byte for byte. - @Test("An encoded single-record batch round-trips and carries a valid CRC") - func encodedBatchRoundTrips() throws { - let encoded = KafkaRecordBatchEncoder.singleRecordBatch( - key: Data("order-1".utf8), - value: Data("{\"id\":1}".utf8), - headers: [KafkaRecordHeader(key: "source", value: Data("tablepro".utf8))], - timestamp: 1_787_709_624_571 - ) - - var reader = KafkaProtocolReader(encoded) - _ = try reader.int64() - let length = Int(try reader.int32()) - _ = try reader.int32() - _ = try reader.int8() - let storedCRC = try reader.uint32() - let bodyStart = reader.offset - let bodyLength = length - (bodyStart - 12) - let body = [UInt8](encoded)[bodyStart ..< bodyStart + bodyLength] - #expect(KafkaCRC32C.checksum(body) == storedCRC) - - let decoded = try KafkaRecordBatchDecoder.decode(blob: encoded, partition: 0, skipAborted: true) - #expect(decoded.records.count == 1) - #expect(decoded.records[0].key == Data("order-1".utf8)) - #expect(decoded.records[0].value == Data("{\"id\":1}".utf8)) - #expect(decoded.records[0].headers.first?.key == "source") - #expect(decoded.records[0].timestamp == 1_787_709_624_571) - } - - @Test("A produced tombstone keeps its null value through the round trip") - func encodedTombstoneRoundTrips() throws { - let encoded = KafkaRecordBatchEncoder.singleRecordBatch( - key: Data("gone".utf8), - value: nil, - headers: [], - timestamp: 1_000 - ) - let decoded = try KafkaRecordBatchDecoder.decode(blob: encoded, partition: 0, skipAborted: true) - #expect(decoded.records[0].value == nil) - #expect(decoded.records[0].key == Data("gone".utf8)) - } -} diff --git a/TableProTests/Plugins/KafkaRoutingTests.swift b/TableProTests/Plugins/KafkaRoutingTests.swift deleted file mode 100644 index b46270bcef..0000000000 --- a/TableProTests/Plugins/KafkaRoutingTests.swift +++ /dev/null @@ -1,389 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -/// The parts of Kafka request routing that can be decided without a broker. -/// -/// Everything here failed silently on a multi-broker cluster and could not fail at all on a -/// single-broker one, which is why issue #2993 survived a full integration suite: with one -/// broker every partition's leader and every group's coordinator is the broker the client is -/// already holding, so a driver that routes nothing is indistinguishable from a correct one. -struct KafkaRoutingTests { - // MARK: - Error code classification - - /// The constants are a hand transcription of Kafka's own table and nothing at runtime checks - /// them, so the two that were transposed are pinned by number. - @Test("72 is LISTENER_NOT_FOUND and 73 is TOPIC_DELETION_DISABLED") - func listenerAndDeletionCodesAreNotTransposed() { - #expect(KafkaErrorCode.listenerNotFound == 72) - #expect(KafkaErrorCode.topicDeletionDisabled == 73) - } - - @Test("A code that says the cluster moved asks for a fresh leader") - func staleMetadataCodesResolveTheLeaderAgain() { - let stale: [Int16] = [ - KafkaErrorCode.unknownTopicOrPartition, - KafkaErrorCode.leaderNotAvailable, - KafkaErrorCode.notLeaderOrFollower, - KafkaErrorCode.replicaNotAvailable, - KafkaErrorCode.networkException, - KafkaErrorCode.kafkaStorageError, - KafkaErrorCode.listenerNotFound, - KafkaErrorCode.fencedLeaderEpoch, - KafkaErrorCode.unknownLeaderEpoch, - KafkaErrorCode.unknownTopicId - ] - for code in stale { - #expect(KafkaErrorCode.retryAction(for: code) == .resolveLeaderAgain, "code \(code)") - } - } - - @Test("A coordinator code asks for a fresh coordinator, not a fresh leader") - func coordinatorCodesFindTheCoordinatorAgain() { - let coordinator: [Int16] = [ - KafkaErrorCode.coordinatorLoadInProgress, - KafkaErrorCode.coordinatorNotAvailable, - KafkaErrorCode.notCoordinator - ] - for code in coordinator { - #expect(KafkaErrorCode.retryAction(for: code) == .findCoordinatorAgain, "code \(code)") - } - } - - /// A broker that is genuinely down is not a stale-metadata problem, and retrying it hides - /// the outage behind a slower failure. Kafka does not classify it retriable either. - @Test("A broker that is down and a real answer are both reported, not retried") - func nonRetriableCodesAreReported() { - #expect(KafkaErrorCode.retryAction(for: KafkaErrorCode.brokerNotAvailable) == .report) - #expect(KafkaErrorCode.retryAction(for: KafkaErrorCode.offsetOutOfRange) == .report) - #expect(KafkaErrorCode.retryAction(for: KafkaErrorCode.topicAuthorizationFailed) == .report) - #expect(KafkaErrorCode.retryAction(for: KafkaErrorCode.none) == .report) - } - - @Test("A transient code is retried where it is, without re-reading metadata") - func transientCodesRetryTheSameBroker() { - #expect(KafkaErrorCode.retryAction(for: KafkaErrorCode.requestTimedOut) == .retrySameBroker) - #expect(KafkaErrorCode.retryAction(for: KafkaErrorCode.offsetNotAvailable) == .retrySameBroker) - } - - // MARK: - Repeating a request - - /// A read can always be sent again. The retry exists for it. - @Test("A read is retried on every code that says the cluster moved") - func aReadRetriesEveryMovedCode() { - for code in [KafkaErrorCode.notLeaderOrFollower, KafkaErrorCode.leaderNotAvailable, - KafkaErrorCode.requestTimedOut, KafkaErrorCode.networkException, - KafkaErrorCode.kafkaStorageError, KafkaErrorCode.offsetNotAvailable] { - #expect(KafkaRequestRepeatability.safeToRepeat.allowsRetry(after: code), "code \(code)") - } - } - - /// A Produce asks for acks = -1 and sends no producer id, so REQUEST_TIMED_OUT can mean the - /// leader appended the record and its replicas were late acknowledging it. Sending it again - /// writes the message twice, with nothing on the cluster able to tell that it was one - /// message. - @Test("A write is not repeated on a code that leaves the outcome unknown") - func aWriteIsNotRepeatedWhenTheOutcomeIsUnknown() { - let ambiguous = [ - KafkaErrorCode.requestTimedOut, - KafkaErrorCode.networkException, - KafkaErrorCode.kafkaStorageError, - KafkaErrorCode.offsetNotAvailable - ] - for code in ambiguous { - #expect(!KafkaRequestRepeatability.onlyWhenBrokerRefusedIt.allowsRetry(after: code), "code \(code)") - #expect(!KafkaErrorCode.provesRequestWasNotApplied(code), "code \(code)") - } - } - - /// The codes that mean the broker turned the request away are safe for a write, and they - /// are the ones that carry the reported bug. - @Test("A write is repeated when the broker refused it outright") - func aWriteIsRepeatedWhenRefused() { - let refused = [ - KafkaErrorCode.notLeaderOrFollower, - KafkaErrorCode.leaderNotAvailable, - KafkaErrorCode.unknownTopicOrPartition, - KafkaErrorCode.replicaNotAvailable, - KafkaErrorCode.listenerNotFound, - KafkaErrorCode.fencedLeaderEpoch, - KafkaErrorCode.unknownLeaderEpoch, - KafkaErrorCode.unknownTopicId - ] - for code in refused { - #expect(KafkaRequestRepeatability.onlyWhenBrokerRefusedIt.allowsRetry(after: code), "code \(code)") - } - } - - @Test("Neither kind repeats a real answer") - func neitherKindRepeatsARealAnswer() { - for code in [KafkaErrorCode.offsetOutOfRange, KafkaErrorCode.topicAuthorizationFailed, - KafkaErrorCode.brokerNotAvailable] { - #expect(!KafkaRequestRepeatability.safeToRepeat.allowsRetry(after: code), "code \(code)") - #expect(!KafkaRequestRepeatability.onlyWhenBrokerRefusedIt.allowsRetry(after: code), "code \(code)") - } - } - - // MARK: - Collecting per-partition answers - - @Test("Every partition answering gives every partition's value") - func everyPartitionAnswers() throws { - let outcomes: [Int32: KafkaPartitionOutcome] = [ - 0: .value(10), 1: .value(20), 2: .value(30) - ] - let values = try KafkaPartitionOutcome.requireAll( - outcomes, - topic: "orders", - api: "ListOffsets", - routing: .advertised - ) - #expect(values == [0: 10, 1: 20, 2: 30]) - } - - /// The shape of #2993: a broker answers for the partitions it leads and rejects the rest in - /// the same reply. The rejection has to name the partitions rather than being reported as - /// though the whole request failed. - @Test("A rejected partition names itself in the error") - func aRejectedPartitionNamesItself() { - let outcomes: [Int32: KafkaPartitionOutcome] = [ - 0: .value(10), - 1: .rejected(code: KafkaErrorCode.notLeaderOrFollower), - 2: .rejected(code: KafkaErrorCode.notLeaderOrFollower) - ] - do { - _ = try KafkaPartitionOutcome.requireAll( - outcomes, - topic: "orders", - api: "ListOffsets", - routing: .advertised - ) - Issue.record("expected the rejected partitions to be reported") - } catch let error as KafkaError { - guard case .partitionsRejected(let topic, let partitions, let api, let code) = error else { - Issue.record("expected partitionsRejected, got \(error)") - return - } - #expect(topic == "orders") - #expect(partitions.sorted() == [1, 2]) - #expect(api == "ListOffsets") - #expect(code == KafkaErrorCode.notLeaderOrFollower) - } catch { - Issue.record("unexpected error \(error)") - } - } - - private func rejectionCode(_ outcomes: [Int32: KafkaPartitionOutcome]) -> Int16? { - do { - _ = try KafkaPartitionOutcome.requireAll( - outcomes, - topic: "orders", - api: "ListOffsets", - routing: .advertised - ) - Issue.record("expected a rejection") - return nil - } catch let error as KafkaError { - guard case .partitionsRejected(_, _, _, let code) = error else { - Issue.record("expected partitionsRejected, got \(error)") - return nil - } - return code - } catch { - Issue.record("unexpected error \(error)") - return nil - } - } - - /// Only one of several partitions' codes can be reported. Picking the numerically smallest - /// let OFFSET_OUT_OF_RANGE (1) mask TOPIC_AUTHORIZATION_FAILED (29), which is the one the - /// user has to act on. - @Test("A permission failure is named ahead of anything else") - func aPermissionFailureIsNamedFirst() { - #expect(rejectionCode([ - 0: .rejected(code: KafkaErrorCode.offsetOutOfRange), - 1: .rejected(code: KafkaErrorCode.topicAuthorizationFailed) - ]) == KafkaErrorCode.topicAuthorizationFailed) - - #expect(rejectionCode([ - 0: .rejected(code: KafkaErrorCode.notLeaderOrFollower), - 5: .rejected(code: KafkaErrorCode.groupAuthorizationFailed) - ]) == KafkaErrorCode.groupAuthorizationFailed) - } - - @Test("A real answer is named ahead of a code that only says the cluster is moving") - func aRealAnswerOutranksATransientOne() { - #expect(rejectionCode([ - 0: .rejected(code: KafkaErrorCode.leaderNotAvailable), - 1: .rejected(code: KafkaErrorCode.offsetOutOfRange) - ]) == KafkaErrorCode.offsetOutOfRange) - } - - /// Two codes of the same rank tie on the lowest partition, so the message does not change - /// between runs of the same broken query. - @Test("Equally useful codes are broken by partition, not by number") - func equalCodesTieOnTheLowestPartition() { - #expect(rejectionCode([ - 2: .rejected(code: KafkaErrorCode.offsetOutOfRange), - 1: .rejected(code: KafkaErrorCode.messageTooLarge) - ]) == KafkaErrorCode.messageTooLarge) - } - - /// Under bootstrap-only routing the same code means something the user can act on, so it is - /// reported as the setting rather than as Kafka's wording. - @Test("Bootstrap-only routing names the setting rather than the error code") - func bootstrapOnlyNamesTheSetting() { - let outcomes: [Int32: KafkaPartitionOutcome] = [ - 0: .value(10), - 3: .rejected(code: KafkaErrorCode.notLeaderOrFollower) - ] - do { - _ = try KafkaPartitionOutcome.requireAll( - outcomes, - topic: "orders", - api: "ListOffsets", - routing: .bootstrapOnly - ) - Issue.record("expected the partitions led elsewhere to be reported") - } catch let error as KafkaError { - guard case .partitionsLedElsewhere(let topic, let partitions) = error else { - Issue.record("expected partitionsLedElsewhere, got \(error)") - return - } - #expect(topic == "orders") - #expect(partitions == [3]) - } catch { - Issue.record("unexpected error \(error)") - } - } - - /// A partition with no answer used to become earliest 0 / latest 0, which reads as a real - /// empty partition: the sidebar under-counted and a tail scan restarted from the log's - /// beginning, with nothing raised. - @Test("A partition that could not be reached is an error, not a zero") - func anUnreachablePartitionIsNotZero() { - let outcomes: [Int32: KafkaPartitionOutcome] = [ - 0: .value(10), - 1: .failed(.brokerUnreachable(nodeId: 2, address: "kafka-2.internal:9092", reason: "timed out")) - ] - #expect(throws: KafkaError.self) { - _ = try KafkaPartitionOutcome.requireAll( - outcomes, - topic: "orders", - api: "ListOffsets", - routing: .advertised - ) - } - } - - /// Fetch and Produce report a partition's error code by throwing it, so the fan-out has to - /// record that as a rejection rather than a failure or those two lose their retry. - @Test("A thrown broker code is recorded as a rejection, so it still retries") - func aThrownBrokerCodeStaysRetriable() { - let thrown = KafkaError.broker(code: KafkaErrorCode.notLeaderOrFollower, api: "Fetch") - guard case .rejected(let code) = KafkaPartitionOutcome.outcome(for: thrown) else { - Issue.record("a thrown broker code must become a rejection") - return - } - #expect(code == KafkaErrorCode.notLeaderOrFollower) - #expect(KafkaErrorCode.retryAction(for: code) == .resolveLeaderAgain) - } - - @Test("A transport failure is recorded as a failure, and is not retried as a moved leader") - func aTransportFailureIsNotARejection() { - let thrown = KafkaError.brokerUnreachable(nodeId: 2, address: "kafka-2:9092", reason: "timed out") - guard case .failed = KafkaPartitionOutcome.outcome(for: thrown) else { - Issue.record("an unreachable broker must not be recorded as a broker rejection") - return - } - } - - // MARK: - Partition filters - - @Test("A partition filter that names every partition is kept") - func aValidPartitionFilterIsKept() throws { - let selected = try KafkaBrowseEngine.resolvePartitions([2, 0], available: [0, 1, 2], topic: "orders") - #expect(selected == [0, 2]) - } - - @Test("No filter reads every partition") - func noFilterReadsEverything() throws { - let selected = try KafkaBrowseEngine.resolvePartitions(nil, available: [0, 1, 2], topic: "orders") - #expect(selected == [0, 1, 2]) - } - - /// Filtering a partition the topic does not have used to return an empty page, which is - /// exactly what an empty topic returns. The topic name has refused to work that way since - /// the driver shipped and a partition number is no different. - @Test("A partition the topic does not have is reported, not dropped") - func anUnknownPartitionIsReported() { - #expect(throws: KafkaError.self) { - _ = try KafkaBrowseEngine.resolvePartitions([0, 99], available: [0, 1, 2], topic: "orders") - } - } - - // MARK: - Which end of the window is the page - - private func record(partition: Int32, offset: Int64) -> KafkaRecord { - KafkaRecord( - offset: offset, - timestamp: offset, - timestampIsLogAppendTime: false, - key: nil, - value: nil, - headers: [], - partition: partition - ) - } - - /// The defect that made `FROM NEWEST` a lie on any multi-partition topic. Each partition is - /// stepped back by the page size and read forward, so the merged run holds several pages and - /// its newest records are at the end. - @Test("A tail scan shows the newest records of its window, not the oldest") - func aTailScanTakesTheTail() { - let merged = KafkaRecordOrdering.merge((0 ..< 9).map { record(partition: Int32($0 % 3), offset: Int64($0)) }) - let page = KafkaRecordOrdering.page(merged, skip: 0, limit: 3, readsBackward: true) - #expect(page.map(\.offset) == [6, 7, 8]) - } - - @Test("A forward scan shows the oldest records of its window") - func aForwardScanTakesTheHead() { - let merged = KafkaRecordOrdering.merge((0 ..< 9).map { record(partition: Int32($0 % 3), offset: Int64($0)) }) - let page = KafkaRecordOrdering.page(merged, skip: 0, limit: 3, readsBackward: false) - #expect(page.map(\.offset) == [0, 1, 2]) - } - - /// Page two of a tail scan is the page before page one, so it steps further back from the - /// same end rather than further forward from the same start. - @Test("Paging a tail scan walks backwards") - func pagingATailScanWalksBackwards() { - let merged = KafkaRecordOrdering.merge((0 ..< 9).map { record(partition: Int32($0 % 3), offset: Int64($0)) }) - #expect(KafkaRecordOrdering.page(merged, skip: 3, limit: 3, readsBackward: true).map(\.offset) == [3, 4, 5]) - #expect(KafkaRecordOrdering.page(merged, skip: 6, limit: 3, readsBackward: true).map(\.offset) == [0, 1, 2]) - } - - @Test("Paging a forward scan walks forwards") - func pagingAForwardScanWalksForwards() { - let merged = KafkaRecordOrdering.merge((0 ..< 9).map { record(partition: Int32($0 % 3), offset: Int64($0)) }) - #expect(KafkaRecordOrdering.page(merged, skip: 3, limit: 3, readsBackward: false).map(\.offset) == [3, 4, 5]) - #expect(KafkaRecordOrdering.page(merged, skip: 6, limit: 3, readsBackward: false).map(\.offset) == [6, 7, 8]) - } - - /// With one partition the two directions select the same rows, which is why every existing - /// assertion passed while the multi-partition case was wrong. - @Test("One partition cannot tell the two directions apart") - func onePartitionHidesTheDirection() { - let merged = KafkaRecordOrdering.merge((0 ..< 5).map { record(partition: 0, offset: Int64($0)) }) - let backward = KafkaRecordOrdering.page(merged, skip: 0, limit: 5, readsBackward: true) - let forward = KafkaRecordOrdering.page(merged, skip: 0, limit: 5, readsBackward: false) - #expect(backward.map(\.offset) == forward.map(\.offset)) - } - - @Test("A page bigger than the window is the whole window") - func aPageBiggerThanTheWindowIsTheWindow() { - let merged = KafkaRecordOrdering.merge((0 ..< 3).map { record(partition: 0, offset: Int64($0)) }) - #expect(KafkaRecordOrdering.page(merged, skip: 0, limit: 10, readsBackward: true).count == 3) - #expect(KafkaRecordOrdering.page(merged, skip: 10, limit: 10, readsBackward: true).isEmpty) - } -} diff --git a/TableProTests/Plugins/KafkaSASLTests.swift b/TableProTests/Plugins/KafkaSASLTests.swift deleted file mode 100644 index b9861e2351..0000000000 --- a/TableProTests/Plugins/KafkaSASLTests.swift +++ /dev/null @@ -1,137 +0,0 @@ -import CryptoKit -import Foundation -import Testing - -@testable import TablePro - -/// SCRAM's cryptography and the two rules that decide whether a hostile broker can get past -/// it. The exchange itself needs a live connection, so what is pinned here is everything that -/// can be checked without one: the key derivation, and the guards that reject a broker. -struct KafkaSASLTests { - // MARK: - Key derivation - - /// RFC 7677 §5's worked example. If the derivation is wrong, every SCRAM login fails - /// against every broker, so this is the anchor for the whole mechanism. - @Test("SCRAM-SHA-256 derives RFC 7677's published salted password") - func rfc7677SaltedPassword() throws { - let salt = Data(base64Encoded: "W22ZaJ0SNY7soEsUEjb6gQ==") - let derived = try KafkaScram.saltedPassword( - password: "pencil", - salt: #require(salt), - iterations: 4_096, - hash: .sha256 - ) - #expect(derived.base64EncodedString() == "xKSVEDI6tPlSysH6mUQZOeeOp01r6B3fcJbodRPcYV0=") - } - - /// The client proof and the server signature come off that same salted password, and the - /// server signature is what proves the peer knew the password. - @Test("SCRAM-SHA-256 produces RFC 7677's client proof and server signature") - func rfc7677ProofAndSignature() throws { - let salt = try #require(Data(base64Encoded: "W22ZaJ0SNY7soEsUEjb6gQ==")) - let salted = try KafkaScram.saltedPassword( - password: "pencil", - salt: salt, - iterations: 4_096, - hash: .sha256 - ) - let authMessage = Data(( - "n=user,r=rOprNGfwEbeRWgbNEkqO," - + "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096," - + "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0" - ).utf8) - - let proof = KafkaScram.clientProof(saltedPassword: salted, authMessage: authMessage, hash: .sha256) - #expect(proof.base64EncodedString() == "dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=") - - let signature = KafkaScram.serverSignature(saltedPassword: salted, authMessage: authMessage, hash: .sha256) - #expect(signature.base64EncodedString() == "6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=") - } - - @Test("SHA-512 derives a 64-byte key, distinct from the SHA-256 one") - func sha512DerivesItsOwnKey() throws { - let salt = Data("salt".utf8) - let sha256 = try KafkaScram.saltedPassword(password: "pencil", salt: salt, iterations: 4_096, hash: .sha256) - let sha512 = try KafkaScram.saltedPassword(password: "pencil", salt: salt, iterations: 4_096, hash: .sha512) - #expect(sha256.count == 32) - #expect(sha512.count == 64) - } - - // MARK: - What a hostile broker is not allowed to do - - /// RFC 7677 §4 sets the floor at 4096, and Kafka's own default is 4096. A broker naming a - /// lower cost is choosing how cheaply an intercepted client proof can be brute-forced - /// offline against a salt it also chose. - @Test("An iteration count below 4096 is rejected") - func lowIterationCountsAreRejected() { - for iterations in [0, 1, 100, 4_095] { - #expect( - KafkaScram.isAcceptableIterationCount(iterations) == false, - "i=\(iterations) should be refused" - ) - } - #expect(KafkaScram.isAcceptableIterationCount(4_096)) - #expect(KafkaScram.isAcceptableIterationCount(100_000)) - #expect(KafkaScram.isAcceptableIterationCount(1_000_001) == false) - } - - /// A broker that does not echo the client's nonce is not answering this exchange, so - /// accepting it would let a recorded challenge be replayed. - @Test("A server nonce that does not extend the client's is rejected") - func serverNonceMustExtendTheClientNonce() { - #expect(KafkaScram.isValidServerNonce("abc123extra", clientNonce: "abc123")) - #expect(KafkaScram.isValidServerNonce("abc123", clientNonce: "abc123") == false) - #expect(KafkaScram.isValidServerNonce("xyzabc123", clientNonce: "abc123") == false) - #expect(KafkaScram.isValidServerNonce("", clientNonce: "abc123") == false) - } - - /// The nonce is the client's only contribution of freshness to the exchange. - @Test("Client nonces are unique and free of SCRAM's delimiters") - func clientNoncesAreUniqueAndSafe() { - var seen = Set() - for _ in 0 ..< 200 { - let nonce = KafkaScram.makeClientNonce() - #expect(nonce.count >= 16) - #expect(nonce.contains(",") == false) - #expect(nonce.contains("=") == false) - #expect(seen.contains(nonce) == false) - seen.insert(nonce) - } - } - - // MARK: - Escaping - - /// RFC 5802's `saslname` escaping belongs to the username in the `n=` attribute, whose - /// commas and equals signs would otherwise break SCRAM's framing. Applying it to the - /// password derives a different key than the broker computed, so any password containing - /// `,` or `=` would fail to authenticate. - @Test("Only the username is saslname-escaped") - func onlyTheUsernameIsEscaped() throws { - #expect(KafkaScram.escapedUsername("plain") == "plain") - #expect(KafkaScram.escapedUsername("a,b") == "a=2Cb") - #expect(KafkaScram.escapedUsername("a=b") == "a=3Db") - #expect(KafkaScram.escapedUsername("a=b,c") == "a=3Db=2Cc") - - // The password reaches PBKDF2 exactly as typed. - let salt = Data("salt".utf8) - let escaped = try KafkaScram.saltedPassword(password: "p=3Dw", salt: salt, iterations: 4_096, hash: .sha256) - let literal = try KafkaScram.saltedPassword(password: "p=w", salt: salt, iterations: 4_096, hash: .sha256) - #expect(escaped != literal) - } - - // MARK: - PLAIN - - /// SASL PLAIN is authzid NUL authcid NUL password, and the leading empty authzid is - /// required rather than optional. - @Test("A PLAIN token is NUL-separated with an empty authzid") - func plainTokenShape() { - let token = KafkaScram.plainAuthToken(username: "user", password: "pencil") - #expect([UInt8](token) == [0] + Array("user".utf8) + [0] + Array("pencil".utf8)) - } - - @Test("A PLAIN token carries a password containing separators unchanged") - func plainTokenDoesNotEscape() { - let token = KafkaScram.plainAuthToken(username: "u", password: "a,b=c") - #expect([UInt8](token) == [0] + Array("u".utf8) + [0] + Array("a,b=c".utf8)) - } -} diff --git a/TableProTests/Plugins/KafkaTLSConfigurationTests.swift b/TableProTests/Plugins/KafkaTLSConfigurationTests.swift deleted file mode 100644 index 8338c1c1fc..0000000000 --- a/TableProTests/Plugins/KafkaTLSConfigurationTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import NIOCore -import NIOPosix -import TableProPluginKit -import Testing - -struct KafkaTLSConfigurationTests { - private let refusedEndpoint = KafkaEndpoint(host: "127.0.0.1", port: 1) - - private func isRefusal(_ error: KafkaError?) -> Bool { - guard case .verifyCaNeedsCertificate? = error else { return false } - return true - } - - @Test("Verify CA with an empty or blank CA path is refused") - func verifyCaWithoutCertificateIsRefused() { - #expect(isRefusal(KafkaConnection.tlsConfigurationError(for: SSLConfiguration(mode: .verifyCa)))) - #expect(isRefusal(KafkaConnection.tlsConfigurationError( - for: SSLConfiguration(mode: .verifyCa, caCertificatePath: " ") - ))) - } - - @Test("Verify CA with a CA path, and every other mode, passes the check") - func otherConfigurationsPassTheCheck() { - #expect(KafkaConnection.tlsConfigurationError( - for: SSLConfiguration(mode: .verifyCa, caCertificatePath: "/certs/ca.pem") - ) == nil) - #expect(KafkaConnection.tlsConfigurationError(for: SSLConfiguration(mode: .verifyIdentity)) == nil) - #expect(KafkaConnection.tlsConfigurationError(for: SSLConfiguration(mode: .required)) == nil) - #expect(KafkaConnection.tlsConfigurationError(for: SSLConfiguration(mode: .preferred)) == nil) - #expect(KafkaConnection.tlsConfigurationError(for: SSLConfiguration(mode: .disabled)) == nil) - } - - @Test("The refusal names the missing CA and does not claim the cluster was unreachable") - func refusalMessageIsItsOwn() { - let message = KafkaError.verifyCaNeedsCertificate.errorDescription ?? "" - #expect(message.contains("CA certificate")) - #expect(!message.contains("Could not reach the Kafka cluster")) - } - - @Test("A cluster with Verify CA and no CA refuses before dialing any bootstrap server") - func clusterRefusesBeforeDialing() async { - let cluster = KafkaCluster( - bootstrap: [refusedEndpoint], - ssl: SSLConfiguration(mode: .verifyCa), - credentials: KafkaCredentials(mechanism: nil, username: "", password: ""), - routing: .advertised, - connectTimeout: KafkaConnectTimeout(milliseconds: 1_000) - ) - let error = await #expect(throws: KafkaError.self) { - try await cluster.connect() - } - #expect(isRefusal(error)) - } - - @Test("A broker connection with Verify CA and no CA refuses before opening a socket") - func connectionRefusesBeforeOpeningASocket() async { - let connection = KafkaConnection(endpoint: refusedEndpoint, clientId: "tablepro-tests") - let error = await #expect(throws: KafkaError.self) { - try await connection.open( - ssl: SSLConfiguration(mode: .verifyCa), - credentials: KafkaCredentials(mechanism: nil, username: "", password: ""), - group: NIOSingletons.posixEventLoopGroup, - timeout: .seconds(1) - ) - } - #expect(isRefusal(error)) - } - - @Test("Verify Identity with no CA still dials the broker") - func verifyIdentityWithoutCertificateStillDials() async { - let connection = KafkaConnection(endpoint: refusedEndpoint, clientId: "tablepro-tests") - let error = await #expect(throws: KafkaError.self) { - try await connection.open( - ssl: SSLConfiguration(mode: .verifyIdentity), - credentials: KafkaCredentials(mechanism: nil, username: "", password: ""), - group: NIOSingletons.posixEventLoopGroup, - timeout: .seconds(1) - ) - } - guard case .connectionFailed? = error else { - Issue.record("expected a dial failure, got \(String(describing: error))") - return - } - } -} diff --git a/TableProTests/Plugins/KafkaTestBroker.swift b/TableProTests/Plugins/KafkaTestBroker.swift deleted file mode 100644 index b299cf6041..0000000000 --- a/TableProTests/Plugins/KafkaTestBroker.swift +++ /dev/null @@ -1,225 +0,0 @@ -import Foundation -import TableProPluginKit - -@testable import TablePro - -/// Drives a real Kafka broker for `KafkaIntegrationTests`. -/// -/// Topic creation and consumer-group commits go through the broker's own CLI rather than -/// through the driver, so a test that checks the driver's reading is never satisfied by the -/// driver's own writing. `scripts/kafka-test-broker.sh up` starts the broker this expects. -enum KafkaTestBroker { - /// Overrides for a broker somewhere other than the default. - /// - /// These are read only if they arrive, and usually they do not: `xcodebuild` does not pass - /// the invoking shell's environment through to the test host, so exporting a variable - /// before `verify.sh test` reaches the runner as nothing. That is why the gate below is a - /// reachability probe rather than a variable check, which cost one run reporting zero - /// executed cases while a broker sat there answering. - static let bootstrapVariable = "TABLEPRO_KAFKA_TEST_BOOTSTRAP" - static let containerVariable = "TABLEPRO_KAFKA_TEST_CONTAINER" - - static let defaultHost = "127.0.0.1" - static let defaultPort = 19_092 - - /// True when a broker actually answers. Asking the socket rather than the environment is - /// what makes this work locally and skip on CI, where nothing is listening, without any - /// plumbing between the two. - static let isConfigured: Bool = canReachBroker() - - static var endpoint: (host: String, port: Int) { - guard let raw = ProcessInfo.processInfo.environment[bootstrapVariable], - case let parts = raw.split(separator: ":"), - parts.count == 2, - let port = Int(parts[1]) else { - return (defaultHost, defaultPort) - } - return (String(parts[0]), port) - } - - static var container: String { - ProcessInfo.processInfo.environment[containerVariable] ?? "tp-kafka-it" - } - - /// A plain TCP connect with a short timeout. Enough to know something is listening, and - /// fast enough that the skip costs nothing on a machine with no broker. - private static func canReachBroker() -> Bool { - let (host, port) = endpoint - let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0) - guard socketDescriptor >= 0 else { return false } - defer { close(socketDescriptor) } - - var timeout = timeval(tv_sec: 2, tv_usec: 0) - setsockopt(socketDescriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) - setsockopt(socketDescriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) - - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.size) - address.sin_family = sa_family_t(AF_INET) - address.sin_port = UInt16(port).bigEndian - guard inet_pton(AF_INET, host, &address.sin_addr) == 1 else { return false } - - let connected = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { raw in - connect(socketDescriptor, raw, socklen_t(MemoryLayout.size)) - } - } - return connected == 0 - } - - static func makeDriver(host: String, port: Int, timeoutSeconds: Int = 10) -> KafkaPluginDriver { - KafkaPluginDriver(config: DriverConnectionConfig( - host: host, - port: port, - username: "", - password: "", - database: "", - ssl: SSLConfiguration(), - additionalFields: [ - "kafkaSecurityProtocol": "PLAINTEXT", - "kafkaConnectTimeout": String(timeoutSeconds) - ] - )) - } - - /// A connected driver plus a topic of its own, torn down afterwards. - /// - /// Each test gets a uniquely suffixed topic. Sharing one would make the suite - /// order-dependent and make a failure impossible to attribute. - static func harness(topic: String, partitions: Int) async throws -> Harness { - let endpoint = self.endpoint - let unique = "\(topic)-\(UInt32.random(in: 0 ... UInt32.max))" - try createTopic(named: unique, partitions: partitions) - - let driver = makeDriver(host: endpoint.host, port: endpoint.port) - try await driver.connect() - return Harness(driver: driver, topic: unique) - } - - struct Harness { - let driver: KafkaPluginDriver - let topic: String - - var quoted: String { KafkaQL.quote(topic) } - - func tearDown() { - driver.disconnect() - try? KafkaTestBroker.deleteTopic(named: topic) - } - - /// Produces through the driver, which is also the produce path under test. - func produce(count: Int, startingAt start: Int = 0) async throws { - for index in start ..< (start + count) { - _ = try await driver.execute( - query: "PRODUCE INTO \(quoted) KEY \"k\(index)\" VALUE \"payload-\(index)\"" - ) - } - } - - /// Produces through the broker's CLI with a compression codec, which the driver has no - /// way to request. Reading these back is the only end-to-end test of decompression. - func produceCompressed(codec: String, count: Int) throws { - let lines = (0 ..< count).map { "payload-\($0)" }.joined(separator: "\n") - try KafkaTestBroker.runCLI( - "kafka-console-producer.sh", - ["--topic", topic, "--compression-codec", codec], - stdin: lines + "\n" - ) - } - - /// Commits a group offset through the broker's CLI, so the lag the driver reports is - /// measured against something the driver did not write. - func commitGroup(named group: String, messages: Int) throws { - try KafkaTestBroker.runCLI( - "kafka-console-consumer.sh", - [ - "--topic", topic, "--group", group, "--from-beginning", - "--max-messages", String(messages), "--timeout-ms", "20000", - "--consumer-property", "enable.auto.commit=true", - "--consumer-property", "auto.commit.interval.ms=200" - ] - ) - } - - func rows(_ query: String) async throws -> [[PluginCellValue]] { - try await driver.execute(query: query).rows - } - - func consume(limit: Int) async throws -> [[PluginCellValue]] { - try await rows("CONSUME \(quoted) FROM OLDEST LIMIT \(limit)") - } - } - - // MARK: - Broker CLI - - /// An absolute path, resolved once. The test host does not inherit the shell's PATH, so - /// spawning a bare `docker` finds nothing and every test fails in milliseconds with an - /// error the runner does not surface. - static let dockerPath: String? = { - ["/usr/local/bin/docker", "/opt/homebrew/bin/docker", "/usr/bin/docker"] - .first { FileManager.default.isExecutableFile(atPath: $0) } - }() - - static func createTopic(named topic: String, partitions: Int) throws { - try runCLI("kafka-topics.sh", [ - "--create", "--topic", topic, - "--partitions", String(partitions), "--replication-factor", "1" - ]) - } - - static func deleteTopic(named topic: String) throws { - try runCLI("kafka-topics.sh", ["--delete", "--topic", topic]) - } - - /// Runs one of the broker's bundled tools inside its container. - /// - /// `--bootstrap-server localhost:9092` is the broker's INTERNAL listener, which is what the - /// CLI must use from inside the container. The external port the driver connects to is a - /// different listener, and using it here fails with a node-assignment timeout. - @discardableResult - static func runCLI(_ tool: String, _ arguments: [String], stdin: String? = nil) throws -> String { - guard let docker = dockerPath else { - throw KafkaTestBrokerError.dockerNotFound - } - let process = Process() - process.executableURL = URL(fileURLWithPath: docker) - process.arguments = ["exec"] - + (stdin == nil ? [] : ["-i"]) - + [container, "/opt/kafka/bin/\(tool)", "--bootstrap-server", "localhost:9092"] - + arguments - - let output = Pipe() - process.standardOutput = output - process.standardError = output - let input = Pipe() - if stdin != nil { process.standardInput = input } - - try process.run() - if let stdin { - input.fileHandleForWriting.write(Data(stdin.utf8)) - input.fileHandleForWriting.closeFile() - } - let data = output.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - - let text = String(data: data, encoding: .utf8) ?? "" - guard process.terminationStatus == 0 else { - throw KafkaTestBrokerError.commandFailed(tool: tool, output: text) - } - return text - } -} - -enum KafkaTestBrokerError: Error, CustomStringConvertible { - case commandFailed(tool: String, output: String) - case dockerNotFound - - var description: String { - switch self { - case .dockerNotFound: - return "docker was not found. The integration suite drives a broker in a container." - case .commandFailed(let tool, let output): - return "\(tool) failed:\n\(output)" - } - } -} diff --git a/TableProTests/Plugins/LibPQCheckConstraintCoverageTests.swift b/TableProTests/Plugins/LibPQCheckConstraintCoverageTests.swift index c3be58081c..7644cd26ae 100644 --- a/TableProTests/Plugins/LibPQCheckConstraintCoverageTests.swift +++ b/TableProTests/Plugins/LibPQCheckConstraintCoverageTests.swift @@ -3,98 +3,10 @@ // TableProTests // -import Foundation @testable import TablePro import Testing struct LibPQCheckConstraintCoverageTests { - private static let pluginDirectory: URL = { - var directory = URL(fileURLWithPath: #filePath) - for _ in 0..<3 { directory.deleteLastPathComponent() } - return directory - .appendingPathComponent("Plugins") - .appendingPathComponent("PostgreSQLDriverPlugin") - }() - - private static let sharedExtensionOwner = "LibPQBackedDriver" - - private static func pluginSources() throws -> [String] { - try FileManager.default - .contentsOfDirectory(at: pluginDirectory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "swift" } - .map { try String(contentsOf: $0, encoding: .utf8) } - } - - private static func matches(_ pattern: String, in text: String) throws -> [NSTextCheckingResult] { - let range = NSRange(location: 0, length: (text as NSString).length) - return try NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]).matches(in: text, range: range) - } - - private static func firstCapture(of match: NSTextCheckingResult, in text: String) -> String { - (text as NSString).substring(with: match.range(at: 1)) - } - - private static func driverClassesByTypeId(pluginSource: String) throws -> [String: String] { - var classes: [String: String] = [:] - for match in try matches(#"case "([^"]+)": return (\w+)\(config: config\)"#, in: pluginSource) { - classes[firstCapture(of: match, in: pluginSource)] = (pluginSource as NSString).substring(with: match.range(at: 2)) - } - for match in try matches(#"default: return (\w+)\(config: config\)"#, in: pluginSource) { - classes["PostgreSQL"] = firstCapture(of: match, in: pluginSource) - } - return classes - } - - private static func owners(ofMember member: String, in sources: [String]) throws -> Set { - var owners: Set = [] - for source in sources { - let declarations = try matches(#"^(?:final )?(?:class|extension) (\w+)"#, in: source) - for memberMatch in try matches(#"^ func \#(member)\("#, in: source) { - guard let owner = declarations.last(where: { $0.range.location < memberMatch.range.location }) else { - continue - } - owners.insert(firstCapture(of: owner, in: source)) - } - } - return owners - } - - private static func superclass(of className: String, in sources: [String]) throws -> String? { - for source in sources { - if let match = try matches(#"^(?:final )?class \#(className): (\w+)"#, in: source).first { - return firstCapture(of: match, in: source) - } - } - return nil - } - - private static func lineage(of className: String, in sources: [String]) throws -> [String] { - var chain = [className] - while let parent = try superclass(of: chain[chain.count - 1], in: sources), parent != sharedExtensionOwner { - chain.append(parent) - } - return chain - } - - @Test("Every libpq driver whose type offers check constraints can fetch them") - func everyCheckConstraintTypeHasAFetch() throws { - let sources = try Self.pluginSources() - let pluginSource = try String( - contentsOf: Self.pluginDirectory.appendingPathComponent("PostgreSQLPlugin.swift"), - encoding: .utf8 - ) - let drivers = try Self.driverClassesByTypeId(pluginSource: pluginSource) - #expect(Set(drivers.keys) == ["PostgreSQL", "Redshift", "CockroachDB", "PGlite"]) - - let owners = try Self.owners(ofMember: "fetchCheckConstraints", in: sources) - #expect(!owners.isEmpty) - for (typeId, className) in drivers where DatabaseType(rawValue: typeId).supportsCheckConstraints { - let lineage = try Self.lineage(of: className, in: sources) - let reachesAFetch = owners.contains(Self.sharedExtensionOwner) || !owners.isDisjoint(with: lineage) - #expect(reachesAFetch, "\(typeId) offers check constraints but \(className) has no fetchCheckConstraints") - } - } - @Test("No type offers check constraint editing without schema editing") func checkConstraintEditingNeedsSchemaEditing() { let inconsistent = DatabaseType.allKnownTypes.filter { diff --git a/TableProTests/Plugins/LibPQConnectionStringTests.swift b/TableProTests/Plugins/LibPQConnectionStringTests.swift index aefae70b8b..829ccf2eaf 100644 --- a/TableProTests/Plugins/LibPQConnectionStringTests.swift +++ b/TableProTests/Plugins/LibPQConnectionStringTests.swift @@ -8,19 +8,6 @@ import TableProPluginKit import Testing struct LibPQConnectionStringTests { - private func pluginSource(_ name: String) throws -> String { - let repository = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - return try String( - contentsOf: repository - .appendingPathComponent("Plugins/PostgreSQLDriverPlugin") - .appendingPathComponent(name), - encoding: .utf8 - ) - } - private func build( user: String = "postgres", password: String? = "hunter2", @@ -92,20 +79,6 @@ struct LibPQConnectionStringTests { ) } - @Test("Bootstrap queries use pollable libpq I/O and adoption follows every post-connect probe") - func bootstrapQueriesStayInsideDeadline() throws { - let connection = try pluginSource("LibPQPluginConnection.swift") - let core = try pluginSource("LibPQDriverCore.swift") - - #expect(connection.contains("if let deadline = activeConnectDeadline")) - #expect(connection.contains("PQsendQuery(connection")) - #expect(connection.contains("PQsocketPoll(")) - #expect(connection.contains("requestServerCancel(on: connection, evenWhenSuppressed: true)")) - let probes = try #require(core.range(of: "await onPostConnect?()")) - let adoption = try #require(core.range(of: "try await pqConn.finishConnecting()")) - #expect(probes.lowerBound < adoption.lowerBound) - } - @Test("The session is pinned to UTF8 in the startup packet, so RESET ALL and DISCARD ALL keep it") func pinsClientEncoding() { #expect(build().contains("client_encoding='UTF8'")) diff --git a/TableProTests/Plugins/LibPQKeepaliveTests.swift b/TableProTests/Plugins/LibPQKeepaliveTests.swift index 5e80f61440..76ae879209 100644 --- a/TableProTests/Plugins/LibPQKeepaliveTests.swift +++ b/TableProTests/Plugins/LibPQKeepaliveTests.swift @@ -8,14 +8,6 @@ import TableProPluginKit import Testing struct LibPQKeepaliveTests { - private static let pluginDirectory: URL = { - var directory = URL(fileURLWithPath: #filePath) - for _ in 0..<3 { directory.deleteLastPathComponent() } - return directory - .appendingPathComponent("Plugins") - .appendingPathComponent("PostgreSQLDriverPlugin") - }() - private func build(options: String? = nil) -> String { LibPQConnectionString.build( host: "db.example.com", @@ -80,28 +72,4 @@ struct LibPQKeepaliveTests { func optionsStayLast() { #expect(build(options: "-c search_path=app").hasSuffix(" options='-c search_path=app'")) } - - /// The keepalives live in the conninfo builder, so they reach every PostgreSQL, Redshift, - /// CockroachDB and PGlite connection only while that builder is the one way the plugin dials. - @Test("The plugin opens libpq connections in one place, from the built conninfo") - func oneConnectPath() throws { - let sources: [(name: String, text: String)] = try FileManager.default - .contentsOfDirectory(at: Self.pluginDirectory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "swift" } - .map { (name: $0.lastPathComponent, text: try String(contentsOf: $0, encoding: .utf8)) } - #expect(sources.contains { $0.name == "LibPQConnectionString.swift" }) - - let otherDialers = ["PQconnectdb", "PQconnectdbParams", "PQconnectStartParams", "PQsetdbLogin"] - let offenders = sources.flatMap { source in - otherDialers.filter { source.text.contains("\($0)(") }.map { "\(source.name): \($0)" } - } - #expect(offenders.isEmpty, "\(offenders)") - - let starts = sources.filter { $0.text.contains("PQconnectStart(") } - #expect(starts.map { $0.name } == ["LibPQPluginConnection.swift"]) - let connection = try #require(starts.first?.text) - #expect(connection.components(separatedBy: "PQconnectStart(").count == 2) - #expect(connection.contains("connectionString.withCString({ PQconnectStart($0) })")) - #expect(connection.contains("private var connectionString: String {\n LibPQConnectionString.build(")) - } } diff --git a/TableProTests/Plugins/LibPQPluginConnectionSourceScanTests.swift b/TableProTests/Plugins/LibPQPluginConnectionSourceScanTests.swift deleted file mode 100644 index e788298aee..0000000000 --- a/TableProTests/Plugins/LibPQPluginConnectionSourceScanTests.swift +++ /dev/null @@ -1,349 +0,0 @@ -// -// LibPQPluginConnectionSourceScanTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// `LibPQPluginConnection` keeps its shared state in underscored stored properties that the -/// connection's queue writes and any thread reads, so each one is only safe while `stateLock` is -/// held. The server version cache broke that: `disconnect()` cleared it after releasing the lock -/// while `serverVersion()` read it with no lock at all, which ThreadSanitizer reported as a data -/// race against PostgreSQL 17.11. Nothing at runtime shows such a race, and the class imports -/// CLibPQ, which this target cannot, so the guard is a source scan. -struct LibPQPluginConnectionSourceScanTests { - private static let connectionSource: URL = { - var directory = URL(fileURLWithPath: #filePath) - for _ in 0..<3 { directory.deleteLastPathComponent() } - return directory - .appendingPathComponent("Plugins") - .appendingPathComponent("PostgreSQLDriverPlugin") - .appendingPathComponent("LibPQPluginConnection.swift") - }() - - private static let membersReachingTheConnection: Set = [ - "connectionHandle", - "fetchResults", - "resolvingUnknownTypes", - "learnTypeNames", - "noteCommandTag", - "applySpatialRendering" - ] - - private static func pluginSources() throws -> [(name: String, text: String)] { - try FileManager.default - .contentsOfDirectory(at: connectionSource.deletingLastPathComponent(), includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "swift" } - .sorted { $0.lastPathComponent < $1.lastPathComponent } - .map { ($0.lastPathComponent, try String(contentsOf: $0, encoding: .utf8)) } - } - - private static func isConnectionFile(_ name: String) -> Bool { - name == "LibPQPluginConnection.swift" || name.hasPrefix("LibPQPluginConnection+") - } - - private static func matches(_ pattern: String, in text: String) throws -> [NSTextCheckingResult] { - let range = NSRange(location: 0, length: (text as NSString).length) - return try NSRegularExpression(pattern: pattern).matches(in: text, range: range) - } - - @Test("Every underscored stored property is read and written while stateLock is held") - func underscoredStateIsOnlyTouchedUnderStateLock() throws { - let source = try String(contentsOf: Self.connectionSource, encoding: .utf8) - let scan = try StateLockScan(source: source) - - #expect(scan.stateNames.isSuperset(of: ["_cachedServerVersion", "_cachedServerVersionNumber", "_isConnected"])) - #expect(scan.endsBalanced, "The scan lost track of a literal or a brace, so the code after it went unchecked") - #expect(scan.unlockedAccesses.isEmpty, "Touched without stateLock held: \(scan.unlockedAccesses)") - } - - @Test("The scan flags an access outside the lock and accepts each way the file takes it") - func scanTellsLockedFromUnlockedAccess() throws { - let source = """ - final class Sample { - private let stateLock = NSLock() - private var _value = 0 - private var _label: String? - - var value: Int { - stateLock.lock() - defer { stateLock.unlock() } - return _value - } - - func read() -> Int { - _value - } - - func write(_ newValue: Int) { - stateLock.lock() - _value = newValue - stateLock.unlock() - _label = "_value { \\(_value) \\"_value\\" }" - } - - func label() -> String? { - // _label is read under the lock { - let sql = \""" - SELECT "_value" FROM t WHERE a = '{' - \""" - return stateLock.withLock { - _label ?? sql - } - } - } - """ - - let scan = try StateLockScan(source: source) - - #expect(scan.stateNames == ["_value", "_label"]) - #expect(scan.endsBalanced) - #expect(scan.unlockedAccesses == ["13:_value", "20:_label", "20:_value"]) - } - - @Test("The scan reports a literal it cannot read instead of passing the code after it unchecked") - func scanReportsLiteralItCannotRead() throws { - let rawStringEndingInBackslash = """ - final class Sample { - private var _value = 0 - private let separator = #"\\"# - - func read() -> Int { - _value - } - } - """ - let regexHoldingQuote = """ - final class Sample { - private var _value = 0 - private let pattern = /"/ - - func read() -> Int { - _value - } - } - """ - - for source in [rawStringEndingInBackslash, regexHoldingQuote] { - let scan = try StateLockScan(source: source) - #expect(!scan.endsBalanced, "Read as balanced: \(source)") - } - } - - @Test("stateLock is named in no plugin file but the connection's own") - func stateLockStaysInTheConnectionFile() throws { - let sources = try Self.pluginSources() - #expect(sources.contains { $0.name == "LibPQPluginConnection.swift" && $0.text.contains("stateLock") }) - - let others = sources.filter { $0.name != "LibPQPluginConnection.swift" && $0.text.contains("stateLock") } - #expect(others.isEmpty, "stateLock named in \(others.map(\.name))") - } - - /// Code in an extension file runs inside a block the connection already put on its queue, so a - /// lock or a dispatch there would be a second owner of the connection's threading. - @Test("The connection's extension files neither lock nor dispatch") - func extensionFilesNeitherLockNorDispatch() throws { - let extensions = try Self.pluginSources().filter { $0.name.hasPrefix("LibPQPluginConnection+") } - #expect(extensions.count >= 3, "Found only \(extensions.map(\.name))") - - let forbidden = [ - "stateLock", "queue.", "DispatchQueue", "pluginDispatch", ".async {", ".sync {", "withLock", ".lock()" - ] - let offenders = extensions.flatMap { source in - forbidden.filter { source.text.contains($0) }.map { "\(source.name): \($0)" } - } - #expect(offenders.isEmpty, "\(offenders)") - } - - /// A member moved out of the connection's file loses the compiler's proof that only a block on - /// the queue calls it. Each one that uses the `PGconn` therefore checks the queue before anything - /// else, and no file outside the connection's own names it. - @Test("Every member other files can reach that uses the PGconn checks the queue first") - func connectionReachingMembersCheckTheQueue() throws { - let sources = try Self.pluginSources() - let connectionText = sources.filter { Self.isConnectionFile($0.name) }.map(\.text).joined(separator: "\n") - let nsText = connectionText as NSString - - for member in Self.membersReachingTheConnection.sorted() { - let declarationPattern = #"\b(?:func|var)\s+"# + member + #"\b[^{]*\{\s*(\S[^\n]*)"# - let declarations = try Self.matches(declarationPattern, in: connectionText) - #expect(declarations.count == 1, "\(member) is declared \(declarations.count) times") - if let declaration = declarations.first { - let firstStatement = nsText.substring(with: declaration.range(at: 1)) - #expect(firstStatement == "preconditionOnQueue()", "\(member) opens with \(firstStatement)") - } - - var namedOutside: [String] = [] - for source in sources where !Self.isConnectionFile(source.name) { - if try !Self.matches(#"\b"# + member + #"\b"#, in: source.text).isEmpty { - namedOutside.append(source.name) - } - } - #expect(namedOutside.isEmpty, "\(member) is named in \(namedOutside)") - } - } - - @Test("Every connection member other files can reach that takes the PGconn is one the queue check covers") - func connectionTakingMembersAreCovered() throws { - let signature = #"(?m)^\s*((?:private\s+|fileprivate\s+)?)(?:static\s+)?func\s+(\w+)\(([^)]*)\)"# - var reachable: [String] = [] - var uncovered: [String] = [] - - for source in try Self.pluginSources() where Self.isConnectionFile(source.name) { - let nsText = source.text as NSString - for match in try Self.matches(signature, in: source.text) where match.range(at: 1).length == 0 { - let name = nsText.substring(with: match.range(at: 2)) - guard nsText.substring(with: match.range(at: 3)).contains("conn: OpaquePointer") else { continue } - reachable.append(name) - if !Self.membersReachingTheConnection.contains(name) { uncovered.append("\(source.name): \(name)") } - } - } - - #expect(Set(reachable).isSuperset(of: ["fetchResults", "learnTypeNames", "noteCommandTag"]), "Found only \(reachable)") - #expect(uncovered.isEmpty, "Not checked for the queue: \(uncovered)") - } -} - -private struct StateLockScan { - let stateNames: Set - let unlockedAccesses: [String] - let endsBalanced: Bool - - init(source: String) throws { - let declarationPattern = try NSRegularExpression(pattern: #"\b(?:var|let)\s+(_[A-Za-z]\w*)"#) - let tokenPattern = try NSRegularExpression( - pattern: #"[{}]|stateLock\.(?:withLock\b|lock\(\)|unlock\(\))|\bdefer\b|(? (units: [UInt16], endsInsideLiteral: Bool) { - let newline = UInt16(UInt8(ascii: "\n")) - let quote = UInt16(UInt8(ascii: "\"")) - let slash = UInt16(UInt8(ascii: "/")) - let star = UInt16(UInt8(ascii: "*")) - let backslash = UInt16(UInt8(ascii: "\\")) - let openParen = UInt16(UInt8(ascii: "(")) - let closeParen = UInt16(UInt8(ascii: ")")) - - var masked = units - var index = 0 - var parenDepth = 0 - var openDelimiter: Int? - var interpolations: [(parenDepth: Int, delimiter: Int)] = [] - - func unit(at offset: Int) -> UInt16 { - offset < units.count ? units[offset] : 0 - } - func delimiterLength(at offset: Int) -> Int { - unit(at: offset + 1) == quote && unit(at: offset + 2) == quote ? 3 : 1 - } - func blank(_ count: Int) { - for offset in index.. String { - var directory = URL(fileURLWithPath: #filePath) - for _ in 0..<3 { directory.deleteLastPathComponent() } - return try String( - contentsOf: directory - .appendingPathComponent("Plugins") - .appendingPathComponent("PostgreSQLDriverPlugin") - .appendingPathComponent(name), - encoding: .utf8 - ) - } - - /// The body of the first `signature` in `source`, up to the brace that closes a member indented - /// four spaces, which is how every member of these two classes is laid out. - private static func body(of signature: String, in source: String) throws -> String { - let start = try #require(source.range(of: signature)) - let end = try #require(source.range(of: "\n }\n", range: start.upperBound.. LibPQPluginError { LibPQPluginError(message: message, sqlState: sqlState, detail: nil) } @@ -84,32 +64,4 @@ struct LibPQSessionCheckTests { #expect(LibPQSessionCheck.refusalState(of: Self.serverError("")) == nil) #expect(LibPQSessionCheck.refusalState(of: CancellationError()) == nil) } - - /// `LibPQPluginConnection` imports CLibPQ, which this target cannot, so the wiring is checked in - /// the source: read the socket before anything else, send nothing inside a transaction block, - /// and take a refusal as an answer only while libpq still reports the connection OK. - @Test("The plugin's ping reads the socket first and accepts a refusal only on a live connection") - func pingIsWiredThroughTheCheck() throws { - let ping = try Self.body( - of: "func ping() async throws {", - in: Self.pluginSource("LibPQPluginConnection.swift") - ) - let socketRead = try #require(ping.range(of: "sessionEndedBeforeSending(conn)")) - let decision = try #require(ping.range(of: "LibPQSessionCheck.sendsStatement(in: transactionStateOnQueue())")) - let statement = try #require(ping.range(of: "executeQuerySync(LibPQSessionCheck.statement)")) - let liveCheck = try #require(ping.range(of: "PQstatus(conn) == CONNECTION_OK")) - let refusal = try #require(ping.range(of: "LibPQSessionCheck.refusalState(of: error)")) - - #expect(socketRead.upperBound < decision.lowerBound) - #expect(decision.upperBound < statement.lowerBound) - #expect(statement.upperBound < liveCheck.lowerBound) - #expect(liveCheck.upperBound < refusal.lowerBound) - - let corePing = try Self.body( - of: "func ping() async throws {", - in: Self.pluginSource("LibPQDriverCore.swift") - ) - #expect(corePing.contains("try await pqConn.ping()")) - #expect(!corePing.contains("executeQuery")) - } } diff --git a/TableProTests/Plugins/LibSQLBusyTimeoutTests.swift b/TableProTests/Plugins/LibSQLBusyTimeoutTests.swift deleted file mode 100644 index db31609275..0000000000 --- a/TableProTests/Plugins/LibSQLBusyTimeoutTests.swift +++ /dev/null @@ -1,388 +0,0 @@ -import CSQLite -import Foundation -import TableProPluginKit -import Testing - -private struct LibSQLBusyTimeoutTestFailure: Error, CustomStringConvertible { - let description: String -} - -private final class LibSQLLockedDatabase: @unchecked Sendable { - private let url: URL - private let locker: OpaquePointer - private let waiter: OpaquePointer - private let lock = NSLock() - private var holdsExclusiveLock = false - - init(state: LibSQLBusyTimeoutState) throws { - url = FileManager.default.temporaryDirectory - .appendingPathComponent("libsql-busy-timeout-\(UUID().uuidString).sqlite") - - var locker: OpaquePointer? - guard sqlite3_open(url.path, &locker) == SQLITE_OK, let locker else { - throw LibSQLBusyTimeoutTestFailure(description: "Could not open the lock-owning SQLite connection") - } - self.locker = locker - - var waiter: OpaquePointer? - guard sqlite3_open(url.path, &waiter) == SQLITE_OK, let waiter else { - sqlite3_close(locker) - try? FileManager.default.removeItem(at: url) - throw LibSQLBusyTimeoutTestFailure(description: "Could not open the waiting SQLite connection") - } - self.waiter = waiter - - guard sqlite3_exec(locker, "CREATE TABLE item(value INTEGER)", nil, nil, nil) == SQLITE_OK else { - throw LibSQLBusyTimeoutTestFailure(description: "Could not create the SQLite fixture table") - } - sqlite3_busy_handler( - waiter, - libSQLBusyTimeoutHandler, - Unmanaged.passUnretained(state).toOpaque() - ) - } - - deinit { - releaseLock() - sqlite3_close(waiter) - sqlite3_close(locker) - try? FileManager.default.removeItem(at: url) - } - - func acquireLock() throws { - guard sqlite3_exec(locker, "BEGIN EXCLUSIVE", nil, nil, nil) == SQLITE_OK else { - throw LibSQLBusyTimeoutTestFailure(description: "Could not acquire the SQLite fixture lock") - } - lock.withLock { holdsExclusiveLock = true } - } - - func releaseLock() { - let shouldRelease = lock.withLock { - guard holdsExclusiveLock else { return false } - holdsExclusiveLock = false - return true - } - if shouldRelease { - sqlite3_exec(locker, "ROLLBACK", nil, nil, nil) - } - } - - func insert() -> Int32 { - sqlite3_exec(waiter, "INSERT INTO item VALUES (1)", nil, nil, nil) - } -} - -private struct LibSQLTestLocalDatabaseRuntime: LibSQLLocalDatabaseRuntime { - func prepareDatabase(_: OpaquePointer, loading _: [LoadableExtension]) throws {} - - func stepFirst(_ statement: OpaquePointer?) -> LibSQLLocalFirstStep { - let firstStep = SQLiteResultColumns.stepFirst(statement) - return LibSQLLocalFirstStep( - result: firstStep.result, - names: firstStep.names, - typeNames: firstStep.typeNames - ) - } -} - -private final class LibSQLBackendLockedDatabase: @unchecked Sendable { - let path: String - - private let url: URL - private let locker: OpaquePointer - private let lock = NSLock() - private var holdsWriteLock = false - - init() throws { - url = FileManager.default.temporaryDirectory - .appendingPathComponent("libsql-backend-lock-\(UUID().uuidString).sqlite") - path = url.path - - var locker: OpaquePointer? - guard sqlite3_open(path, &locker) == SQLITE_OK, let locker else { - throw LibSQLBusyTimeoutTestFailure(description: "Could not open the backend lock-owning connection") - } - self.locker = locker - - guard sqlite3_exec(locker, "CREATE TABLE item(value INTEGER)", nil, nil, nil) == SQLITE_OK else { - sqlite3_close(locker) - try? FileManager.default.removeItem(at: url) - throw LibSQLBusyTimeoutTestFailure(description: "Could not create the backend fixture table") - } - } - - deinit { - releaseLock() - sqlite3_close(locker) - try? FileManager.default.removeItem(at: url) - } - - func acquireWriteLock() throws { - guard sqlite3_exec(locker, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { - throw LibSQLBusyTimeoutTestFailure(description: "Could not acquire the backend fixture lock") - } - lock.withLock { holdsWriteLock = true } - } - - func releaseLock() { - let shouldRelease = lock.withLock { - guard holdsWriteLock else { return false } - holdsWriteLock = false - return true - } - if shouldRelease { - sqlite3_exec(locker, "ROLLBACK", nil, nil, nil) - } - } -} - -private enum LibSQLBackendOperationOutcome: Equatable, Sendable { - case success - case cancellation - case failure(String) - - var isLockFailure: Bool { - guard case .failure(let message) = self else { return false } - let normalized = message.lowercased() - if normalized.contains("locked") { return true } - if normalized.contains("busy") { return true } - return false - } - - var wasCancelled: Bool { - self == .cancellation - } -} - -@Suite("libSQL local busy timeout", .serialized) -struct LibSQLBusyTimeoutTests { - @Test("No limit keeps retrying until the lock is released", .timeLimit(.minutes(1))) - func noLimitWaitsForLockRelease() async throws { - let state = LibSQLBusyTimeoutState() - state.setTimeout(milliseconds: 0) - let database = try LibSQLLockedDatabase(state: state) - try database.acquireLock() - - let insert = Task.detached { - state.beginOperation() - return database.insert() - } - let sawRetry = await waitForRetry(state) - database.releaseLock() - - let result = await boundedResult(of: insert, state: state, database: database) - #expect(sawRetry) - #expect(result == SQLITE_OK) - } - - @Test("Cancellation ends an unlimited lock wait", .timeLimit(.minutes(1))) - func cancellationEndsUnlimitedWait() async throws { - let state = LibSQLBusyTimeoutState() - state.setTimeout(milliseconds: 0) - let database = try LibSQLLockedDatabase(state: state) - try database.acquireLock() - - let insert = Task.detached { - state.beginOperation() - return database.insert() - } - let sawRetry = await waitForRetry(state) - if sawRetry { - state.cancel() - } else { - database.releaseLock() - } - - let result = await boundedResult(of: insert, state: state, database: database) - database.releaseLock() - #expect(sawRetry) - #expect(result == SQLITE_BUSY) - } - - @Test("A positive timeout stops at its retry boundary") - func positiveTimeoutHasFiniteBoundary() { - let state = LibSQLBusyTimeoutState() - state.setTimeout(milliseconds: 25) - state.beginOperation() - - #expect(state.shouldRetry(afterRetryCount: 0)) - #expect(state.shouldRetry(afterRetryCount: 2)) - #expect(!state.shouldRetry(afterRetryCount: 3)) - } - - @Test("A finite timeout rejects a locked backend insert", .timeLimit(.minutes(1))) - func finiteTimeoutRejectsLockedBackendInsert() async throws { - try await withBackend { backend, database in - await backend.applyBusyTimeout(25) - try database.acquireWriteLock() - - let insert = Task { - await Self.bufferedOutcome(backend, query: "INSERT INTO item VALUES (1)") - } - let outcome = await BoundedCall.result( - within: .seconds(2), - onDeadline: { - backend.cancelBusyWait() - database.releaseLock() - }, - of: { await insert.value } - ) - database.releaseLock() - - let completed = try #require(outcome) - #expect(completed.isLockFailure) - #expect(try await Self.rowCount(backend) == 0) - } - } - - @Test("Driver cancellation rejects a locked streaming insert", .timeLimit(.minutes(1))) - func driverCancellationRejectsLockedStreamingInsert() async throws { - try await withDriver { driver, database in - try await driver.applyQueryTimeout(0) - try database.acquireWriteLock() - - let insert = Task { - await Self.streamingOutcome(driver, query: "INSERT INTO item VALUES (2) RETURNING value") - } - let sawRetry = await Self.waitForDriverRetry(driver) - if sawRetry { - try driver.cancelQuery() - } else { - database.releaseLock() - } - - let outcome = await BoundedCall.result( - within: .seconds(2), - onDeadline: { - try? driver.cancelQuery() - database.releaseLock() - }, - of: { await insert.value } - ) - database.releaseLock() - try await driver.applyQueryTimeout(1) - - #expect(sawRetry) - let completed = try #require(outcome) - #expect(completed.wasCancelled) - #expect(try await Self.rowCount(driver) == 0) - } - } - - private func waitForRetry(_ state: LibSQLBusyTimeoutState) async -> Bool { - for _ in 0 ..< 2_000 { - if state.hasRetried { return true } - try? await Task.sleep(for: .milliseconds(1)) - } - return false - } - - private static func waitForDriverRetry(_ driver: LibSQLPluginDriver) async -> Bool { - for _ in 0 ..< 2_000 { - if driver.hasRetriedLocalBusyWait { return true } - try? await Task.sleep(for: .milliseconds(1)) - } - return false - } - - private func boundedResult( - of task: Task, - state: LibSQLBusyTimeoutState, - database: LibSQLLockedDatabase - ) async -> Int32? { - await BoundedCall.result( - within: .seconds(2), - onDeadline: { - state.cancel() - database.releaseLock() - }, - of: { await task.value } - ) - } - - private func withBackend( - _ operation: (SQLiteLocalBackend, LibSQLBackendLockedDatabase) async throws -> Void - ) async throws { - let database = try LibSQLBackendLockedDatabase() - let backend = SQLiteLocalBackend(runtime: LibSQLTestLocalDatabaseRuntime()) - try await backend.open(path: database.path, loading: []) - do { - try await operation(backend, database) - } catch { - await backend.close() - throw error - } - await backend.close() - } - - private func withDriver( - _ operation: (LibSQLPluginDriver, LibSQLBackendLockedDatabase) async throws -> Void - ) async throws { - let database = try LibSQLBackendLockedDatabase() - let config = DriverConnectionConfig( - host: "", - port: 0, - username: "", - password: "", - database: "", - additionalFields: [ - "libsqlMode": "local", - "libsqlFilePath": database.path - ] - ) - let driver = LibSQLPluginDriver( - config: config, - localDatabaseRuntime: LibSQLTestLocalDatabaseRuntime() - ) - do { - try await driver.connect() - try await operation(driver, database) - } catch { - try? driver.cancelQuery() - database.releaseLock() - driver.disconnect() - throw error - } - driver.disconnect() - } - - private static func bufferedOutcome( - _ backend: SQLiteLocalBackend, - query: String - ) async -> LibSQLBackendOperationOutcome { - do { - _ = try await backend.executeQuery(query) - return .success - } catch { - return .failure(errorMessage(error)) - } - } - - private static func streamingOutcome( - _ driver: LibSQLPluginDriver, - query: String - ) async -> LibSQLBackendOperationOutcome { - do { - for try await _ in driver.streamRows(query: query) {} - return .success - } catch is CancellationError { - return .cancellation - } catch { - return .failure(errorMessage(error)) - } - } - - private static func rowCount(_ backend: SQLiteLocalBackend) async throws -> Int { - let result = try await backend.executeQuery("SELECT COUNT(*) FROM item") - return Int(result.rows.first?.first?.asText ?? "") ?? -1 - } - - private static func rowCount(_ driver: LibSQLPluginDriver) async throws -> Int { - let result = try await driver.execute(query: "SELECT COUNT(*) FROM item") - return Int(result.rows.first?.first?.asText ?? "") ?? -1 - } - - private static func errorMessage(_ error: Error) -> String { - (error as? LibSQLError)?.message ?? String(describing: error) - } -} diff --git a/TableProTests/Plugins/LibSQLConnectionFieldsTests.swift b/TableProTests/Plugins/LibSQLConnectionFieldsTests.swift deleted file mode 100644 index 327e758cde..0000000000 --- a/TableProTests/Plugins/LibSQLConnectionFieldsTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// LibSQLConnectionFieldsTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct LibSQLConnectionFieldsTests { - private func libsqlFields() throws -> [ConnectionField] { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - let entry = try #require(defaults.first { $0.typeId == "libSQL" }) - return entry.snapshot.connection.additionalConnectionFields - } - - @Test("Registry entry declares mode, URL, file path and extension fields") - func registryDeclaresAllFields() throws { - let fields = try libsqlFields() - #expect(fields.map(\.id) == ["libsqlMode", "databaseUrl", "libsqlFilePath", LoadableExtensionList.fieldId]) - } - - @Test("Extensions are offered only for a local database file") - func extensionsVisibleOnlyForLocal() throws { - let fields = try libsqlFields() - let extensions = try #require(fields.first { $0.id == LoadableExtensionList.fieldId }) - #expect(extensions.content == .loadableExtensions) - #expect(extensions.visibleWhen == FieldVisibilityRule(fieldId: "libsqlMode", values: ["local"])) - } - - @Test("The Turso alias declares the same fields as libSQL") - func tursoMatchesLibSQL() throws { - let turso = try #require( - PluginMetadataRegistry.shared.snapshot(for: DatabaseType(rawValue: "Turso"))? - .connection.additionalConnectionFields - ) - let libsql = try libsqlFields() - let tursoIds = turso.map { $0.id } - let libsqlIds = libsql.map { $0.id } - let tursoContent = turso.map { $0.content } - let libsqlContent = libsql.map { $0.content } - #expect(tursoIds == libsqlIds) - #expect(tursoContent == libsqlContent) - } - - @Test("Mode dropdown defaults to remote and offers a local option") - func modeDropdownDefaultsToRemote() throws { - let fields = try libsqlFields() - let mode = try #require(fields.first { $0.id == "libsqlMode" }) - #expect(mode.defaultValue == "remote") - guard case .dropdown(let options) = mode.fieldType else { - Issue.record("Expected a dropdown field type") - return - } - #expect(options.map(\.value) == ["remote", "local"]) - } - - @Test("Database URL is required and visible only in remote mode") - func databaseUrlVisibleOnlyForRemote() throws { - let fields = try libsqlFields() - let url = try #require(fields.first { $0.id == "databaseUrl" }) - #expect(url.isRequired) - #expect(url.visibleWhen == FieldVisibilityRule(fieldId: "libsqlMode", values: ["remote"])) - } - - @Test("File path is a required text field visible only in local mode") - func filePathVisibleOnlyForLocal() throws { - let fields = try libsqlFields() - let path = try #require(fields.first { $0.id == "libsqlFilePath" }) - #expect(path.isRequired) - #expect(path.fieldType == .text) - #expect(path.visibleWhen == FieldVisibilityRule(fieldId: "libsqlMode", values: ["local"])) - } - - @Test("Password row stays for remote mode and hides for local mode") - func passwordHidingFollowsMode() throws { - let fields = try libsqlFields() - #expect(!fields.hidesPassword(forValues: [:])) - #expect(!fields.hidesPassword(forValues: ["libsqlMode": "remote"])) - #expect(fields.hidesPassword(forValues: ["libsqlMode": "local"])) - } - - @Test("Saved connections without a mode value resolve to remote visibility") - @MainActor - func missingModeValueShowsRemoteFields() throws { - let type = DatabaseType(rawValue: "libSQL") - let fields = try libsqlFields() - let url = try #require(fields.first { $0.id == "databaseUrl" }) - let path = try #require(fields.first { $0.id == "libsqlFilePath" }) - #expect(PluginFieldRendering.isFieldVisible(url, type: type, values: [:])) - #expect(!PluginFieldRendering.isFieldVisible(path, type: type, values: [:])) - } - - @Test("Local mode swaps field visibility") - @MainActor - func localModeSwapsVisibility() throws { - let type = DatabaseType(rawValue: "libSQL") - let fields = try libsqlFields() - let url = try #require(fields.first { $0.id == "databaseUrl" }) - let path = try #require(fields.first { $0.id == "libsqlFilePath" }) - let values = ["libsqlMode": "local"] - #expect(!PluginFieldRendering.isFieldVisible(url, type: type, values: values)) - #expect(PluginFieldRendering.isFieldVisible(path, type: type, values: values)) - } - - @Test("libSQL claims no file extensions so SQLite keeps file-open routing") - func noFileExtensionClaim() throws { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - let entry = try #require(defaults.first { $0.typeId == "libSQL" }) - #expect(entry.snapshot.schema.fileExtensions.isEmpty) - } -} diff --git a/TableProTests/Plugins/LibSQLRelationDDLTests.swift b/TableProTests/Plugins/LibSQLRelationDDLTests.swift deleted file mode 100644 index ae5dc72fce..0000000000 --- a/TableProTests/Plugins/LibSQLRelationDDLTests.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// LibSQLRelationDDLTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -private struct LibSQLRelationDDLRuntime: LibSQLLocalDatabaseRuntime { - func prepareDatabase(_: OpaquePointer, loading _: [LoadableExtension]) throws {} - - func stepFirst(_ statement: OpaquePointer?) -> LibSQLLocalFirstStep { - let firstStep = SQLiteResultColumns.stepFirst(statement) - return LibSQLLocalFirstStep(result: firstStep.result, names: firstStep.names, typeNames: firstStep.typeNames) - } -} - -/// SQLite, libSQL and D1 read a relation's DDL from `sqlite_master` the same way, and libSQL's is the -/// one that runs against a real file here. A view used to fail with "Failed to fetch DDL for table", -/// which Show DDL and Copy DDL showed for every view. -struct LibSQLRelationDDLTests { - private func withDriver(_ body: (LibSQLPluginDriver) async throws -> Void) async throws { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("libsql-relation-ddl-\(UUID().uuidString).sqlite") - defer { try? FileManager.default.removeItem(at: url) } - let driver = LibSQLPluginDriver( - config: DriverConnectionConfig( - host: "", - port: 0, - username: "", - password: "", - database: "", - additionalFields: ["libsqlMode": "local", "libsqlFilePath": url.path] - ), - localDatabaseRuntime: LibSQLRelationDDLRuntime() - ) - try await driver.connect() - defer { driver.disconnect() } - _ = try await driver.execute(query: "CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT)") - _ = try await driver.execute( - query: "CREATE VIEW named_item AS SELECT id, name FROM item WHERE name IS NOT NULL -- kept" - ) - try await body(driver) - } - - /// SQLite stores the trailing comment, so nothing is appended after it. - @Test("A view answers with its own CREATE VIEW, exactly as written") - func viewAnswersWithItsDefinition() async throws { - try await withDriver { driver in - let ddl = try await driver.fetchTableDDL(table: "named_item", schema: nil) - #expect(ddl == "CREATE VIEW named_item AS SELECT id, name FROM item WHERE name IS NOT NULL -- kept") - } - } - - @Test("A table still answers with its CREATE TABLE") - func tableStillAnswersWithItsTable() async throws { - try await withDriver { driver in - let ddl = try await driver.fetchTableDDL(table: "item", schema: nil) - #expect(ddl.hasPrefix("CREATE TABLE")) - #expect(!ddl.contains("CREATE VIEW")) - } - } - - @Test("A name that is neither still fails") - func missingRelationStillFails() async throws { - try await withDriver { driver in - await #expect(throws: LibSQLError.self) { - _ = try await driver.fetchTableDDL(table: "absent", schema: nil) - } - } - } -} diff --git a/TableProTests/Plugins/MSSQLEntraConnectSessionTests.swift b/TableProTests/Plugins/MSSQLEntraConnectSessionTests.swift deleted file mode 100644 index 6ae7ff25c2..0000000000 --- a/TableProTests/Plugins/MSSQLEntraConnectSessionTests.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation -import Testing - -struct MSSQLEntraConnectSessionTests { - @Test("Entra refresh requests allow the longest supported connect window") - func connectSessionTimeouts() { - let configuration = MSSQLEntraConnectSession.configuration() - - #expect(configuration.timeoutIntervalForRequest == 600) - #expect(configuration.timeoutIntervalForResource == 3_600) - #expect(MSSQLEntraConnectSession.shared.configuration.timeoutIntervalForRequest == 600) - #expect(MSSQLEntraConnectSession.shared.configuration.timeoutIntervalForResource == 3_600) - } -} diff --git a/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift b/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift deleted file mode 100644 index 6bed95f49f..0000000000 --- a/TableProTests/Plugins/MSSQLFreeTDSConfigTests.swift +++ /dev/null @@ -1,292 +0,0 @@ -// -// MSSQLFreeTDSConfigTests.swift -// TableProTests -// - -import Foundation -import TableProMSSQLCore -import TableProPluginKit -import Testing - -struct MSSQLFreeTDSConfigTests { - private func entry( - host: String = "db.example.com", - port: Int = 1_433, - mode: SSLMode, - caCertificatePath: String? = nil - ) throws -> MSSQLFreeTDSServerEntry { - try MSSQLFreeTDSServerEntry( - host: host, - port: port, - encryption: MSSQLSSLMapping.encryptionLevel(for: mode), - verification: MSSQLSSLMapping.certificateVerification(for: mode), - caCertificatePath: caCertificatePath - ) - } - - private func lines(_ entry: MSSQLFreeTDSServerEntry) -> [String] { - entry.text.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } - } - - @Test("Every SSL mode maps to the verification it advertises") - func modeMapping() { - #expect(MSSQLSSLMapping.certificateVerification(for: .disabled) == .none) - #expect(MSSQLSSLMapping.certificateVerification(for: .preferred) == .none) - #expect(MSSQLSSLMapping.certificateVerification(for: .required) == .none) - #expect(MSSQLSSLMapping.certificateVerification(for: .verifyCa) == .chain) - #expect(MSSQLSSLMapping.certificateVerification(for: .verifyIdentity) == .chainAndHostname) - } - - @Test("Every SSL mode writes an entry, and each carries the level its mode maps to") - func everyModeCarriesItsLevel() throws { - for mode in SSLMode.allCases { - let level = MSSQLSSLMapping.encryptionLevel(for: mode).rawValue - #expect(lines(try entry(mode: mode)).contains("encryption = \(level)"), "\(mode)") - } - #expect(lines(try entry(mode: .required)).contains("encryption = require")) - } - - @Test("Each level is written as libtds spells it") - func levelSpelling() throws { - let spellings: [(level: MSSQLEncryptionLevel, spelling: String)] = [ - (.request, "request"), (.require, "require") - ] - for (level, spelling) in spellings { - let server = try MSSQLFreeTDSServerEntry( - host: "db", port: 1_433, encryption: level, verification: .none, caCertificatePath: nil - ) - #expect(lines(server).contains("encryption = \(spelling)"), "\(level)") - } - } - - @Test("The entry is named after the host, so the login packet carries the host as its server name") - func namedAfterTheHost() throws { - let server = try entry(host: "myserver.database.windows.net", port: 1_433, mode: .required) - - #expect(server.name == "myserver.database.windows.net") - #expect(lines(server).prefix(4) == [ - "[myserver.database.windows.net]", - "host = myserver.database.windows.net", - "port = 1433", - "tds version = 7.4" - ]) - } - - @Test("An IP address is named with its port, so connects to other ports on it, every tunnel among them, never share a name") - func addressNamedWithItsPort() throws { - let cases: [(host: String, port: Int, name: String, dialled: String)] = [ - ("127.0.0.1", 54_321, "127.0.0.1,54321", "127.0.0.1"), - ("10.0.0.5", 1_433, "10.0.0.5,1433", "10.0.0.5"), - ("::1", 1_433, "::1,1433", "::1"), - ("[::1]", 1_433, "::1,1433", "::1"), - ("fe80::1%en0", 14_330, "fe80::1%en0,14330", "fe80::1%en0") - ] - for (host, port, name, dialled) in cases { - let server = try entry(host: host, port: port, mode: .required) - #expect(server.name == name, "\(host)") - #expect(lines(server).prefix(3) == ["[\(name)]", "host = \(dialled)", "port = \(port)"], "\(host)") - } - } - - @Test("A host name in brackets is read without them, as libtds reads one") - func bracketsAroundAHostAreDropped() throws { - let server = try entry(host: "[db.example.com]", mode: .required) - - #expect(server.name == "db.example.com") - #expect(lines(server).prefix(2) == ["[db.example.com]", "host = db.example.com"]) - } - - @Test("Every entry states the authority, the hostname check and the service principal, so a section named global cannot lend its own") - func everyEntryIsSelfContained() throws { - for mode in SSLMode.allCases { - let options = lines(try entry(mode: mode)).dropFirst().map { - $0.split(separator: "=", maxSplits: 1).first.map { $0.trimmingCharacters(in: .whitespaces) } ?? "" - } - #expect(options == [ - "host", "port", "tds version", "encryption", "ca file", "check certificate hostname", "spn" - ], "\(mode)") - } - } - - @Test("Windows Authentication writes its service principal into the entry, past the 128 bytes a login field takes") - func servicePrincipalIsWritten() throws { - let host = "sql-prod-availability-group-listener-001.finance.emea.corp.contoso-international.com" - let principal = "MSSQLSvc/\(host):1433@CORP.CONTOSO-INTERNATIONAL.COM" - let options = MSSQLConnectionOptions( - host: host, - user: "", - password: "", - database: "app", - authMethod: .windows, - kerberosServicePrincipal: principal - ) - - let server = try MSSQLFreeTDSServerEntry(options: options) - - #expect(principal.utf8.count > 128) - #expect(server.servicePrincipal == principal) - #expect(lines(server).last == "spn = \(principal)") - } - - @Test("A service principal is written only for Windows Authentication") - func servicePrincipalNeedsWindowsAuthentication() throws { - let options = MSSQLConnectionOptions( - host: "db.example.com", - user: "sa", - password: "secret", - database: "app", - kerberosServicePrincipal: "MSSQLSvc/db.example.com:1433@EXAMPLE.COM" - ) - - let server = try MSSQLFreeTDSServerEntry(options: options) - - #expect(server.servicePrincipal == nil) - #expect(lines(server).last == "spn =") - } - - @Test("A service principal libtds would cut short or reshape is refused", arguments: [ - "MSSQLSvc/" + String(repeating: "h", count: 240) + ":1433@EXAMPLE.COM", - "MSSQLSvc/db.example.com:1433@EXAMPLE;COM", - "MSSQLSvc/db.example.com:1433@EXAMPLE#COM", - "MSSQLSvc/db.example.com:1433\n\tencryption = off" - ]) - func unreadableServicePrincipals(principal: String) { - #expect(throws: MSSQLFreeTDSConfigError.unreadableServicePrincipal) { - try MSSQLFreeTDSServerEntry( - host: "db.example.com", - port: 1_433, - encryption: .require, - verification: .none, - caCertificatePath: nil, - servicePrincipal: principal - ) - } - } - - @Test("Verify CA pins an authority and turns off the hostname check libtds makes by default") - func verifyCaConfiguration() throws { - let text = lines(try entry(mode: .verifyCa)) - - #expect(text.contains("encryption = require")) - #expect(text.contains("ca file = \(MSSQLFreeTDSConfig.systemTrustStorePath)")) - #expect(text.contains("check certificate hostname = no")) - } - - @Test("Verify Identity also checks the hostname") - func verifyIdentityConfiguration() throws { - let text = lines(try entry(mode: .verifyIdentity)) - - #expect(text.contains("ca file = \(MSSQLFreeTDSConfig.systemTrustStorePath)")) - #expect(text.contains("check certificate hostname = yes")) - } - - @Test("A user supplied authority wins over the system trust store") - func userSuppliedAuthority() throws { - let text = lines(try entry(mode: .verifyCa, caCertificatePath: "/Users/me/My Certs/corp-ca.pem")) - - #expect(text.contains("ca file = /Users/me/My Certs/corp-ca.pem")) - #expect(!text.contains("ca file = \(MSSQLFreeTDSConfig.systemTrustStorePath)")) - } - - @Test("A blank authority path falls back to the system trust store") - func blankAuthorityFallsBack() { - #expect(MSSQLFreeTDSConfig.authorityPath(userSupplied: nil) == MSSQLFreeTDSConfig.systemTrustStorePath) - #expect(MSSQLFreeTDSConfig.authorityPath(userSupplied: " ") == MSSQLFreeTDSConfig.systemTrustStorePath) - #expect(MSSQLFreeTDSConfig.authorityPath(userSupplied: "/tmp/ca.pem") == "/tmp/ca.pem") - } - - @Test("A non-verifying mode names no authority and checks no hostname, even when a path is set") - func nonVerifyingConfiguration() throws { - for mode in [SSLMode.disabled, .preferred, .required] { - let text = lines(try entry(mode: mode, caCertificatePath: "/tmp/ca.pem")) - #expect(text.contains("ca file ="), "\(mode)") - #expect(text.contains("check certificate hostname = no"), "\(mode)") - } - } - - @Test("The entry for a connection carries its level, its checks and its authority") - func entryFromConnectionOptions() throws { - var options = MSSQLConnectionOptions( - host: "db.example.com", - port: 14_330, - user: "sa", - password: "secret", - database: "app", - encryptionLevel: MSSQLSSLMapping.encryptionLevel(for: .verifyIdentity) - ) - options.certificateVerification = MSSQLSSLMapping.certificateVerification(for: .verifyIdentity) - options.caCertificatePath = "/certs/corp.pem" - - let text = lines(try MSSQLFreeTDSServerEntry(options: options)) - - #expect(text == [ - "[db.example.com]", - "host = db.example.com", - "port = 14330", - "tds version = 7.4", - "encryption = require", - "ca file = /certs/corp.pem", - "check certificate hostname = yes", - "spn =" - ]) - } - - @Test("A connection built without a level asks for request, never off") - func defaultLevel() { - let options = MSSQLConnectionOptions(host: "db", user: "sa", password: "secret", database: "app") - #expect(options.encryptionLevel == .request) - } - - @Test("A host that would read back as something else is refused", arguments: [ - "", - "db.example.com\n\tencryption = off", - "db.example.com\r", - "db example.com", - "[db.example.com", - "db.example.com]", - "[]", - "[[::1]]", - "db=example.com", - "db.example.com;comment", - "db.example.com#comment", - String(repeating: "a", count: 250) - ]) - func unreadableHosts(host: String) { - #expect(throws: MSSQLFreeTDSConfigError.unreadableHost) { - try entry(host: host, mode: .required) - } - } - - @Test("Host names are written as given", arguments: [ - "localhost", "sql-01.corp.example.com", "MyServer", "myserver.database.windows.net" - ]) - func readableHosts(host: String) throws { - #expect(try entry(host: host, mode: .required).name == host) - } - - @Test("A port outside 1 to 65535 is refused", arguments: [0, -1, 65_536]) - func invalidPorts(port: Int) { - #expect(throws: MSSQLFreeTDSConfigError.invalidPort(port)) { - try entry(port: port, mode: .required) - } - } - - @Test("An authority path libtds would cut short or reshape is refused", arguments: [ - "/certs/ca;old.pem", - "/certs/#1.pem", - "/certs/ca.pem ", - "/certs/two spaces.pem", - "/certs/ca\n\tcheck certificate hostname = no", - "/" + String(repeating: "c", count: 250) - ]) - func unreadableAuthorityPaths(path: String) { - #expect(throws: MSSQLFreeTDSConfigError.unreadableAuthorityPath) { - try entry(mode: .verifyCa, caCertificatePath: path) - } - } - - @Test("The system trust store is present on this machine") - func systemTrustStoreExists() { - #expect(FileManager.default.fileExists(atPath: MSSQLFreeTDSConfig.systemTrustStorePath)) - } -} diff --git a/TableProTests/Plugins/MSSQLSSLMappingTests.swift b/TableProTests/Plugins/MSSQLSSLMappingTests.swift deleted file mode 100644 index 52e2e1cf9c..0000000000 --- a/TableProTests/Plugins/MSSQLSSLMappingTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// MSSQLSSLMappingTests.swift -// TableProTests -// - -import Foundation -import TableProMSSQLCore -import TableProPluginKit -import Testing - -struct MSSQLSSLMappingTests { - @Test("disabled maps to request: off would send the login unencrypted, and a server that forces encryption drops it") - func disabled() { - #expect(MSSQLSSLMapping.encryptionLevel(for: .disabled) == .request) - } - - @Test("preferred maps to request") - func preferred() { - #expect(MSSQLSSLMapping.encryptionLevel(for: .preferred) == .request) - } - - @Test("required maps to require") - func required() { - #expect(MSSQLSSLMapping.encryptionLevel(for: .required) == .require) - } - - @Test("verifyCa maps to require") - func verifyCa() { - #expect(MSSQLSSLMapping.encryptionLevel(for: .verifyCa) == .require) - } - - @Test("verifyIdentity maps to require") - func verifyIdentity() { - #expect(MSSQLSSLMapping.encryptionLevel(for: .verifyIdentity) == .require) - } -} diff --git a/TableProTests/Plugins/MSSQLSessionTransactionTests.swift b/TableProTests/Plugins/MSSQLSessionTransactionTests.swift deleted file mode 100644 index 94696fbd26..0000000000 --- a/TableProTests/Plugins/MSSQLSessionTransactionTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// MSSQLSessionTransactionTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct MSSQLSessionTransactionTests { - @Test("A transaction count above zero is an open transaction") - func openTransactionIsCounted() { - #expect(MSSQLSessionTransaction.state(tranCount: "1", transactionState: "1") == .inTransaction) - #expect(MSSQLSessionTransaction.state(tranCount: "2", transactionState: "1") == .inTransaction) - } - - @Test("Nothing open reads as idle") - func noTransactionIsIdle() { - #expect(MSSQLSessionTransaction.state(tranCount: "0", transactionState: "0") == .idle) - } - - /// `XACT_STATE()` answers -1 for a transaction that can no longer be committed, which is what a - /// batch-aborting error leaves behind. Telling the user to commit that one discards their work. - @Test("An uncommittable transaction is its own answer") - func uncommittableTransactionIsAborted() { - #expect(MSSQLSessionTransaction.state(tranCount: "1", transactionState: "-1") == .abortedTransaction) - #expect(MSSQLSessionTransaction.state(tranCount: "0", transactionState: "-1") == .abortedTransaction) - } - - @Test("A count that is missing or not a number says nothing") - func unreadableCountIsUnknown() { - #expect(MSSQLSessionTransaction.state(tranCount: nil, transactionState: "0") == .unknown) - #expect(MSSQLSessionTransaction.state(tranCount: "", transactionState: "0") == .unknown) - #expect(MSSQLSessionTransaction.state(tranCount: "none", transactionState: "0") == .unknown) - } - - @Test("Padding around the numbers a driver returns is read through") - func paddedNumbersParse() { - #expect(MSSQLSessionTransaction.state(tranCount: " 1 ", transactionState: " 1 ") == .inTransaction) - #expect(MSSQLSessionTransaction.state(tranCount: " 0 ", transactionState: " -1 ") == .abortedTransaction) - } - - /// Measured on Azure SQL Edge: with `SET IMPLICIT_TRANSACTIONS ON` and nothing run since, - /// `@@TRANCOUNT` is 0 and stays 0, so nothing is pending and a caller's own transaction commits - /// only its own statements. The first statement after that makes it 1. - @Test("Implicit transactions on their own are not a transaction") - func implicitTransactionsAloneAreIdle() { - #expect(MSSQLSessionTransaction.state(tranCount: "0", transactionState: "0") == .idle) - #expect(MSSQLSessionTransaction.probe == "SELECT @@TRANCOUNT, XACT_STATE()") - } -} diff --git a/TableProTests/Plugins/MSSQLStatementGeneratorTests.swift b/TableProTests/Plugins/MSSQLStatementGeneratorTests.swift deleted file mode 100644 index a10b984bf0..0000000000 --- a/TableProTests/Plugins/MSSQLStatementGeneratorTests.swift +++ /dev/null @@ -1,219 +0,0 @@ -// -// MSSQLStatementGeneratorTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct MSSQLStatementGeneratorTests { - private let columns = ["ID", "Status", "Qty"] - - private func generator(primaryKeys: [String] = ["ID"]) -> MSSQLStatementGenerator { - MSSQLStatementGenerator(qualifiedTable: "[dbo].[defs]", columns: columns, primaryKeyColumns: primaryKeys) - } - - private func update(_ column: String, to value: PluginCellValue) -> PluginRowChange { - PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: column, oldValue: .text("old"), newValue: value)], - originalRow: [.text("1"), .text("old"), .text("1")] - ) - } - - @Test - func defaultMarkerInSetBecomesTheDefaultKeywordAndBindsNothing() throws { - let statement = try #require(try generator().updateStatement(for: update("Status", to: .text("__DEFAULT__")))) - #expect(statement.statement == "UPDATE [dbo].[defs] SET [Status] = DEFAULT WHERE [ID] = ?") - #expect(statement.parameters == [.text("1")]) - } - - @Test - func ordinaryValueInSetIsBound() throws { - let statement = try #require(try generator().updateStatement(for: update("Status", to: .text("new")))) - #expect(statement.statement == "UPDATE [dbo].[defs] SET [Status] = ? WHERE [ID] = ?") - #expect(statement.parameters == [.text("new"), .text("1")]) - } - - @Test - func keylessUpdateMatchesEveryColumnAndTouchesOneRow() throws { - let statement = try #require(try generator(primaryKeys: []).updateStatement(for: update("Status", to: .text("new")))) - #expect( - statement.statement - == "SET NOCOUNT OFF; UPDATE TOP (1) [dbo].[defs] SET [Status] = ? WHERE [ID] = ? AND [Status] = ? AND [Qty] = ?" - ) - #expect(statement.parameters == [.text("new"), .text("1"), .text("old"), .text("1")]) - } - - @Test - func keylessMatchOnAnEmptyStringComparesItRatherThanAskingForNull() throws { - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "Status", oldValue: .text(""), newValue: .text("new"))], - originalRow: [.text("1"), .text(""), .null] - ) - let statement = try #require(try generator(primaryKeys: []).updateStatement(for: change)) - #expect(statement.statement.hasSuffix("WHERE [ID] = ? AND [Status] = ? AND [Qty] IS NULL")) - #expect(statement.parameters == [.text("new"), .text("1"), .text("")]) - } - - @Test - func anUnboundKeylessWriteSetsTheCountInAScopeOfItsOwn() throws { - let change = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: [.null, .null, .null]) - let statement = try #require(try generator(primaryKeys: []).deleteStatement(for: change)) - #expect( - statement.statement - == "EXEC sp_executesql N'SET NOCOUNT OFF; DELETE TOP (1) FROM [dbo].[defs] WHERE [ID] IS NULL AND [Status] IS NULL AND [Qty] IS NULL'" - ) - #expect(statement.parameters.isEmpty) - } - - @Test - func keyedWritesLeaveTheSessionsCountSettingAlone() throws { - let change = update("Status", to: .text("new")) - let written = try #require(try generator().updateStatement(for: change)) - let deleted = try #require(try generator().deleteStatement(for: change)) - #expect(!written.statement.contains("NOCOUNT")) - #expect(!deleted.statement.contains("NOCOUNT")) - } - - @Test - func insertLeavesDefaultMarkedColumnsOut() throws { - let statement = try generator().insertStatement(values: [.text("__DEFAULT__"), .text("open"), .text("__DEFAULT__")]) - #expect(statement.statement == "INSERT INTO [dbo].[defs] ([Status]) VALUES (?)") - #expect(statement.parameters == [.text("open")]) - } - - @Test - func insertOfOnlyDefaultsUsesDefaultValues() throws { - let marker = PluginCellValue.text("__DEFAULT__") - let statement = try generator().insertStatement(values: [marker, marker, marker]) - #expect(statement.statement == "INSERT INTO [dbo].[defs] DEFAULT VALUES") - #expect(statement.parameters.isEmpty) - } - - @Test - func updateWithoutCellChangesWritesNothing() { - let probe = PluginRowChange(rowIndex: 0, type: .update, cellChanges: [], originalRow: nil) - let statements = generator().statements( - for: [probe], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - #expect(statements.isEmpty) - } - - @Test - func keylessDeleteTouchesOneRow() throws { - let change = PluginRowChange( - rowIndex: 0, type: .delete, cellChanges: [], originalRow: [.text("1"), .null, .text("2")] - ) - let statement = try #require(try generator(primaryKeys: []).deleteStatement(for: change)) - #expect( - statement.statement - == "SET NOCOUNT OFF; DELETE TOP (1) FROM [dbo].[defs] WHERE [ID] = ? AND [Status] IS NULL AND [Qty] = ?" - ) - #expect(statement.parameters == [.text("1"), .text("2")]) - } - - @Test - func deletesFollowEveryOtherWrite() { - let delete = PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: [.text("2"), .null, .null]) - let insert = PluginRowChange(rowIndex: 2, type: .insert, cellChanges: [], originalRow: nil) - let statements = generator().statements( - for: [delete, insert], - insertedRowData: [2: [.text("__DEFAULT__"), .text("a"), .text("3")]], - deletedRowIndices: [1], - insertedRowIndices: [2] - ) - #expect(statements.map(\.statement) == [ - "INSERT INTO [dbo].[defs] ([Status], [Qty]) VALUES (?, ?)", - "DELETE FROM [dbo].[defs] WHERE [ID] = ?" - ]) - } - - private func keyless(context: PluginRowWriteContext) -> MSSQLStatementGenerator { - var generator = MSSQLStatementGenerator(qualifiedTable: "[dbo].[notes]", columns: ["a", "n", "s"], primaryKeyColumns: []) - generator.context = context - return generator - } - - private func keylessEdit(original: [PluginCellValue]) -> PluginRowChange { - PluginRowChange( - rowIndex: 4, - type: .update, - cellChanges: [(columnIndex: 2, columnName: "s", oldValue: original[2], newValue: .text("EDITED"))], - originalRow: original - ) - } - - @Test - func keylessMatchWithAColumnLeftOutIsRefusedRatherThanWritingAnotherRow() { - var context = PluginRowWriteContext() - context.rowMatchExcludedColumns = ["n"] - let change = keylessEdit(original: [.text("1"), .text("second"), .text("keep")]) - #expect(throws: PluginRowWriteRefusal.self) { - _ = try keyless(context: context).updateStatement(for: change) - } - #expect(throws: PluginRowWriteRefusal.self) { - _ = try keyless(context: context).deleteStatement(for: change) - } - } - - @Test - func keylessMatchCastsTextPolicyColumnsToTheBoundType() throws { - var context = PluginRowWriteContext() - context.rowMatchTextColumns = ["n", "s"] - let change = keylessEdit(original: [.text("1"), .text("long text"), .bytes(Data([0x01]))]) - let statement = try #require(try keyless(context: context).updateStatement(for: change)) - #expect(statement.statement.hasSuffix( - "WHERE [a] = ? AND CAST([n] AS NVARCHAR(MAX)) = ? AND CAST([s] AS VARBINARY(MAX)) = ?" - )) - } - - @Test - func anUpdateOfAServerOwnedColumnIsRefused() { - var owning = generator() - owning.context.serverOwnedColumns = ["ID"] - let change = PluginRowChange( - rowIndex: 2, - type: .update, - cellChanges: [(columnIndex: 0, columnName: "ID", oldValue: .text("1761"), newValue: .text("1890"))], - originalRow: [.text("1761"), .text("old"), .text("1")] - ) - do { - _ = try owning.updateStatement(for: change) - Issue.record("An IDENTITY column was assigned") - } catch let refusal as PluginRowWriteRefusal { - #expect(refusal.rowIndex == 2) - #expect(refusal.reason.contains("ID")) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test - func anInsertCarryingAValueForAServerOwnedColumnIsRefused() { - var owning = generator() - owning.context.serverOwnedColumns = ["ID"] - #expect(throws: PluginRowWriteRefusal.self) { - _ = try owning.insertStatement(values: [.text("7"), .text("a"), .text("1")], rowIndex: 1) - } - } - - @Test - func eachWriteNamesTheChangeItCarriesOut() throws { - let first = update("Status", to: .text("x")) - let second = PluginRowChange( - rowIndex: 3, - type: .update, - cellChanges: [(columnIndex: 2, columnName: "Qty", oldValue: .text("1"), newValue: .text("2"))], - originalRow: [.text("2"), .text("b"), .text("1")] - ) - let writes = try generator().rowWrites( - for: [first, second], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - #expect(writes.map(\.rowIndices) == [[0], [3]]) - } -} diff --git a/TableProTests/Plugins/MSSQLTableDefinitionSQLTests.swift b/TableProTests/Plugins/MSSQLTableDefinitionSQLTests.swift deleted file mode 100644 index 090720c04a..0000000000 --- a/TableProTests/Plugins/MSSQLTableDefinitionSQLTests.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// MSSQLTableDefinitionSQLTests.swift -// TableProTests -// -// The CREATE TABLE the SQL Server driver writes for a copied or new table. -// - -import Foundation -import TableProPluginKit -import Testing - -struct MSSQLTableDefinitionSQLTests { - private static func column(_ name: String, primaryKey: Bool = false) -> PluginColumnDefinition { - PluginColumnDefinition(name: name, dataType: "INT", isNullable: !primaryKey, isPrimaryKey: primaryKey) - } - - private static func index(_ name: String, _ columns: [String], type: String?) -> PluginIndexDefinition { - PluginIndexDefinition(name: name, columns: columns, indexType: type) - } - - private static func createTable( - columns: [PluginColumnDefinition], - indexes: [PluginIndexDefinition] - ) -> String? { - MSSQLTableDefinitionSQL.createTable( - PluginCreateTableDefinition(tableName: "orders", columns: columns, indexes: indexes), - schema: "dbo" - ) - } - - /// A table holds one clustered index and a primary key is clustered unless it says otherwise, so - /// the copied `CLUSTERED` index would be refused with "Cannot create more than one clustered index". - @Test("A single-column key is written NONCLUSTERED when another index is CLUSTERED") - func inlineKeyYieldsToAClusteredIndex() { - let sql = Self.createTable( - columns: [Self.column("id", primaryKey: true), Self.column("placed_at")], - indexes: [Self.index("ix_placed", ["placed_at"], type: "CLUSTERED")] - ) - #expect(sql == """ - CREATE TABLE [dbo].[orders] ( - [id] INT NOT NULL PRIMARY KEY NONCLUSTERED, - [placed_at] INT NULL - ); - - CREATE CLUSTERED INDEX [ix_placed] ON [dbo].[orders] ([placed_at]); - """) - } - - @Test("A composite key is written NONCLUSTERED when another index is CLUSTERED") - func compositeKeyYieldsToAClusteredIndex() throws { - let sql = try #require(Self.createTable( - columns: [Self.column("a", primaryKey: true), Self.column("b", primaryKey: true), Self.column("c")], - indexes: [Self.index("ix_c", ["c"], type: "clustered")] - )) - #expect(sql.contains(" PRIMARY KEY NONCLUSTERED ([a], [b])")) - #expect(!sql.contains("[a] INT NOT NULL PRIMARY KEY")) - } - - @Test("Without a CLUSTERED index the key keeps the server's default") - func keyStaysDefaultWithoutAClusteredIndex() throws { - let sql = try #require(Self.createTable( - columns: [Self.column("id", primaryKey: true), Self.column("placed_at")], - indexes: [ - Self.index("ix_placed", ["placed_at"], type: "NONCLUSTERED"), - Self.index("ix_other", ["placed_at"], type: "BTREE") - ] - )) - #expect(sql.contains("[id] INT NOT NULL PRIMARY KEY,")) - #expect(!sql.contains("NONCLUSTERED,")) - #expect(sql.contains("CREATE NONCLUSTERED INDEX [ix_placed] ON [dbo].[orders] ([placed_at])")) - #expect(sql.contains("CREATE INDEX [ix_other] ON [dbo].[orders] ([placed_at])")) - } - - @Test("A column added to an existing table never carries a key clause") - func addedColumnHasNoKeyClause() { - #expect(MSSQLTableDefinitionSQL.columnDefinition(Self.column("id", primaryKey: true), inlinePrimaryKey: nil) - == "[id] INT NOT NULL") - } - - @Test("A table with no columns writes nothing") - func emptyTableWritesNothing() { - #expect(Self.createTable(columns: [], indexes: []) == nil) - #expect(MSSQLTableDefinitionSQL.createTableStatements( - PluginCreateTableDefinition(tableName: "orders", columns: []), schema: "dbo" - ) == nil) - } - - // MARK: - Descriptions - - private static func commented(_ name: String, _ comment: String?) -> PluginColumnDefinition { - PluginColumnDefinition(name: name, dataType: "INT", isNullable: true, comment: comment) - } - - @Test("Create Table writes each column description after the table and its indexes") - func createTableStatementsCarryColumnDescriptions() throws { - let statements = try #require(MSSQLTableDefinitionSQL.createTableStatements( - PluginCreateTableDefinition( - tableName: "orders", - columns: [Self.commented("id", "The key"), Self.commented("note", nil), Self.commented("x", "")], - indexes: [Self.index("ix_note", ["note"], type: nil)] - ), - schema: "dbo" - )) - #expect(statements == [ - "CREATE TABLE [dbo].[orders] (\n [id] INT NULL,\n [note] INT NULL,\n [x] INT NULL\n)", - "CREATE INDEX [ix_note] ON [dbo].[orders] ([note])", - "EXEC sys.sp_addextendedproperty @name = N'MS_Description', @value = N'The key', " - + "@level0type = N'SCHEMA', @level0name = N'dbo', @level1type = N'TABLE', @level1name = N'orders', " - + "@level2type = N'COLUMN', @level2name = N'id'", - ]) - } - - /// An older app sends this text whole, so it stays the table and its indexes only. - @Test("The single Create Table text carries no description") - func createTableTextLeavesDescriptionsOut() throws { - let sql = try #require(Self.createTable(columns: [Self.commented("id", "The key")], indexes: [])) - #expect(sql == "CREATE TABLE [dbo].[orders] (\n [id] INT NULL\n);") - } - - @Test("A table comment updates the description when it exists and adds it when it does not") - func tableCommentUpserts() { - let sql = MSSQLTableDefinitionSQL.commentStatement( - objectType: "TABLE", schema: "dbo", object: "orders", comment: "Placed orders" - ) - let target = "@level0type = N'SCHEMA', @level0name = N'dbo', @level1type = N'TABLE', @level1name = N'orders'" - #expect(sql == """ - IF EXISTS (SELECT 1 FROM sys.fn_listextendedproperty(N'MS_Description', N'SCHEMA', N'dbo', \ - N'TABLE', N'orders', NULL, NULL)) - EXEC sys.sp_updateextendedproperty @name = N'MS_Description', @value = N'Placed orders', \(target) - ELSE - EXEC sys.sp_addextendedproperty @name = N'MS_Description', @value = N'Placed orders', \(target) - """) - } - - @Test("A view comment names the view level") - func viewCommentUsesTheViewLevel() throws { - let sql = try #require(MSSQLTableDefinitionSQL.commentStatement( - objectType: "VIEW", schema: "sales", object: "v", comment: "c" - )) - #expect(sql.contains("N'SCHEMA', N'sales', N'VIEW', N'v', NULL, NULL")) - #expect(sql.contains("@level1type = N'VIEW', @level1name = N'v'")) - #expect(!sql.contains("N'TABLE'")) - } - - @Test("A nil or empty comment drops the description only when there is one") - func emptyCommentDrops() { - let expected = """ - IF EXISTS (SELECT 1 FROM sys.fn_listextendedproperty(N'MS_Description', N'SCHEMA', N'dbo', \ - N'TABLE', N'orders', NULL, NULL)) - EXEC sys.sp_dropextendedproperty @name = N'MS_Description', @level0type = N'SCHEMA', \ - @level0name = N'dbo', @level1type = N'TABLE', @level1name = N'orders' - """ - for comment in [nil, ""] as [String?] { - let sql = MSSQLTableDefinitionSQL.commentStatement( - objectType: "TABLE", schema: "dbo", object: "orders", comment: comment - ) - #expect(sql == expected) - } - } - - @Test("Other kinds cannot be commented", arguments: ["MATERIALIZED VIEW", "SYSTEM TABLE", "SEQUENCE"]) - func otherKindsHaveNoCommentStatement(kind: String) { - #expect(MSSQLTableDefinitionSQL.commentStatement(objectType: kind, schema: "s", object: "t", comment: nil) == nil) - } - - @Test("Names and the comment are N literals with their quotes doubled") - func descriptionLiteralsAreEscaped() throws { - let sql = try #require(MSSQLTableDefinitionSQL.commentStatement( - objectType: "TABLE", schema: "o'neil", object: "it's", comment: "日本'語" - )) - #expect(sql.contains("N'SCHEMA', N'o''neil', N'TABLE', N'it''s', NULL, NULL")) - #expect(sql.contains("@value = N'日本''語'")) - #expect(!sql.contains("N'o'neil'")) - } - - @Test("A view description is read from the view level") - func viewDescriptionQuery() { - #expect(MSSQLTableDefinitionSQL.descriptionQuery(schema: "dbo", objectKind: "VIEW", object: "v") - == "SELECT CAST(value AS NVARCHAR(MAX)) FROM sys.fn_listextendedproperty(N'MS_Description', " - + "N'SCHEMA', N'dbo', N'VIEW', N'v', NULL, NULL)") - } -} diff --git a/TableProTests/Plugins/MSSQLTypeQueryTests.swift b/TableProTests/Plugins/MSSQLTypeQueryTests.swift deleted file mode 100644 index a849cefd60..0000000000 --- a/TableProTests/Plugins/MSSQLTypeQueryTests.swift +++ /dev/null @@ -1,252 +0,0 @@ -// -// MSSQLTypeQueryTests.swift -// TableProTests -// -// The catalog SQL and the CREATE TYPE synthesis for SQL Server user-defined types. -// - -import Foundation -import Testing - -@testable import TablePro - -struct MSSQLTypeQueryTests { - @Test("Only user-defined types are listed, and the three kinds are separated") - func listsOnlyUserDefinedTypes() { - let sql = MSSQLTypeQueries.userDefinedTypeList(schema: "dbo") - #expect(sql.contains("t.is_user_defined = 1")) - #expect(sql.contains("WHEN t.is_table_type = 1 THEN 'TABLE'")) - #expect(sql.contains("WHEN t.is_assembly_type = 1 THEN 'CLR'")) - #expect(sql.contains("ELSE 'ALIAS'")) - } - - /// `user_type_id` survives a rename and never changes, unlike the name the listing showed. - @Test("A type is addressed by user_type_id, not by name or kind") - func identityIsUserTypeId() { - #expect(MSSQLTypeQueries.userDefinedTypeList(schema: "dbo") - .contains("CONVERT(varchar(11), t.user_type_id) AS identity_id")) - } - - /// max_length is in bytes, so an n-type is halved, and -1 is MAX. Measured against SQL Server - /// 2022: nvarchar(320) reports 640 and nvarchar(max) reports -1. - @Test("The base type spelling handles the byte-length and MAX traps") - func baseTypeSpellingHandlesLengths() { - let sql = MSSQLTypeQueries.userDefinedTypeList(schema: "dbo") - #expect(sql.contains("bt.name IN ('nvarchar', 'nchar')")) - #expect(sql.contains("CONVERT(varchar(11), t.max_length / 2)")) - #expect(sql.contains("WHEN t.max_length = -1 THEN 'max'")) - #expect(sql.contains("bt.name IN ('decimal', 'numeric')")) - } - - @Test("A quote in a schema or type name is escaped as a national literal") - func literalsAreEscaped() { - #expect(MSSQLTypeQueries.userDefinedTypeList(schema: "it's").contains("N'it''s'")) - #expect(MSSQLTypeQueries.tableTypeColumns(schema: "販売", name: "o'brien") - .contains("N'o''brien'")) - #expect(MSSQLTypeQueries.tableTypeIndexes(schema: "dbo", name: "it's").contains("N'it''s'")) - } - - /// The hidden table behind a table type is what sys.columns is keyed on; sys.table_types alone - /// has no columns. - @Test("Table type columns come through type_table_object_id in declaration order") - func tableTypeColumnsReadTheHiddenTable() { - let sql = MSSQLTypeQueries.tableTypeColumns(schema: "dbo", name: "t") - #expect(sql.contains("c.object_id = tt.type_table_object_id")) - #expect(sql.contains("ORDER BY c.column_id")) - #expect(sql.contains("sys.identity_columns")) - #expect(sql.contains("sys.computed_columns")) - #expect(sql.contains("sys.default_constraints")) - } - - /// An INCLUDE column is not part of the key, and listing it as one is the same defect the - /// table index reader carries. Rows come back one key at a time, so they group by index before - /// they sort by ordinal; the caller folds them back into one index each. - @Test("Table type index keys exclude INCLUDE columns and sort by index then key ordinal") - func indexKeysExcludeIncludedColumns() { - let sql = MSSQLTypeQueries.tableTypeIndexes(schema: "dbo", name: "t") - #expect(sql.contains("ic.is_included_column = 0")) - #expect(sql.contains("ORDER BY i.index_id, ic.key_ordinal")) - #expect(sql.contains("i.type > 0")) - } -} - -struct MSSQLTypeDefinitionTests { - /// Executed verbatim against SQL Server 2022 and accepted. - @Test("An alias type rebuilds its CREATE TYPE ... FROM statement") - func aliasStatement() { - #expect(MSSQLTypeDefinition.aliasStatement( - schema: "dbo", name: "EmailAddress", baseType: "nvarchar(320)", isNullable: false - ) == "CREATE TYPE [dbo].[EmailAddress] FROM nvarchar(320) NOT NULL;") - #expect(MSSQLTypeDefinition.aliasStatement( - schema: "sales", name: "BigText", baseType: "nvarchar(max)", isNullable: true - ) == "CREATE TYPE [sales].[BigText] FROM nvarchar(max) NULL;") - } - - @Test("A bracket in a name is doubled, not left to close the identifier early") - func bracketsAreEscaped() { - #expect(MSSQLTypeDefinition.bracketed("a]b") == "[a]]b]") - #expect(MSSQLTypeDefinition.aliasStatement( - schema: "dbo", name: "a]b", baseType: "int", isNullable: true - ).contains("[a]]b]")) - } - - /// The whole shape, measured: this statement was executed against SQL Server 2022 and created - /// a type matching the fixture it was rebuilt from. - @Test("A table type rebuilds identity, default, collation, computed column and inline index") - func tableStatement() { - let sql = MSSQLTypeDefinition.tableStatement( - schema: "dbo", - name: "OrderLineTable", - columns: [ - .init(name: "LineId", type: "int", isNullable: false, identitySpec: "1,1"), - .init(name: "Sku", type: "varchar(32)", isNullable: false, collation: "SQL_Latin1_General_CP1_CI_AS"), - .init(name: "Qty", type: "int", isNullable: false, defaultDefinition: "((1))"), - .init(name: "Price", type: "decimal(18,4)", isNullable: true), - .init(name: "Note", type: "nvarchar(200)", isNullable: true, collation: "Latin1_General_BIN2"), - .init(name: "Total", type: nil, isNullable: true, computedDefinition: "([Qty]*[Price])") - ], - indexes: [ - .init(name: "PK__TT_Order__2EAE", isPrimaryKey: true, isUnique: true, - typeDescription: "CLUSTERED", keys: [.init(column: "LineId", isDescending: false)]), - .init(name: "ix_sku", isPrimaryKey: false, isUnique: false, - typeDescription: "NONCLUSTERED", keys: [.init(column: "Sku", isDescending: false)]) - ], - databaseCollation: "SQL_Latin1_General_CP1_CI_AS" - ) - #expect(sql.hasPrefix("CREATE TYPE [dbo].[OrderLineTable] AS TABLE (")) - #expect(sql.contains("[LineId] int IDENTITY(1,1) NOT NULL PRIMARY KEY CLUSTERED")) - #expect(sql.contains("[Qty] int NOT NULL DEFAULT ((1))")) - #expect(sql.contains("[Total] AS ([Qty]*[Price])")) - #expect(sql.contains("INDEX [ix_sku] NONCLUSTERED ([Sku])")) - #expect(sql.hasSuffix(");")) - } - - /// A column keeps its collation in sys.columns whether or not it differs, so emitting it - /// unconditionally puts a COLLATE clause on every column the user never wrote. - @Test("Only a collation that differs from the database's is spelled out") - func collationIsOmittedWhenItMatchesTheDatabase() { - let sql = MSSQLTypeDefinition.tableStatement( - schema: "dbo", - name: "t", - columns: [ - .init(name: "Sku", type: "varchar(32)", isNullable: false, collation: "SQL_Latin1_General_CP1_CI_AS"), - .init(name: "Note", type: "nvarchar(200)", isNullable: true, collation: "Latin1_General_BIN2") - ], - indexes: [], - databaseCollation: "SQL_Latin1_General_CP1_CI_AS" - ) - #expect(!sql.contains("[Sku] varchar(32) COLLATE")) - #expect(sql.contains("[Note] nvarchar(200) COLLATE Latin1_General_BIN2 NULL")) - } - - /// The constraint name is server-generated, so carrying it forward is noise; a multi-column key - /// still needs its own clause because it cannot sit on one column. - @Test("A composite primary key becomes its own clause rather than an inline one") - func compositePrimaryKeyIsATableClause() { - let sql = MSSQLTypeDefinition.tableStatement( - schema: "dbo", - name: "t", - columns: [ - .init(name: "a", type: "int", isNullable: false), - .init(name: "b", type: "int", isNullable: false) - ], - indexes: [.init(name: "PK__x", isPrimaryKey: true, isUnique: true, typeDescription: "NONCLUSTERED", - keys: [.init(column: "a", isDescending: false), .init(column: "b", isDescending: false)])], - databaseCollation: nil - ) - #expect(sql.contains("PRIMARY KEY NONCLUSTERED ([a], [b])")) - #expect(!sql.contains("[a] int NOT NULL PRIMARY KEY")) - #expect(!sql.contains("PK__x")) - } - - /// SQL Server defaults a primary key to CLUSTERED, so a NONCLUSTERED one replayed with a - /// different layout and failed outright when the type also had a clustered secondary index. - @Test("A NONCLUSTERED primary key keeps its clustering on the inline form too") - func inlinePrimaryKeyKeepsClustering() { - let sql = MSSQLTypeDefinition.tableStatement( - schema: "dbo", - name: "t", - columns: [.init(name: "Id", type: "int", isNullable: false)], - indexes: [.init(name: "PK__x", isPrimaryKey: true, isUnique: true, - typeDescription: "NONCLUSTERED", keys: [.init(column: "Id", isDescending: false)])], - databaseCollation: nil - ) - #expect(sql.contains("[Id] int NOT NULL PRIMARY KEY NONCLUSTERED")) - } - - /// A comma is legal inside a bracketed identifier, so the comma-joined string this replaced - /// turned one column into two; and the string had nowhere to carry DESC at all. - @Test("A descending key keeps its direction and a comma in a name stays one column") - func descendingKeysAndCommasSurvive() { - let sql = MSSQLTypeDefinition.tableStatement( - schema: "dbo", - name: "t", - columns: [.init(name: "Ranked", type: "int", isNullable: false), - .init(name: "a,b", type: "int", isNullable: false)], - indexes: [.init(name: "ix", isPrimaryKey: false, isUnique: false, typeDescription: "NONCLUSTERED", - keys: [.init(column: "Ranked", isDescending: true), - .init(column: "a,b", isDescending: false)])], - databaseCollation: nil - ) - #expect(sql.contains("INDEX [ix] NONCLUSTERED ([Ranked] DESC, [a,b])")) - } - - /// Dropping a CHECK recreates the type with weaker validation than the original. - @Test("CHECK constraints are carried into the rebuilt statement") - func checkConstraintsSurvive() { - let sql = MSSQLTypeDefinition.tableStatement( - schema: "dbo", - name: "t", - columns: [.init(name: "Amount", type: "decimal(10,2)", isNullable: true)], - indexes: [], - checkConstraints: ["([Amount]>=(0))"], - databaseCollation: nil - ) - #expect(sql.contains("CHECK ([Amount]>=(0))")) - } - - /// Without the clause the replay makes a disk-backed type, and a hash index needs its bucket - /// count or it cannot be created at all. - @Test("A memory-optimized table type keeps its option and hash bucket count") - func memoryOptimizedTableType() { - let sql = MSSQLTypeDefinition.tableStatement( - schema: "dbo", - name: "t", - columns: [.init(name: "Id", type: "int", isNullable: false)], - indexes: [.init(name: "ix_hash", isPrimaryKey: false, isUnique: false, - typeDescription: "NONCLUSTERED HASH", - keys: [.init(column: "Id", isDescending: false)], bucketCount: 1_024)], - isMemoryOptimized: true, - databaseCollation: nil - ) - #expect(sql.contains("INDEX [ix_hash] HASH ([Id]) WITH (BUCKET_COUNT = 1024)")) - #expect(sql.hasSuffix("WITH (MEMORY_OPTIMIZED = ON);")) - } - - /// A table-type column can itself be an alias or CLR type, and a bare name binds to a different - /// type or fails outright when the UDT lives outside the default schema. - @Test("A user-defined column type is rendered schema-qualified by the catalog query") - func userDefinedColumnTypesAreQualified() { - let sql = MSSQLTypeQueries.tableTypeColumns(schema: "dbo", name: "t") - #expect(sql.contains("bt.is_user_defined = 1")) - #expect(sql.contains("SCHEMA_NAME(bt.schema_id)")) - } - - @Test("Index keys and check constraints are read as structured catalog rows") - func indexAndCheckQueriesAreStructured() { - let indexes = MSSQLTypeQueries.tableTypeIndexes(schema: "dbo", name: "t") - #expect(indexes.contains("ic.is_descending_key")) - #expect(indexes.contains("sys.hash_indexes")) - #expect(!indexes.contains("FOR XML PATH")) - #expect(MSSQLTypeQueries.tableTypeCheckConstraints(schema: "dbo", name: "t") - .contains("sys.check_constraints")) - } - - /// A CLR type's managed class is free to differ from the SQL type's name, and substituting the - /// SQL name produced an EXTERNAL NAME pointing at a class that does not exist. - @Test("A CLR type names its assembly") - func clrStatementNamesTheAssembly() { - #expect(MSSQLTypeDefinition.clrStatement(schema: "dbo", name: "Geo", assembly: "SpatialLib", assemblyClass: "Spatial.Point") - == "CREATE TYPE [dbo].[Geo] EXTERNAL NAME [SpatialLib].[Spatial.Point];") - } -} diff --git a/TableProTests/Plugins/MongoBsonAssemblyTests.swift b/TableProTests/Plugins/MongoBsonAssemblyTests.swift deleted file mode 100644 index 643a4b6b56..0000000000 --- a/TableProTests/Plugins/MongoBsonAssemblyTests.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// MongoBsonAssemblyTests.swift -// TableProTests -// - -import Foundation -import JavaScriptCore -import TableProPluginKit -import Testing - -struct MongoBsonAssemblyTests { - private typealias Part = MongoBsonAssembly.Part - - @Test("Text with no operator document below the root is handed to libbson unchanged") - func textWithoutOperatorDocumentsIsWhole() { - let texts = [ - "{\"status\":\"new\",\"total\":{\"$gt\":{\"$numberInt\":\"5\"}}}", - "[{\"$match\":{\"a\":{\"$numberInt\":\"1\"}}},{\"$limit\":{\"$numberInt\":\"1\"}}]", - "{\"count\":\"t\",\"query\":{\"a\":{\"$exists\":true,\"$type\":\"binData\"}}}", - "{\"$type\":\"binData\"}", - "{\"a\":{\"$binary\":\"AAAA\",\"$type\":\"00\"}}", - "{\"a\":{\"$not\":{\"$regularExpression\":{\"pattern\":\"x\",\"options\":\"i\"}}}}", - "{\"q\":\"{\\\"$type\\\":\\\"binData\\\"}\"}", - "{\"a\":{\"k\":\"$type\"}}", - "{}" - ] - for text in texts { - #expect(MongoBsonAssembly.plan(text) == .whole(text)) - } - } - - @Test("A $type operator becomes a document whose member libbson reads with a literal key") - func typeOperatorBecomesADocument() { - #expect(MongoBsonAssembly.plan("{\"sig\":{\"$type\":\"binData\"}}") == .parts([ - .document(key: "sig", parts: [.member("{\"$type\":\"binData\"}")]) - ])) - #expect(MongoBsonAssembly.plan("{\"tags\":{\"$type\":[\"array\",\"null\"]}}") == .parts([ - .document(key: "tags", parts: [.member("{\"$type\":[\"array\",\"null\"]}")]) - ])) - } - - @Test("A $regex or $options operator becomes a document, with a regular expression value inside kept whole") - func regexOperatorBecomesADocument() { - #expect(MongoBsonAssembly.plan("{\"name\":{\"$regex\":\"^a\",\"$exists\":true}}") == .parts([ - .document(key: "name", parts: [.member("{\"$regex\":\"^a\"}"), .member("{\"$exists\":true}")]) - ])) - #expect(MongoBsonAssembly.plan("{\"name\":{\"$options\":\"i\",\"$regex\":\"^a\"}}") == .parts([ - .document(key: "name", parts: [.member("{\"$options\":\"i\"}"), .member("{\"$regex\":\"^a\"}")]) - ])) - let regexValue = "{\"$regularExpression\":{\"pattern\":\"^a\",\"options\":\"i\"}}" - #expect(MongoBsonAssembly.plan("{\"name\":{\"$regex\":\(regexValue)}}") == .parts([ - .document(key: "name", parts: [.member("{\"$regex\":\(regexValue)}")]) - ])) - } - - @Test("A pipeline is keyed by position, and only the stage holding an operator document is taken apart") - func pipelineElementsAreKeyedByPosition() { - let plan = MongoBsonAssembly.plan( - "[{\"$match\":{\"a\":{\"$type\":\"array\"}}},{\"$limit\":{\"$numberInt\":\"1\"}}]" - ) - #expect(plan == .parts([ - .document(key: "0", parts: [ - .document(key: "$match", parts: [ - .document(key: "a", parts: [.member("{\"$type\":\"array\"}")]) - ]) - ]), - .member("{\"1\":{\"$limit\":{\"$numberInt\":\"1\"}}}") - ])) - } - - @Test("An array holding an operator document becomes a BSON array") - func nestedArrayBecomesAnArray() { - let plan = MongoBsonAssembly.plan("{\"$or\":[{\"a\":{\"$type\":\"string\"}},{\"b\":true}]}") - #expect(plan == .parts([ - .array(key: "$or", parts: [ - .document(key: "0", parts: [.document(key: "a", parts: [.member("{\"$type\":\"string\"}")])]), - .member("{\"1\":{\"b\":true}}") - ]) - ])) - } - - @Test("An escaped operator key is recognised, since libbson decodes a key before reading it") - func escapedOperatorKeyIsRecognised() { - #expect(MongoBsonAssembly.plan("{\"a\":{\"\\u0024type\":\"binData\"}}") == .parts([ - .document(key: "a", parts: [.member("{\"$type\":\"binData\"}")]) - ])) - #expect(MongoBsonAssembly.holdsOperatorDocument("{\"a\":{\"$re\\u0067ex\":\"x\",\"$exists\":true}}")) - } - - @Test("A value wrapper is never taken apart, whatever it holds") - func wrapperIsNeverTakenApart() { - let code = "{\"$code\":\"f\",\"$scope\":{\"a\":{\"$type\":\"binData\"}}}" - #expect(MongoBsonAssembly.plan("{\"x\":\(code)}") == .parts([.member("{\"x\":\(code)}")])) - } - - @Test("Malformed text is left to libbson rather than cut short") - func malformedTextIsWhole() { - let unclosed = "{\"a\":{\"$type\":\"binData\"}" - #expect(MongoBsonAssembly.plan(unclosed) == .whole(unclosed)) - } - - @Test("Every other member keeps its exact text, duplicates included") - func membersKeepTheirExactText() { - let plan = MongoBsonAssembly.plan( - "{\"a\":{\"$type\":\"double\"},\"n\":{\"$numberLong\":\"9007199254740993\"},\"f\":1.0,\"a\":2}" - ) - #expect(plan == .parts([ - .document(key: "a", parts: [.member("{\"$type\":\"double\"}")]), - .member("{\"n\":{\"$numberLong\":\"9007199254740993\"}}"), - .member("{\"f\":1.0}"), - .member("{\"a\":2}") - ])) - } - - @Test("Joining every part back gives the text that was planned, in order") - func partsJoinBackToTheInput() throws { - let texts = [ - "{\"name\":{\"$regex\":{\"$regularExpression\":{\"pattern\":\"^a\",\"options\":\"i\"}}},\"n\":1}", - "[{\"$match\":{\"sig\":{\"$type\":\"binData\"},\"x\":[1,{\"y\":{\"$options\":\"i\",\"$regex\":\"z\"}}]}}]", - "{\"create\":\"v\",\"validator\":{\"$or\":[{\"a\":{\"$type\":\"double\"}},{\"a\":null}]},\"a\":{\"$b\":1}}", - "{\"_id\":{\"$oid\":\"507f1f77bcf86cd799439011\"},\"m\":{\"$type\":\"x\",\"k\":[]},\"m\":\"dup\"}" - ] - for text in texts { - guard case .parts(let parts) = MongoBsonAssembly.plan(text) else { - Issue.record("\(text) was not taken apart") - continue - } - let isArray = text.hasPrefix("[") - let body = try joined(parts, isArray: isArray) - #expect((isArray ? "[\(body)]" : "{\(body)}") == text) - } - } - - @Test("The shell's own serialization of an operator document reaches the planner as a document") - func shellOutputIsTakenApart() throws { - let host = MongoScriptPreludeTests.RecordingHost() - host.replies = ["1", "2", "{\"insertedIds\": [{\"$oid\": \"507f1f77bcf86cd799439011\"}], \"insertedCount\": 1}"] - let context = try MongoScriptContext.make( - execute: { host.handle($0) }, - emit: { host.record(printed: $0) } - ) - - context.evaluateScript("db.t.find({sig: {$type: \"binData\"}}); db.t.find({name: {$regex: /^a/i}})") - context.evaluateScript("db.t.insertOne({m: {$regex: \"a\", $options: \"i\"}})") - #expect(context.exception == nil) - - let filters = host.requests(op: "openCursor").compactMap { $0["filter"] as? String } - let documents = host.requests(op: "insertOne").compactMap { $0["document"] as? String } - #expect(filters.count == 2) - #expect(documents.count == 1) - for text in filters + documents { - #expect(MongoBsonAssembly.plan(text) != .whole(text)) - } - } - - @Test("A raw filter row and a grid regex reach the count as the same document the rows query sends") - func filterBarOutputIsTakenApart() throws { - let raw = try #require(MongoDBRawFilterNormalizer().normalize("{sig: {$type: \"binData\"}}")) - #expect(MongoBsonAssembly.plan(raw) != .whole(raw)) - - let builder = MongoDBQueryBuilder() - let contains = builder.buildFilterDocument(from: [ - PluginQueryFilter(column: "name", op: "CONTAINS", value: "a", isCaseSensitive: false) - ]) - let notContains = builder.buildFilterDocument(from: [ - PluginQueryFilter(column: "name", op: "NOT CONTAINS", value: "a", isCaseSensitive: false) - ]) - #expect(MongoBsonAssembly.plan(contains) != .whole(contains)) - #expect(MongoBsonAssembly.plan(notContains) == .whole(notContains)) - } - - private func joined(_ parts: [Part], isArray: Bool) throws -> String { - try parts.enumerated().map { index, part in - let (key, value) = try keyAndValue(of: part) - guard isArray else { return "\(MongoScriptJson.jsonString(key)):\(value)" } - #expect(key == String(index)) - return value - }.joined(separator: ",") - } - - private func keyAndValue(of part: Part) throws -> (key: String, value: String) { - switch part { - case .member(let json): - let members = MongoScriptJson.members(of: json) - #expect(members.count == 1) - let only = try #require(members.first) - return (only.key, only.value) - case .document(let key, let parts): - return (key, "{\(try joined(parts, isArray: false))}") - case .array(let key, let parts): - return (key, "[\(try joined(parts, isArray: true))]") - } - } -} diff --git a/TableProTests/Plugins/MongoDBAuthSourceResolverTests.swift b/TableProTests/Plugins/MongoDBAuthSourceResolverTests.swift deleted file mode 100644 index c6f8b9e659..0000000000 --- a/TableProTests/Plugins/MongoDBAuthSourceResolverTests.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -@testable import TablePro -import Testing - -struct MongoDBAuthSourceResolverTests { - @Test("An explicit auth source wins over everything else") - func testExplicitWins() { - let resolved = MongoDBAuthSourceResolver.resolve( - explicitAuthSource: "accounts", - configuredDatabase: "shop", - useSrv: false - ) - #expect(resolved == "accounts") - } - - @Test("A blank explicit auth source falls through") - func testBlankExplicitFallsThrough() { - let resolved = MongoDBAuthSourceResolver.resolve( - explicitAuthSource: "", - configuredDatabase: "shop", - useSrv: false - ) - #expect(resolved == "shop") - } - - @Test("SRV connections authenticate against admin") - func testSrvUsesAdmin() { - let resolved = MongoDBAuthSourceResolver.resolve( - explicitAuthSource: nil, - configuredDatabase: "shop", - useSrv: true - ) - #expect(resolved == "admin") - } - - @Test("Without a configured database the fallback is admin") - func testEmptyConfiguredUsesAdmin() { - let resolved = MongoDBAuthSourceResolver.resolve( - explicitAuthSource: nil, - configuredDatabase: "", - useSrv: false - ) - #expect(resolved == "admin") - } - - @Test("A configured database is the fallback when no auth source is set") - func testConfiguredDatabaseIsFallback() { - let resolved = MongoDBAuthSourceResolver.resolve( - explicitAuthSource: nil, - configuredDatabase: "shop", - useSrv: false - ) - #expect(resolved == "shop") - } -} diff --git a/TableProTests/Plugins/MongoDBCollectionDDLTests.swift b/TableProTests/Plugins/MongoDBCollectionDDLTests.swift deleted file mode 100644 index 76c73fd99e..0000000000 --- a/TableProTests/Plugins/MongoDBCollectionDDLTests.swift +++ /dev/null @@ -1,300 +0,0 @@ -// -// MongoDBCollectionDDLTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoDBCollectionDDLTests { - private func column( - _ name: String, - _ type: String, - nullable: Bool = true, - primaryKey: Bool = false - ) -> PluginColumnDefinition { - PluginColumnDefinition( - name: name, - dataType: type, - isNullable: nullable, - defaultValue: nil, - isPrimaryKey: primaryKey, - autoIncrement: false, - comment: nil, - unsigned: false, - onUpdate: nil, - charset: nil, - collation: nil - ) - } - - private func definition(_ name: String, _ columns: [PluginColumnDefinition]) -> PluginCreateTableDefinition { - PluginCreateTableDefinition(tableName: name, columns: columns) - } - - private func index( - _ name: String, - _ columns: [String], - unique: Bool = false, - type: String? = nil, - whereClause: String? = nil - ) -> PluginIndexDefinition { - PluginIndexDefinition(name: name, columns: columns, isUnique: unique, indexType: type, whereClause: whereClause) - } - - /// The options the statement passes to `createCollection`, read back as JSON. - private func options(of statement: String) throws -> [String: Any] { - let start = try #require(statement.firstIndex(of: "{")) - let end = try #require(statement.lastIndex(of: "}")) - let data = try #require(String(statement[start ... end]).data(using: .utf8)) - return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - } - - private func jsonSchema(of statement: String) throws -> [String: Any] { - let validator = try #require(try options(of: statement)["validator"] as? [String: Any]) - return try #require(validator["$jsonSchema"] as? [String: Any]) - } - - @Test("The reported draft becomes a collection whose validator lists every field in order") - func reportedDraftBecomesAValidator() throws { - let statement = MongoDBCollectionDDL.createCollectionStatement(for: definition("articles", [ - column("_id", "objectId", nullable: false, primaryKey: true), - column("title", "string", nullable: false), - column("seoDescription", "string"), - column("slug", "string"), - column("content", "string"), - column("tags", "array"), - column("lang", "string"), - column("date", "date"), - column("schemaVersion", "int") - ])) - - #expect(statement.hasPrefix("db.createCollection(\"articles\", {")) - let schema = try jsonSchema(of: statement) - #expect(schema["bsonType"] as? String == "object") - #expect(schema["required"] as? [String] == ["title"]) - let properties = try #require(schema["properties"] as? [String: Any]) - #expect((properties["_id"] as? [String: Any])?["bsonType"] as? String == "objectId") - #expect((properties["title"] as? [String: Any])?["bsonType"] as? String == "string") - #expect((properties["tags"] as? [String: Any])?["bsonType"] as? [String] == ["array", "null"]) - #expect((properties["schemaVersion"] as? [String: Any])?["bsonType"] as? [String] == ["int", "null"]) - - let declared = MongoDBCollectionSchema.parse(jsonSchema: statement.jsonSchemaText) - #expect(declared.fields.map { $0.name } == [ - "_id", "title", "seoDescription", "slug", "content", "tags", "lang", "date", "schemaVersion" - ]) - } - - @Test("Leaving the server's validation level and action out keeps its strict, error defaults") - func validationDefaultsAreLeftToTheServer() throws { - let statement = MongoDBCollectionDDL.createCollectionStatement( - for: definition("articles", [column("title", "string")]) - ) - let options = try options(of: statement) - #expect(Set(options.keys) == ["validator"]) - } - - @Test("A collection with no fields beyond _id stays schemaless") - func onlyIdMeansNoValidator() { - #expect(MongoDBCollectionDDL.createCollectionStatement(for: definition("events", [])) == "db.createCollection(\"events\")") - #expect( - MongoDBCollectionDDL.createCollectionStatement( - for: definition("events", [column("_id", "objectId", nullable: false, primaryKey: true)]) - ) == "db.createCollection(\"events\")" - ) - } - - @Test("No required list is written when every field is nullable, because an empty one is refused") - func noRequiredWhenEverythingIsNullable() throws { - let statement = MongoDBCollectionDDL.createCollectionStatement( - for: definition("notes", [column("body", "string")]) - ) - #expect(try jsonSchema(of: statement)["required"] == nil) - } - - @Test("A type is matched to its alias whatever its case, since the server's aliases are case-sensitive") - func typeAliasesAreNormalised() throws { - let statement = MongoDBCollectionDDL.createCollectionStatement(for: definition("t", [ - column("ref", "ObjectId", nullable: false), - column("name", "STRING", nullable: false), - column("flag", "Bool", nullable: false) - ])) - let properties = try #require(try jsonSchema(of: statement)["properties"] as? [String: Any]) - #expect((properties["ref"] as? [String: Any])?["bsonType"] as? String == "objectId") - #expect((properties["name"] as? [String: Any])?["bsonType"] as? String == "string") - #expect((properties["flag"] as? [String: Any])?["bsonType"] as? String == "bool") - } - - @Test("A nullable null field is not written as a duplicate type") - func nullTypeIsNotDuplicated() throws { - let statement = MongoDBCollectionDDL.createCollectionStatement( - for: definition("t", [column("nothing", "null")]) - ) - let properties = try #require(try jsonSchema(of: statement)["properties"] as? [String: Any]) - #expect((properties["nothing"] as? [String: Any])?["bsonType"] as? String == "null") - } - - @Test("Names with quotes, backslashes and non-ASCII text stay inside their string literals") - func namesAreEscaped() throws { - let statement = MongoDBCollectionDDL.createCollectionStatement(for: definition("a\"b\\c", [ - column("título \"x\"", "string", nullable: false) - ])) - #expect(statement.hasPrefix("db.createCollection(\"a\\\"b\\\\c\", {")) - let schema = try jsonSchema(of: statement) - #expect(schema["required"] as? [String] == ["título \"x\""]) - } - - @Test("A type MongoDB lacks still produces a statement, so the server explains the refusal") - func unknownTypeStillProducesAStatement() { - let statement = MongoDBCollectionDDL.createCollectionStatement( - for: definition("t", [column("n", "VARCHAR(255)", nullable: false)]) - ) - #expect(statement.contains("\"bsonType\": \"VARCHAR(255)\"")) - } - - // MARK: - Refusals - - @Test("A primary key on a field other than _id is refused and names the field") - func primaryKeyOnIdFieldIsRefused() throws { - let reason = try #require(MongoDBCollectionDDL.refusal(for: .addColumn( - column("id", "objectId", nullable: false, primaryKey: true) - ))) - #expect(reason.contains("id")) - #expect(reason.contains("_id")) - } - - @Test("_id as the primary key is accepted") - func idAsPrimaryKeyIsAccepted() { - #expect(MongoDBCollectionDDL.refusal(for: .addColumn( - column("_id", "objectId", nullable: false, primaryKey: true) - )) == nil) - } - - @Test("_id typed as anything but objectId is refused, because the grid lets the server generate it") - func idWithAnotherTypeIsRefused() { - #expect(MongoDBCollectionDDL.refusal(for: .addColumn(column("_id", "string", nullable: false))) != nil) - } - - @Test("A type that is not a BSON alias is refused and names the field and the type") - func unknownTypeIsRefused() throws { - let reason = try #require(MongoDBCollectionDDL.refusal(for: .addColumn(column("title", "VARCHAR")))) - #expect(reason.contains("title")) - #expect(reason.contains("VARCHAR")) - } - - @Test("Every listed type is accepted", arguments: MongoDBBsonType.aliases) - func listedTypesAreAccepted(alias: String) { - #expect(MongoDBCollectionDDL.refusal(for: .addColumn(column("f", alias))) == nil) - } - - @Test("Field names a JavaScript object reorders or drops are refused", arguments: ["10", "0", "4294967294", "__proto__"]) - func shellReorderedNamesAreRefused(name: String) { - #expect(MongoDBCollectionDDL.refusal(for: .addColumn(column(name, "string"))) != nil) - } - - @Test("Names that only look numeric, or are past the array-index range, are kept", arguments: ["007", "10a", "a10", "4294967295", "99999999999"]) - func nonCanonicalNumericNamesAreKept(name: String) { - #expect(MongoDBCollectionDDL.refusal(for: .addColumn(column(name, "string"))) == nil) - } - - @Test("Field names MongoDB cannot address are refused", arguments: ["$price", "a.b"]) - func unaddressableNamesAreRefused(name: String) { - #expect(MongoDBCollectionDDL.refusal(for: .addColumn(column(name, "string"))) != nil) - } - - // MARK: - Indexes - - @Test("An index keeps its fields in the order they were given") - func indexKeepsKeyOrder() { - let statement = MongoDBCollectionDDL.createIndexStatement( - collection: "articles", index: index("lang_date", ["lang", "date", "slug"], unique: true) - ) - #expect(statement == "db.articles.createIndex({\"lang\": 1, \"date\": 1, \"slug\": 1}, {\"name\": \"lang_date\", \"unique\": true})") - } - - @Test("An index with no name lets the server name it") - func unnamedIndexOmitsItsName() { - #expect(MongoDBCollectionDDL.createIndexStatement(collection: "articles", index: index("", ["slug"])) - == "db.articles.createIndex({\"slug\": 1})") - } - - @Test("A collection the shell cannot name as a property is reached through getCollection") - func indexOnAnAwkwardCollectionName() throws { - let statement = try #require(MongoDBCollectionDDL.createIndexStatement( - collection: "my.collection", index: index("", ["slug"]) - )) - #expect(statement.hasPrefix("db.getCollection(\"my.collection\").createIndex(")) - } - - @Test("The editor's index types map to MongoDB's key kinds") - func indexTypesMap() { - let cases: [(type: String, key: String)] = [ - ("BTREE", "1"), ("HASH", "\"hashed\""), ("FULLTEXT", "\"text\""), ("SPATIAL", "\"2dsphere\"") - ] - for entry in cases { - let statement = MongoDBCollectionDDL.createIndexStatement( - collection: "c", index: index("", ["f"], type: entry.type) - ) - #expect(statement == "db.c.createIndex({\"f\": \(entry.key)})", "\(entry.type)") - } - } - - @Test("An index type MongoDB has no equivalent for is refused and produces no statement") - func unsupportedIndexTypeIsRefused() { - let gin = index("g", ["f"], type: "GIN") - #expect(MongoDBCollectionDDL.createIndexStatement(collection: "c", index: gin) == nil) - #expect(MongoDBCollectionDDL.refusal(for: .addIndex(gin)) != nil) - #expect(MongoDBCollectionDDL.unsupportedIndexTypes.contains("GIN")) - } - - @Test("A hashed index can be neither unique nor compound") - func hashedIndexLimits() { - #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("h", ["f"], unique: true, type: "HASH"))) != nil) - #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("h", ["f", "g"], type: "HASH"))) != nil) - #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("h", ["f"], type: "HASH"))) == nil) - } - - @Test("An ascending index on _id alone is refused, because MongoDB already has one") - func indexOnIdIsRefused() { - #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("i", ["_id"]))) != nil) - #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("i", ["_id", "lang"]))) == nil) - } - - @Test("A hashed index on _id is its own index, the one a hashed shard key needs") - func hashedIdIndexIsAccepted() { - #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("h", ["_id"], type: "HASH"))) == nil) - } - - @Test("A text index cannot be unique") - func uniqueTextIndexIsRefused() { - #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("t", ["body"], unique: true, type: "FULLTEXT"))) != nil) - #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("t", ["body"], type: "FULLTEXT"))) == nil) - } - - @Test("A partial index written in SQL is refused") - func whereClauseIsRefused() { - #expect(MongoDBCollectionDDL.refusal(for: .addIndex(index("p", ["f"], whereClause: "f > 0"))) != nil) - } -} - -private extension String { - /// The `$jsonSchema` object inside a `createCollection` statement, as text. - var jsonSchemaText: String { - guard let marker = range(of: "\"$jsonSchema\": ") else { return "{}" } - let tail = self[marker.upperBound...] - var depth = 0 - var end = tail.startIndex - for index in tail.indices { - if tail[index] == "{" { depth += 1 } - if tail[index] == "}" { - depth -= 1 - if depth == 0 { - end = index - break - } - } - } - return String(tail[tail.startIndex ... end]) - } -} diff --git a/TableProTests/Plugins/MongoDBCollectionSchemaTests.swift b/TableProTests/Plugins/MongoDBCollectionSchemaTests.swift deleted file mode 100644 index 6b5b8ecad6..0000000000 --- a/TableProTests/Plugins/MongoDBCollectionSchemaTests.swift +++ /dev/null @@ -1,243 +0,0 @@ -// -// MongoDBCollectionSchemaTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoDBCollectionSchemaTests { - /// A `listCollections` reply as `bson_as_canonical_extended_json` writes it, fields deliberately - /// out of alphabetical order so a dictionary round trip would show. - private func reply(options: String) -> String { - """ - { "cursor" : { "id" : { "$numberLong" : "0" }, "ns" : "blog.$cmd.listCollections", "firstBatch" : [ \ - { "name" : "articles", "type" : "collection", "options" : \(options), \ - "info" : { "readOnly" : false }, "idIndex" : { "v" : { "$numberInt" : "2" }, "key" : { "_id" : { "$numberInt" : "1" } }, "name" : "_id_" } } ] }, \ - "ok" : { "$numberDouble" : "1.0" } } - """ - } - - private let articlesOptions = """ - { "validator" : { "$jsonSchema" : { "bsonType" : "object", "required" : [ "title", "status" ], \ - "properties" : { "title" : { "bsonType" : "string" }, "tags" : { "bsonType" : [ "array", "null" ] }, \ - "date" : { "bsonType" : [ "date", "null" ] }, "status" : { "enum" : [ "draft", "live" ] }, \ - "views" : { "bsonType" : "long" }, "attachment" : { "bsonType" : "binData" }, \ - "score" : { "type" : "number" } } } }, "validationLevel" : "strict", "validationAction" : "error" } - """ - - @Test("Declared fields come back in the order the validator lists them") - func fieldsKeepTheirOrder() { - let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) - #expect(schema.fields.map { $0.name } == ["title", "tags", "date", "status", "views", "attachment", "score"]) - } - - @Test("A field is required only when the validator's required list names it") - func requiredComesFromTheRequiredList() throws { - let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) - #expect(try #require(schema.field(named: "title")).isRequired) - #expect(try #require(schema.field(named: "status")).isRequired) - #expect(try #require(schema.field(named: "tags")).isRequired == false) - } - - @Test("A nullable union and a string enum type the value, and an open or binary type does not") - func valueKinds() { - let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) - #expect(schema.valueKinds["title"] == .string) - #expect(schema.valueKinds["tags"] == .array) - #expect(schema.valueKinds["date"] == .date) - #expect(schema.valueKinds["views"] == .int64) - #expect(schema.valueKinds["status"] == .string) - #expect(schema.valueKinds["attachment"] == nil) - #expect(schema.valueKinds["score"] == nil) - } - - @Test("A field takes null only when its declared type lists null, whether or not it is required") - func nullIsDecidedByTheTypeNotTheRequiredList() throws { - let schema = MongoDBCollectionSchema.parse(jsonSchema: """ - {"bsonType": "object", "required": ["deletedAt", "title"], "properties": { - "deletedAt": {"bsonType": ["date", "null"]}, "title": {"bsonType": "string"}, - "nick": {"bsonType": "string"}, "note": {"type": ["string", "null"]}, "free": {"description": "any"}, - "level": {"enum": [1, 2]}, "mode": {"enum": ["a", null]}, "code": {"anyOf": [{"bsonType": "string"}]} - }} - """) - - #expect(try #require(schema.field(named: "deletedAt")).admitsNull) - #expect(try #require(schema.field(named: "title")).admitsNull == false) - #expect(try #require(schema.field(named: "nick")).admitsNull == false) - #expect(try #require(schema.field(named: "note")).admitsNull) - #expect(try #require(schema.field(named: "free")).admitsNull) - #expect(try #require(schema.field(named: "level")).admitsNull == false) - #expect(try #require(schema.field(named: "mode")).admitsNull) - #expect(try #require(schema.field(named: "code")).admitsNull == false) - } - - /// Measured on 7.0.43: `{s: null}` is refused with 121 under `bsonType: ["string", "null"]` plus - /// `enum: ["draft"]` and plus `not: {bsonType: "null"}`, and stored under `enum: ["draft", null]` - /// and under `minLength: 3` with `pattern: "^x"`. - @Test("Every keyword of a field's rule decides null together, not the type alone") - func siblingKeywordsDecideNullTogether() throws { - let schema = MongoDBCollectionSchema.parse(jsonSchema: """ - {"properties": { - "status": {"bsonType": ["string", "null"], "enum": ["draft"]}, - "stage": {"bsonType": ["string", "null"], "enum": ["draft", null]}, - "code": {"bsonType": ["string", "null"], "minLength": 3, "pattern": "^x"}, - "mode": {"enum": ["a", null], "not": {"bsonType": "null"}}, - "kind": {"type": ["string", "null"], "enum": ["a"]} - }} - """) - - #expect(try #require(schema.field(named: "status")).admitsNull == false) - #expect(try #require(schema.field(named: "stage")).admitsNull) - #expect(try #require(schema.field(named: "code")).admitsNull) - #expect(try #require(schema.field(named: "mode")).admitsNull == false) - #expect(try #require(schema.field(named: "kind")).admitsNull == false) - } - - /// Measured on 7.0.43: a top-level `anyOf` or `patternProperties` refuses null in a field whose - /// own rule takes it, and an `additionalProperties` schema refuses it in every undeclared field. - @Test("A rule over the whole document can refuse null in any field, so none is taken to admit it") - func documentWideRulesDecideNullForEveryField() { - for rule in [#""anyOf": [{"properties": {"s": {"bsonType": "string"}}}]"#, - #""patternProperties": {"^s": {"bsonType": "string"}}"#, - #""dependencies": {"a": {"properties": {"s": {"bsonType": "string"}}}}"#] { - let schema = MongoDBCollectionSchema.parse(jsonSchema: #"{"properties": {"s": {"bsonType": ["string", "null"]}}, "# + rule + "}") - #expect(schema.admitsNull(fieldNamed: "s") == false, "\(rule)") - #expect(schema.admitsNull(fieldNamed: "other") == false, "\(rule)") - } - - let plain = MongoDBCollectionSchema.parse(jsonSchema: #"{"properties": {"s": {"bsonType": ["string", "null"]}, "t": {"bsonType": "string"}}}"#) - #expect(plain.admitsNull(fieldNamed: "s")) - #expect(plain.admitsNull(fieldNamed: "t") == false) - #expect(plain.admitsNull(fieldNamed: "other")) - } - - @Test("additionalProperties decides null for the fields the validator does not declare") - func additionalPropertiesDecidesUndeclaredFields() { - let typed = MongoDBCollectionSchema.parse(jsonSchema: """ - {"additionalProperties": {"bsonType": "string"}, "properties": {"_id": {}, "t": {"bsonType": ["string", "null"]}}} - """) - #expect(typed.admitsNull(fieldNamed: "t")) - #expect(typed.admitsNull(fieldNamed: "u") == false) - - let nullable = MongoDBCollectionSchema.parse(jsonSchema: #"{"additionalProperties": {"bsonType": ["string", "null"]}}"#) - #expect(nullable.admitsNull(fieldNamed: "u")) - - let closed = MongoDBCollectionSchema.parse(jsonSchema: #"{"additionalProperties": false, "properties": {"_id": {}}}"#) - #expect(closed.admitsNull(fieldNamed: "u") == false) - } - - /// Measured on 7.0.43: `{$jsonSchema: {…s: ["string", "null"]…}, s: {$type: "string"}}` refuses - /// `{s: null}` with 121, and `validationAction: "warn"` stores it. - @Test("A query operator beside $jsonSchema refuses null everywhere, and a validator that only warns refuses nothing") - func validatorSettingsDecideNull() { - let besideSchema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: """ - { "validator" : { "$jsonSchema" : { "properties" : { "s" : { "bsonType" : [ "string", "null" ] } } }, "s" : { "$type" : "string" } } } - """)) - #expect(besideSchema.field(named: "s") != nil) - #expect(besideSchema.admitsNull(fieldNamed: "s") == false) - - let queryOnly = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: """ - { "validator" : { "age" : { "$gte" : { "$numberInt" : "0" } } } } - """)) - #expect(queryOnly.isEmpty) - #expect(queryOnly.admitsNull(fieldNamed: "age") == false) - - for setting in [#""validationAction" : "warn""#, #""validationLevel" : "off""#] { - let unenforced = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: """ - { "validator" : { "$jsonSchema" : { "properties" : { "t" : { "bsonType" : "string" } } } }, \(setting) } - """)) - #expect(unenforced.admitsNull(fieldNamed: "t"), "\(setting)") - } - - let enforced = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) - #expect(enforced.admitsNull(fieldNamed: "title") == false) - #expect(enforced.admitsNull(fieldNamed: "date")) - #expect(MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: "{ }")).admitsNull(fieldNamed: "any")) - } - - @Test("A string enum is kept as the field's allowed values") - func enumsAreKept() { - let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) - #expect(schema.allowedValues["status"] == ["draft", "live"]) - #expect(schema.allowedValues["title"] == nil) - } - - @Test("A declared field is shown with the type name a sampled one of its kind would have") - func declaredTypeNamesMatchSampledOnes() throws { - let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) - let date = try #require(schema.field(named: "date")) - let attachment = try #require(schema.field(named: "attachment")) - let score = try #require(schema.field(named: "score")) - #expect(date.columnTypeName(representation: .unspecified) - == BsonDocumentFlattener.typeName(for: .date, representation: .unspecified)) - #expect(attachment.columnTypeName(representation: .unspecified) - == BsonDocumentFlattener.typeName(for: .binary(subtype: 0), representation: .unspecified)) - #expect(score.columnTypeName(representation: .unspecified) == "number") - } - - @Test("A collection with no validator, or a validator without $jsonSchema, declares nothing") - func noSchemaDeclaresNothing() { - #expect(MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: "{ }")).isEmpty) - #expect(MongoDBCollectionSchema.parse( - listCollectionsReply: reply(options: "{ \"validator\" : { \"age\" : { \"$gte\" : { \"$numberInt\" : \"0\" } } } }") - ).isEmpty) - #expect(MongoDBCollectionSchema.parse(listCollectionsReply: "{ \"cursor\" : { \"firstBatch\" : [ ] } }").isEmpty) - #expect(MongoDBCollectionSchema.parse(listCollectionsReply: "not json").isEmpty) - } - - @Test("A field whose name carries an escaped quote keeps its own name and its place") - func escapedFieldNamesSurvive() { - let schema = MongoDBCollectionSchema.parse(jsonSchema: """ - { "properties" : { "a\\"b" : { "bsonType" : "string" }, "ab" : { "bsonType" : "int" }, \ - "plain" : { "bsonType" : "bool" } } } - """) - #expect(schema.fields.map { $0.name } == ["a\"b", "ab", "plain"]) - } - - @Test("An enum of strings with no type of its own types the field as a string") - func stringEnumTypesTheField() throws { - let schema = MongoDBCollectionSchema.parse(jsonSchema: """ - { "properties" : { "level" : { "enum" : [ "1", "2" ] } } } - """) - let level = try #require(schema.field(named: "level")) - #expect(level.valueKind == .string) - #expect(level.allowedValues == ["1", "2"]) - } - - /// U+0600 joins the `\` after it into one `Character`, so a scan by `Character` missed the - /// escape and ended the string at the quote libbson had escaped. - @Test("A key holding a Prepend character before an escaped quote is read whole") - func membersReadKeysScalarByScalar() { - let json = "{\"x\u{0600}\\\"y\": 1, \"next\": 2}" - let keys = MongoScriptJson.members(of: json).map { $0.key } - #expect(keys == ["x\u{0600}\"y", "next"]) - } - - @Test("The command asks for one collection by name, escaped") - func listCollectionsCommandIsEscaped() throws { - let command = MongoDBCollectionSchema.listCollectionsCommand(for: "a\"b") - let data = try #require(command.data(using: .utf8)) - let parsed = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - #expect((parsed["filter"] as? [String: Any])?["name"] as? String == "a\"b") - #expect(parsed["maxTimeMS"] as? Int == MongoDBCollectionSchema.listCollectionsTimeoutMS) - } - - // MARK: - Shape - - @Test("An empty collection presents _id and then its declared fields") - func emptyCollectionColumns() { - let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) - #expect(MongoDBCollectionShape.emptyCollectionColumns(declaring: schema) - == ["_id", "title", "tags", "date", "status", "views", "attachment", "score"]) - #expect(MongoDBCollectionShape.emptyCollectionColumns(declaring: .empty) == ["_id"]) - } - - @Test("Declared fields no sampled document holds follow the sampled columns") - func unseenDeclaredColumns() { - let schema = MongoDBCollectionSchema.parse(listCollectionsReply: reply(options: articlesOptions)) - #expect(MongoDBCollectionShape.declaredColumnsMissing(from: ["_id", "title", "extra"], schema: schema) - == ["tags", "date", "status", "views", "attachment", "score"]) - } -} diff --git a/TableProTests/Plugins/MongoDBCreateDatabasePlanTests.swift b/TableProTests/Plugins/MongoDBCreateDatabasePlanTests.swift deleted file mode 100644 index b94a6acea8..0000000000 --- a/TableProTests/Plugins/MongoDBCreateDatabasePlanTests.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct MongoDBCreateDatabasePlanTests { - @Test("The typed collection name is used") - func testUsesTypedName() { - let name = MongoDBCreateDatabasePlan.firstCollectionName( - from: [MongoDBCreateDatabasePlan.firstCollectionFieldId: "orders"], - databaseName: "shop" - ) - #expect(name == "orders") - } - - @Test("Surrounding whitespace is trimmed") - func testTrimsWhitespace() { - let name = MongoDBCreateDatabasePlan.firstCollectionName( - from: [MongoDBCreateDatabasePlan.firstCollectionFieldId: " orders\n"], - databaseName: "shop" - ) - #expect(name == "orders") - } - - @Test("A blank collection name falls back to the database name") - func testBlankFallsBack() { - let name = MongoDBCreateDatabasePlan.firstCollectionName( - from: [MongoDBCreateDatabasePlan.firstCollectionFieldId: " "], - databaseName: "shop" - ) - #expect(name == "shop") - } - - @Test("An app that does not send the field still creates a collection") - func testMissingFieldFallsBack() { - let name = MongoDBCreateDatabasePlan.firstCollectionName(from: [:], databaseName: "shop") - #expect(name == "shop") - } - - @Test("The legacy form spec initializer declares no text inputs") - func testLegacySpecHasNoTextInputs() { - let spec = PluginCreateDatabaseFormSpec(fields: []) - #expect(spec.textInputs.isEmpty) - } -} diff --git a/TableProTests/Plugins/MongoDBFieldNameUpdateTests.swift b/TableProTests/Plugins/MongoDBFieldNameUpdateTests.swift deleted file mode 100644 index b7fb9173b2..0000000000 --- a/TableProTests/Plugins/MongoDBFieldNameUpdateTests.swift +++ /dev/null @@ -1,252 +0,0 @@ -// -// MongoDBFieldNameUpdateTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -/// Measured on MongoDB 7.0.43: a classic `$set` reads each key as a path, so `price.usd` walks into -/// a sub-document, `$price` is refused with code 52 and the empty name with code 56. Inside a -/// pipeline, `$set` of `tags.1` rewrote every element of `tags` and reported success. -struct MongoDBFieldNameUpdateTests { - private func update( - _ cells: [(column: String, old: PluginCellValue, new: PluginCellValue)], - columns: [String], - removing removed: Set = [], - kinds: [String: BsonValueKind] = [:], - version: String? = "7.0.43" - ) throws -> String { - let gen = MongoDBStatementGenerator( - collectionName: "items", - columns: columns, - columnKinds: kinds, - fieldKinds: MongoDBFieldKinds(kinds.mapValues { [$0] }), - capabilities: { MongoDBCapabilities.parse(version) } - ) - var change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: cells.map { cell in - (columnIndex: columns.firstIndex(of: cell.column) ?? 0, columnName: cell.column, oldValue: cell.old, newValue: cell.new) - }, - originalRow: [.text("1")] + columns.dropFirst().map { _ in PluginCellValue.null } - ) - change.absentColumns = Set(removed.compactMap { columns.firstIndex(of: $0) }) - let writes = try gen.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - return try #require(writes.first?.statement) - } - - /// The update argument of `updateOne(filter, update)`, parsed. - private func updateArgument(of statement: String) throws -> Any { - let open = try #require(statement.range(of: "}, ")).upperBound - let text = String(statement[open ..< statement.index(before: statement.endIndex)]) - return try JSONSerialization.jsonObject(with: Data(text.utf8)) - } - - private func stages(of statement: String) throws -> [[String: Any]] { - try #require(try updateArgument(of: statement) as? [[String: Any]]) - } - - @Test("Editing a field named price.usd sends $setField with $literal, not $set on a path") - func dottedNameUsesSetField() throws { - let statement = try update([("price.usd", "10", "12")], columns: ["_id", "price.usd"], kinds: ["price.usd": .int32]) - - let setField = #"{"field": {"$literal": "price.usd"}, "input": "$$ROOT", "value": {"$literal": 12}}"# - #expect(statement == #"db.items.updateOne({"_id": 1}, [{"$replaceWith": {"$setField": "# + setField + "}}])") - } - - @Test("A leading $, the empty name and __proto__ take the same route") - func otherUnaddressableNamesUseSetField() throws { - for name in ["$price", "", "__proto__"] { - let statement = try update([(name, "a", "b")], columns: ["_id", name]) - let stage = try #require(try stages(of: statement).first) - let setField = try #require((stage["$replaceWith"] as? [String: Any])?["$setField"] as? [String: Any]) - #expect((setField["field"] as? [String: Any])?["$literal"] as? String == name, "\(name)") - #expect((setField["value"] as? [String: Any])?["$literal"] as? String == "b", "\(name)") - } - } - - @Test("Removing a field with a dotted name uses $unsetField") - func dottedNameRemovalUsesUnsetField() throws { - let statement = try update([("price.usd", "10", nil)], columns: ["_id", "price.usd"], removing: ["price.usd"]) - - #expect(statement.contains(#"{"$replaceWith": {"$unsetField": {"field": {"$literal": "price.usd"}, "input": "$$ROOT"}}}"#)) - #expect(!statement.contains(#""$unset": {"#)) - } - - @Test("A row mixing an ordinary and a dotted field is one pipeline: $set with $literal, then $replaceWith") - func mixedRowIsOnePipeline() throws { - let statement = try update( - [("name", "a", "b"), ("price.usd", "10", "12"), ("note", "x", nil)], - columns: ["_id", "name", "price.usd", "note"], - removing: ["note"] - ) - - let stages = try stages(of: statement) - #expect(stages.count == 3) - #expect(((stages[0]["$set"] as? [String: Any])?["name"] as? [String: Any])?["$literal"] as? String == "b") - #expect(stages[1]["$unset"] as? [String] == ["note"]) - #expect(stages[2]["$replaceWith"] != nil) - } - - @Test("A row of ordinary fields keeps the classic $set document, which every server version reads") - func ordinaryRowStaysClassic() throws { - let statement = try update( - [("name", "a", "b"), ("note", "x", nil)], columns: ["_id", "name", "note"], removing: ["note"], version: "4.4.0" - ) - - #expect(statement == #"db.items.updateOne({"_id": 1}, {"$set": {"name": "b"}, "$unset": {"note": ""}})"#) - } - - @Test("A typed value inside $literal keeps its wrapper") - func typedValueKeepsItsWrapper() throws { - let statement = try update( - [("when", "2024-01-02T03:04:05Z", "2024-01-02T03:04:06Z"), ("a.b", "1", "2")], - columns: ["_id", "when", "a.b"], - kinds: ["when": .date] - ) - - #expect(statement.contains(#""when": {"$literal": {"$date": {"$numberLong": "1704164646000"}}}"#)) - } - - @Test("A string that looks like a field path is wrapped in $literal so the pipeline does not read it") - func pathLikeStringIsLiteral() throws { - let statement = try update([("name", "a", "$notAPath"), ("a.b", "1", "2")], columns: ["_id", "name", "a.b"]) - - #expect(statement.contains(#""name": {"$literal": "$notAPath"}"#)) - } - - @Test("A server older than 5.0 refuses before anything is sent, and an unknown version sends the pipeline") - func pipelineNeedsMongoDB5() throws { - #expect(throws: MongoDBWriteRefusal.fieldNeedsMongoDB5(field: "price.usd").refusal(ofRow: 0)) { - try update([("price.usd", "10", "12")], columns: ["_id", "price.usd"], version: "4.4.29") - } - let unknown = try update([("price.usd", "10", "12")], columns: ["_id", "price.usd"], version: nil) - #expect(unknown.contains("$setField")) - } - - @Test("$setField is known from 5.0 on, and unknown when the version is") - func fieldExpressionCapability() { - #expect(MongoDBCapabilities.parse("4.4.29").supportsFieldExpressions == false) - #expect(MongoDBCapabilities.parse("5.0.0").supportsFieldExpressions == true) - #expect(MongoDBCapabilities.parse("7.0.43").supportsFieldExpressions == true) - #expect(MongoDBCapabilities.parse(nil).supportsFieldExpressions == nil) - } - - // MARK: - Pipeline and nested paths together - - @Test("A row that edits price.usd and tags[1] writes the whole tags array, and no stage names a path") - func pipelineWritesArraysWhole() throws { - let statement = try update( - [("price.usd", "10", "12"), ("tags", #"["a","b","c"]"#, #"["a","Z","c"]"#)], - columns: ["_id", "price.usd", "tags"], - kinds: ["tags": .array] - ) - - let stages = try stages(of: statement) - let set = try #require(stages.first?["$set"] as? [String: Any]) - #expect(set.keys.allSatisfy { !$0.contains(".") }) - #expect((set["tags"] as? [String: Any])?["$literal"] as? [String] == ["a", "Z", "c"]) - #expect(!statement.contains("tags.1")) - } - - @Test("A removal inside an array of documents is written as the whole array under the pipeline route") - func pipelineWritesNestedRemovalWhole() throws { - let statement = try update( - [("$p", "1", "2"), ("items", #"[{"sku":"a","qty":1}]"#, #"[{"sku":"a"}]"#)], - columns: ["_id", "$p", "items"], - kinds: ["items": .array] - ) - - let stages = try stages(of: statement) - let set = try #require(stages.first?["$set"] as? [String: Any]) - let items = try #require((set["items"] as? [String: Any])?["$literal"] as? [[String: Any]]) - #expect(items.count == 1) - #expect(items[0]["qty"] == nil) - #expect(!statement.contains("items.0")) - #expect(stages.allSatisfy { $0["$unset"] == nil }) - } - - @Test("The same edits without a special name stay classic and name only the changed paths") - func classicRouteKeepsPaths() throws { - let statement = try update( - [("tags", #"["a","b","c"]"#, #"["a","Z","c"]"#), ("items", #"[{"sku":"a","qty":1}]"#, #"[{"sku":"a"}]"#)], - columns: ["_id", "tags", "items"], - kinds: ["tags": .array, "items": .array] - ) - - #expect(statement == #"db.items.updateOne({"_id": 1}, {"$set": {"tags.1": "Z"}, "$unset": {"items.0.qty": ""}})"#) - } - - // MARK: - Inserts - - private func insert(_ values: [PluginCellValue], columns: [String]) throws -> [PluginRowWrite] { - try MongoDBStatementGenerator(collectionName: "items", columns: columns).generateRowWrites( - from: [PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil)], - insertedRowData: [0: values], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - - /// Measured on 7.0.43 through the shell: insertOne and insertMany fail with "[22] invalid - /// document for insert: empty key" for an empty name at the top level or inside a value, while a - /// classic $set of a value holding one and $setField of an empty name both succeed. - @Test("A new document with an empty field name is refused, since libmongoc will not insert it") - func insertRefusesAnEmptyName() { - #expect(throws: MongoDBWriteRefusal.emptyFieldNameInNewDocument.refusal(ofRow: 0)) { - try insert([nil, "x"], columns: ["_id", ""]) - } - } - - @Test("A new document holding an empty key inside a value is refused at any depth") - func insertRefusesANestedEmptyKey() { - for nested in [#"{"":5}"#, #"{"a":[{"b":1,"":2}]}"#] { - #expect(throws: MongoDBWriteRefusal.emptyKeyInNewDocument(field: "m").refusal(ofRow: 0), "\(nested)") { - try insert([nil, .text(nested)], columns: ["_id", "m"]) - } - } - } - - @Test("A restored document holding an empty key is refused rather than failing in the shell") - func restoreRefusesAnEmptyKey() { - let gen = MongoDBStatementGenerator( - collectionName: "items", columns: ["_id", "", "m"], fieldKinds: MongoDBFieldKinds(["m": [.document]]) - ) - - #expect(gen.generateRestore(rows: [["507f1f77bcf86cd799439011", "x", nil]]) == nil) - #expect(gen.generateRestore(rows: [["507f1f77bcf86cd799439011", nil, #"{"":5}"#]]) == nil) - } - - @Test("An update carries a value holding an empty key, which the shell writes") - func updateCarriesANestedEmptyKey() throws { - let statement = try update([("m", nil, #"{"":6}"#)], columns: ["_id", "m"]) - - #expect(statement == #"db.items.updateOne({"_id": 1}, {"$set": {"m": {"":6}}})"#) - } - - @Test("A new document cannot hold __proto__, which the shell reads as the prototype and drops") - func insertRefusesPrototype() { - #expect(throws: MongoDBWriteRefusal.prototypeFieldInNewDocument.refusal(ofRow: 0)) { - try insert([nil, "x"], columns: ["_id", "__proto__"]) - } - } - - @Test("A new document keeps a dotted or $ name as a literal key, which insert accepts") - func insertKeepsDottedNames() throws { - let gen = MongoDBStatementGenerator(collectionName: "items", columns: ["_id", "k.d", "$q"]) - - let writes = try gen.generateRowWrites( - from: [PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil)], - insertedRowData: [0: [nil, "7", "8"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(writes.first?.statement == #"db.items.insertOne({"k.d": 7, "$q": 8})"#) - } -} diff --git a/TableProTests/Plugins/MongoDBFieldPresenceWriteTests.swift b/TableProTests/Plugins/MongoDBFieldPresenceWriteTests.swift deleted file mode 100644 index 5db3558b3f..0000000000 --- a/TableProTests/Plugins/MongoDBFieldPresenceWriteTests.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// MongoDBFieldPresenceWriteTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -/// Measured on MongoDB 7.0.43 with a validator of `required: ["deletedAt"]` and `bsonType: -/// ["date", "null"]`: `$unset` of `deletedAt` fails with 121 "Document failed validation", and -/// `$set: {deletedAt: null}` saves and still matches `{deletedAt: {$exists: true}}`. -struct MongoDBFieldPresenceWriteTests { - private static let identity: PluginCellValue = "507f1f77bcf86cd799439011" - - private func update( - _ cells: [(column: Int, old: PluginCellValue, new: PluginCellValue)], - columns: [String], - removing removed: Set = [] - ) throws -> String { - let gen = MongoDBStatementGenerator(collectionName: "items", columns: columns) - var change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: cells.map { (columnIndex: $0.column, columnName: columns[$0.column], oldValue: $0.old, newValue: $0.new) }, - originalRow: [Self.identity] + columns.dropFirst().map { _ in PluginCellValue.null } - ) - if !removed.isEmpty { - change.absentColumns = removed - } - let writes = try gen.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - return try #require(writes.first?.statement) - } - - @Test("Set NULL stores null and leaves the field in the document") - func setNullStoresNull() throws { - let statement = try update([(1, "2024-05-01T10:00:00Z", .null)], columns: ["_id", "deletedAt"]) - - #expect(statement == #"db.items.updateOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}}, {"$set": {"deletedAt": null}})"#) - #expect(!statement.contains("$unset")) - } - - @Test("Remove Field writes $unset, and only for the field it names") - func removeFieldWritesUnset() throws { - let statement = try update( - [(1, "a", .null), (2, "b", .null)], - columns: ["_id", "nick", "deletedAt"], - removing: [1] - ) - - #expect(statement.contains(#""$unset": {"nick": ""}"#)) - #expect(statement.contains(#""$set": {"deletedAt": null}"#)) - } - - @Test("A value written into a field the document lacked is a $set") - func valueIntoMissingFieldIsSet() throws { - let statement = try update([(1, .null, "Ada")], columns: ["_id", "nick"]) - - #expect(statement == #"db.items.updateOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}}, {"$set": {"nick": "Ada"}})"#) - } - - @Test("A field named with a dot takes null through $setField and is removed through $unsetField") - func specialNamesKeepNullAndRemovalApart() throws { - let nulled = try update([(1, "10", .null)], columns: ["_id", "price.usd"]) - #expect(nulled.contains(#""$setField": {"field": {"$literal": "price.usd"}, "input": "$$ROOT", "value": {"$literal": null}}"#)) - #expect(!nulled.contains("$unsetField")) - - let removed = try update([(1, "10", .null)], columns: ["_id", "price.usd"], removing: [1]) - #expect(removed.contains(#""$unsetField": {"field": {"$literal": "price.usd"}, "input": "$$ROOT"}"#)) - } - - @Test("A new row keeps its null fields and leaves out the ones it does not have") - func insertTellsNullFromMissing() throws { - let gen = MongoDBStatementGenerator(collectionName: "items", columns: ["_id", "name", "deletedAt", "nick"]) - var change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - change.absentColumns = [3] - - let writes = try gen.generateRowWrites( - from: [change], - insertedRowData: [0: ["__DEFAULT__", "Ada", .null, .null]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(writes.map(\.statement) == [#"db.items.insertOne({"name": "Ada", "deletedAt": null})"#]) - } - - @Test("A NULL _id on a new row is the server's to generate, not a null key") - func nullIdentityOnInsertIsLeftOut() throws { - let gen = MongoDBStatementGenerator(collectionName: "items", columns: ["_id", "name"]) - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - - let writes = try gen.generateRowWrites( - from: [change], insertedRowData: [0: [.null, "Ada"]], deletedRowIndices: [], insertedRowIndices: [0] - ) - - #expect(writes.map(\.statement) == [#"db.items.insertOne({"name": "Ada"})"#]) - } - - /// Measured on 7.0.43: insertOne fails with "[22] invalid document for insert: empty key" for an - /// empty name whatever it holds, null included, and the shell drops `__proto__` whatever it - /// holds. Leaving the field out is the only route, and it is what the refusal names. - @Test("A new row with an empty or __proto__ field refuses null there and leaves the field out once removed") - func unwritableNamesAreLeftOutOnlyWhenMissing() throws { - for name in ["", "__proto__"] { - let gen = MongoDBStatementGenerator(collectionName: "items", columns: ["_id", "name", name]) - var change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let row: [PluginCellValue] = [.null, "Ada", .null] - - #expect(throws: PluginRowWriteRefusal.self, "\(name)") { - try gen.generateRowWrites( - from: [change], insertedRowData: [0: row], deletedRowIndices: [], insertedRowIndices: [0] - ) - } - - change.absentColumns = [2] - let writes = try gen.generateRowWrites( - from: [change], insertedRowData: [0: row], deletedRowIndices: [], insertedRowIndices: [0] - ) - #expect(writes.map(\.statement) == [#"db.items.insertOne({"name": "Ada"})"#], "\(name)") - } - } - - @Test("Putting a deleted document back keeps its null fields and leaves missing ones missing") - func restoreTellsNullFromMissing() throws { - let gen = MongoDBStatementGenerator(collectionName: "items", columns: ["_id", "name", "deletedAt", "nick"]) - - let restored = try #require(gen.generateRestore( - rows: [[Self.identity, "Ada", .null, .null]], - absentCells: [0: [3]] - )?.first) - - #expect(restored.statement == #"db.items.insertOne({"_id": {"$oid": "507f1f77bcf86cd799439011"}, "name": "Ada", "deletedAt": null})"#) - } -} diff --git a/TableProTests/Plugins/MongoDBIndexEntryTests.swift b/TableProTests/Plugins/MongoDBIndexEntryTests.swift deleted file mode 100644 index a3c9c36e14..0000000000 --- a/TableProTests/Plugins/MongoDBIndexEntryTests.swift +++ /dev/null @@ -1,205 +0,0 @@ -// -// MongoDBIndexEntryTests.swift -// TableProTests -// -// Fixtures are listIndexes documents as libmongoc 1.28 renders them in canonical Extended JSON, -// read from MongoDB 7.0.43. -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoDBIndexEntryTests { - private func entry(_ json: String) throws -> MongoDBIndexEntry { - try #require(MongoDBIndexEntry(json: json)) - } - - private func int(_ value: Int) -> String { - "{ \"$numberInt\" : \"\(value)\" }" - } - - @Test("A compound key keeps the order the server sent, in the columns and in the statement") - func compoundKeyKeepsServerOrder() throws { - let index = try entry(""" - { "v" : \(int(2)), "key" : { "zeta" : \(int(1)), "alpha" : \(int(1)), "mid" : \(int(-1)), \ - "beta" : \(int(1)), "omega" : \(int(-1)), "delta" : \(int(1)) }, \ - "name" : "zeta_1_alpha_1_mid_-1_beta_1_omega_-1_delta_1", "unique" : true, "sparse" : true } - """) - - #expect(index.columns == ["zeta", "alpha", "mid", "beta", "omega", "delta"]) - #expect(index.isUnique) - #expect(index.kind == .btree) - #expect(index.createIndexStatement(collection: "people") == """ - db.people.createIndex({ "zeta" : 1, "alpha" : 1, "mid" : -1, "beta" : 1, "omega" : -1, "delta" : 1 }, \ - { "name" : "zeta_1_alpha_1_mid_-1_beta_1_omega_-1_delta_1", "unique" : true, "sparse" : true }) - """) - } - - @Test("A descending field keeps its direction") - func directionSurvives() throws { - let index = try entry(""" - { "v" : \(int(2)), "key" : { "lastName" : \(int(1)), "firstName" : \(int(-1)) }, \ - "name" : "lastName_1_firstName_-1" } - """) - - #expect(index.columns == ["lastName", "firstName"]) - #expect(index.createIndexStatement(collection: "people").contains("\"firstName\" : -1")) - #expect(!index.isUnique) - } - - @Test("A TTL index keeps its expiry") - func ttlOptionIsKept() throws { - let index = try entry(""" - { "v" : \(int(2)), "key" : { "ts" : \(int(1)) }, "name" : "ts_1", "expireAfterSeconds" : \(int(3_600)) } - """) - - #expect(index.createIndexStatement(collection: "people") - == "db.people.createIndex({ \"ts\" : 1 }, { \"name\" : \"ts_1\", \"expireAfterSeconds\" : 3600 })") - } - - @Test("A partial filter keeps its member order and the collation loses only its ICU version") - func partialFilterAndCollationAreKept() throws { - let index = try entry(""" - { "v" : \(int(2)), "key" : { "age" : \(int(1)) }, "name" : "age_1", \ - "partialFilterExpression" : { "b" : { "$gt" : \(int(1)) }, "age" : { "$exists" : true } }, \ - "collation" : { "locale" : "fr", "caseLevel" : false, "strength" : \(int(2)), "version" : "57.1" } } - """) - - #expect(index.createIndexStatement(collection: "people") == """ - db.people.createIndex({ "age" : 1 }, { "name" : "age_1", \ - "partialFilterExpression" : { "b" : { "$gt" : 1 }, "age" : { "$exists" : true } }, \ - "collation" : { "locale" : "fr", "caseLevel" : false, "strength" : 2 } }) - """) - } - - @Test("An Int64 and a whole Double in a key or a partial filter keep their types in the statement") - func typedValuesKeepTheirTypes() throws { - let index = try entry(""" - { "v" : \(int(2)), "key" : { "w" : { "$numberDouble" : "1.0" } }, "name" : "typed", \ - "partialFilterExpression" : { "big" : { "$gt" : { "$numberLong" : "9007199254740993" } }, \ - "w" : { "$gte" : { "$numberDouble" : "1.0" } }, "a" : { "$gt" : { "$numberLong" : "2" } }, \ - "f" : { "$lt" : { "$numberDouble" : "2.5" } } }, "expireAfterSeconds" : { "$numberLong" : "3600" } } - """) - - #expect(index.createIndexStatement(collection: "src") == """ - db.src.createIndex({ "w" : Double(1.0) }, { "name" : "typed", \ - "partialFilterExpression" : { "big" : { "$gt" : NumberLong("9007199254740993") }, \ - "w" : { "$gte" : Double(1.0) }, "a" : { "$gt" : NumberLong("2") }, "f" : { "$lt" : 2.5 } }, \ - "expireAfterSeconds" : NumberLong("3600") }) - """) - } - - @Test("The catalog's own members never reach createIndex") - func versionAndNamespaceAreDropped() throws { - let index = try entry(""" - { "v" : \(int(1)), "key" : { "a" : \(int(1)) }, "name" : "a_1", "ns" : "shop.orders", "sparse" : true } - """) - - #expect(index.options.map(\.key) == ["name", "sparse"]) - } - - @Test("A text index lists its weighted fields where _fts stands, between its other key fields") - func textIndexListsWeightedFields() throws { - let index = try entry(""" - { "v" : \(int(2)), "key" : { "a" : \(int(1)), "_fts" : "text", "_ftsx" : \(int(1)), "z" : \(int(-1)) }, \ - "name" : "a_1_alpha_text_zeta_text_z_-1", "weights" : { "alpha" : \(int(1)), "zeta" : \(int(1)) }, \ - "default_language" : "english", "language_override" : "language", "textIndexVersion" : \(int(3)) } - """) - - #expect(index.columns == ["a", "alpha", "zeta", "z"]) - #expect(index.kind == .text) - #expect(index.pluginIndexInfo.type == "FULLTEXT") - let statement = index.createIndexStatement(collection: "notes") - for option in ["\"weights\" : { \"alpha\" : 1, \"zeta\" : 1 }", "\"default_language\" : \"english\"", - "\"language_override\" : \"language\"", "\"textIndexVersion\" : 3"] { - #expect(statement.contains(option)) - } - } - - @Test("The key decides the index type, and every option of that type is kept") - func kindFromKey() throws { - let cases: [(json: String, type: String, option: String?)] = [ - ("{ \"v\" : \(int(2)), \"key\" : { \"h\" : \"hashed\" }, \"name\" : \"h_hashed\" }", "HASH", nil), - ( - """ - { "v" : \(int(2)), "key" : { "loc" : "2dsphere" }, "name" : "loc_2dsphere", \ - "2dsphereIndexVersion" : \(int(3)) } - """, - "SPATIAL", "\"2dsphereIndexVersion\" : 3" - ), - ( - """ - { "v" : \(int(2)), "key" : { "p" : "2d" }, "name" : "p_2d", "bits" : \(int(20)), \ - "min" : { "$numberDouble" : "-500.0" }, "max" : { "$numberDouble" : "500.0" } } - """, - "2D", "\"bits\" : 20, \"min\" : Double(-500.0), \"max\" : Double(500.0)" - ), - ( - """ - { "v" : \(int(2)), "key" : { "$**" : \(int(1)) }, "name" : "$**_1", \ - "wildcardProjection" : { "alpha" : \(int(1)), "zeta" : \(int(1)) } } - """, - "WILDCARD", "\"wildcardProjection\" : { \"alpha\" : 1, \"zeta\" : 1 }" - ), - ("{ \"v\" : \(int(2)), \"key\" : { \"tags.$**\" : \(int(1)) }, \"name\" : \"tags.$**_1\" }", "WILDCARD", nil), - ( - "{ \"v\" : \(int(2)), \"key\" : { \"omega\" : \(int(1)) }, \"name\" : \"omega_1\", \"hidden\" : true }", - "BTREE", "\"hidden\" : true" - ), - ( - "{ \"v\" : \(int(2)), \"key\" : { \"a\" : \(int(1)), \"loc\" : \"2dsphere\" }, \"name\" : \"a_1_loc_2dsphere\" }", - "SPATIAL", nil - ) - ] - - for testCase in cases { - let index = try entry(testCase.json) - #expect(index.pluginIndexInfo.type == testCase.type, "\(testCase.json)") - if let option = testCase.option { - #expect(index.createIndexStatement(collection: "c").contains(option), "\(testCase.json)") - } - } - } - - @Test("The four types the structure editor writes read back as the names it wrote them under") - func kindNamesMatchTheStructureEditor() { - let spellings: [(value: String, type: String)] = [ - (int(1), "BTREE"), ("\"hashed\"", "HASH"), ("\"text\"", "FULLTEXT"), ("\"2dsphere\"", "SPATIAL") - ] - for spelling in spellings { - #expect(MongoDBIndexKind(keyFields: [(name: "a", value: spelling.value)]).pluginTypeName == spelling.type) - } - } - - @Test("_id_ is the primary key and unique without saying so") - func idIndexIsPrimaryAndUnique() throws { - let index = try entry("{ \"v\" : \(int(2)), \"key\" : { \"_id\" : \(int(1)) }, \"name\" : \"_id_\" }") - - #expect(index.isPrimary) - #expect(index.isUnique) - #expect(index.pluginIndexInfo.columns == ["_id"]) - } - - @Test("A field name holding a quote and a backslash comes back decoded and goes out escaped") - func fieldNameWithQuoteRoundTrips() throws { - let index = try entry("{ \"v\" : \(int(2)), \"key\" : { \"a\\\"b\\\\c\" : \(int(1)) }, \"name\" : \"a\\\"b\\\\c_1\" }") - - #expect(index.columns == ["a\"b\\c"]) - #expect(index.name == "a\"b\\c_1") - #expect(index.createIndexStatement(collection: "c").hasSuffix("{ \"name\" : \"a\\\"b\\\\c_1\" })")) - } - - @Test("A collection whose name the shell would read as a method is reached through getCollection") - func shadowedCollectionName() throws { - let index = try entry("{ \"v\" : \(int(2)), \"key\" : { \"a\" : \(int(1)) }, \"name\" : \"a_1\" }") - - #expect(index.createIndexStatement(collection: "stats").hasPrefix("db.getCollection(\"stats\").createIndex(")) - } - - @Test("A document with no key or no name is not an index") - func incompleteDocumentsAreSkipped() { - #expect(MongoDBIndexEntry(json: "{ \"v\" : \(int(2)), \"name\" : \"a_1\" }") == nil) - #expect(MongoDBIndexEntry(json: "{ \"v\" : \(int(2)), \"key\" : { \"a\" : \(int(1)) } }") == nil) - } -} diff --git a/TableProTests/Plugins/MongoDBJsonLayoutTests.swift b/TableProTests/Plugins/MongoDBJsonLayoutTests.swift deleted file mode 100644 index 6284159944..0000000000 --- a/TableProTests/Plugins/MongoDBJsonLayoutTests.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// MongoDBJsonLayoutTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoDBJsonLayoutTests { - @Test("Members keep their order, one per line, nested two spaces a level") - func indentedKeepsOrder() { - let text = MongoDBJsonLayout.indented("{ \"zeta\" : 1, \"alpha\" : { \"b\" : [ 1, 2 ] } }") - - #expect(text == """ - { - "zeta": 1, - "alpha": { - "b": [ - 1, - 2 - ] - } - } - """) - } - - @Test("Empty documents and arrays stay on one line") - func emptyContainers() { - #expect(MongoDBJsonLayout.indented("{ \"a\" : { }, \"b\" : [ ] }") == "{\n \"a\": {},\n \"b\": []\n}") - #expect(MongoDBJsonLayout.indented("[ ]") == "[]") - } - - @Test("Punctuation and escapes inside a string are left as they are") - func stringsAreUntouched() { - let text = MongoDBJsonLayout.indented("{ \"k\" : \"a, {b}: [c] \\\" d\" }") - - #expect(text == "{\n \"k\": \"a, {b}: [c] \\\" d\"\n}") - } - - @Test("A depth indents every line after the first for text that sits inside a statement") - func depthOffsetsNesting() { - #expect(MongoDBJsonLayout.indented("{ \"a\" : 1 }", depth: 1) == "{\n \"a\": 1\n }") - } - - @Test("An object built from members uses libbson's own spacing") - func objectSpacing() { - #expect(MongoDBJsonLayout.object([(key: "name", value: "\"a_1\""), (key: "unique", value: "true")]) - == "{ \"name\" : \"a_1\", \"unique\" : true }") - #expect(MongoDBJsonLayout.object([]) == "{ }") - } - - @Test("A constructor call stays on one line, commas and braces inside it included") - func constructorCallsStayWhole() { - let text = MongoDBJsonLayout.indented( - "[ { \"b\" : BinData(4, \"AA==\"), \"t\" : Timestamp(5, 6), \"c\" : Code(\"f()\", { \"x\" : 1 }) } ]" - ) - - #expect(text == """ - [ - { - "b": BinData(4, "AA=="), - "t": Timestamp(5, 6), - "c": Code("f()", { "x" : 1 }) - } - ] - """) - } - - @Test("A shell object writes __proto__ as a computed key, and an Extended JSON object as a plain one") - func shellObjectKeepsProtoAMember() { - let members = [(key: "__proto__", value: "1"), (key: "a", value: "2")] - - #expect(MongoDBJsonLayout.shellObject(members) == "{ [\"__proto__\"] : 1, \"a\" : 2 }") - #expect(MongoDBJsonLayout.object(members) == "{ \"__proto__\" : 1, \"a\" : 2 }") - #expect(MongoDBJsonLayout.shellObject([]) == "{ }") - } - - @Test("A computed key stays on its line, and an array holding one string does not become one") - func computedKeysStayWhole() { - let text = MongoDBJsonLayout.indented( - "{ [\"__proto__\"] : { [\"__pro\\\"to__\"] : 1 }, \"b\" : [ \"x\" ], \"c\" : [ \"y\" ] }" - ) - - #expect(text == """ - { - ["__proto__"]: { - ["__pro\\"to__"]: 1 - }, - "b": [ - "x" - ], - "c": [ - "y" - ] - } - """) - } - - @Test("new Date keeps the space between its two words") - func newDateKeepsItsSpace() { - #expect(MongoDBJsonLayout.indented("{ \"d\" : new Date(-62198755200000) }") - == "{\n \"d\": new Date(-62198755200000)\n}") - } - - @Test("A collation keeps every member but the server's ICU version") - func portableCollation() { - #expect(MongoDBCollation.portable( - "{ \"locale\" : \"en\", \"strength\" : { \"$numberInt\" : \"2\" }, \"version\" : \"57.1\" }" - ) == "{ \"locale\" : \"en\", \"strength\" : { \"$numberInt\" : \"2\" } }") - } -} diff --git a/TableProTests/Plugins/MongoDBNameValidatorTests.swift b/TableProTests/Plugins/MongoDBNameValidatorTests.swift deleted file mode 100644 index 79f0698ac0..0000000000 --- a/TableProTests/Plugins/MongoDBNameValidatorTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation -@testable import TablePro -import Testing - -struct MongoDBNameValidatorTests { - @Test("A plain database name passes") - func testValidDatabaseName() throws { - try MongoDBNameValidator.validateDatabaseName("shop") - } - - @Test("An empty database name is rejected") - func testEmptyDatabaseName() { - #expect(throws: MongoDBNameValidationError.emptyDatabaseName) { - try MongoDBNameValidator.validateDatabaseName("") - } - } - - @Test("Each character MongoDB forbids in a database name is rejected", arguments: ["/", "\\", ".", "\"", "$"]) - func testForbiddenDatabaseCharacters(character: String) { - #expect(throws: MongoDBNameValidationError.databaseNameInvalidCharacter(Character(character))) { - try MongoDBNameValidator.validateDatabaseName("sh\(character)op") - } - } - - @Test("A database name of 64 bytes or more is rejected") - func testDatabaseNameLength() throws { - try MongoDBNameValidator.validateDatabaseName(String(repeating: "a", count: 63)) - #expect(throws: MongoDBNameValidationError.databaseNameTooLong) { - try MongoDBNameValidator.validateDatabaseName(String(repeating: "a", count: 64)) - } - } - - @Test("Database name length counts bytes, not characters") - func testDatabaseNameCountsBytes() { - #expect(throws: MongoDBNameValidationError.databaseNameTooLong) { - try MongoDBNameValidator.validateDatabaseName(String(repeating: "é", count: 32)) - } - } - - @Test("A plain collection name passes") - func testValidCollectionName() throws { - try MongoDBNameValidator.validateCollectionName("orders", inDatabase: "shop") - } - - @Test("An empty collection name is rejected") - func testEmptyCollectionName() { - #expect(throws: MongoDBNameValidationError.emptyCollectionName) { - try MongoDBNameValidator.validateCollectionName("", inDatabase: "shop") - } - } - - @Test("A collection name containing a dollar sign is rejected") - func testCollectionNameDollarSign() { - #expect(throws: MongoDBNameValidationError.collectionNameInvalidCharacter("$")) { - try MongoDBNameValidator.validateCollectionName("or$ders", inDatabase: "shop") - } - } - - @Test("A collection name in the system namespace is rejected") - func testCollectionNameReservedPrefix() { - #expect(throws: MongoDBNameValidationError.collectionNameReservedPrefix) { - try MongoDBNameValidator.validateCollectionName("system.users", inDatabase: "shop") - } - } - - @Test("A dot inside a collection name is allowed") - func testCollectionNameDotIsAllowed() throws { - try MongoDBNameValidator.validateCollectionName("orders.archive", inDatabase: "shop") - } - - @Test("A namespace longer than 255 bytes is rejected") - func testNamespaceLength() throws { - let database = String(repeating: "a", count: 63) - try MongoDBNameValidator.validateCollectionName(String(repeating: "b", count: 191), inDatabase: database) - #expect(throws: MongoDBNameValidationError.namespaceTooLong) { - try MongoDBNameValidator.validateCollectionName(String(repeating: "b", count: 192), inDatabase: database) - } - } -} diff --git a/TableProTests/Plugins/MongoDBNamespaceEntryTests.swift b/TableProTests/Plugins/MongoDBNamespaceEntryTests.swift deleted file mode 100644 index b4b68f2403..0000000000 --- a/TableProTests/Plugins/MongoDBNamespaceEntryTests.swift +++ /dev/null @@ -1,297 +0,0 @@ -// -// MongoDBNamespaceEntryTests.swift -// TableProTests -// -// Fixtures are listCollections entries as libmongoc 1.28 renders them in canonical Extended JSON, -// read from MongoDB 7.0.43. -// - -import Foundation -import Testing - -@testable import TablePro - -struct MongoDBNamespaceEntryTests { - private static let adults = """ - { "name" : "adults", "type" : "view", "options" : { "viewOn" : "people", "pipeline" : \ - [ { "$match" : { "age" : { "$gte" : { "$numberInt" : "4" } } } }, \ - { "$sort" : { "lastName" : { "$numberInt" : "1" }, "firstName" : { "$numberInt" : "-1" } } } ], \ - "collation" : { "locale" : "en", "strength" : { "$numberInt" : "2" }, "version" : "57.1" } }, \ - "info" : { "readOnly" : true } } - """ - - private static let plainView = """ - { "name" : "plainview", "type" : "view", "options" : { "viewOn" : "people", "pipeline" : \ - [ { "$project" : { "lastName" : { "$numberInt" : "1" } } } ] }, "info" : { "readOnly" : true } } - """ - - private static let typedView = """ - { "name" : "typed", "type" : "view", "options" : { "viewOn" : "src", "pipeline" : \ - [ { "$match" : { "a" : { "$gte" : { "$numberLong" : "1" } }, "w" : { "$eq" : { "$numberDouble" : "1.0" } }, \ - "big" : { "$eq" : { "$numberLong" : "9007199254740993" } }, "i" : { "$numberInt" : "7" }, \ - "neg0" : { "$numberDouble" : "-0.0" }, "when" : { "$date" : { "$numberLong" : "1577934245678" } }, \ - "many" : { "$in" : [ { "$minKey" : 1 }, { "$timestamp" : { "t" : 5, "i" : 6 } } ] } } } ], \ - "collation" : { "locale" : "en", "strength" : { "$numberInt" : "2" }, "version" : "57.1" } }, \ - "info" : { "readOnly" : true } } - """ - - private static let int1 = "{ \"$numberInt\" : \"1\" }" - private static let int2 = "{ \"$numberInt\" : \"2\" }" - - private func entry(_ json: String) throws -> MongoDBNamespaceEntry { - try #require(MongoDBNamespaceEntry(json: json)) - } - - private func index(_ json: String) throws -> MongoDBIndexEntry { - try #require(MongoDBIndexEntry(json: json)) - } - - @Test("Each kind of namespace listCollections reports maps to the table type the app reads") - func kindMapping() throws { - let expected: [(json: String, type: String)] = [ - ("{ \"name\" : \"people\", \"type\" : \"collection\" }", "TABLE"), - ("{ \"name\" : \"adults\", \"type\" : \"view\" }", "VIEW"), - ("{ \"name\" : \"metrics\", \"type\" : \"timeseries\" }", "TABLE"), - ("{ \"name\" : \"system.views\", \"type\" : \"collection\" }", "SYSTEM TABLE"), - ("{ \"name\" : \"system.buckets.metrics\", \"type\" : \"collection\" }", "SYSTEM TABLE"), - ("{ \"name\" : \"system.profile\", \"type\" : \"collection\" }", "SYSTEM TABLE"), - ("{ \"name\" : \"legacy\" }", "TABLE"), - ("{ \"name\" : \"systemx\", \"type\" : \"collection\" }", "TABLE") - ] - - for namespace in expected { - #expect(try entry(namespace.json).pluginTableType == namespace.type, "\(namespace.json)") - } - } - - @Test("The plugin's spelling decodes to the app's view, table and system table kinds") - func kindMappingDecodesInTheApp() throws { - let expected: [(json: String, kind: TableInfo.TableType)] = [ - ("{ \"name\" : \"adults\", \"type\" : \"view\" }", .view), - ("{ \"name\" : \"people\", \"type\" : \"collection\" }", .table), - ("{ \"name\" : \"metrics\", \"type\" : \"timeseries\" }", .table), - ("{ \"name\" : \"system.views\", \"type\" : \"collection\" }", .systemTable) - ] - - for namespace in expected { - let decoded = PluginTableKindDecoder.decode(try entry(namespace.json).pluginTableType) - #expect(decoded.kind == namespace.kind, "\(namespace.json)") - #expect(!decoded.isSystemVersioned) - } - } - - @Test("A view's statement keeps its pipeline's stage and $sort order, and its collation without the ICU version") - func createViewKeepsPipelineOrderAndCollation() throws { - #expect(try entry(Self.adults).createViewStatement() == """ - db.createView("adults", "people", [ - { - "$match": { - "age": { - "$gte": 4 - } - } - }, - { - "$sort": { - "lastName": 1, - "firstName": -1 - } - } - ], { - "collation": { - "locale": "en", - "strength": 2 - } - }) - """) - } - - @Test("A view with no collation is created with three arguments") - func createViewWithoutCollationHasThreeArguments() throws { - #expect(try entry(Self.plainView).createViewStatement() == """ - db.createView("plainview", "people", [ - { - "$project": { - "lastName": 1 - } - } - ]) - """) - } - - @Test("The collMod that redefines a view leaves its collation out, which collMod refuses") - func collModOmitsCollation() throws { - #expect(try entry(Self.adults).collModStatement() == """ - db.runCommand({ - "collMod": "adults", - "viewOn": "people", - "pipeline": [ - { - "$match": { - "age": { - "$gte": 4 - } - } - }, - { - "$sort": { - "lastName": 1, - "firstName": -1 - } - } - ] - }) - """) - } - - @Test("A view's Int64, whole Double, date and bounds are written through the constructors that keep their types") - func viewStatementsKeepValueTypes() throws { - let view = try entry(Self.typedView) - let match = """ - "$match": { - "a": { - "$gte": NumberLong("1") - }, - "w": { - "$eq": Double(1.0) - }, - "big": { - "$eq": NumberLong("9007199254740993") - }, - "i": 7, - "neg0": Double(-0.0), - "when": ISODate("2020-01-02T03:04:05.678Z"), - "many": { - "$in": [ - MinKey(), - Timestamp(5, 6) - ] - } - } - """ - - #expect(view.createViewStatement() == """ - db.createView("typed", "src", [ - { - \(match) - } - ], { - "collation": { - "locale": "en", - "strength": 2 - } - }) - """) - #expect(try #require(view.collModStatement()).contains("\"$eq\": NumberLong(\"9007199254740993\")")) - #expect(try #require(view.collModStatement()).contains("\"$eq\": Double(1.0)")) - } - - @Test("A collection has no view statements") - func nonViewHasNoViewStatements() throws { - let collection = try entry("{ \"name\" : \"people\", \"type\" : \"collection\", \"options\" : { } }") - - #expect(collection.createViewStatement() == nil) - #expect(collection.collModStatement() == nil) - } - - @Test("A view's DDL has its own header, so MQL export never appends it to exported documents") - func viewDDLHasViewHeaderAndNoIndexes() throws { - let text = MongoDBNamespaceDDL.text(name: "adults", entry: try entry(Self.adults), indexes: []) - - #expect(text.hasPrefix("// View: adults\ndb.createView(\"adults\", \"people\", [")) - #expect(!text.contains("// Collection:")) - #expect(!text.contains("// Indexes")) - } - - @Test("A capped collection's size and max are read from the options") - func cappedSizeIsRead() throws { - let capped = try entry(""" - { "name" : "capped1", "type" : "collection", "options" : { "capped" : true, \ - "size" : { "$numberInt" : "4096" }, "max" : { "$numberLong" : "10" } } } - """) - - #expect(MongoDBNamespaceDDL.text(name: "capped1", entry: capped, indexes: []) - == "// Collection: capped1\n// Capped: true, size: 4096, max: 10") - } - - @Test("A validator keeps its properties in declared order and the collection name is escaped") - func validatorKeepsPropertyOrder() throws { - let validated = try entry(""" - { "name" : "we\\"ird", "type" : "collection", "options" : { "validator" : { "$jsonSchema" : \ - { "bsonType" : "object", "properties" : { "zeta" : { "bsonType" : "string" }, \ - "alpha" : { "bsonType" : "long", "minimum" : { "$numberLong" : "9007199254740993" } } } } } } } - """) - - #expect(MongoDBNamespaceDDL.text(name: "we\"ird", entry: validated, indexes: []) == """ - // Collection: we"ird - - // Validator - db.runCommand({ - "collMod": "we\\"ird", - "validator": { - "$jsonSchema": { - "bsonType": "object", - "properties": { - "zeta": { - "bsonType": "string" - }, - "alpha": { - "bsonType": "long", - "minimum": NumberLong("9007199254740993") - } - } - } - } - }) - """) - } - - @Test("A time-series collection says so in its DDL") - func timeSeriesLineIsWritten() throws { - let metrics = try entry(""" - { "name" : "metrics", "type" : "timeseries", "options" : { "timeseries" : { "timeField" : "ts", \ - "metaField" : "meta", "granularity" : "hours", "bucketMaxSpanSeconds" : { "$numberInt" : "2592000" } } } } - """) - - #expect(MongoDBNamespaceDDL.text(name: "metrics", entry: metrics, indexes: []) == """ - // Collection: metrics - // Time series: { "timeField" : "ts", "metaField" : "meta", "granularity" : "hours", \ - "bucketMaxSpanSeconds" : 2592000 } - """) - } - - @Test("Indexes are written in listIndexes order and _id_ is left out") - func ddlSkipsIdIndexAndKeepsListOrder() throws { - let people = try entry("{ \"name\" : \"people\", \"type\" : \"collection\", \"options\" : { } }") - let indexes = try [ - index("{ \"v\" : \(Self.int2), \"key\" : { \"_id\" : \(Self.int1) }, \"name\" : \"_id_\" }"), - index("{ \"v\" : \(Self.int2), \"key\" : { \"zeta\" : \(Self.int1) }, \"name\" : \"zeta_1\" }"), - index("{ \"v\" : \(Self.int2), \"key\" : { \"alpha\" : \(Self.int1) }, \"name\" : \"alpha_1\" }") - ] - - #expect(MongoDBNamespaceDDL.text(name: "people", entry: people, indexes: indexes) == """ - // Collection: people - - // Indexes - db.people.createIndex({ "zeta" : 1 }, { "name" : "zeta_1" }) - db.people.createIndex({ "alpha" : 1 }, { "name" : "alpha_1" }) - """) - } - - @Test("A namespace whose entry could not be read still gets its header and indexes") - func nilEntryStillWritesIndexes() throws { - let indexes = try [index("{ \"v\" : \(Self.int2), \"key\" : { \"a\" : \(Self.int1) }, \"name\" : \"a_1\" }")] - - #expect(MongoDBNamespaceDDL.text(name: "people", entry: nil, indexes: indexes) == """ - // Collection: people - - // Indexes - db.people.createIndex({ "a" : 1 }, { "name" : "a_1" }) - """) - } - - @Test("An entry with no name is not a namespace") - func entryWithoutNameIsSkipped() { - #expect(MongoDBNamespaceEntry(json: "{ \"type\" : \"collection\" }") == nil) - } -} diff --git a/TableProTests/Plugins/MongoDBNestedFilterTests.swift b/TableProTests/Plugins/MongoDBNestedFilterTests.swift deleted file mode 100644 index cc06a94fb2..0000000000 --- a/TableProTests/Plugins/MongoDBNestedFilterTests.swift +++ /dev/null @@ -1,447 +0,0 @@ -// -// MongoDBNestedFilterTests.swift -// TableProTests -// -// Nested field paths, $elemMatch grouping and BSON-typed filter values -// (compiled via symlink from MongoDBDriverPlugin). -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct MongoDBNestedFilterTests { - private static let normalizer = MongoDBRawFilterNormalizer() - - private var rawFilterBuilder: MongoDBQueryBuilder { - MongoDBQueryBuilder(rawFilterNormalizer: { Self.normalizer.normalize($0) }) - } - - private func filter( - _ column: String, - _ op: String, - _ value: String, - secondValue: String? = nil, - elementScope: String? = nil, - isCaseSensitive: Bool = true - ) -> PluginQueryFilter { - PluginQueryFilter( - column: column, op: op, value: value, isCaseSensitive: isCaseSensitive, - secondValue: secondValue, elementScope: elementScope - ) - } - - // MARK: - Dot Notation - - @Test("A dotted path reaches the query verbatim, with the dot intact") - func dottedPathIsNotEscaped() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("customer.country", "=", "US")] - ) - #expect(doc == "{\"customer.country\": \"US\"}") - } - - @Test("A dotted path works with a comparison operator") - func dottedPathComparison() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("customer.age", ">=", "18")] - ) - #expect(doc == "{\"customer.age\": {\"$gte\": 18}}") - } - - @Test("A single array-path condition stays dot notation, never $elemMatch") - func singleArrayConditionIsNotWrapped() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("items.sku", "=", "A100")] - ) - #expect(doc == "{\"items.sku\": \"A100\"}") - #expect(!doc.contains("$elemMatch")) - } - - @Test("Two array-path conditions without a scope stay independent") - func twoUnscopedArrayConditionsStayIndependent() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("items.price", ">", "500"), filter("items.name", "=", "Laptop")] - ) - #expect(!doc.contains("$elemMatch")) - #expect(doc.contains("\"items.price\": {\"$gt\": 500}")) - #expect(doc.contains("\"items.name\": \"Laptop\"")) - } - - // MARK: - $elemMatch - - @Test("Two conditions sharing a scope collapse into one $elemMatch") - func scopedConditionsCollapse() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [ - filter("items.price", ">", "500", elementScope: "items"), - filter("items.name", "=", "Laptop", elementScope: "items"), - ] - ) - #expect(doc == "{\"items\": {\"$elemMatch\": {\"price\": {\"$gt\": 500}, \"name\": \"Laptop\"}}}") - } - - @Test("A single scoped condition is still emitted as $elemMatch, never silently downgraded") - func singleScopedConditionKeepsElemMatch() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("items.sku", "=", "A100", elementScope: "items")] - ) - #expect(doc == "{\"items\": {\"$elemMatch\": {\"sku\": \"A100\"}}}") - } - - @Test("Two different array prefixes produce two separate $elemMatch clauses") - func separatePrefixesDoNotMerge() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [ - filter("items.sku", "=", "A100", elementScope: "items"), - filter("shipments.carrier", "=", "DHL", elementScope: "shipments"), - ] - ) - #expect(doc.contains("\"items\": {\"$elemMatch\": {\"sku\": \"A100\"}}")) - #expect(doc.contains("\"shipments\": {\"$elemMatch\": {\"carrier\": \"DHL\"}}")) - #expect(doc.hasPrefix("{\"$and\": [")) - } - - @Test("A scoped condition mixes with an unscoped one under the logic operator") - func scopedAndUnscopedCombine() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [ - filter("customer.country", "=", "US"), - filter("items.price", ">", "500", elementScope: "items"), - ], - logicMode: "and" - ) - #expect(doc.contains("\"customer.country\": \"US\"")) - #expect(doc.contains("\"items\": {\"$elemMatch\": {\"price\": {\"$gt\": 500}}}")) - } - - @Test("Scoped conditions honour the OR logic mode alongside other rows") - func scopedRespectsOrLogicMode() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [ - filter("customer.country", "=", "US"), - filter("items.price", ">", "500", elementScope: "items"), - ], - logicMode: "or" - ) - #expect(doc.hasPrefix("{\"$or\": [")) - } - - @Test("A deeper path under a scope keeps its remaining segments inside $elemMatch") - func deeperPathKeepsRemainder() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("orders.customer.country", "=", "US", elementScope: "orders")] - ) - #expect(doc == "{\"orders\": {\"$elemMatch\": {\"customer.country\": \"US\"}}}") - } - - @Test("Two scoped conditions claiming the same key fall back to $and inside $elemMatch") - func collidingKeysInsideScopeUseAnd() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [ - filter("items.price", ">", "10", elementScope: "items"), - filter("items.price", "<", "90", elementScope: "items"), - ] - ) - #expect(doc.contains("$elemMatch")) - #expect(doc.contains("\"$and\"")) - #expect(doc.contains("{\"$gt\": 10}")) - #expect(doc.contains("{\"$lt\": 90}")) - } - - @Test("A scope whose conditions all drop does not emit an empty $elemMatch") - func emptyScopeIsDropped() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("items.sku", "UNKNOWN_OP", "x", elementScope: "items")] - ) - #expect(!doc.contains("$elemMatch")) - #expect(doc == MongoDBQueryBuilder.impossibleFilter) - } - - // MARK: - Raw Filter Document - - @Test("A raw filter row is used as the filter document, not as a field named __RAW__") - func rawFilterIsUsedAsDocument() { - let doc = rawFilterBuilder.buildFilterDocument( - from: [filter(MongoDBQueryBuilder.rawFilterColumn, "=", "{\"customer.country\": \"US\"}")] - ) - #expect(!doc.contains("__RAW__")) - #expect(doc == "{\"$and\": [{\"customer.country\":\"US\"}]}") - } - - @Test("A raw filter row that is not a document is dropped rather than matching everything") - func rawFilterRejectsNonDocument() { - let doc = rawFilterBuilder.buildFilterDocument( - from: [filter(MongoDBQueryBuilder.rawFilterColumn, "=", "customer.country = 'US'")] - ) - #expect(!doc.contains("__RAW__")) - #expect(doc == MongoDBQueryBuilder.impossibleFilter) - } - - @Test("A raw filter of {} keeps MongoDB's own meaning of match everything") - func rawFilterEmptyDocumentMatchesAll() { - let doc = rawFilterBuilder.buildFilterDocument( - from: [filter(MongoDBQueryBuilder.rawFilterColumn, "=", "{}")] - ) - #expect(doc == "{\"$and\": [{}]}") - #expect(doc != MongoDBQueryBuilder.impossibleFilter) - } - - @Test("A raw filter combines with a column row under the logic operator") - func rawFilterCombinesWithColumnRow() { - let doc = rawFilterBuilder.buildFilterDocument( - from: [ - filter(MongoDBQueryBuilder.rawFilterColumn, "=", "{\"items.sku\": \"A100\"}"), - filter("customer.country", "=", "US"), - ] - ) - #expect(doc.hasPrefix("{\"$and\": [")) - #expect(doc.contains("{\"items.sku\":\"A100\"}")) - #expect(doc.contains("\"customer.country\": \"US\"")) - } - - // MARK: - BETWEEN - - /// Built from the real `asPluginQueryFilter` output rather than a hand-made fixture: the app - /// still joins `value` as `lower,upper` for older plugins, so a fixture that passes the bare - /// lower bound tests a shape the app never sends. - @Test("BETWEEN from the app's own encoding uses the lower bound, not the joined string") - func betweenFromAppEncoding() { - let plugin = TableFilter( - columnName: "age", filterOperator: .between, value: "18", secondValue: "65" - ).asPluginQueryFilter - #expect(plugin.value == "18,65") - - let doc = MongoDBQueryBuilder().buildFilterDocument(from: [plugin]) - #expect(doc == "{\"age\": {\"$gte\": 18, \"$lte\": 65}}") - } - - @Test("BETWEEN from the app's encoding survives a comma inside either bound") - func betweenFromAppEncodingWithComma() { - let plugin = TableFilter( - columnName: "name", filterOperator: .between, value: "Smith, John", secondValue: "Zed" - ).asPluginQueryFilter - let doc = MongoDBQueryBuilder().buildFilterDocument(from: [plugin]) - #expect(doc == "{\"name\": {\"$gte\": \"Smith, John\", \"$lte\": \"Zed\"}}") - } - - @Test("BETWEEN reads secondValue, so a bound holding a comma survives") - func betweenUsesSecondValue() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("name", "BETWEEN", "Smith, John", secondValue: "Zed")] - ) - #expect(doc == "{\"name\": {\"$gte\": \"Smith, John\", \"$lte\": \"Zed\"}}") - } - - @Test("BETWEEN still parses the joined form when no secondValue is supplied") - func betweenFallsBackToJoinedValue() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("age", "BETWEEN", "18, 65")] - ) - #expect(doc == "{\"age\": {\"$gte\": 18, \"$lte\": 65}}") - } - - @Test("BETWEEN with a blank bound matches nothing rather than widening") - func betweenBlankBound() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("age", "BETWEEN", "18", secondValue: " ")] - ) - #expect(doc == MongoDBQueryBuilder.impossibleFilter) - } - - // MARK: - BSON Type Coercion - - @Test("A Date column compares against an Extended JSON date, not a string") - func dateColumnIsCoerced() { - let builder = MongoDBQueryBuilder(columnKinds: ["createdAt": .date]) - let doc = builder.buildFilterDocument( - from: [filter("createdAt", ">=", "2024-01-01T00:00:00Z")] - ) - #expect(doc == "{\"createdAt\": {\"$gte\": {\"$date\": {\"$numberLong\": \"1704067200000\"}}}}") - } - - @Test("A nested Date path is coerced the same way as a top-level one") - func nestedDateIsCoerced() { - let builder = MongoDBQueryBuilder(columnKinds: ["customer.registeredAt": .date]) - let doc = builder.buildFilterDocument( - from: [filter("customer.registeredAt", ">=", "2024-01-01")] - ) - #expect(doc.contains("\"$date\"")) - #expect(doc.contains("\"customer.registeredAt\"")) - } - - @Test("A Date inside $elemMatch is coerced from the full path's kind") - func dateInsideElemMatchIsCoerced() { - let builder = MongoDBQueryBuilder(columnKinds: ["items.shippedAt": .date]) - let doc = builder.buildFilterDocument( - from: [filter("items.shippedAt", ">=", "2024-01-01", elementScope: "items")] - ) - #expect(doc.contains("\"items\": {\"$elemMatch\": {\"shippedAt\": {\"$gte\": {\"$date\"")) - } - - @Test("An unparseable date falls back to the literal rather than emitting a bad wrapper") - func unparseableDateFallsBack() { - let builder = MongoDBQueryBuilder(columnKinds: ["createdAt": .date]) - let doc = builder.buildFilterDocument( - from: [filter("createdAt", ">=", "last tuesday")] - ) - #expect(doc == "{\"createdAt\": {\"$gte\": \"last tuesday\"}}") - } - - @Test("An ObjectId column compares as $oid on a range operator") - func objectIdRangeIsCoerced() { - let builder = MongoDBQueryBuilder(columnKinds: ["_id": .objectId]) - let doc = builder.buildFilterDocument( - from: [filter("_id", ">", "507f1f77bcf86cd799439011")] - ) - #expect(doc == "{\"_id\": {\"$gt\": {\"$oid\": \"507f1f77bcf86cd799439011\"}}}") - } - - @Test("A Decimal128 column compares as $numberDecimal") - func decimalIsCoerced() { - let builder = MongoDBQueryBuilder(columnKinds: ["price": .decimal128]) - let doc = builder.buildFilterDocument(from: [filter("price", ">=", "19.99")]) - #expect(doc == "{\"price\": {\"$gte\": {\"$numberDecimal\": \"19.99\"}}}") - } - - @Test("A 24-hex value on a known string column is not guessed as an ObjectId") - func hexStringColumnIsNotGuessed() { - let builder = MongoDBQueryBuilder(columnKinds: ["token": .string]) - let doc = builder.buildFilterDocument( - from: [filter("token", "=", "507f1f77bcf86cd799439011")] - ) - #expect(doc == "{\"token\": \"507f1f77bcf86cd799439011\"}") - } - - @Test("A 24-hex value on an unsampled column keeps the both-ways match") - func hexStringUnknownColumnKeepsAlternatives() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("ref", "=", "507f1f77bcf86cd799439011")] - ) - #expect(doc.contains("$or")) - #expect(doc.contains("$oid")) - } - - // MARK: - Ignore Case Against Non-String Kinds - - @Test("Ignore case on a numeric column does not become a regex that matches nothing") - func ignoreCaseOnNumberStaysExact() { - let builder = MongoDBQueryBuilder(columnKinds: ["age": .int32]) - let doc = builder.buildFilterDocument( - from: [filter("age", "=", "28", isCaseSensitive: false)] - ) - #expect(doc == "{\"age\": 28}") - #expect(!doc.contains("$regex")) - } - - @Test("Ignore case on a numeric column does not invert a not-equals into match-all") - func ignoreCaseOnNumberNotEquals() { - let builder = MongoDBQueryBuilder(columnKinds: ["age": .int32]) - let doc = builder.buildFilterDocument( - from: [filter("age", "!=", "28", isCaseSensitive: false)] - ) - #expect(doc == "{\"age\": {\"$ne\": 28}}") - #expect(!doc.contains("$not")) - } - - @Test("Ignore case on a string column still uses an anchored regex") - func ignoreCaseOnStringUsesRegex() { - let builder = MongoDBQueryBuilder(columnKinds: ["name": .string]) - let doc = builder.buildFilterDocument( - from: [filter("name", "=", "alice", isCaseSensitive: false)] - ) - #expect(doc.contains("\"$regex\": \"^alice$\"")) - #expect(doc.contains("\"$options\": \"i\"")) - } - - // MARK: - Relative Paths - - @Test("A path is re-keyed relative to its scope") - func relativePathStripsPrefix() { - #expect(MongoDBQueryBuilder.relativePath("items.sku", under: "items") == "sku") - #expect(MongoDBQueryBuilder.relativePath("orders.items.sku", under: "orders") == "items.sku") - } - - @Test("A path that does not sit under the scope is left alone") - func relativePathLeavesUnrelatedPath() { - #expect(MongoDBQueryBuilder.relativePath("customer.name", under: "items") == "customer.name") - #expect(MongoDBQueryBuilder.relativePath("itemsTotal", under: "items") == "itemsTotal") - } - - @Test("A scope whose name is outside the BMP strips exactly its own characters") - func relativePathHandlesNonBmpScope() { - #expect(MongoDBQueryBuilder.relativePath("🎁items.sku", under: "🎁items") == "sku") - #expect(MongoDBQueryBuilder.relativePath("café.name", under: "café") == "name") - } - - @Test("A non-BMP array field still produces a usable $elemMatch") - func elementMatchWithNonBmpScope() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("🎁items.sku", "=", "A100", elementScope: "🎁items")] - ) - #expect(doc == "{\"🎁items\": {\"$elemMatch\": {\"sku\": \"A100\"}}}") - } - - // MARK: - Logic Mode Inside a Scope - - @Test("Two same-element rows under match-any become $or inside the $elemMatch") - func scopedRowsHonourOrLogicMode() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [ - filter("items.price", ">", "500", elementScope: "items"), - filter("items.name", "=", "Laptop", elementScope: "items"), - ], - logicMode: "or" - ) - #expect(doc.contains("$elemMatch")) - #expect(doc.contains("\"$or\"")) - #expect(!doc.contains("\"$and\"")) - } - - @Test("Two same-element rows under match-all stay a plain $elemMatch body") - func scopedRowsHonourAndLogicMode() { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [ - filter("items.price", ">", "500", elementScope: "items"), - filter("items.name", "=", "Laptop", elementScope: "items"), - ], - logicMode: "and" - ) - #expect(doc == "{\"items\": {\"$elemMatch\": {\"price\": {\"$gt\": 500}, \"name\": \"Laptop\"}}}") - } - - @Test("A single same-element row is unaffected by the logic mode") - func singleScopedRowIgnoresLogicMode() { - let expected = "{\"items\": {\"$elemMatch\": {\"sku\": \"A100\"}}}" - for mode in ["and", "or"] { - let doc = MongoDBQueryBuilder().buildFilterDocument( - from: [filter("items.sku", "=", "A100", elementScope: "items")], logicMode: mode - ) - #expect(doc == expected) - } - } - - // MARK: - String Columns - - @Test("A numeric-looking value on a known string field stays quoted") - func stringColumnKeepsQuotes() { - let builder = MongoDBQueryBuilder(columnKinds: ["customer.zip": .string]) - let doc = builder.buildFilterDocument(from: [filter("customer.zip", "=", "12345")]) - #expect(doc == "{\"customer.zip\": \"12345\"}") - } - - @Test("A range on a known string field compares against strings") - func stringColumnRangeKeepsQuotes() { - let builder = MongoDBQueryBuilder(columnKinds: ["sku": .string]) - let doc = builder.buildFilterDocument(from: [filter("sku", ">=", "100")]) - #expect(doc == "{\"sku\": {\"$gte\": \"100\"}}") - } - - @Test("A value on an unsampled field is still typed by its own spelling") - func unknownColumnStillAutoTypes() { - let doc = MongoDBQueryBuilder().buildFilterDocument(from: [filter("qty", "=", "12345")]) - #expect(doc == "{\"qty\": 12345}") - } -} diff --git a/TableProTests/Plugins/MongoDBNestedValueWriteTests.swift b/TableProTests/Plugins/MongoDBNestedValueWriteTests.swift deleted file mode 100644 index b8ffecb06d..0000000000 --- a/TableProTests/Plugins/MongoDBNestedValueWriteTests.swift +++ /dev/null @@ -1,539 +0,0 @@ -// -// MongoDBNestedValueWriteTests.swift -// TableProTests -// - -import Foundation -import JavaScriptCore -import TableProNumberFormatting -import TableProPluginKit -import Testing - -struct MongoDBNestedValueWriteTests { - private static let address = #"{"zip":"100","city":"HN","verifiedAt":{"$date":"2024-05-01T10:00:00.123Z"},"# - + #""ownerId":{"$oid":"65a1b2c3d4e5f60718293a4b"},"n":{"$numberLong":"5"},"d":3.0}"# - - private func update( - _ column: String, - from oldText: String, - to newText: String, - kind: BsonValueKind = .document - ) throws -> String { - try update(column, from: .text(oldText), to: newText, held: [kind], majority: kind) - } - - /// An edit in a field whose documents held `held`, or were never read when it is nil, typed by - /// its `majority` kind. - private func update( - _ column: String, - from oldValue: PluginCellValue, - to newText: String, - held: Set?, - majority: BsonValueKind = .document - ) throws -> String { - let gen = MongoDBStatementGenerator( - collectionName: "people", - columns: ["_id", column], - columnKinds: [column: majority], - fieldKinds: MongoDBFieldKinds(held.map { [column: $0] } ?? [:]) - ) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: column, oldValue: oldValue, newValue: .text(newText))], - originalRow: [.text("1"), oldValue] - ) - let writes = try gen.generateRowWrites(from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: []) - return try #require(writes.first?.statement) - } - - private func insert(_ column: String, _ text: String, held: Set? = nil, typed: Bool = false) throws -> String { - let gen = MongoDBStatementGenerator( - collectionName: "people", - columns: ["_id", column], - fieldKinds: MongoDBFieldKinds(held.map { [column: $0] } ?? [:]) - ) - let filledIn = typed ? [(columnIndex: 1, columnName: column, oldValue: PluginCellValue.null, newValue: PluginCellValue.text(text))] : [] - let writes = try gen.generateRowWrites( - from: [PluginRowChange(rowIndex: 0, type: .insert, cellChanges: filledIn, originalRow: nil)], - insertedRowData: [0: [nil, .text(text)]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - return try #require(writes.first?.statement) - } - - // MARK: - Display - - @Test("A nested document shows its stored order and its BSON types") - func displayKeepsOrderAndTypes() throws { - let canonical = #"{"_id":{"$numberInt":"1"},"address":{"zip":"100","city":"HN","# - + #""verifiedAt":{"$date":{"$numberLong":"1714557600123"}},"ownerId":{"$oid":"65a1b2c3d4e5f60718293a4b"},"# - + #""amount":{"$numberDecimal":"1847.270000000000000000000000001"},"n":{"$numberLong":"5"},"# - + #""d":{"$numberDouble":"3.0"},"i":{"$numberInt":"7"},"bin":{"$binary":{"base64":"AAECAw==","subType":"05"}}}}"# - let document: [String: Any] = ["_id": Int32(1), "address": ["zip": "100"]] - - let rows = BsonDocumentFlattener.flatten( - documents: [document], columns: ["_id", "address"], kinds: [.int32, .document], - representation: .unspecified, storedTexts: [canonical] - ) - - let shown = #"{"zip":"100","city":"HN","verifiedAt":{"$date":"2024-05-01T10:00:00.123Z"},"# - + #""ownerId":{"$oid":"65a1b2c3d4e5f60718293a4b"},"amount":{"$numberDecimal":"1847.270000000000000000000000001"},"# - + #""n":{"$numberLong":"5"},"d":3.0,"i":7,"bin":{"$binary":{"base64":"AAECAw==","subType":"05"}}}"# - #expect(rows[0][1] == .text(shown)) - } - - @Test("Doubles show their shortest spelling, and values outside the relaxed form keep their wrappers") - func displayKeepsWrappersItCannotRelax() { - let canonical = MongoDocumentText.Value.object([ - .init(key: "tenth", value: .object([.init(key: "$numberDouble", value: .string("0.10000000000000000555"))])), - .init(key: "e", value: .object([.init(key: "$numberDouble", value: .string("1e+20"))])), - .init(key: "nan", value: .object([.init(key: "$numberDouble", value: .string("NaN"))])), - .init(key: "old", value: .object([.init(key: "$date", value: .object([.init(key: "$numberLong", value: .string("-1"))]))])) - ]) - - let shown = MongoExtendedJsonForm.display(canonical).compactText - - #expect(shown == #"{"tenth":0.1,"e":1e+20,"nan":{"$numberDouble":"NaN"},"old":{"$date":{"$numberLong":"-1"}}}"#) - } - - @Test("A cell without stored text keeps the older rendering") - func displayWithoutStoredTextIsUnchanged() { - let rows = BsonDocumentFlattener.flatten( - documents: [["tags": ["b", "a"]]], columns: ["tags"], kinds: [.array], representation: .unspecified - ) - - #expect(rows[0][0] == .text(#"["b","a"]"#)) - } - - // MARK: - Updates - - @Test("Editing one nested key writes only that path") - func singleLeafEdit() throws { - let edited = Self.address.replacingOccurrences(of: #""city":"HN""#, with: #""city":"HN2""#) - - let statement = try update("address", from: Self.address, to: edited) - - #expect(statement == #"db.people.updateOne({"_id": 1}, {"$set": {"address.city": "HN2"}})"#) - } - - @Test("Removing a nested key writes $unset on its path") - func nestedRemoval() throws { - let edited = Self.address.replacingOccurrences(of: #""zip":"100","#, with: "") - - let statement = try update("address", from: Self.address, to: edited) - - #expect(statement == #"db.people.updateOne({"_id": 1}, {"$unset": {"address.zip": ""}})"#) - } - - @Test("One key added at the end is set on its path") - func appendedKeyIsAPath() throws { - let edited = String(Self.address.dropLast()) + #","extra":{"$numberLong":"9"}}"# - - let statement = try update("address", from: Self.address, to: edited) - - #expect(statement == #"db.people.updateOne({"_id": 1}, {"$set": {"address.extra": {"$numberLong":"9"}}})"#) - } - - @Test("Keys the server would append in its own order are written as the whole object") - func reorderedAdditionsAreWrittenWhole() throws { - let twoNew = try update("meta", from: #"{"a":1}"#, to: #"{"a":1,"z":2,"b":3}"#) - let inserted = try update("meta", from: #"{"a":1,"c":3}"#, to: #"{"a":1,"b":2,"c":3}"#) - let moved = try update("meta", from: #"{"a":1,"b":2}"#, to: #"{"b":2,"a":1}"#) - - #expect(twoNew.contains(#"{"$set": {"meta": {"a":1,"z":2,"b":3}}}"#)) - #expect(inserted.contains(#"{"$set": {"meta": {"a":1,"b":2,"c":3}}}"#)) - #expect(moved.contains(#"{"$set": {"meta": {"b":2,"a":1}}}"#)) - } - - @Test("A same-length array edit writes the index, a length change writes the array with its wrappers") - func arrayEdits() throws { - let tags = #"[{"$oid":"65a1b2c3d4e5f60718293a4b"},"b",3.0]"# - - let index = try update("tags", from: tags, to: tags.replacingOccurrences(of: #""b""#, with: #""B""#), kind: .array) - let grown = try update("tags", from: tags, to: String(tags.dropLast()) + #","c"]"#, kind: .array) - - #expect(index == #"db.people.updateOne({"_id": 1}, {"$set": {"tags.1": "B"}})"#) - #expect(grown == #"db.people.updateOne({"_id": 1}, {"$set": {"tags": [{"$oid":"65a1b2c3d4e5f60718293a4b"},"b",{"$numberDouble":"3.0"},"c"]}})"#) - } - - @Test("A changed nested key that holds a dot is written as the whole enclosing value") - func unaddressableNestedKeyEscalates() throws { - let statement = try update("meta", from: #"{"a.b":1,"c":2}"#, to: #"{"a.b":5,"c":2}"#) - - #expect(statement.contains(#"{"$set": {"meta": {"a.b":5,"c":2}}}"#)) - } - - @Test("A value whose type changed is written whole at its path") - func typeChangeWritesTheValue() throws { - let statement = try update("meta", from: #"{"a":{"x":1}}"#, to: #"{"a":[1]}"#) - - #expect(statement.contains(#"{"$set": {"meta.a": [1]}}"#)) - } - - @Test("An edit that only reformats the text still writes the value, so the row is not left unwritten") - func reformatOnlyWritesTheWholeValue() throws { - let statement = try update("meta", from: #"{"a":1}"#, to: "{\n \"a\": 1\n}") - - #expect(statement.contains(#"{"$set": {"meta": {"a":1}}}"#)) - } - - @Test("Text that is not JSON in a document column is refused") - func unreadableJsonIsRefused() { - #expect(throws: MongoDBWriteRefusal.unreadableJSON(field: "meta").refusal(ofRow: 0)) { - try update("meta", from: #"{"a":1}"#, to: #"{"a":}"#) - } - } - - @Test("A complete value typed over one shortened for display replaces the field whole") - func truncatedOldValueIsReplacedWhole() throws { - let stored = #"{"big":""# + String(repeating: "x", count: 12_000) + #"","keep":1}"# - let shown = JSONTruncation.truncate(stored, maxLength: BsonDocumentFlattener.maxNestedJsonLength) - - let statement = try update("meta", from: shown, to: #"{"a":1,"d":3.0}"#) - - #expect(statement == #"db.people.updateOne({"_id": 1}, {"$set": {"meta": {"a":1,"d":{"$numberDouble":"3.0"}}}})"#) - } - - @Test("An edit that keeps the shortened text is still refused") - func truncatedEditIsRefused() { - let stored = #"{"keep":1,"big":""# + String(repeating: "x", count: 12_000) + #""}"# - let shown = JSONTruncation.truncate(stored, maxLength: BsonDocumentFlattener.maxNestedJsonLength) - - #expect(throws: MongoDBWriteRefusal.truncatedValue(field: "meta").refusal(ofRow: 0)) { - try update("meta", from: shown, to: shown.replacingOccurrences(of: #""keep":1"#, with: #""keep":2"#)) - } - } - - @Test("An edit to a value shortened right after an inner document, in a field that also held strings, is refused") - func truncatedAfterAnInnerDocumentInAMixedFieldIsRefused() throws { - let stored = "[" + Array(repeating: #"{"k":"v"}"#, count: 1_500).joined(separator: ",") + "]" - let shown = JSONTruncation.truncate(stored, maxLength: BsonDocumentFlattener.maxNestedJsonLength) - try #require(shown.hasSuffix("}...")) - let edited = shown.replacingOccurrences(of: #"[{"k":"v"}"#, with: #"[{"k":"w"}"#) - - #expect(throws: MongoDBWriteRefusal.truncatedValue(field: "f").refusal(ofRow: 0)) { - try update("f", from: .text(shown), to: edited, held: [.array, .string], majority: .array) - } - } - - @Test("Text that opens with a bracket and trails off is written as a string") - func bracketedProseIsWrittenAsAString() throws { - let statement = try update( - "f", from: .text("plain"), to: "[DRAFT] Chapter one...", held: [.string], majority: .string - ) - - #expect(statement == #"db.people.updateOne({"_id": 1}, {"$set": {"f": "[DRAFT] Chapter one..."}})"#) - } - - // MARK: - A field that held documents and strings - - /// Measured on 7.0.43: in a field where most rows hold documents, `{"$set": {"f.a": 2}}` on the - /// row holding the string `{"a":1}` fails with "[28] Cannot create field 'a' in element". - @Test("A string that reads as JSON, in a field of mostly documents, is never diffed into a path") - func jsonLookingStringInAMixedFieldIsRefused() { - #expect(throws: MongoDBWriteRefusal.documentOrText(field: "f").refusal(ofRow: 0)) { - try update("f", from: .text(#"{"a":1}"#), to: #"{"a":2}"#, held: [.document, .string]) - } - } - - @Test("A document in a field that also held strings is written whole or not at all, never as a path") - func documentInAMixedFieldIsNeverAPath() throws { - #expect(throws: MongoDBWriteRefusal.documentOrText(field: "f").refusal(ofRow: 0)) { - try update("f", from: .text(#"{"a":1,"b":2}"#), to: #"{"a":5,"b":2}"#, held: [.document, .string]) - } - #expect(throws: MongoDBWriteRefusal.documentOrText(field: "f").refusal(ofRow: 0)) { - try update("f", from: .text("[1,2]"), to: "[1,3]", held: [.array, .string], majority: .array) - } - } - - @Test("Text that is not JSON typed over a string that is not JSON either stays a string") - func malformedStringInAMixedFieldStaysAString() throws { - let statement = try update("f", from: .text("{abc"), to: "{abcd", held: [.document, .string]) - - #expect(statement == #"db.people.updateOne({"_id": 1}, {"$set": {"f": "{abcd"}})"#) - } - - @Test("JSON typed over a string in a field that also held documents is refused, since it could be either") - func jsonOverAStringInAMixedFieldIsRefused() { - for old: PluginCellValue in [.text("{abc"), .text("plain"), .null] { - #expect(throws: MongoDBWriteRefusal.documentOrText(field: "f").refusal(ofRow: 0), "\(old)") { - try update("f", from: old, to: #"{"x":1}"#, held: [.document, .string]) - } - } - } - - @Test("Text that is not JSON typed over a document in a mixed field is refused, not stored as a string") - func malformedTextOverAPossibleDocumentIsRefused() { - #expect(throws: MongoDBWriteRefusal.documentOrText(field: "f").refusal(ofRow: 0)) { - try update("f", from: .text(#"{"a":1}"#), to: #"{"a":"#, held: [.document, .string]) - } - } - - @Test("A document and an array in one field are told apart by their text, so each is still diffed") - func documentsAndArraysAreToldApart() throws { - let document = try update("f", from: .text(#"{"a":1,"b":2}"#), to: #"{"a":5,"b":2}"#, held: [.document, .array]) - let array = try update("f", from: .text("[1,2]"), to: "[1,3]", held: [.document, .array]) - - #expect(document == #"db.people.updateOne({"_id": 1}, {"$set": {"f.a": 5}})"#) - #expect(array == #"db.people.updateOne({"_id": 1}, {"$set": {"f.1": 3}})"#) - } - - @Test("A field whose documents were never read is never diffed") - func unreadFieldIsNeverAPath() { - #expect(throws: MongoDBWriteRefusal.documentOrText(field: "f").refusal(ofRow: 0)) { - try update("f", from: .text(#"{"a":1}"#), to: #"{"a":2}"#, held: nil) - } - } - - @Test("A copied value that reads as JSON in a mixed field is refused, and one that does not is a string") - func copiedValuesInAMixedField() throws { - #expect(throws: MongoDBWriteRefusal.documentOrText(field: "f").refusal(ofRow: 0)) { - try insert("f", #"{"a":1}"#, held: [.document, .string]) - } - #expect(throws: MongoDBWriteRefusal.documentOrText(field: "f").refusal(ofRow: 0)) { - try insert("f", "{abc", held: [.document, .string], typed: true) - } - let copied = try insert("f", "{abc", held: [.document, .string]) - #expect(copied == #"db.people.insertOne({"f": "{abc"})"#) - } - - @Test("Text that reads as JSON in a field that only held strings stays a string when copied, restored or typed over") - func stringOnlyFieldKeepsJSONShapedText() throws { - let copied = try insert("f", #"{"a":1}"#, held: [.string]) - let edited = try update("f", from: .text("plain"), to: "[1,2]", held: [.string], majority: .string) - let typed = try insert("f", #"{"a":1}"#, held: [.string], typed: true) - let strings = MongoDBStatementGenerator( - collectionName: "people", columns: ["_id", "f"], fieldKinds: MongoDBFieldKinds(["f": [.string]]) - ) - let restored = try #require(strings.generateRestore(rows: [["507f1f77bcf86cd799439011", #"{"a":1}"#]])?.first) - - #expect(copied == #"db.people.insertOne({"f": "{\"a\":1}"})"#) - #expect(edited == #"db.people.updateOne({"_id": 1}, {"$set": {"f": "[1,2]"}})"#) - #expect(typed.contains(#""f": {"a":1}"#)) - #expect(restored.statement.contains(#""f": "{\"a\":1}""#)) - } - - @Test("A restore that reads as JSON in a field never read is refused, and one that does not is a string") - func restoreIntoAnUnreadField() throws { - let relaunched = MongoDBStatementGenerator(collectionName: "people", columns: ["_id", "f"]) - let documents = MongoDBStatementGenerator( - collectionName: "people", columns: ["_id", "f"], fieldKinds: MongoDBFieldKinds(["f": [.document]]) - ) - - #expect(relaunched.generateRestore(rows: [["507f1f77bcf86cd799439011", #"{"a":1}"#]]) == nil) - let text = try #require(relaunched.generateRestore(rows: [["507f1f77bcf86cd799439011", "{abc"]])?.first) - #expect(text.statement.contains(#""f": "{abc""#)) - let document = try #require(documents.generateRestore(rows: [["507f1f77bcf86cd799439011", #"{"a":1}"#]])?.first) - #expect(document.statement.contains(#""f": {"a":1}"#)) - } - - // MARK: - Recording what each field held - - @Test("Each field records every kind it held, nulls aside") - func fieldKindsRecordEveryKind() { - let recorded = MongoDBFieldKinds.recording( - [["f": ["a": 1], "g": "x", "n": NSNull()], ["f": "{\"a\":1}", "g": "y"], ["f": [1]]], - representation: .unspecified - ) - - #expect(recorded.kinds(of: "f") == [.document, .string, .array]) - #expect(recorded.kinds(of: "g") == [.string]) - #expect(recorded.kinds(of: "n") == nil) - #expect(recorded.kinds(of: "missing") == nil) - } - - @Test("A later page adds to what a field held, so a string read earlier is not forgotten") - func fieldKindsMergeRatherThanReplace() { - let merged = MongoDBFieldKinds(["f": [.string]]).merging(MongoDBFieldKinds(["f": [.document], "g": [.int32]])) - - #expect(merged.kinds(of: "f") == [.string, .document]) - #expect(merged.kinds(of: "g") == [.int32]) - } - - @Test("Past the field limit no new field is recorded, and an unrecorded field reads as unknown") - func fieldKindsStopAtTheLimit() { - let full = MongoDBFieldKinds(Dictionary(uniqueKeysWithValues: (0 ..< MongoDBFieldKinds.fieldLimit).map { ("k\($0)", Set([BsonValueKind.string])) })) - - let merged = full.merging(MongoDBFieldKinds(["k0": [.document], "new": [.document]])) - - #expect(merged.kinds(of: "k0") == [.string, .document]) - #expect(merged.kinds(of: "new") == nil) - } - - // MARK: - Whole values - - @Test("A whole value keeps a whole-number double as a double and a 64-bit integer as one") - func wholeValueKeepsNumberTypes() throws { - let statement = try insert("meta", #"{"d":3.0,"z":-0.0,"e":1e3,"f":1.5,"n":{"$numberLong":"5"},"big":9007199254740993,"i":7}"#) - - let written = #""meta": {"d":{"$numberDouble":"3.0"},"z":{"$numberDouble":"-0.0"},"e":{"$numberDouble":"1e3"},"# - + #""f":1.5,"n":{"$numberLong":"5"},"big":{"$numberLong":"9007199254740993"},"i":7}"# - #expect(statement.contains(written)) - } - - @Test("An integer past 64 bits inside a value is refused") - func integerPastInt64IsRefused() { - #expect(throws: MongoDBWriteRefusal.integerTooLarge(field: "meta").refusal(ofRow: 0)) { - try insert("meta", #"{"n":99999999999999999999}"#) - } - } - - @Test("A key JavaScript would move or drop is refused, and number-like keys already first are written") - func shellKeyOrder() throws { - #expect(throws: MongoDBWriteRefusal.reorderedByTheShell(field: "meta").refusal(ofRow: 0)) { - try insert("meta", #"{"zip":"1","2":"two"}"#) - } - #expect(throws: MongoDBWriteRefusal.reorderedByTheShell(field: "meta").refusal(ofRow: 0)) { - try insert("meta", #"{"a":{"__proto__":1}}"#) - } - #expect(throws: MongoDBWriteRefusal.reorderedByTheShell(field: "meta").refusal(ofRow: 0)) { - try insert("meta", #"{"10":1,"9":2}"#) - } - let ordered = try insert("meta", #"{"2":"two","10":"ten","zip":"1"}"#) - #expect(ordered.contains(#""meta": {"2":"two","10":"ten","zip":"1"}"#)) - } - - @Test("An object that opens with a wrapper key and holds other members is a document, so its keys are checked") - func wrapperShapedDocumentIsCheckedAsADocument() throws { - typealias Member = MongoDocumentText.Member - let hex = MongoDocumentText.Value.string("507f1f77bcf86cd799439011") - - #expect(throws: MongoDBWriteRefusal.reorderedByTheShell(field: "meta").refusal(ofRow: 0)) { - try insert("meta", #"{"$oid":"x","__proto__":1}"#) - } - #expect(MongoExtendedJsonForm.isWrapper([Member(key: "$oid", value: hex)])) - #expect(!MongoExtendedJsonForm.isWrapper([Member(key: "$oid", value: hex), Member(key: "note", value: hex)])) - #expect(MongoExtendedJsonForm.isWrapper([Member(key: "$binary", value: hex), Member(key: "$type", value: hex)])) - #expect(MongoExtendedJsonForm.isWrapper([Member(key: "$regex", value: hex), Member(key: "$options", value: hex)])) - #expect(!MongoExtendedJsonForm.isWrapper([ - Member(key: "$regex", value: hex), Member(key: "$options", value: hex), Member(key: "$options", value: hex) - ])) - } - - @Test("Only array-index keys up to 4294967294 are moved, so a larger number-like key is written where it is") - func shellKeyOrderStopsAtTheArrayIndexRange() throws { - #expect(throws: MongoDBWriteRefusal.reorderedByTheShell(field: "meta").refusal(ofRow: 0)) { - try insert("meta", #"{"name":"a","4294967294":"b"}"#) - } - let past = try insert("meta", #"{"name":"a","4294967295":"b","99999999999":"c"}"#) - #expect(past.contains(#""meta": {"name":"a","4294967295":"b","99999999999":"c"}"#)) - } - - @Test("A timestamp and the boundary keys are written as the shell's own values, which keep their numbers bare") - func numericWrappersUseTheShellsValues() throws { - let statement = try insert( - "meta", - #"{"ts":{"$timestamp":{"t":1700000000,"i":7}},"lo":{"$minKey":1},"hi":{"$maxKey":1},"# - + #""arr":[{"$timestamp":{"i":2,"t":4294967295}},{"$maxKey":1}]}"# - ) - - #expect(statement.contains( - #""meta": {"ts":Timestamp(1700000000, 7),"lo":MinKey,"hi":MaxKey,"arr":[Timestamp(4294967295, 2),MaxKey]}"# - )) - } - - @Test("A code scope is spelled as a document, so its numbers keep their types and its keys their order") - func codeScopeIsSpelledAsADocument() throws { - let statement = try insert("meta", #"{"c":{"$code":"f","$scope":{"n":3.0,"i":{"$numberInt":"1"}}}}"#) - - #expect(statement.contains(#""meta": {"c":{"$code":"f","$scope":{"n":{"$numberDouble":"3.0"},"i":{"$numberInt":"1"}}}}"#)) - #expect(throws: MongoDBWriteRefusal.reorderedByTheShell(field: "meta").refusal(ofRow: 0)) { - try insert("meta", #"{"c":{"$code":"f","$scope":{"a":1,"2":2}}}"#) - } - } - - // MARK: - Through the shell - - private static let everyWrapper = #"{"oid":{"$oid":"65a1b2c3d4e5f60718293a4b"},"sym":{"$symbol":"s"},"# - + #""i":{"$numberInt":"7"},"l":{"$numberLong":"5"},"d":{"$numberDouble":"3.0"},"f":{"$numberDouble":"1.5"},"# - + #""dec":{"$numberDecimal":"1.5"},"bin":{"$binary":{"base64":"AAECAw==","subType":"05"}},"# - + #""code":{"$code":"f"},"scoped":{"$code":"f","$scope":{"x":{"$numberInt":"1"}}},"# - + #""ts":{"$timestamp":{"t":1700000000,"i":7}},"re":{"$regularExpression":{"pattern":"^a","options":"i"}},"# - + #""ptr":{"$dbPointer":{"$ref":"c","$id":{"$oid":"65a1b2c3d4e5f60718293a4b"}}},"# - + #""old":{"$date":{"$numberLong":"-1"}},"lo":{"$minKey":1},"hi":{"$maxKey":1},"u":{"$undefined":true},"# - + #""arr":[{"$timestamp":{"t":1,"i":2}},{"$minKey":1}],"# - + #""big":{"name":{"$numberInt":"1"},"4294967295":{"$numberInt":"2"}}}"# - - private func shownCell(_ canonical: String) throws -> PluginCellValue { - let rows = BsonDocumentFlattener.flatten( - documents: [["_id": Int32(1), "m": ["x": 1]]], columns: ["_id", "m"], kinds: [.int32, .document], - representation: .unspecified, storedTexts: [#"{"_id":{"$numberInt":"1"},"m":"# + canonical + "}"] - ) - return try #require(rows.first?[1]) - } - - private func evaluate(_ statement: String, reply: String) throws -> MongoScriptPreludeTests.RecordingHost { - let host = MongoScriptPreludeTests.RecordingHost() - host.replies = [reply] - let context = try MongoScriptContext.make(execute: { host.handle($0) }, emit: { host.record(printed: $0) }) - context.evaluateScript(statement) - #expect(context.exception == nil, "\(context.exception?.toString() ?? "")") - return host - } - - @Test("A duplicated row hands the shell every nested BSON type as the Extended JSON it was read as") - func duplicateRoundTripsThroughTheShell() throws { - let cell = try shownCell(Self.everyWrapper) - let gen = MongoDBStatementGenerator(collectionName: "people", columns: ["_id", "m"], columnKinds: ["m": .document]) - let writes = try gen.generateRowWrites( - from: [PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil)], - insertedRowData: [0: ["__DEFAULT__", cell]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - let statement = try #require(writes.first?.statement) - - let host = try evaluate(statement, reply: #"{"insertedIds": [{"$oid": "507f1f77bcf86cd799439011"}], "insertedCount": 1}"#) - - #expect(host.requests(op: "insertOne").first?["document"] as? String == #"{"m":"# + Self.everyWrapper + "}") - } - - @Test("A nested value written whole hands the shell its BSON types unchanged") - func wholeRewriteRoundTripsThroughTheShell() throws { - let cell = try shownCell(Self.everyWrapper) - let shown = try #require(cell.asText) - let grown = String(shown.dropLast()) + #","extra":1,"extra2":2}"# - - let statement = try update("m", from: shown, to: grown) - let host = try evaluate(statement, reply: #"{"n": 1, "nModified": 1}"#) - - let written = String(Self.everyWrapper.dropLast()) + #","extra":{"$numberInt":"1"},"extra2":{"$numberInt":"2"}}"# - #expect(host.requests(op: "update").first?["update"] as? String == #"{"$set":{"m":"# + written + "}}") - } - - @Test("A duplicated row keeps a nested ObjectId and date") - func duplicateKeepsNestedWrappers() throws { - let statement = try insert("address", Self.address) - - #expect(statement.contains(#""ownerId":{"$oid":"65a1b2c3d4e5f60718293a4b"}"#)) - #expect(statement.contains(#""verifiedAt":{"$date":"2024-05-01T10:00:00.123Z"}"#)) - #expect(statement.contains(#""d":{"$numberDouble":"3.0"}"#)) - } - - @Test("A document _id is matched in the order it is stored") - func documentIdKeepsItsOrder() throws { - let gen = MongoDBStatementGenerator(collectionName: "people", columns: ["_id", "name"], identityKind: .document) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "name", oldValue: "a", newValue: "b")], - originalRow: [.text(#"{"z":1,"a":{"$numberLong":"2"}}"#), "a"] - ) - - let writes = try gen.generateRowWrites(from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: []) - - #expect(writes.first?.statement == #"db.people.updateOne({"_id": {"z":1,"a":{"$numberLong":"2"}}}, {"$set": {"name": "b"}})"#) - } - - // MARK: - Parsing one value - - @Test("One value parses with the document reader's strictness, top-level $ names included") - func valueParsing() throws { - #expect(try MongoDocumentText.Value(parsing: #"{"$price":1}"#) == .object([.init(key: "$price", value: .number("1"))])) - #expect(try MongoDocumentText.Value(parsing: " [1, \"a\"] ") == .array([.number("1"), .string("a")])) - #expect(throws: MongoDocumentText.Refusal.trailingContent) { try MongoDocumentText.Value(parsing: "[1] [2]") } - #expect(throws: MongoDocumentText.Refusal.duplicateField("a")) { try MongoDocumentText.Value(parsing: #"{"a":1,"a":2}"#) } - } -} diff --git a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift deleted file mode 100644 index 46541111c4..0000000000 --- a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift +++ /dev/null @@ -1,1226 +0,0 @@ -// -// MongoDBQueryBuilderTests.swift -// TableProTests -// -// Tests for MongoDBQueryBuilder (compiled via symlink from MongoDBDriverPlugin). -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct MongoDBQueryBuilderTests { - private let builder = MongoDBQueryBuilder() - - // MARK: - Base Query - - @Test("Base query with defaults") - func baseQueryDefaults() { - let query = builder.buildBaseQuery(collection: "users") - #expect(query == "db.users.find({}).limit(200)") - } - - @Test("Base query with custom limit") - func baseQueryCustomLimit() { - let query = builder.buildBaseQuery(collection: "users", limit: 50) - #expect(query == "db.users.find({}).limit(50)") - } - - @Test("Base query with offset") - func baseQueryWithOffset() { - let query = builder.buildBaseQuery(collection: "users", limit: 50, offset: 100) - #expect(query == "db.users.find({}).skip(100).limit(50)") - } - - @Test("Base query with zero offset omits skip") - func baseQueryZeroOffset() { - let query = builder.buildBaseQuery(collection: "users", limit: 200, offset: 0) - #expect(!query.contains(".skip(")) - } - - @Test("Base query with ascending sort") - func baseQueryAscendingSort() { - let query = builder.buildBaseQuery( - collection: "users", - sortColumns: [(columnIndex: 0, ascending: true)], - columns: ["name", "email"] - ) - #expect(query.contains(".sort({\"name\": 1})")) - #expect(query.contains(".limit(200)")) - } - - @Test("Base query with descending sort") - func baseQueryDescendingSort() { - let query = builder.buildBaseQuery( - collection: "users", - sortColumns: [(columnIndex: 1, ascending: false)], - columns: ["name", "email"] - ) - #expect(query.contains(".sort({\"email\": -1})")) - } - - @Test("Base query with multiple sort columns") - func baseQueryMultiSort() { - let query = builder.buildBaseQuery( - collection: "users", - sortColumns: [(columnIndex: 0, ascending: true), (columnIndex: 1, ascending: false)], - columns: ["name", "age"] - ) - #expect(query.contains(".sort({\"name\": 1, \"age\": -1})")) - } - - @Test("Base query with out-of-bounds sort column index is ignored") - func baseQueryOutOfBoundsSortIndex() { - let query = builder.buildBaseQuery( - collection: "users", - sortColumns: [(columnIndex: 5, ascending: true)], - columns: ["name"] - ) - #expect(!query.contains(".sort(")) - } - - @Test("Collection with special characters goes through getCollection") - func collectionWithSpecialChars() { - let query = builder.buildBaseQuery(collection: "my.collection") - #expect(query.hasPrefix("db.getCollection(\"my.collection\")")) - } - - @Test("Collection starting with number goes through getCollection") - func collectionStartingWithNumber() { - let query = builder.buildBaseQuery(collection: "123abc") - #expect(query.hasPrefix("db.getCollection(\"123abc\")")) - } - - @Test("Collection with simple name uses dot notation") - func collectionSimpleName() { - let query = builder.buildBaseQuery(collection: "users") - #expect(query.hasPrefix("db.users")) - } - - @Test("Collection with underscore uses dot notation") - func collectionWithUnderscore() { - let query = builder.buildBaseQuery(collection: "my_collection") - #expect(query.hasPrefix("db.my_collection")) - } - - // MARK: - Filtered Query - - @Test("Filtered query with equals operator") - func filteredQueryEquals() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "name", op: "=", value: "Alice")] - ) - #expect(query.contains("\"name\": \"Alice\"")) - } - - @Test("Filtered query with numeric equals") - func filteredQueryNumericEquals() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "age", op: "=", value: "30")] - ) - #expect(query.contains("\"age\": 30")) - } - - @Test("Filtered query with boolean value") - func filteredQueryBoolean() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "active", op: "=", value: "true")] - ) - #expect(query.contains("\"active\": true")) - } - - @Test("Filtered query with multiple filters AND logic") - func filteredQueryMultipleAnd() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [ - PluginQueryFilter(column: "name", op: "=", value: "Alice"), - PluginQueryFilter(column: "age", op: ">", value: "25") - ], - logicMode: "and" - ) - #expect(query.contains("$and")) - #expect(query.contains("\"name\": \"Alice\"")) - #expect(query.contains("\"age\": {\"$gt\": 25}")) - } - - @Test("Filtered query with multiple filters OR logic") - func filteredQueryMultipleOr() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [ - PluginQueryFilter(column: "name", op: "=", value: "Alice"), - PluginQueryFilter(column: "name", op: "=", value: "Bob") - ], - logicMode: "or" - ) - #expect(query.contains("$or")) - } - - @Test("Filtered query with single filter omits logic operator") - func filteredQuerySingleFilter() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "name", op: "=", value: "Alice")] - ) - #expect(!query.contains("$and")) - #expect(!query.contains("$or")) - } - - @Test("Filtered query with not-equal operator") - func filteredQueryNotEqual() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "status", op: "!=", value: "inactive")] - ) - #expect(query.contains("\"$ne\": \"inactive\"")) - } - - @Test("Filtered query with greater-than-or-equal operator") - func filteredQueryGte() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "age", op: ">=", value: "18")] - ) - #expect(query.contains("\"$gte\": 18")) - } - - @Test("Filtered query with less-than operator") - func filteredQueryLt() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "score", op: "<", value: "100")] - ) - #expect(query.contains("\"$lt\": 100")) - } - - @Test("Filtered query with CONTAINS operator") - func filteredQueryContains() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [ - PluginQueryFilter(column: "name", op: "CONTAINS", value: "ali", isCaseSensitive: false) - ] - ) - #expect(query.contains("\"$regex\": \"ali\"")) - #expect(query.contains("\"$options\": \"i\"")) - } - - @Test("Filtered query with CONTAINS matching case drops the ignore-case option") - func filteredQueryContainsMatchingCase() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "name", op: "CONTAINS", value: "ali")] - ) - #expect(query.contains("\"$regex\": \"ali\"")) - #expect(!query.contains("$options")) - } - - @Test("Filtered query with NOT CONTAINS operator") - func filteredQueryNotContains() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "name", op: "NOT CONTAINS", value: "test")] - ) - #expect(query.contains( - "{\"name\": {\"$not\": {\"$regularExpression\": {\"pattern\": \"test\", \"options\": \"\"}}}}" - )) - } - - @Test("A negated match sends a regular expression value, which $not takes on every server") - func negatedMatchesSendARegularExpressionValue() { - let notContains = parseFilter(builder.buildFilterDocument(from: [ - PluginQueryFilter(column: "name", op: "NOT CONTAINS", value: "a.b", isCaseSensitive: false) - ])) - let notEqual = parseFilter(builder.buildFilterDocument(from: [ - PluginQueryFilter(column: "name", op: "!=", value: "Alice", isCaseSensitive: false) - ])) - - let containsRegex = negatedRegex(in: notContains, field: "name") - #expect(containsRegex?["pattern"] as? String == "a\\.b") - #expect(containsRegex?["options"] as? String == "i") - let equalRegex = negatedRegex(in: notEqual, field: "name") - #expect(equalRegex?["pattern"] as? String == "^Alice$") - #expect(equalRegex?["options"] as? String == "i") - } - - @Test("A positive match keeps the operator form mongosh reads") - func positiveMatchesKeepTheOperatorForm() { - let doc = builder.buildFilterDocument(from: [ - PluginQueryFilter(column: "name", op: "CONTAINS", value: "ali", isCaseSensitive: false) - ]) - #expect(doc == "{\"name\": {\"$regex\": \"ali\", \"$options\": \"i\"}}") - } - - private func negatedRegex(in filter: [String: Any]?, field: String) -> [String: Any]? { - let condition = filter?[field] as? [String: Any] - let negated = condition?["$not"] as? [String: Any] - return negated?["$regularExpression"] as? [String: Any] - } - - @Test("Filtered query with STARTS WITH operator") - func filteredQueryStartsWith() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "name", op: "STARTS WITH", value: "Al")] - ) - #expect(query.contains("\"$regex\": \"^Al\"")) - } - - @Test("Filtered query with ENDS WITH operator") - func filteredQueryEndsWith() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "name", op: "ENDS WITH", value: "ice")] - ) - #expect(query.contains("\"$regex\": \"ice$\"")) - } - - @Test("Filtered query with IS NULL operator") - func filteredQueryIsNull() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "email", op: "IS NULL", value: "")] - ) - #expect(query.contains("\"email\": null")) - } - - @Test("Filtered query with IS NOT NULL operator") - func filteredQueryIsNotNull() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "email", op: "IS NOT NULL", value: "")] - ) - #expect(query.contains("\"email\": {\"$ne\": null}")) - } - - @Test("Filtered query with IS EMPTY operator") - func filteredQueryIsEmpty() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "bio", op: "IS EMPTY", value: "")] - ) - #expect(query.contains("\"bio\": \"\"")) - } - - @Test("Filtered query with IS NOT EMPTY operator") - func filteredQueryIsNotEmpty() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "bio", op: "IS NOT EMPTY", value: "")] - ) - #expect(query.contains("\"bio\": {\"$ne\": \"\"}")) - } - - @Test("Filtered query with REGEX operator") - func filteredQueryRegex() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "name", op: "REGEX", value: "^[A-Z].*")] - ) - #expect(query.contains("\"$regex\": \"^[A-Z].*\"")) - } - - @Test("Filtered query with sort and offset") - func filteredQueryWithSortAndOffset() { - let query = builder.buildFilteredQuery( - collection: "users", - queryFilters: [PluginQueryFilter(column: "name", op: "=", value: "Alice")], - sortColumns: [(columnIndex: 0, ascending: true)], - columns: ["name"], - limit: 50, - offset: 25 - ) - #expect(query.contains(".sort({\"name\": 1})")) - #expect(query.contains(".skip(25)")) - #expect(query.contains(".limit(50)")) - } - - // MARK: - Filter Document - - @Test("Filter document with IN operator") - func filterDocumentIn() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "status", op: "IN", value: "active, inactive, pending")] - ) - #expect(doc.contains("\"$in\"")) - #expect(doc.contains("\"active\"")) - #expect(doc.contains("\"inactive\"")) - #expect(doc.contains("\"pending\"")) - } - - @Test("Filter document with IN operator numeric values") - func filterDocumentInNumeric() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "age", op: "IN", value: "18, 25, 30")] - ) - #expect(doc.contains("\"$in\": [18, 25, 30]")) - } - - @Test("Filter document with NOT IN operator") - func filterDocumentNotIn() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "status", op: "NOT IN", value: "banned, deleted")] - ) - #expect(doc.contains("\"$nin\"")) - #expect(doc.contains("\"banned\"")) - #expect(doc.contains("\"deleted\"")) - } - - @Test("Filter document with BETWEEN operator") - func filterDocumentBetween() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "age", op: "BETWEEN", value: "18, 65")] - ) - #expect(doc.contains("\"$gte\": 18")) - #expect(doc.contains("\"$lte\": 65")) - } - - @Test("Filter document with BETWEEN invalid format matches nothing") - func filterDocumentBetweenInvalid() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "age", op: "BETWEEN", value: "18")] - ) - #expect(doc == MongoDBQueryBuilder.impossibleFilter) - } - - @Test("Filter document with empty filters returns empty object") - func filterDocumentEmpty() { - let doc = builder.buildFilterDocument(from: []) - #expect(doc == "{}") - } - - @Test("Filter document with unknown operator matches nothing") - func filterDocumentUnknownOp() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "x", op: "UNKNOWN_OP", value: "y")] - ) - #expect(doc == MongoDBQueryBuilder.impossibleFilter) - } - - @Test("A filter whose conditions all drop never widens to the whole collection") - func filterDocumentNeverFailsOpen() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "x", op: "UNKNOWN_OP", value: "y")] - ) - #expect(doc != "{}") - } - - @Test("Filter document with float value") - func filterDocumentFloat() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "price", op: "=", value: "19.99")] - ) - #expect(doc.contains("\"price\": 19.99")) - } - - @Test("Filter document emits JSON-valid scientific numbers") - func filterDocumentScientificNumber() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "score", op: "=", value: "1.5e-3")] - ) - let parsed = parseFilter(doc) - #expect(parsed?["score"] as? Double == 0.0015) - } - - @Test("Filter document quotes non-JSON numeric spellings") - func filterDocumentQuotesNonJsonNumericSpellings() { - let values = [".5", "1.", "+7", "01", "NaN", "Infinity"] - for value in values { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "score", op: "=", value: value)] - ) - let parsed = parseFilter(doc) - #expect(parsed?["score"] as? String == value) - } - } - - @Test("Filter document quotes integers that overflow Int64 to preserve precision") - func filterDocumentQuotesInt64Overflow() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "code", op: "=", value: "12345678901234567890")] - ) - let parsed = parseFilter(doc) - #expect(parsed?["code"] as? String == "12345678901234567890") - } - - @Test("Filter document quotes exponents that overflow Double instead of emitting Infinity") - func filterDocumentQuotesOutOfRangeExponent() { - for value in ["1e400", "-1e400", "1.5e400"] { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "score", op: "=", value: value)] - ) - let parsed = parseFilter(doc) - #expect(parsed?["score"] as? String == value) - } - } - - @Test("Filter document emits the largest Int64 integer unquoted") - func filterDocumentEmitsMaxInt64() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "code", op: "=", value: "9223372036854775807")] - ) - #expect(doc.contains("\"code\": 9223372036854775807")) - } - - @Test("Filter document with null literal") - func filterDocumentNullLiteral() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "field", op: "=", value: "null")] - ) - #expect(doc.contains("\"field\": null")) - } - - // MARK: - Combined Query - // TODO: Re-enable when buildCombinedQuery API is restored - #if false - @Test("Combined query wraps filter and search in $and") - func combinedQuery() { - let query = builder.buildCombinedQuery( - collection: "users", - filters: [PluginQueryFilter(column: "age", op: ">", value: "25")], - searchText: "john", - searchColumns: ["name", "email"] - ) - #expect(query.contains("$and")) - #expect(query.contains("\"$gt\": 25")) - #expect(query.contains("$or")) - #expect(query.contains("\"$regex\": \"john\"")) - } - - @Test("Combined query with sort and offset") - func combinedQueryWithSortAndOffset() { - let query = builder.buildCombinedQuery( - collection: "users", - filters: [PluginQueryFilter(column: "age", op: ">", value: "18")], - searchText: "test", - searchColumns: ["name"], - sortColumns: [(columnIndex: 0, ascending: false)], - columns: ["name"], - limit: 100, - offset: 50 - ) - #expect(query.contains(".sort({\"name\": -1})")) - #expect(query.contains(".skip(50)")) - #expect(query.contains(".limit(100)")) - } - #endif - - // MARK: - Count Query - - @Test("Count query with default filter") - func countQueryDefault() { - let query = builder.buildCountQuery(collection: "users") - #expect(query == "db.users.countDocuments({})") - } - - @Test("Count query with custom filter") - func countQueryWithFilter() { - let query = builder.buildCountQuery(collection: "users", filterJson: "{\"active\": true}") - #expect(query == "db.users.countDocuments({\"active\": true})") - } - - @Test("Count query with special collection name") - func countQuerySpecialCollection() { - let query = builder.buildCountQuery(collection: "my.data") - #expect(query.hasPrefix("db.getCollection(\"my.data\")")) - #expect(query.contains(".countDocuments({})")) - } - - // MARK: - Export Query - - @Test("Export query streams the whole collection") - func exportQueryHasNoLimit() { - let query = builder.buildExportQuery(collection: "users") - #expect(query == "db.users.find({})") - } - - @Test("Export query reaches a dotted collection through getCollection") - func exportQueryDottedCollection() { - let query = builder.buildExportQuery(collection: "logs.2024.06") - #expect(query == "db.getCollection(\"logs.2024.06\").find({})") - } - - @Test("Export query escapes quotes and backslashes in the collection name") - func exportQueryEscapesCollectionName() { - let query = builder.buildExportQuery(collection: "say\"hi\\bye") - #expect(query == "db.getCollection(\"say\\\"hi\\\\bye\").find({})") - } - - @Test("Export query parses back to a find on the same collection") - func exportQueryRoundTripsThroughTheParser() throws { - for collection in ["users", "logs.2024.06", "stats", "2024_orders", "say\"hi"] { - let operation = try MongoShellParser.parse(builder.buildExportQuery(collection: collection)) - if case .find(let parsed, let filter, let options) = operation { - #expect(parsed == collection) - #expect(filter == "{}") - #expect(options.limit == nil) - } else { - Issue.record("Expected .find operation for \(collection)") - } - } - } - - // MARK: - ObjectId Matching - - @Test("Equals on an ObjectId value matches both the ObjectId and the string form") - func equalsObjectIdDualMatch() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "_id", op: "=", value: "66c0fa26dfcb27034e646356")] - ) - let parsed = parseFilter(doc) - let branches = parsed?["$or"] as? [[String: Any]] - #expect(branches?.count == 2) - let oid = (branches?.first?["_id"] as? [String: Any])?["$oid"] as? String - #expect(oid == "66c0fa26dfcb27034e646356") - #expect(branches?.last?["_id"] as? String == "66c0fa26dfcb27034e646356") - } - - @Test("Equals on a non-ObjectId string stays a plain string match") - func equalsNonObjectIdString() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "_id", op: "=", value: "user-123")] - ) - #expect(!doc.contains("$or")) - #expect(!doc.contains("$oid")) - #expect(doc.contains("\"_id\": \"user-123\"")) - } - - @Test("Equals on a 23-character hex value is not treated as an ObjectId") - func equalsShortHexNotObjectId() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "_id", op: "=", value: "66c0fa26dfcb27034e64635")] - ) - #expect(!doc.contains("$oid")) - } - - @Test("Equals on a 24-character non-hex value is not treated as an ObjectId") - func equalsNonHexNotObjectId() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "_id", op: "=", value: "zzc0fa26dfcb27034e646356")] - ) - #expect(!doc.contains("$oid")) - } - - @Test("ObjectId matching applies to non-_id reference fields too") - func equalsObjectIdReferenceField() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "userId", op: "=", value: "66c0fa26dfcb27034e646356")] - ) - let branches = parseFilter(doc)?["$or"] as? [[String: Any]] - let oid = (branches?.first?["userId"] as? [String: Any])?["$oid"] as? String - #expect(oid == "66c0fa26dfcb27034e646356") - } - - @Test("Not-equals on an ObjectId value excludes both the ObjectId and the string form") - func notEqualsObjectIdDualMatch() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "_id", op: "!=", value: "66c0fa26dfcb27034e646356")] - ) - let nin = (parseFilter(doc)?["_id"] as? [String: Any])?["$nin"] as? [Any] - #expect(nin?.count == 2) - let oid = (nin?.first as? [String: Any])?["$oid"] as? String - #expect(oid == "66c0fa26dfcb27034e646356") - #expect(nin?.last as? String == "66c0fa26dfcb27034e646356") - } - - @Test("IN expands an ObjectId item to both forms and leaves plain items alone") - func inExpandsObjectIdItems() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "_id", op: "IN", value: "66c0fa26dfcb27034e646356, plain-id")] - ) - let inArray = (parseFilter(doc)?["_id"] as? [String: Any])?["$in"] as? [Any] - #expect(inArray?.count == 3) - let oid = (inArray?.first as? [String: Any])?["$oid"] as? String - #expect(oid == "66c0fa26dfcb27034e646356") - let strings = inArray?.compactMap { $0 as? String } - #expect(strings?.contains("66c0fa26dfcb27034e646356") == true) - #expect(strings?.contains("plain-id") == true) - } - - @Test("NOT IN expands an ObjectId item to both forms") - func notInExpandsObjectIdItems() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "_id", op: "NOT IN", value: "66c0fa26dfcb27034e646356, plain-id")] - ) - let ninArray = (parseFilter(doc)?["_id"] as? [String: Any])?["$nin"] as? [Any] - #expect(ninArray?.count == 3) - let oid = (ninArray?.first as? [String: Any])?["$oid"] as? String - #expect(oid == "66c0fa26dfcb27034e646356") - let strings = ninArray?.compactMap { $0 as? String } - #expect(strings?.contains("plain-id") == true) - } - - @Test("An ObjectId equals combined with another filter stays valid JSON under $and") - func objectIdEqualsCombinedWithAndFilter() { - let doc = builder.buildFilterDocument( - from: [ - PluginQueryFilter(column: "_id", op: "=", value: "66c0fa26dfcb27034e646356"), - PluginQueryFilter(column: "shop", op: "=", value: "acme") - ], - logicMode: "and" - ) - let branches = parseFilter(doc)?["$and"] as? [[String: Any]] - #expect(branches?.count == 2) - let or = branches?.first?["$or"] as? [[String: Any]] - let oid = (or?.first?["_id"] as? [String: Any])?["$oid"] as? String - #expect(oid == "66c0fa26dfcb27034e646356") - #expect(branches?.last?["shop"] as? String == "acme") - } - - // MARK: - Security (NoSQL injection) - - private func parseFilter(_ json: String) -> [String: Any]? { - guard let data = json.data(using: .utf8) else { return nil } - return try? JSONSerialization.jsonObject(with: data) as? [String: Any] - } - - @Test("REGEX value cannot break out of the regex string to inject operators") - func regexInjectionContained() { - let payload = ".*\"}, \"$where\": \"function(){return true}\", \"_\":{\"a\":\"" - let doc = parseFilter( - builder.buildFilterDocument(from: [ - PluginQueryFilter(column: "name", op: "REGEX", value: payload, isCaseSensitive: false) - ]) - ) - #expect(doc != nil) - #expect(doc.map { Array($0.keys) } == ["name"]) - let inner = doc?["name"] as? [String: Any] - #expect(inner.map { Array($0.keys).sorted() } == ["$options", "$regex"]) - #expect(inner?["$regex"] as? String == payload) - #expect(inner?["$options"] as? String == "i") - } - - @Test("Regex injection payload stays contained when matching case") - func regexInjectionContainedMatchingCase() { - let payload = ".*\"}, \"$where\": \"function(){return true}\", \"_\":{\"a\":\"" - let doc = parseFilter( - builder.buildFilterDocument(from: [PluginQueryFilter(column: "name", op: "REGEX", value: payload)]) - ) - #expect(doc.map { Array($0.keys) } == ["name"]) - let inner = doc?["name"] as? [String: Any] - #expect(inner.map { Array($0.keys) } == ["$regex"]) - #expect(inner?["$regex"] as? String == payload) - } - - @Test("CONTAINS value cannot break out of the regex string to inject operators") - func containsInjectionContained() { - let payload = "\"}, \"$where\": \"return true" - let doc = parseFilter( - builder.buildFilterDocument(from: [PluginQueryFilter(column: "name", op: "CONTAINS", value: payload)]) - ) - #expect(doc != nil) - #expect(doc.map { Array($0.keys) } == ["name"]) - let regex = (doc?["name"] as? [String: Any])?["$regex"] as? String - #expect(regex?.contains("$where") == true) - } - - @Test("NOT CONTAINS value cannot break out of the nested regex string") - func notContainsInjectionContained() { - let payload = "\"}}, \"$where\": \"1==1" - let doc = parseFilter( - builder.buildFilterDocument(from: [PluginQueryFilter(column: "name", op: "NOT CONTAINS", value: payload)]) - ) - #expect(doc != nil) - #expect(doc.map { Array($0.keys) } == ["name"]) - #expect((negatedRegex(in: doc, field: "name")?["pattern"] as? String)?.contains("$where") == true) - } - - @Test("STARTS WITH escapes embedded double quotes as data") - func startsWithEscapesQuote() { - let doc = parseFilter( - builder.buildFilterDocument(from: [PluginQueryFilter(column: "name", op: "STARTS WITH", value: "Al\"ce")]) - ) - #expect(doc != nil) - let inner = doc?["name"] as? [String: Any] - #expect(inner?["$regex"] as? String == "^Al\"ce") - } - - @Test("ENDS WITH escapes embedded double quotes as data") - func endsWithEscapesQuote() { - let doc = parseFilter( - builder.buildFilterDocument(from: [PluginQueryFilter(column: "name", op: "ENDS WITH", value: "ce\"Al")]) - ) - #expect(doc != nil) - let inner = doc?["name"] as? [String: Any] - #expect(inner?["$regex"] as? String == "ce\"Al$") - } - - @Test("CONTAINS escapes a backslash to a literal-backslash regex") - func containsEscapesBackslash() { - let doc = parseFilter( - builder.buildFilterDocument(from: [PluginQueryFilter(column: "path", op: "CONTAINS", value: "\\")]) - ) - #expect(doc != nil) - let inner = doc?["path"] as? [String: Any] - #expect(inner?["$regex"] as? String == "\\\\") - } - - @Test("REGEX preserves regex metacharacters literally") - func regexPreservesMetacharacters() { - let value = "^[A-Z].*\\d$" - let doc = parseFilter( - builder.buildFilterDocument(from: [PluginQueryFilter(column: "name", op: "REGEX", value: value)]) - ) - #expect(doc != nil) - let inner = doc?["name"] as? [String: Any] - #expect(inner?["$regex"] as? String == value) - } - - @Test("REGEX keeps an embedded double quote as data") - func regexEscapesQuote() { - let doc = parseFilter( - builder.buildFilterDocument(from: [PluginQueryFilter(column: "name", op: "REGEX", value: "a\"b")]) - ) - #expect(doc != nil) - let inner = doc?["name"] as? [String: Any] - #expect(inner?["$regex"] as? String == "a\"b") - } - - @Test("CONTAINS treats regex metacharacters as literals") - func containsTreatsMetacharactersLiterally() { - let doc = parseFilter( - builder.buildFilterDocument(from: [PluginQueryFilter(column: "name", op: "CONTAINS", value: "a.b")]) - ) - #expect(doc != nil) - let inner = doc?["name"] as? [String: Any] - #expect(inner?["$regex"] as? String == "a\\.b") - } - - // MARK: - Binary UUID filters - - private static let uuid = "8cd003eb-4a25-4324-9332-88fce2da0d1a" - private static let javaBase64 = "JEMlSusD0IwaDdri/Igykw==" - - @Test("Equality on a UUID wrapper filters on BSON binary, not on the wrapper text") - func equalityUsesBinary() { - let doc = builder.buildFilterDocument( - from: [ - PluginQueryFilter( - column: "ref", op: "=", value: "LegacyJavaUUID(\"\(Self.uuid)\")" - ) - ] - ) - #expect(doc.contains("\"$binary\"")) - #expect(doc.contains("\"subType\": \"03\"")) - #expect(doc.contains(Self.javaBase64)) - } - - /// A binary field has no case, so a case-insensitive regex would match nothing. - @Test("A case-insensitive equality on a UUID wrapper still matches exactly") - func caseInsensitiveEqualityStaysExact() { - let doc = builder.buildFilterDocument( - from: [ - PluginQueryFilter( - column: "ref", - op: "=", - value: "LegacyJavaUUID(\"\(Self.uuid)\")", - isCaseSensitive: false - ) - ] - ) - #expect(doc.contains("\"$binary\"")) - #expect(!doc.contains("$regex")) - } - - @Test("Inequality on a UUID wrapper uses $ne with binary") - func inequalityUsesBinary() { - let doc = builder.buildFilterDocument( - from: [ - PluginQueryFilter( - column: "ref", op: "!=", value: "UUID(\"\(Self.uuid)\")" - ) - ] - ) - #expect(doc.contains("\"$ne\"")) - #expect(doc.contains("\"subType\": \"04\"")) - } - - @Test("IN over UUID wrappers uses binary values") - func inListUsesBinary() { - let doc = builder.buildFilterDocument( - from: [ - PluginQueryFilter( - column: "ref", - op: "IN", - value: "UUID(\"\(Self.uuid)\"), LegacyJavaUUID(\"\(Self.uuid)\")" - ) - ] - ) - #expect(doc.contains("\"$in\"")) - #expect(doc.contains("\"subType\": \"04\"")) - } - - @Test("A plain string value is unaffected by UUID handling") - func plainStringUnaffected() { - let doc = builder.buildFilterDocument( - from: [PluginQueryFilter(column: "name", op: "=", value: "UUID-ish")] - ) - #expect(!doc.contains("$binary")) - } - - // MARK: - Collection accessor - - @Test("A collection named after a db method is reached through getCollection") - func shadowedCollectionNamesUseGetCollection() { - for name in ["stats", "version", "toString", "constructor", "valueOf", "getName", "__proto__"] { - let query = builder.buildBaseQuery(collection: name) - #expect(query == "db.getCollection(\"\(name)\").find({}).limit(200)", "collection \(name)") - } - } - - @Test("A shadowed collection name parses back to a find on that collection") - func shadowedCollectionNameRoundTripsThroughTheParser() throws { - let operation = try MongoShellParser.parse(builder.buildBaseQuery(collection: "stats")) - guard case .find(let collection, _, _) = operation else { - Issue.record("Expected .find operation") - return - } - #expect(collection == "stats") - } - - @Test("Every method the shell puts on db is a name the accessor refuses to spell as db.") - func accessorCoversEveryDatabaseMethodOfTheShell() throws { - let regex = try NSRegularExpression(pattern: #"DB\.prototype\.([A-Za-z_][A-Za-z0-9_]*)\s*="#) - let source = MongoScriptPrelude.source - let matches = regex.matches(in: source, range: NSRange(source.startIndex..., in: source)) - let members = matches.compactMap { Range($0.range(at: 1), in: source).map { String(source[$0]) } } - #expect(members.count > 10) - for member in members { - #expect(MongoCollectionAccessor.isShadowedByDatabaseMember(member), "db.\(member) is a method") - } - } - - /// U+0D4E is a Unicode Prepend letter: it joins the next scalar into one `Character`, so a - /// `(` after it answered `isLetter` and the name went into the statement bare, as code. - @Test("A collection name is spelled bare only when it is a plain ASCII identifier") - func accessorSpellsOnlyAsciiIdentifiersBare() { - #expect(MongoCollectionAccessor.expression(for: "orders") == "db.orders") - #expect(MongoCollectionAccessor.expression(for: "order_2") == "db.order_2") - for name in ["a\u{0D4E}(\u{0D4E})", "tên", "2025", "a b", "a;b"] { - #expect(MongoCollectionAccessor.expression(for: name).hasPrefix("db.getCollection(\""), "\(name)") - } - } - - @Test("Escaping works scalar by scalar, so a quote joined to a Prepend letter is still escaped") - func escapingSeesQuotesInsideGraphemeClusters() throws { - let hostile = "x\u{0600}\"}); db.victim.drop(); ({\"" - let escaped = MongoDBQueryBuilder.escapeJsonString(hostile) - let decoded = try JSONSerialization.jsonObject( - with: Data("\"\(escaped)\"".utf8), options: [.fragmentsAllowed] - ) as? String - #expect(decoded == hostile) - #expect(MongoDBQueryBuilder.escapeJsonString("a\r\nb") == "a\\r\\nb") - } - - @Test("A line break the editor's statement scanner ends a string at is written as an escape") - func escapingCoversScannerLineBreaks() { - let escaped = MongoDBQueryBuilder.escapeJsonString("a\u{2028}b\u{2029}c\u{85}d") - #expect(escaped == "a\\u2028b\\u2029c\\u0085d") - } - - // MARK: - Raw filter normalization - - private static let normalizer = MongoDBRawFilterNormalizer() - - private var normalizingBuilder: MongoDBQueryBuilder { - MongoDBQueryBuilder(rawFilterNormalizer: { Self.normalizer.normalize($0) }) - } - - @Test("A raw filter in shell syntax is rewritten to Extended JSON for find and count alike") - func rawFilterInShellSyntaxBecomesExtendedJSON() throws { - let raw = PluginQueryFilter( - column: MongoDBQueryBuilder.rawFilterColumn, op: "RAW", - value: "{status: 'active', _id: ObjectId(\"507f1f77bcf86cd799439011\"), n: 3}" - ) - let doc = normalizingBuilder.buildFilterDocument(from: [raw]) - let expected = "{\"$and\": [{\"status\":\"active\"," - + "\"_id\":{\"$oid\":\"507f1f77bcf86cd799439011\"},\"n\":{\"$numberInt\":\"3\"}}]}" - #expect(doc == expected) - let parsed = try JSONSerialization.jsonObject(with: Data(doc.utf8)) as? [String: Any] - #expect((parsed?["$and"] as? [[String: Any]])?.count == 1) - let find = normalizingBuilder.buildFilteredQuery(collection: "users", queryFilters: [raw]) - #expect(find == "db.users.find(\(expected)).limit(200)") - } - - @Test("A raw filter the shell cannot evaluate is dropped, never spliced as typed") - func rawFilterThatFailsToEvaluateIsDropped() { - let raw = PluginQueryFilter( - column: MongoDBQueryBuilder.rawFilterColumn, op: "RAW", value: "{status: }" - ) - let doc = normalizingBuilder.buildFilterDocument(from: [raw]) - #expect(doc == MongoDBQueryBuilder.impossibleFilter) - } - - @Test("A raw filter row that closes the find call reaches neither the statement nor the count") - func rawFilterInjectionIsDropped() { - let injection = PluginQueryFilter( - column: MongoDBQueryBuilder.rawFilterColumn, op: "RAW", - value: "{}]}).limit(1); db.users.drop(); db.orders.find({\"$and\": [{}" - ) - let status = PluginQueryFilter(column: "status", op: "=", value: "active") - - let find = normalizingBuilder.buildFilteredQuery(collection: "orders", queryFilters: [injection, status]) - #expect(find == "db.orders.find({\"status\": \"active\"}).limit(200)") - let count = normalizingBuilder.buildFilterDocument(from: [injection, status]) - #expect(count == "{\"status\": \"active\"}") - let alone = normalizingBuilder.buildFilteredQuery(collection: "orders", queryFilters: [injection]) - #expect(alone == "db.orders.find(\(MongoDBQueryBuilder.impossibleFilter)).limit(200)") - for statement in [find, count, alone] { - #expect(!statement.contains("drop"), "\(statement)") - } - } - - @Test("A builder with no normalizer drops a raw row rather than splicing its text") - func rawFilterWithoutNormalizerIsDropped() { - let raw = PluginQueryFilter( - column: MongoDBQueryBuilder.rawFilterColumn, op: "RAW", value: "{\"status\": \"active\"}" - ) - #expect(builder.buildFilterDocument(from: [raw]) == MongoDBQueryBuilder.impossibleFilter) - } - - @Test( - "Normalizer output reaches the statement only when it is exactly one JSON object", - arguments: [ - "{}]}); db.users.drop(); ({", - "{\"a\": 1}); db.users.drop(); ({", - "{\"a\": 1}{\"b\": 2}", - "{\"a\": 1} // ", - "[{\"a\": 1}]", - "\"{}\"", - "{a: 1}", - "{\"__proto__\": {\"a\": 1}}", - "{\"a\": [{\"__proto__\": 1}]}", - ] - ) - func normalizerOutputMustBeOneJsonObject(output: String) { - let stubbed = MongoDBQueryBuilder(rawFilterNormalizer: { _ in output }) - let raw = PluginQueryFilter(column: MongoDBQueryBuilder.rawFilterColumn, op: "RAW", value: "{a: 1}") - let query = stubbed.buildFilteredQuery(collection: "users", queryFilters: [raw]) - #expect(query == "db.users.find(\(MongoDBQueryBuilder.impossibleFilter)).limit(200)") - } - - @Test("Normalizer output that is one JSON object is the row's document") - func normalizerOutputThatIsOneObjectIsUsed() { - let stubbed = MongoDBQueryBuilder(rawFilterNormalizer: { _ in "{\"a\":{\"$numberInt\":\"1\"}}" }) - let raw = PluginQueryFilter(column: MongoDBQueryBuilder.rawFilterColumn, op: "RAW", value: "{a: 1}") - #expect(stubbed.buildFilterDocument(from: [raw]) == "{\"$and\": [{\"a\":{\"$numberInt\":\"1\"}}]}") - } - - @Test("A raw row that reassigns the serializer changes neither its own next run nor a later row") - func rawFilterCannotRedefineLaterNormalization() { - let normalizer = MongoDBRawFilterNormalizer() - let isolated = MongoDBQueryBuilder(rawFilterNormalizer: { normalizer.normalize($0) }) - let poison = PluginQueryFilter( - column: MongoDBQueryBuilder.rawFilterColumn, op: "RAW", - value: "{status: (__ejson = function () { return '{\"x\": 1}' }, \"a\")}" - ) - let later = PluginQueryFilter(column: MongoDBQueryBuilder.rawFilterColumn, op: "RAW", value: "{n: 1}") - - #expect(isolated.buildFilterDocument(from: [poison]) == "{\"$and\": [{\"status\":\"a\"}]}") - #expect(isolated.buildFilterDocument(from: [poison]) == "{\"$and\": [{\"status\":\"a\"}]}") - #expect(isolated.buildFilterDocument(from: [later]) == "{\"$and\": [{\"n\":{\"$numberInt\":\"1\"}}]}") - } - - @Test("UUID, legacy UUID and HexData rows normalize to the binary the shell sends") - func rawFilterBinaryConstructorsNormalize() { - let uuid = "3b241101-e2bb-4255-8caf-4136c566a962" - let expected = [ - "{ref: UUID(\"\(uuid)\")}": - "{\"ref\":{\"$binary\":{\"base64\":\"OyQRAeK7QlWMr0E2xWapYg==\",\"subType\":\"04\"}}}", - "{ref: LegacyJavaUUID(\"\(uuid)\")}": - "{\"ref\":{\"$binary\":{\"base64\":\"VUK74gERJDtiqWbFNkGvjA==\",\"subType\":\"03\"}}}", - "{blob: HexData(0, \"00ff\")}": - "{\"blob\":{\"$binary\":{\"base64\":\"AP8=\",\"subType\":\"00\"}}}", - ] - for (row, document) in expected { - let raw = PluginQueryFilter(column: MongoDBQueryBuilder.rawFilterColumn, op: "RAW", value: row) - #expect(normalizingBuilder.buildFilterDocument(from: [raw]) == "{\"$and\": [\(document)]}", "\(row)") - } - } - - @Test("A normalized raw row keeps line breaks the statement scanner splits on as escapes") - func rawFilterLineBreaksAreEscaped() { - let raw = PluginQueryFilter( - column: MongoDBQueryBuilder.rawFilterColumn, op: "RAW", value: "{name: \"a\\u2028b\\u0085c\"}" - ) - #expect(normalizingBuilder.buildFilterDocument(from: [raw]) == "{\"$and\": [{\"name\":\"a\\u2028b\\u0085c\"}]}") - } - - @Test("The normalizer serializes dates and regex literals the way the shell does") - func normalizerSerializesShellValues() { - let normalized = Self.normalizer.normalize("{at: ISODate(\"2024-01-02T00:00:00Z\"), name: /^bo/i}") - let expected = "{\"at\":{\"$date\":{\"$numberLong\":\"1704153600000\"}}," - + "\"name\":{\"$regularExpression\":{\"pattern\":\"^bo\",\"options\":\"i\"}}}" - #expect(normalized == expected) - #expect(Self.normalizer.normalize("{}") == "{}") - #expect(Self.normalizer.normalize("{_id: ObjectId()}") == nil) - #expect(Self.normalizer.normalize("{ref: UUID()}") == nil) - #expect(Self.normalizer.normalize("{blob: HexData(0, \"0g\")}") == nil) - } -} - -/// A table tab, its count and a collection export run what the builder writes, and the plugin -/// refuses writes only for a statement the classifier proves a read. -struct MongoDBQueryBuilderReadTierTests { - private static let normalizer = MongoDBRawFilterNormalizer() - private static let uuid = "3b241101-e2bb-4255-8caf-4136c566a962" - private static let collections = ["orders", "stats", "logs.2024.06", "say\"hi\\bye", "tên", "__proto__"] - - private var builder: MongoDBQueryBuilder { - MongoDBQueryBuilder( - columnKinds: ["createdAt": .date, "price": .decimal128, "owner": .objectId, "name": .string], - rawFilterNormalizer: { Self.normalizer.normalize($0) } - ) - } - - private func raw(_ document: String) -> PluginQueryFilter { - PluginQueryFilter(column: MongoDBQueryBuilder.rawFilterColumn, op: "RAW", value: document) - } - - private func scoped(_ column: String, _ op: String, _ value: String) -> PluginQueryFilter { - PluginQueryFilter(column: column, op: op, value: value, secondValue: nil, elementScope: "items") - } - - private var filters: [PluginQueryFilter] { - [ - PluginQueryFilter(column: "name", op: "=", value: "Alice"), - PluginQueryFilter(column: "status", op: "=", value: "Active", isCaseSensitive: false), - PluginQueryFilter(column: "ref", op: "=", value: "507f1f77bcf86cd799439011"), - PluginQueryFilter(column: "ref", op: "!=", value: "507f1f77bcf86cd799439011"), - PluginQueryFilter(column: "note", op: "!=", value: "x", isCaseSensitive: false), - PluginQueryFilter(column: "owner", op: "=", value: "507f1f77bcf86cd799439011"), - PluginQueryFilter(column: "key", op: "=", value: "UUID(\"\(Self.uuid)\")"), - PluginQueryFilter(column: "age", op: ">", value: "30"), - PluginQueryFilter(column: "age", op: ">=", value: "-1.5e3"), - PluginQueryFilter(column: "createdAt", op: "<", value: "2024-01-02T00:00:00Z"), - PluginQueryFilter(column: "price", op: "<=", value: "9.99"), - PluginQueryFilter(column: "name", op: "CONTAINS", value: "a.b\"c\\d/e"), - PluginQueryFilter(column: "name", op: "NOT CONTAINS", value: "x", isCaseSensitive: false), - PluginQueryFilter(column: "name", op: "STARTS WITH", value: "Al"), - PluginQueryFilter(column: "name", op: "ENDS WITH", value: "ce"), - PluginQueryFilter(column: "deletedAt", op: "IS NULL", value: ""), - PluginQueryFilter(column: "deletedAt", op: "IS NOT NULL", value: ""), - PluginQueryFilter(column: "name", op: "IS EMPTY", value: ""), - PluginQueryFilter(column: "name", op: "IS NOT EMPTY", value: ""), - PluginQueryFilter(column: "name", op: "REGEX", value: "^a[/]b$"), - PluginQueryFilter(column: "tag", op: "IN", value: "a, 2, true, null, 507f1f77bcf86cd799439011"), - PluginQueryFilter(column: "tag", op: "NOT IN", value: "a, b, UUID(\"\(Self.uuid)\")", isCaseSensitive: false), - PluginQueryFilter(column: "age", op: "BETWEEN", value: "18,65", secondValue: "65", elementScope: nil), - scoped("items.sku", "=", "A100"), - scoped("items.qty", ">", "2"), - PluginQueryFilter(column: "multi\nline\tkey", op: "=", value: "tab\there\u{1}"), - PluginQueryFilter(column: "name", op: "=", value: "a\u{2028})})\u{2028}x"), - PluginQueryFilter(column: "note\u{85})})\u{85}y", op: "CONTAINS", value: "b\u{2029}"), - raw("{status: 'active', _id: ObjectId(\"507f1f77bcf86cd799439011\")}"), - raw("{at: ISODate(\"2024-01-02T00:00:00Z\"), name: /^bo/i}"), - raw("{ref: LegacyJavaUUID(\"\(Self.uuid)\")}"), - raw("{blob: HexData(0, \"00ff\")}"), - raw("{name: \"a\\u2028)]})\\u2028x\"}"), - raw("{}]}).limit(1); db.users.drop(); db.orders.find({\"$and\": [{}"), - raw("{}"), - ] - } - - private func expectProvenRead(_ statement: String) { - #expect(QueryClassifier.classifyTier(statement, databaseType: .mongodb) == .safe, "\(statement)") - } - - @Test("A sorted, paged browse is a proven read") - func browseIsProvenRead() { - for collection in Self.collections { - expectProvenRead(builder.buildBaseQuery(collection: collection)) - expectProvenRead(builder.buildBaseQuery( - collection: collection, - sortColumns: [(columnIndex: 0, ascending: true), (columnIndex: 1, ascending: false)], - columns: ["name", "multi\nline"], - limit: 50, - offset: 100 - )) - } - } - - @Test("Every filter row, alone and combined under either logic mode, browses as a proven read") - func filteredBrowseIsProvenRead() { - let combined = builder.buildFilteredQuery(collection: "orders", queryFilters: filters) - #expect(combined.contains("$elemMatch")) - #expect(combined.contains("{\"blob\":{\"$binary\"")) - - for collection in Self.collections { - for mode in ["and", "or"] { - expectProvenRead(builder.buildFilteredQuery( - collection: collection, - queryFilters: filters, - logicMode: mode, - sortColumns: [(columnIndex: 0, ascending: false)], - columns: ["name"], - limit: 100, - offset: 300 - )) - for filter in filters { - expectProvenRead(builder.buildFilteredQuery( - collection: collection, queryFilters: [filter], logicMode: mode - )) - } - } - } - } - - @Test("The count query over the same filters is a proven read") - func countIsProvenRead() { - for collection in Self.collections { - expectProvenRead(builder.buildCountQuery(collection: collection)) - for mode in ["and", "or"] { - let filter = builder.buildFilterDocument(from: filters, logicMode: mode) - expectProvenRead(builder.buildCountQuery(collection: collection, filterJson: filter)) - } - } - } - - @Test("The collection export query is a proven read") - func exportIsProvenRead() { - for collection in Self.collections { - expectProvenRead(MongoDBQueryBuilder().buildExportQuery(collection: collection)) - } - } - - /// An MCP client names filter columns freely, and a browse that is not a proven read runs with the - /// shell allowed to write. - @Test( - "A filter or sort on an operator-like field name builds no condition and stays a proven read", - arguments: ["$out", "$merge", "$where", "__proto__"] - ) - func operatorLikeFieldNamesStayProvenReads(field: String) { - let filters = [ - PluginQueryFilter(column: field, op: "=", value: "x"), - PluginQueryFilter(column: "items.qty", op: ">", value: "2", secondValue: nil, elementScope: field), - ] - for filter in filters { - let statement = builder.buildFilteredQuery( - collection: "users", - queryFilters: [filter], - sortColumns: [(columnIndex: 0, ascending: true)], - columns: [field], - limit: 100, - offset: 0 - ) - expectProvenRead(statement) - #expect(!statement.contains("\"\(field)\""), "\(statement)") - } - } - - @Test( - "A raw filter row holding a writing stage key builds no condition", - arguments: [#"{"x": {"$out": 1}}"#, #"{"$merge": "copy"}"#, #"{"a": [{"b": {"$out": "c"}}]}"#] - ) - func rawRowWithWritingStageKeyIsDropped(document: String) { - let statement = builder.buildFilteredQuery(collection: "users", queryFilters: [raw(document)]) - expectProvenRead(statement) - #expect(statement.contains(MongoDBQueryBuilder.impossibleFilter)) - } -} diff --git a/TableProTests/Plugins/MongoDBSSLMappingTests.swift b/TableProTests/Plugins/MongoDBSSLMappingTests.swift deleted file mode 100644 index e1941b2a67..0000000000 --- a/TableProTests/Plugins/MongoDBSSLMappingTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct MongoDBSSLMappingTests { - @Test("Disabled returns empty parameter list") - func testDisabled() { - let params = MongoDBSSLMapping.uriParameters(for: SSLConfiguration(mode: .disabled)) - #expect(params.isEmpty) - } - - @Test("Preferred enables TLS with invalid-cert tolerance") - func testPreferred() { - let params = MongoDBSSLMapping.uriParameters(for: SSLConfiguration(mode: .preferred)) - #expect(params.contains("tls=true")) - #expect(params.contains("tlsAllowInvalidCertificates=true")) - } - - @Test("Required enables TLS with invalid-cert tolerance (same as Preferred for this driver)") - func testRequired() { - let params = MongoDBSSLMapping.uriParameters(for: SSLConfiguration(mode: .required)) - #expect(params.contains("tls=true")) - #expect(params.contains("tlsAllowInvalidCertificates=true")) - } - - @Test("Verify CA enables TLS with hostname tolerance only") - func testVerifyCA() { - let config = SSLConfiguration(mode: .verifyCa, caCertificatePath: "/tmp/ca.pem") - let params = MongoDBSSLMapping.uriParameters(for: config) - #expect(params.contains("tls=true")) - #expect(params.contains("tlsAllowInvalidHostnames=true")) - #expect(params.contains { $0.hasPrefix("tlsCAFile=") }) - } - - @Test("Verify Identity enables TLS with full validation") - func testVerifyIdentity() { - let config = SSLConfiguration(mode: .verifyIdentity, caCertificatePath: "/tmp/ca.pem") - let params = MongoDBSSLMapping.uriParameters(for: config) - #expect(params.contains("tls=true")) - #expect(!params.contains("tlsAllowInvalidCertificates=true")) - #expect(!params.contains("tlsAllowInvalidHostnames=true")) - #expect(params.contains { $0.hasPrefix("tlsCAFile=") }) - } - - @Test("Client certificate path is included when set") - func testClientCert() { - let config = SSLConfiguration( - mode: .verifyIdentity, - caCertificatePath: "/tmp/ca.pem", - clientCertificatePath: "/tmp/client.pem" - ) - let params = MongoDBSSLMapping.uriParameters(for: config) - #expect(params.contains { $0.hasPrefix("tlsCertificateKeyFile=") }) - } - - @Test("Paths with special characters are URL-encoded") - func testPathEncoding() { - let config = SSLConfiguration( - mode: .verifyIdentity, - caCertificatePath: "/path with spaces/ca.pem" - ) - let params = MongoDBSSLMapping.uriParameters(for: config) - let caParam = params.first { $0.hasPrefix("tlsCAFile=") } - #expect(caParam?.contains(" ") == false) - #expect(caParam?.contains("%20") == true) - } -} diff --git a/TableProTests/Plugins/MongoDBShellLiteralTests.swift b/TableProTests/Plugins/MongoDBShellLiteralTests.swift deleted file mode 100644 index 4abd87733e..0000000000 --- a/TableProTests/Plugins/MongoDBShellLiteralTests.swift +++ /dev/null @@ -1,157 +0,0 @@ -// -// MongoDBShellLiteralTests.swift -// TableProTests -// -// Inputs are canonical Extended JSON as libmongoc 1.28 renders it, read from MongoDB 7.0.43. -// - -import Foundation -import Testing - -struct MongoDBShellLiteralTests { - private func render(_ canonical: String) -> String { - MongoDBShellLiteral.render(canonical) - } - - @Test("An Int32 stays a bare number, and an Int64 and a whole Double name their type") - func numbersKeepTheirType() { - #expect(render(""" - { "a" : { "$numberInt" : "7" }, "b" : { "$numberLong" : "9007199254740993" }, \ - "c" : { "$numberLong" : "1" }, "d" : { "$numberDouble" : "1.0" }, "e" : { "$numberDouble" : "-0.0" }, \ - "f" : { "$numberDouble" : "1e+20" }, "g" : { "$numberInt" : "-2147483648" } } - """) == """ - { "a" : 7, "b" : NumberLong("9007199254740993"), "c" : NumberLong("1"), "d" : Double(1.0), \ - "e" : Double(-0.0), "f" : Double(1e+20), "g" : -2147483648 } - """) - } - - @Test("A fraction is written in the fewest digits that read back as the same Double") - func fractionsUseTheShortestSpelling() { - #expect(render("{ \"$numberDouble\" : \"0.10000000000000000555\" }") == "0.1") - #expect(render("{ \"$numberDouble\" : \"2.5\" }") == "2.5") - #expect(render("{ \"$numberDouble\" : \"-9.9999999999999995475e-08\" }") == "-1e-07") - } - - @Test("Infinity and NaN are the shell's own names for them") - func nonFiniteDoubles() { - #expect(render("{ \"$numberDouble\" : \"Infinity\" }") == "Infinity") - #expect(render("{ \"$numberDouble\" : \"-Infinity\" }") == "-Infinity") - #expect(render("{ \"$numberDouble\" : \"NaN\" }") == "NaN") - } - - @Test("Every Decimal128 goes through NumberDecimal, NaN and the infinities included") - func decimals() { - #expect(render("{ \"$numberDecimal\" : \"1.50\" }") == "NumberDecimal(\"1.50\")") - #expect(render("{ \"$numberDecimal\" : \"-1.23E-7\" }") == "NumberDecimal(\"-1.23E-7\")") - #expect(render("{ \"$numberDecimal\" : \"NaN\" }") == "NumberDecimal(\"NaN\")") - #expect(render("{ \"$numberDecimal\" : \"Infinity\" }") == "NumberDecimal(\"Infinity\")") - #expect(render("{ \"$numberDecimal\" : \"-Infinity\" }") == "NumberDecimal(\"-Infinity\")") - } - - @Test("A date is an ISODate in the proleptic Gregorian calendar JavaScript counts in") - func datesAreISODates() { - let cases: [(millis: String, text: String)] = [ - ("1577934245678", "2020-01-02T03:04:05.678Z"), - ("0", "1970-01-01T00:00:00.000Z"), - ("-1", "1969-12-31T23:59:59.999Z"), - ("951782400000", "2000-02-29T00:00:00.000Z"), - ("-12219292800001", "1582-10-14T23:59:59.999Z"), - ("-62135596800000", "0001-01-01T00:00:00.000Z"), - ("253402300799999", "9999-12-31T23:59:59.999Z") - ] - for testCase in cases { - #expect( - render("{ \"$date\" : { \"$numberLong\" : \"\(testCase.millis)\" } }") == "ISODate(\"\(testCase.text)\")", - "\(testCase.millis)" - ) - } - } - - @Test("A date outside the years ISODate can spell is a Date of its instant, as far as a Date reaches") - func farDatesAreDates() { - for millis in ["-62135596800001", "253402300800000", "8640000000000000", "-8640000000000000"] { - #expect(render("{ \"$date\" : { \"$numberLong\" : \"\(millis)\" } }") == "new Date(\(millis))", "\(millis)") - } - } - - @Test("A date past what a JavaScript Date holds keeps its wrapper, since no shell can write it otherwise") - func datesPastJavaScriptKeepTheirWrapper() { - for millis in ["8640000000000001", "-8640000000000001", "-9223372036854775808", "9223372036854775807"] { - let wrapper = "{ \"$date\" : { \"$numberLong\" : \"\(millis)\" } }" - #expect(render(wrapper) == wrapper, "\(millis)") - } - } - - @Test("Every other type with a shell constructor is written through it") - func otherConstructors() { - #expect(render("{ \"$oid\" : \"5f1d7a3b2c4e5a6b7c8d9e0f\" }") == "ObjectId(\"5f1d7a3b2c4e5a6b7c8d9e0f\")") - #expect(render("{ \"$binary\" : { \"base64\" : \"AAEC\", \"subType\" : \"04\" } }") == "BinData(4, \"AAEC\")") - #expect(render("{ \"$binary\" : { \"base64\" : \"AAEC\", \"subType\" : \"80\" } }") == "BinData(128, \"AAEC\")") - #expect(render("{ \"$timestamp\" : { \"t\" : 5, \"i\" : 6 } }") == "Timestamp(5, 6)") - #expect(render("{ \"$minKey\" : 1 }") == "MinKey()") - #expect(render("{ \"$maxKey\" : 1 }") == "MaxKey()") - #expect(render("{ \"$code\" : \"function () { return 1; }\" }") == "Code(\"function () { return 1; }\")") - #expect(render("{ \"$code\" : \"x\", \"$scope\" : { \"y\" : { \"$numberLong\" : \"2\" } } }") - == "Code(\"x\", { \"y\" : NumberLong(\"2\") })") - } - - @Test("A regular expression and a symbol go through the constructors mongosh names them with") - func regularExpressionsAndSymbols() { - #expect(render("{ \"$regularExpression\" : { \"pattern\" : \"a\\\\/b\", \"options\" : \"i\" } }") - == #"BSONRegExp("a\\/b", "i")"#) - #expect(render("{ \"$regularExpression\" : { \"pattern\" : \"(?x) a # b\\n\", \"options\" : \"lx\" } }") - == #"BSONRegExp("(?x) a # b\n", "lx")"#) - #expect(render("{ \"$symbol\" : \"q\\u2028r\" }") == #"BSONSymbol("q\u2028r")"#) - } - - @Test("A DBPointer and undefined, which mongosh cannot write, keep their wrapper") - func typesNoShellWritesKeepTheirWrapper() { - #expect(render("{ \"$undefined\" : true }") == "{ \"$undefined\" : true }") - #expect(render(""" - { "$dbPointer" : { "$ref" : "c", "$id" : { "$oid" : "5f1d7a3b2c4e5a6b7c8d9e0f" } } } - """) == """ - { "$dbPointer" : { "$ref" : "c", "$id" : ObjectId("5f1d7a3b2c4e5a6b7c8d9e0f") } } - """) - } - - @Test("A member named __proto__ is a computed key, which adds it as a member rather than a prototype") - func protoMembersAreComputedKeys() { - #expect(render(""" - { "__proto__" : { "$numberInt" : "1" }, "a" : { "__proto__" : { "b" : "c" } }, "proto" : "__proto__" } - """) == """ - { ["__proto__"] : 1, "a" : { ["__proto__"] : { "b" : "c" } }, "proto" : "__proto__" } - """) - } - - @Test("Query operators are documents, so what they hold is written and they are not") - func operatorsAreRecursedInto() { - #expect(render(""" - [ { "$match" : { "a" : { "$gte" : { "$numberLong" : "1" } }, "b" : { "$in" : [ { "$numberInt" : "1" }, "s", null, true ] } } }, \ - { "$sort" : { "a" : { "$numberInt" : "1" }, "w" : { "$numberInt" : "-1" } } } ] - """) == """ - [ { "$match" : { "a" : { "$gte" : NumberLong("1") }, "b" : { "$in" : [ 1, "s", null, true ] } } }, \ - { "$sort" : { "a" : 1, "w" : -1 } } ] - """) - } - - @Test("A string or a member name holding a line separator libbson left raw is written with it escaped") - func rawLineSeparatorsAreEscaped() { - let canonical = "{ \"k\u{2028}\" : \"v\u{2029}w\u{85}\", \"n\" : [ \"x\\ny\" ] }" - - #expect(render(canonical) == #"{ "k\u2028" : "v\u2029w\u0085", "n" : [ "x\ny" ] }"#) - } - - @Test("A value that is neither a literal nor a number is written as the string it spells") - func unknownScalarsBecomeStrings() { - #expect(render("{ \"a\" : 1.5e3, \"b\" : -0, \"c\" : undefined, \"d\" : x\u{2028}y }") - == #"{ "a" : 1.5e3, "b" : -0, "c" : "undefined", "d" : "x\u2028y" }"#) - } - - @Test("Strings, including ones that look like a wrapper, and empty containers are left alone") - func scalarsAndEmptyContainers() { - #expect(render("{ \"k\" : \"{ \\\"$numberLong\\\" : \\\"1\\\" }\" }") == "{ \"k\" : \"{ \\\"$numberLong\\\" : \\\"1\\\" }\" }") - #expect(render("[ ]") == "[ ]") - #expect(render("{ }") == "{ }") - #expect(render("\"text\"") == "\"text\"") - } -} diff --git a/TableProTests/Plugins/MongoDBShellTextTests.swift b/TableProTests/Plugins/MongoDBShellTextTests.swift deleted file mode 100644 index 3817b631b2..0000000000 --- a/TableProTests/Plugins/MongoDBShellTextTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// MongoDBShellTextTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoDBShellTextTests { - @Test("A comment keeps a plain name as it is") - func plainCommentIsUnchanged() { - #expect(MongoDBShellText.comment("Collection: people") == "// Collection: people") - #expect(MongoDBShellText.comment("View: người dùng") == "// View: người dùng") - } - - @Test("Every character that ends a line or cannot be seen is written as its escape in a comment") - func commentEscapesLineEndings() { - let cases: [(name: String, spelled: String)] = [ - ("a\nb", "a\\nb"), - ("a\rb", "a\\rb"), - ("a\r\nb", "a\\r\\nb"), - ("a\tb", "a\\tb"), - ("a\u{2028}b", "a\\u2028b"), - ("a\u{2029}b", "a\\u2029b"), - ("a\u{85}b", "a\\u0085b"), - ("a\u{0B}b", "a\\u000bb"), - ("a\u{0C}b", "a\\u000cb"), - ("a\u{00}b", "a\\u0000b"), - ("a\u{7F}b", "a\\u007fb") - ] - for testCase in cases { - let line = MongoDBShellText.comment("View: \(testCase.name)") - - #expect(line == "// View: \(testCase.spelled)", "\(testCase.name.debugDescription)") - #expect(!line.contains { $0.isNewline }, "\(testCase.name.debugDescription)") - } - } - - @Test("A block comment closer and quotes cannot end a line comment, so they stay as they are") - func commentKeepsCloserAndQuotes() { - #expect(MongoDBShellText.comment("View: a */ b \" c ' d") == "// View: a */ b \" c ' d") - } - - @Test("A name made of identifier characters is reached with a dot") - func identifierNamesUseTheDot() { - #expect(MongoDBShellText.collection("people") == "db.people") - #expect(MongoDBShellText.collection("_audit2") == "db._audit2") - #expect(MongoDBShellText.collection("người_dùng") == "db.người_dùng") - } - - @Test("Any other name is reached through getCollection, escaped as a string") - func otherNamesUseGetCollection() { - let cases: [(name: String, expression: String)] = [ - ("stats", "db.getCollection(\"stats\")"), - ("2024", "db.getCollection(\"2024\")"), - ("a.b", "db.getCollection(\"a.b\")"), - ("a b", "db.getCollection(\"a b\")"), - ("a\"b", "db.getCollection(\"a\\\"b\")"), - ("a\nb", "db.getCollection(\"a\\nb\")"), - ("a\u{2028}b", "db.getCollection(\"a\\u2028b\")"), - ("a\u{2029}b", "db.getCollection(\"a\\u2029b\")"), - ("a\u{0D4E};b", "db.getCollection(\"a\u{0D4E};b\")") - ] - for testCase in cases { - #expect(MongoDBShellText.collection(testCase.name) == testCase.expression, "\(testCase.name.debugDescription)") - } - } -} diff --git a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift b/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift deleted file mode 100644 index df632e6c54..0000000000 --- a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift +++ /dev/null @@ -1,1100 +0,0 @@ -// -// MongoDBStatementGeneratorTests.swift -// TableProTests -// -// Tests for MongoDBStatementGenerator (compiled via symlink from MongoDBDriverPlugin). -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoDBStatementGeneratorTests { - // MARK: - INSERT - - @Test("Simple insert generates insertOne, skipping _id") - func simpleInsert() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name", "email"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: [nil, "Alice", "alice@example.com"] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("insertOne")) - #expect(stmt.contains("\"email\": \"alice@example.com\"")) - #expect(stmt.contains("\"name\": \"Alice\"")) - #expect(!stmt.contains("\"_id\"")) - } - - @Test("Insert skips __DEFAULT__ sentinel values") - func insertSkipsDefaultSentinel() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name", "age"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: [nil, "Bob", "__DEFAULT__"] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("\"name\": \"Bob\"")) - #expect(!stmt.contains("__DEFAULT__")) - #expect(!stmt.contains("\"age\"")) - } - - @Test("An insert leaves out a field the new row does not have") - func insertLeavesOutAbsentFields() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name", "email"] - ) - - var change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - change.absentColumns = [2] - - let insertedData: [Int: [PluginCellValue]] = [ - 0: [nil, "Carol", nil] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.map(\.statement) == [#"db.users.insertOne({"name": "Carol"})"#]) - } - - @Test("An insert writes NULL as null, so a duplicated null field keeps its null") - func insertWritesNullAsNull() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name", "deletedAt"] - ) - - var change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - change.absentColumns = [] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [0: ["__DEFAULT__", "Carol", nil]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.map(\.statement) == [#"db.users.insertOne({"name": "Carol", "deletedAt": null})"#]) - } - - @Test("A new row with every cell empty inserts the empty document") - func insertAllNilWritesEmptyDocument() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: [nil, "__DEFAULT__"] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.map { $0.statement } == ["db.users.insertOne({})"]) - #expect(results.map { $0.rowIndices } == [[0]]) - } - - @Test("Insert uses cellChanges as fallback when insertedRowData missing") - func insertFallbackToCellChanges() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [ - (columnIndex: 1, columnName: "name", oldValue: nil, newValue: "Dave") - ], - originalRow: nil - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("\"name\": \"Dave\"")) - } - - @Test("Insert with numeric value auto-detects type") - func insertNumericValue() throws { - let gen = MongoDBStatementGenerator( - collectionName: "data", - columns: ["_id", "count"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: [nil, "42"] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("\"count\": 42")) - } - - @Test("Insert emits JSON-valid decimal and exponent numbers") - func insertEmitsJsonValidNumbers() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "decimal", "exponent"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: [nil, "0.5", "1e3"] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - let document = firstArgumentObject(in: results[0].statement) - #expect(document?["decimal"] as? Double == 0.5) - #expect(document?["exponent"] as? Double == 1_000) - } - - @Test("Insert quotes non-JSON numeric spellings") - func insertQuotesNonJsonNumericSpellings() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "leadingDecimal", "trailingDecimal", "leadingPlus", "leadingZero"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: [nil, ".5", "1.", "+7", "01"] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - let document = firstArgumentObject(in: results[0].statement) - #expect(document?["leadingDecimal"] as? String == ".5") - #expect(document?["trailingDecimal"] as? String == "1.") - #expect(document?["leadingPlus"] as? String == "+7") - #expect(document?["leadingZero"] as? String == "01") - } - - /// The maximum Int64 is past 2^53, where a bare JavaScript literal rounds: JavaScriptCore reads - /// `9223372036854775807` as `9223372036854776000`. It has to cross as `$numberLong`. - @Test("Insert quotes integers that overflow Int64 and writes the largest ones as $numberLong") - func insertQuotesInt64Overflow() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "overflow", "maxInt64"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 0: [nil, "12345678901234567890", "9223372036854775807"] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - let document = firstArgumentObject(in: results[0].statement) - #expect(document?["overflow"] as? String == "12345678901234567890") - #expect((document?["maxInt64"] as? [String: Any])?["$numberLong"] as? String == "9223372036854775807") - } - - @Test("Insert not in insertedRowIndices is skipped") - func insertNotInIndicesSkipped() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let change = PluginRowChange( - rowIndex: 5, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let insertedData: [Int: [PluginCellValue]] = [ - 5: [nil, "Eve"] - ] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: insertedData, - deletedRowIndices: [], - insertedRowIndices: [0] // does not contain 5 - ) - - #expect(results.isEmpty) - } - - // MARK: - UPDATE - - @Test("Update with ObjectId _id") - func updateWithObjectId() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name", "email"] - ) - - let objectId = "507f1f77bcf86cd799439011" - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Alicia") - ], - originalRow: [.text(objectId), "Alice", "alice@example.com"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("updateOne")) - #expect(stmt.contains("\"$oid\": \"\(objectId)\"")) - #expect(stmt.contains("\"$set\"")) - #expect(stmt.contains("\"name\": \"Alicia\"")) - } - - @Test("Update with numeric _id") - func updateWithNumericId() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 1, columnName: "name", oldValue: "Bob", newValue: "Robert") - ], - originalRow: ["42", "Bob"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("{\"_id\": 42}")) - } - - @Test("Update with string _id") - func updateWithStringId() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 1, columnName: "name", oldValue: "X", newValue: "Y") - ], - originalRow: ["my-custom-id", "X"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("{\"_id\": \"my-custom-id\"}")) - } - - @Test("Update with $set and $unset") - func updateSetAndUnset() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name", "bio"] - ) - - var change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Alicia"), - (columnIndex: 2, columnName: "bio", oldValue: "Some bio", newValue: nil) - ], - originalRow: ["507f1f77bcf86cd799439011", "Alice", "Some bio"] - ) - change.absentColumns = [2] - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("\"$set\"")) - #expect(stmt.contains("\"name\": \"Alicia\"")) - #expect(stmt.contains("\"$unset\"")) - #expect(stmt.contains("\"bio\": \"\"")) - } - - @Test("An edit of _id is refused, because MongoDB never changes a document's _id") - func updateRefusesIdChange() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 0, columnName: "_id", oldValue: "old", newValue: "new") - ], - originalRow: ["old", "Alice"] - ) - - #expect(throws: MongoDBWriteRefusal.identityChanged.refusal(ofRow: 0)) { - try gen.generateRowWrites(from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: []) - } - } - - @Test("An update of a row with no _id is refused rather than left out") - func updateWithoutIdIsRefused() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["name", "email"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 0, columnName: "name", oldValue: "A", newValue: "B") - ], - originalRow: ["A", "a@b.com"] - ) - - #expect(throws: MongoDBWriteRefusal.missingIdentity.refusal(ofRow: 0)) { - try gen.generateRowWrites(from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: []) - } - } - - @Test("Update with empty cellChanges is skipped") - func updateEmptyCellChanges() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [], - originalRow: ["507f1f77bcf86cd799439011", "Alice"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [], - insertedRowIndices: [] - ) - - #expect(results.isEmpty) - } - - // MARK: - DELETE - - @Test("Delete with ObjectId uses $oid filter") - func deleteWithObjectId() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let objectId = "507f1f77bcf86cd799439011" - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: [.text(objectId), "Alice"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("deleteOne")) - #expect(stmt.contains("\"$oid\": \"\(objectId)\"")) - } - - @Test("Bulk delete uses deleteMany with $in") - func bulkDeleteMany() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let id1 = "507f1f77bcf86cd799439011" - let id2 = "507f1f77bcf86cd799439022" - - let changes = [ - PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: [.text(id1), "Alice"]), - PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: [.text(id2), "Bob"]) - ] - - let results = try gen.generateRowWrites( - from: changes, - insertedRowData: [:], - deletedRowIndices: [0, 1], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("deleteMany")) - #expect(stmt.contains("\"$in\"")) - #expect(stmt.contains("{\"$oid\": \"\(id1)\"}")) - #expect(stmt.contains("{\"$oid\": \"\(id2)\"}")) - } - - @Test("Bulk delete with numeric ids") - func bulkDeleteNumericIds() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let changes = [ - PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: ["1", "Alice"]), - PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: ["2", "Bob"]) - ] - - let results = try gen.generateRowWrites( - from: changes, - insertedRowData: [:], - deletedRowIndices: [0, 1], - insertedRowIndices: [] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("deleteMany")) - #expect(stmt.contains("\"$in\": [1, 2]")) - } - - @Test("Delete quotes an _id that overflows Int64 to preserve precision") - func deleteQuotesInt64OverflowId() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["12345678901234567890", "Alice"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(results[0].statement.contains("{\"_id\": \"12345678901234567890\"}")) - } - - @Test("Delete keeps a decimal or exponent _id quoted so a string _id still matches") - func deleteQuotesNonIntegerId() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - for id in ["1.5", "1e3"] { - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: [PluginCellValue.text(id), "Alice"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [0], - insertedRowIndices: [] - ) - - #expect(results[0].statement.contains("{\"_id\": \"\(id)\"}")) - } - } - - /// An all-field filter cannot express a binary value and drops every column it cannot - /// stringify, so it deletes the first partial match rather than the intended document. - @Test("A collection with no _id column refuses the delete instead of matching on every field") - func singleDeleteWithoutIdColumnIsRefused() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["name", "email"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: ["Alice", "alice@example.com"] - ) - - #expect(throws: MongoDBWriteRefusal.missingIdentity.refusal(ofRow: 0)) { - try gen.generateRowWrites(from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: []) - } - } - - @Test("Delete not in deletedRowIndices is skipped") - func deleteNotInIndicesSkipped() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let change = PluginRowChange( - rowIndex: 5, - type: .delete, - cellChanges: [], - originalRow: ["507f1f77bcf86cd799439011", "Alice"] - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [:], - deletedRowIndices: [0], // does not contain 5 - insertedRowIndices: [] - ) - - #expect(results.isEmpty) - } - - @Test("A delete without its original row is refused") - func deleteNoOriginalRowIsRefused() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: nil - ) - - #expect(throws: MongoDBWriteRefusal.missingIdentity.refusal(ofRow: 0)) { - try gen.generateRowWrites(from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: []) - } - } - - // MARK: - Mixed Operations - - @Test("Mixed insert, update, and delete in one batch") - func mixedOperations() throws { - let gen = MongoDBStatementGenerator( - collectionName: "users", - columns: ["_id", "name", "email"] - ) - - let objectId = "507f1f77bcf86cd799439011" - let changes = [ - PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ), - PluginRowChange( - rowIndex: 1, - type: .update, - cellChanges: [ - (columnIndex: 1, columnName: "name", oldValue: "Bob", newValue: "Robert") - ], - originalRow: [.text(objectId), "Bob", "bob@test.com"] - ), - PluginRowChange( - rowIndex: 2, - type: .delete, - cellChanges: [], - originalRow: ["507f1f77bcf86cd799439022", "Carol", "carol@test.com"] - ) - ] - - let insertedData: [Int: [PluginCellValue]] = [ - 0: [nil, "Alice", "alice@test.com"] - ] - - let results = try gen.generateRowWrites( - from: changes, - insertedRowData: insertedData, - deletedRowIndices: [2], - insertedRowIndices: [0] - ) - - #expect(results.count == 3) - #expect(results[0].statement.contains("insertOne")) - #expect(results[1].statement.contains("updateOne")) - #expect(results[2].statement.contains("deleteOne")) - } - - // MARK: - Collection Accessor - - @Test("Collection with dots goes through getCollection") - func collectionBracketNotation() throws { - let gen = MongoDBStatementGenerator( - collectionName: "my.collection", - columns: ["_id", "name"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [0: [nil, "Test"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("db.getCollection(\"my.collection\")")) - } - - // MARK: - Value Type Detection - - @Test("Boolean values are serialized as booleans") - func booleanSerialization() throws { - let gen = MongoDBStatementGenerator( - collectionName: "data", - columns: ["_id", "active"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [0: [nil, "true"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("\"active\": true")) - } - - @Test("Float values are serialized as numbers") - func floatSerialization() throws { - let gen = MongoDBStatementGenerator( - collectionName: "data", - columns: ["_id", "price"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [0: [nil, "19.99"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("\"price\": 19.99")) - } - - @Test("A JSON object value is written as the object it spells") - func jsonObjectPassthrough() throws { - let gen = MongoDBStatementGenerator( - collectionName: "data", - columns: ["_id", "metadata"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [0: [nil, "{\"nested\": true}"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("\"metadata\": {\"nested\":true}")) - } - - @Test("A JSON array value is written as the array it spells") - func jsonArrayPassthrough() throws { - let gen = MongoDBStatementGenerator( - collectionName: "data", - columns: ["_id", "tags"] - ) - - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [], - originalRow: nil - ) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [0: [nil, "[1, 2, 3]"]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("\"tags\": [1,2,3]")) - } - // MARK: - Binary UUID round trip - - private static let uuid = "8cd003eb-4a25-4324-9332-88fce2da0d1a" - private static let javaBase64 = "JEMlSusD0IwaDdri/Igykw==" - private static let standardBase64 = "jNAD60olQySTMoj84toNGg==" - - @Test("Editing a legacy UUID field writes BSON binary, not a string") - func updateWritesLegacyUuidBinary() throws { - let gen = MongoDBStatementGenerator(collectionName: "docs", columns: ["_id", "ref"]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - ( - columnIndex: 1, - columnName: "ref", - oldValue: .null, - newValue: .text("LegacyJavaUUID(\"\(Self.uuid)\")") - ) - ], - originalRow: ["507f1f77bcf86cd799439011", .null] - ) - - let results = try gen.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("\"subType\": \"03\"")) - #expect(stmt.contains(Self.javaBase64)) - #expect(!stmt.contains("\"ref\": \"LegacyJavaUUID")) - } - - @Test("Inserting a standard UUID writes BSON binary subtype 4") - func insertWritesStandardUuidBinary() throws { - let gen = MongoDBStatementGenerator(collectionName: "docs", columns: ["_id", "ref"]) - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - - let results = try gen.generateRowWrites( - from: [change], - insertedRowData: [0: [nil, .text("UUID(\"\(Self.uuid)\")")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("\"subType\": \"04\"")) - #expect(results[0].statement.contains(Self.standardBase64)) - } - - @Test("An _id that is a legacy UUID filters on binary, not on the wrapper text") - func updateFiltersOnBinaryId() throws { - let gen = MongoDBStatementGenerator(collectionName: "docs", columns: ["_id", "name"]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 1, columnName: "name", oldValue: .text("a"), newValue: .text("b")) - ], - originalRow: [.text("LegacyJavaUUID(\"\(Self.uuid)\")"), .text("a")] - ) - - let results = try gen.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("updateOne({\"_id\": {\"$binary\"")) - #expect(stmt.contains(Self.javaBase64)) - } - - @Test("Deleting a document with a legacy UUID _id filters on binary") - func deleteFiltersOnBinaryId() throws { - let gen = MongoDBStatementGenerator(collectionName: "docs", columns: ["_id", "name"]) - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: [.text("LegacyJavaUUID(\"\(Self.uuid)\")"), .text("a")] - ) - - let results = try gen.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] - ) - - #expect(results.count == 1) - #expect(results[0].statement.contains("deleteOne({\"_id\": {\"$binary\"")) - } - - @Test("A bulk delete of UUID _ids uses binary values inside $in") - func bulkDeleteUsesBinaryIds() throws { - let gen = MongoDBStatementGenerator(collectionName: "docs", columns: ["_id"]) - let changes = [ - PluginRowChange( - rowIndex: 0, type: .delete, cellChanges: [], - originalRow: [.text("LegacyJavaUUID(\"\(Self.uuid)\")")] - ), - PluginRowChange( - rowIndex: 1, type: .delete, cellChanges: [], - originalRow: [.text("UUID(\"\(Self.uuid)\")")] - ) - ] - - let results = try gen.generateRowWrites( - from: changes, insertedRowData: [:], deletedRowIndices: [0, 1], insertedRowIndices: [] - ) - - #expect(results.count == 1) - let stmt = results[0].statement - #expect(stmt.contains("deleteMany")) - #expect(stmt.contains("\"subType\": \"03\"")) - #expect(stmt.contains("\"subType\": \"04\"")) - } - - /// Matching on the remaining fields cannot express a binary value, so it would - /// delete the first partial match instead of the intended document. - @Test("A delete whose binary _id has no known subtype is refused rather than matched on other fields") - func deleteWithoutUsableIdIsRefused() throws { - let gen = MongoDBStatementGenerator(collectionName: "docs", columns: ["_id", "name"]) - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: [.bytes(Data([0x01, 0x02])), .text("Alice")] - ) - - #expect(throws: MongoDBWriteRefusal.identitySubtypeUnknown.refusal(ofRow: 0)) { - try gen.generateRowWrites(from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: []) - } - } - - // MARK: - Restore - - /// An ordinary insert drops `_id` so the server picks one. Undoing a delete has the opposite - /// requirement: a new `_id` is a different document, and whatever referenced the old one is - /// still pointing at nothing. - @Test("Restoring a deleted document keeps its original _id") - func restoreKeepsObjectId() throws { - let gen = MongoDBStatementGenerator(collectionName: "users", columns: ["_id", "name"]) - - let statements = gen.generateRestore(rows: [["507f1f77bcf86cd799439011", "Alice"]]) - - #expect(statements?.count == 1) - let statement = statements?.first?.statement ?? "" - #expect(statement.hasPrefix("db.users.insertOne(")) - let document = firstArgumentObject(in: statement) - #expect((document?["_id"] as? [String: Any])?["$oid"] as? String == "507f1f77bcf86cd799439011") - #expect(document?["name"] as? String == "Alice") - } - - @Test("A numeric key is restored as a number, not a string") - func restoreKeepsNumericId() throws { - let gen = MongoDBStatementGenerator(collectionName: "counters", columns: ["_id", "value"]) - - let statement = gen.generateRestore(rows: [["42", "7"]])?.first?.statement ?? "" - - let document = firstArgumentObject(in: statement) - #expect(document?["_id"] as? Int == 42) - } - - @Test("A restored document keeps its binary field with the subtype it was read with") - func restoreKeepsBinaryField() throws { - var subtypes = MongoDBBinarySubtypes.empty - subtypes.record(Data([0x01]), subtype: 5, field: "avatar") - let gen = MongoDBStatementGenerator( - collectionName: "users", columns: ["_id", "avatar"], binarySubtypes: subtypes - ) - - let statement = try #require(gen.generateRestore(rows: [["507f1f77bcf86cd799439011", .bytes(Data([0x01]))]])?.first) - - #expect(statement.statement.contains(#""avatar": {"$binary": {"base64": "AQ==", "subType": "05"}}"#)) - } - - /// Dropping the field would restore a document that is missing it, and report success. - @Test("A binary field whose subtype is unknown refuses the restore rather than dropping the field") - func restoreRefusesUnknownBinarySubtype() throws { - let gen = MongoDBStatementGenerator(collectionName: "users", columns: ["_id", "avatar"]) - - #expect(gen.generateRestore(rows: [["507f1f77bcf86cd799439011", .bytes(Data([0x01]))]]) == nil) - } - - @Test("A collection with no _id column cannot be restored") - func restoreRefusesWithoutIdColumn() throws { - let gen = MongoDBStatementGenerator(collectionName: "users", columns: ["name", "email"]) - - #expect(gen.generateRestore(rows: [["Alice", "alice@example.com"]]) == nil) - } -} - -private func firstArgumentObject(in statement: String) -> [String: Any]? { - guard let openParen = statement.firstIndex(of: "("), - let closeParen = statement.lastIndex(of: ")"), - openParen < closeParen else { return nil } - let json = String(statement[statement.index(after: openParen) ..< closeParen]) - guard let data = json.data(using: .utf8) else { return nil } - return try? JSONSerialization.jsonObject(with: data) as? [String: Any] -} diff --git a/TableProTests/Plugins/MongoDBStructureEditingParityTests.swift b/TableProTests/Plugins/MongoDBStructureEditingParityTests.swift deleted file mode 100644 index 83414ef1bc..0000000000 --- a/TableProTests/Plugins/MongoDBStructureEditingParityTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// MongoDBStructureEditingParityTests.swift -// TableProTests -// -// The app describes MongoDB from a curated copy of the plugin's statics until the registry plugin -// loads, and plugins never load under XCTest, so every Structure tab test reads the copy. The -// plugin reads its flags from `MongoDBStructureEditing`, which is compiled into this target, so the -// two are compared here instead of drifting until the plugin silently replaces one with the other. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct MongoDBStructureEditingParityTests { - private func curated() throws -> PluginMetadataSnapshot { - try #require(PluginMetadataRegistry.shared.builtInDefaults().first { $0.typeId == "MongoDB" }?.snapshot) - } - - @Test("Structure capabilities match the plugin") - func structureCapabilities() throws { - let snapshot = try curated() - - #expect(snapshot.supportsSchemaEditing == MongoDBStructureEditing.supportsSchemaEditing) - #expect(snapshot.capabilities.supportsAddColumn == MongoDBStructureEditing.supportsAddColumn) - #expect(snapshot.capabilities.supportsModifyColumn == MongoDBStructureEditing.supportsModifyColumn) - #expect(snapshot.capabilities.supportsDropColumn == MongoDBStructureEditing.supportsDropColumn) - #expect(snapshot.capabilities.supportsAddIndex == MongoDBStructureEditing.supportsAddIndex) - #expect(snapshot.capabilities.supportsDropIndex == MongoDBStructureEditing.supportsDropIndex) - } - - @Test("The curated copy says a collection's columns are a sample") - func columnsAreSampled() throws { - #expect(try curated().capabilities.columnsAreSampled) - } -} diff --git a/TableProTests/Plugins/MongoDBWriteBackBinaryTests.swift b/TableProTests/Plugins/MongoDBWriteBackBinaryTests.swift deleted file mode 100644 index 62362ec56a..0000000000 --- a/TableProTests/Plugins/MongoDBWriteBackBinaryTests.swift +++ /dev/null @@ -1,307 +0,0 @@ -// -// MongoDBWriteBackBinaryTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoDBWriteBackBinaryTests { - private static let objectId = "507f1f77bcf86cd799439011" - private static let png = Data([0x89, 0x50]) - private static let signature = Data([0x00, 0x01, 0x02, 0x03]) - - private func generator( - columns: [String] = ["_id", "name", "thumbnail"], - subtypes: MongoDBBinarySubtypes = .empty, - declaredBinary: Set = [], - identityKind: BsonValueKind? = nil - ) -> MongoDBStatementGenerator { - MongoDBStatementGenerator( - collectionName: "items", - columns: columns, - identityKind: identityKind, - binarySubtypes: subtypes, - declaredBinaryFields: declaredBinary - ) - } - - private func subtypes(_ entries: [(Data, UInt8, String)]) -> MongoDBBinarySubtypes { - var recorded = MongoDBBinarySubtypes.empty - for (data, subtype, field) in entries { - recorded.record(data, subtype: subtype, field: field) - } - return recorded - } - - /// A new row whose `typed` columns the user filled in and whose `missing` columns it lacks; - /// every other value was copied into it. - private func insert( - _ values: [PluginCellValue], - typed: Set = [], - missing absentColumns: Set = [], - columns: [String] = ["_id", "name", "thumbnail"], - with gen: MongoDBStatementGenerator - ) throws -> [PluginRowWrite] { - let filledIn = typed.sorted().map { index in - (columnIndex: index, columnName: columns[index], oldValue: PluginCellValue.null, newValue: values[index]) - } - var change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: filledIn, originalRow: nil) - change.absentColumns = absentColumns - return try gen.generateRowWrites( - from: [change], - insertedRowData: [0: values], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - - private func edit( - _ column: String, - from oldValue: PluginCellValue, - to newValue: PluginCellValue, - with gen: MongoDBStatementGenerator - ) throws -> [PluginRowWrite] { - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 2, columnName: column, oldValue: oldValue, newValue: newValue)], - originalRow: [.text(Self.objectId), .text("a"), oldValue] - ) - return try gen.generateRowWrites(from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: []) - } - - // MARK: - Inserts - - @Test("A duplicated row keeps its binary field, with the subtype the value was read with") - func duplicateKeepsGenericBinary() throws { - let gen = generator(subtypes: subtypes([(Self.png, 0, "thumbnail")])) - - let writes = try insert(["__DEFAULT__", "a", .bytes(Self.png)], with: gen) - - #expect(writes.count == 1) - #expect(writes[0].statement.contains(#""thumbnail": {"$binary": {"base64": "iVA=", "subType": "00"}}"#)) - #expect(writes[0].rowIndices == [0]) - } - - @Test("A subtype-5 value is written back as subtype 05") - func duplicateKeepsSubtypeFive() throws { - let gen = generator(columns: ["_id", "name", "sig"], subtypes: subtypes([(Self.signature, 5, "sig")])) - - let writes = try insert(["__DEFAULT__", "b", .bytes(Self.signature)], with: gen) - - #expect(writes[0].statement.contains(#""sig": {"$binary": {"base64": "AAECAw==", "subType": "05"}}"#)) - } - - @Test("Bytes seen with two subtypes in one field are refused, never inserted without them") - func ambiguousSubtypeIsRefused() { - let gen = generator(subtypes: subtypes([(Self.png, 0, "thumbnail"), (Self.png, 5, "thumbnail")])) - - #expect(throws: MongoDBWriteRefusal.binarySubtypeUnknown(field: "thumbnail").refusal(ofRow: 0)) { - try insert(["__DEFAULT__", "a", .bytes(Self.png)], with: gen) - } - } - - @Test("Bytes never read and in a field no validator declares binary are refused") - func unknownBytesAreRefused() { - #expect(throws: MongoDBWriteRefusal.binarySubtypeUnknown(field: "thumbnail").refusal(ofRow: 0)) { - try insert(["__DEFAULT__", "a", .bytes(Self.png)], with: generator()) - } - } - - @Test("Bytes typed into a new document, in a field the validator declares binary, are generic binary") - func declaredBinaryFieldTakesSubtypeZero() throws { - let gen = generator(declaredBinary: ["thumbnail"]) - - let writes = try insert(["__DEFAULT__", "a", .bytes(Self.png)], typed: [2], with: gen) - - #expect(writes[0].statement.contains(#""subType": "00""#)) - } - - @Test("Bytes copied into a new document, as a duplicate or a paste does, never take a default subtype") - func copiedBytesInADeclaredFieldAreRefused() { - let gen = generator(declaredBinary: ["thumbnail"]) - - #expect(throws: MongoDBWriteRefusal.binarySubtypeUnknown(field: "thumbnail").refusal(ofRow: 0)) { - try insert(["__DEFAULT__", "a", .bytes(Self.png)], typed: [1], with: gen) - } - } - - @Test("Bytes typed into a new document keep a subtype that was read for them") - func typedBytesKeepARecordedSubtype() throws { - let gen = generator(subtypes: subtypes([(Self.png, 5, "thumbnail")]), declaredBinary: ["thumbnail"]) - - let writes = try insert(["__DEFAULT__", "a", .bytes(Self.png)], typed: [2], with: gen) - - #expect(writes[0].statement.contains(#""subType": "05""#)) - } - - @Test("A row empty apart from a value that cannot be written is refused, not inserted as {}") - func refusedValueNeverBecomesEmptyDocument() { - #expect(throws: MongoDBWriteRefusal.binarySubtypeUnknown(field: "thumbnail").refusal(ofRow: 0)) { - try insert([nil, nil, .bytes(Self.png)], missing: [0, 1], with: generator()) - } - } - - @Test("An _id typed into a new row is kept, typed the way the filters type it") - func typedIdIsKept() throws { - let writes = try insert(["1001", "a", nil], missing: [2], with: generator(identityKind: .int32)) - - #expect(writes[0].statement == #"db.items.insertOne({"_id": 1001, "name": "a"})"#) - } - - // MARK: - Updates - - @Test("Editing a binary cell writes $set with the bytes, never $unset") - func editedBinaryIsSet() throws { - let gen = generator(subtypes: subtypes([(Self.png, 0, "thumbnail")])) - - let writes = try edit("thumbnail", from: .bytes(Self.png), to: .bytes(Data([0x01, 0x02])), with: gen) - - #expect(writes.count == 1) - let statement = writes[0].statement - #expect(statement.contains(#""$set": {"thumbnail": {"$binary": {"base64": "AQI=", "subType": "00"}}}"#)) - #expect(!statement.contains("$unset")) - } - - @Test("Edited bytes keep the subtype of the value they replace, not one recorded for other bytes") - func editKeepsTheReplacedValuesSubtype() throws { - let gen = generator(subtypes: subtypes([(Self.png, 5, "thumbnail"), (Data([0x01]), 0, "thumbnail")])) - - let writes = try edit("thumbnail", from: .bytes(Self.png), to: .bytes(Data([0x01])), with: gen) - - #expect(writes[0].statement.contains(#""subType": "05""#)) - } - - @Test("Emptying a binary cell writes empty bytes of the same subtype") - func emptyTextOverBinaryIsEmptyBinary() throws { - let gen = generator(subtypes: subtypes([(Self.png, 5, "thumbnail")])) - - let writes = try edit("thumbnail", from: .bytes(Self.png), to: .text(""), with: gen) - - #expect(writes[0].statement.contains(#""thumbnail": {"$binary": {"base64": "", "subType": "05"}}"#)) - } - - @Test("Text typed over a binary value is refused rather than stored as a string") - func textOverBinaryIsRefused() { - let gen = generator(subtypes: subtypes([(Self.png, 0, "thumbnail")])) - - #expect(throws: MongoDBWriteRefusal.binaryNeedsBytes(field: "thumbnail").refusal(ofRow: 0)) { - try edit("thumbnail", from: .bytes(Self.png), to: .text("hello"), with: gen) - } - } - - @Test("Bytes put back over a missing value keep the subtype they were read with") - func bytesOverNullKeepTheirRecordedSubtype() throws { - let gen = generator(subtypes: subtypes([(Self.signature, 5, "thumbnail")]), declaredBinary: ["thumbnail"]) - - let writes = try edit("thumbnail", from: .null, to: .bytes(Self.signature), with: gen) - - #expect(writes[0].statement.contains(#""thumbnail": {"$binary": {"base64": "AAECAw==", "subType": "05"}}"#)) - } - - /// Data Rewind undoes Set NULL on a binary field with this very edit, holding bytes alone, so a - /// generator that has not read them since a relaunch cannot tell them from bytes the user typed. - @Test("Bytes set over a missing value with no recorded subtype are refused, even in a declared binary field") - func bytesOverNullWithoutASubtypeAreRefused() { - let relaunched = generator(declaredBinary: ["thumbnail"]) - - #expect(throws: MongoDBWriteRefusal.binarySubtypeUnknown(field: "thumbnail").refusal(ofRow: 0)) { - try edit("thumbnail", from: .null, to: .bytes(Self.signature), with: relaunched) - } - } - - // MARK: - Restore - - @Test("A restore keeps the subtype its bytes were read with") - func restoreKeepsTheRecordedSubtype() throws { - let gen = generator(subtypes: subtypes([(Self.signature, 5, "thumbnail")]), declaredBinary: ["thumbnail"]) - - let restored = try #require(gen.generateRestore(rows: [[.text(Self.objectId), "a", .bytes(Self.signature)]])?.first) - - #expect(restored.statement.contains(#""thumbnail": {"$binary": {"base64": "AAECAw==", "subType": "05"}}"#)) - } - - /// After a relaunch the driver is new and its registry empty, and a Data Rewind record holds the - /// bytes without their subtype. Subtype 0 in a declared binary field used to fill the gap, which - /// stored a subtype-5 value as subtype 0 and reported the restore as done. - @Test("A restore by a new generator with an empty registry refuses bytes rather than assuming a subtype") - func restoreAfterARelaunchRefusesBytes() { - let relaunched = generator(declaredBinary: ["thumbnail"]) - - #expect(relaunched.generateRestore(rows: [[.text(Self.objectId), "a", .bytes(Self.signature)]]) == nil) - } - - @Test("DEFAULT is refused on an update, because MongoDB has no default values") - func defaultMarkerIsRefused() { - #expect(throws: MongoDBWriteRefusal.noDefaultValue(field: "thumbnail").refusal(ofRow: 0)) { - try edit("thumbnail", from: .text("x"), to: .text("__DEFAULT__"), with: generator()) - } - } - - // MARK: - Binary _id - - @Test("A document keyed by generic binary is updated and deleted through a $binary filter") - func binaryIdFiltersOnBinary() throws { - let key = Data([0xAB, 0xCD, 0xEF]) - let gen = generator(columns: ["_id", "name"], subtypes: subtypes([(key, 5, "_id")])) - let update = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "name", oldValue: .text("k"), newValue: .text("k2"))], - originalRow: [.bytes(key), .text("k")] - ) - let delete = PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: [.bytes(key), .text("k")]) - - let writes = try gen.generateRowWrites( - from: [update, delete], insertedRowData: [:], deletedRowIndices: [1], insertedRowIndices: [] - ) - - let filter = #"{"_id": {"$binary": {"base64": "q83v", "subType": "05"}}}"# - #expect(writes.map { $0.statement } == [ - "db.items.updateOne(\(filter), {\"$set\": {\"name\": \"k2\"}})", - "db.items.deleteOne(\(filter))" - ]) - #expect(writes.map { $0.rowIndices } == [[0], [1]]) - } - - /// The same bytes under another subtype are another `_id`, so a subtype borrowed from the other - /// rows could delete a different document. - @Test("A binary _id with no recorded subtype is refused, even when every sampled _id shares one") - func binaryIdWithoutARecordedSubtypeIsRefused() { - let gen = generator(columns: ["_id", "name"], identityKind: .binary(subtype: 3)) - let delete = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: [.bytes(Data([1])), "k"]) - - #expect(throws: MongoDBWriteRefusal.identitySubtypeUnknown.refusal(ofRow: 0)) { - try gen.generateRowWrites(from: [delete], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: []) - } - } - - // MARK: - Recording - - @Test("Every top-level binary value is recorded by field and value") - func recordingReadsTopLevelBinary() { - let recorded = MongoDBBinarySubtypes.recording([ - ["_id": MongoDBObjectId(hex: Self.objectId), "sig": MongoDBBinaryValue(data: Self.signature, subtype: 5)], - ["thumb": MongoDBBinaryValue(data: Self.png, subtype: 0), "nested": ["sig": MongoDBBinaryValue(data: Self.png, subtype: 9)]] - ]) - - #expect(recorded.subtypes(of: Self.signature, in: "sig") == [5]) - #expect(recorded.subtypes(of: Self.png, in: "thumb") == [0]) - #expect(recorded.subtypes(of: Self.png, in: "sig").isEmpty) - #expect(recorded.count == 2) - } - - @Test("Merging a later page keeps earlier values and keeps a value seen twice ambiguous") - func mergingAddsRatherThanReplaces() { - let first = subtypes([(Self.png, 0, "blob")]) - let second = subtypes([(Self.png, 5, "blob"), (Self.signature, 5, "blob")]) - - let merged = first.merging(second) - - #expect(merged.subtypes(of: Self.png, in: "blob") == [0, 5]) - #expect(merged.subtypes(of: Self.signature, in: "blob") == [5]) - #expect(merged.count == 3) - } -} diff --git a/TableProTests/Plugins/MongoDBWriteBackTypeTests.swift b/TableProTests/Plugins/MongoDBWriteBackTypeTests.swift deleted file mode 100644 index 44a6e8e30d..0000000000 --- a/TableProTests/Plugins/MongoDBWriteBackTypeTests.swift +++ /dev/null @@ -1,299 +0,0 @@ -// -// MongoDBWriteBackTypeTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoDBWriteBackTypeTests { - private func update( - column: String, - to newValue: PluginCellValue, - kinds: [String: BsonValueKind], - declared: [String: BsonValueKind] = [:] - ) throws -> String? { - let gen = MongoDBStatementGenerator( - collectionName: "products", - columns: ["_id", column], - columnKinds: kinds, - declaredKinds: declared - ) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: column, oldValue: .null, newValue: newValue)], - originalRow: [.text("507f1f77bcf86cd799439011"), .null] - ) - return try gen.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ).first?.statement - } - - @Test("A decimal column stays decimal instead of collapsing to a double") - func decimalColumnKeepsItsType() throws { - let stmt = try #require(try update( - column: "price", - to: .text("1847.270000000000000000000000001"), - kinds: ["price": .decimal128] - )) - #expect(stmt.contains(#"{"$numberDecimal": "1847.270000000000000000000000001"}"#)) - } - - @Test("A 64-bit integer column is not narrowed to int32") - func int64ColumnKeepsItsWidth() throws { - let stmt = try #require(try update(column: "qty", to: .text("5"), kinds: ["qty": .int64])) - #expect(stmt.contains(#"{"$numberLong": "5"}"#)) - } - - @Test("A double column stays a double even when the value looks whole") - func doubleColumnKeepsItsType() throws { - let stmt = try #require(try update(column: "rate", to: .text("3"), kinds: ["rate": .double])) - #expect(stmt.contains(#"{"$numberDouble": "3"}"#)) - } - - @Test("A column with no recorded type writes a bare number, as before") - func unknownColumnWritesBareNumber() throws { - let stmt = try #require(try update(column: "count", to: .text("7"), kinds: [:])) - #expect(stmt.contains(#""count": 7"#)) - } - - @Test("A truncated nested value is refused instead of stored as a fragment") - func truncatedValueIsNotWritten() { - let fragment = "{\"a\": 1, \"b\": \"" + String(repeating: "x", count: 50) + "..." - #expect(throws: MongoDBWriteRefusal.truncatedValue(field: "payload").refusal(ofRow: 0)) { - try update(column: "payload", to: .text(fragment), kinds: [:]) - } - } - - @Test("A complete nested value still writes") - func completeNestedValueWrites() throws { - let stmt = try #require(try update(column: "payload", to: .text(#"{"a":1}"#), kinds: [:])) - #expect(stmt.contains(#""payload": {"a":1}"#)) - } - - @Test("A decimal wider than a 64-bit integer still writes as a decimal") - func wideDecimalKeepsItsType() throws { - let digits = "12345678901234567890123456789012" - let stmt = try #require(try update(column: "price", to: .text(digits), kinds: ["price": .decimal128])) - #expect(stmt.contains("{\"$numberDecimal\": \"\(digits)\"}")) - } - - @Test("A half-typed brace is written, not mistaken for truncated text") - func userTypedBraceStillWrites() throws { - let stmt = try #require(try update(column: "note", to: .text("{draft"), kinds: [:])) - #expect(stmt.contains(#""note": "{draft""#)) - } - - // MARK: - Dates and ObjectIds - - @Test("A date column is written as a BSON date, not the text the grid shows") - func dateColumnWritesADate() throws { - let stmt = try #require(try update(column: "when", to: .text("2024-01-02T03:04:05Z"), kinds: ["when": .date])) - #expect(stmt.contains(#""when": {"$date": {"$numberLong": "1704164645000"}}"#)) - } - - @Test("A date the picker wrote is read in the local zone, which is the zone the picker shows") - func pickedDateIsLocalWallClock() throws { - let stmt = try #require(try update(column: "when", to: .text("2024-01-02 03:04:05"), kinds: ["when": .date])) - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = .current - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - let local = try #require(formatter.date(from: "2024-01-02 03:04:05")) - let millis = Int64((local.timeIntervalSince1970 * 1_000).rounded()) - #expect(stmt.contains("{\"$date\": {\"$numberLong\": \"\(millis)\"}}")) - } - - @Test("A date-only value is a date too") - func dateOnlyIsADate() throws { - let stmt = try #require(try update(column: "day", to: .text("2024-01-02"), kinds: ["day": .date])) - #expect(stmt.contains(#""day": {"$date": "#)) - } - - @Test("A bare number typed into a date column stays a number rather than becoming 1970") - func numberInADateColumnStaysANumber() throws { - let stmt = try #require(try update(column: "day", to: .text("2024"), kinds: ["day": .date])) - #expect(stmt.contains(#""day": 2024"#)) - } - - @Test("A field holding ObjectIds keeps holding ObjectIds") - func objectIdColumnWritesAnObjectId() throws { - let stmt = try #require(try update( - column: "authorId", to: .text("65a1b2c3d4e5f60718293a4b"), kinds: ["authorId": .objectId] - )) - #expect(stmt.contains(#""authorId": {"$oid": "65a1b2c3d4e5f60718293a4b"}"#)) - } - - // MARK: - Declared kinds - - @Test("A field the validator declares a string is written as one, whatever the text looks like") - func declaredStringIsAlwaysAString() throws { - for text in ["123", "true", "null", "[1, 2]"] { - let stmt = try #require(try update(column: "code", to: .text(text), kinds: [:], declared: ["code": .string])) - let escaped = text.replacingOccurrences(of: "\"", with: "\\\"") - #expect(stmt.contains("\"code\": \"\(escaped)\""), "\(text)") - } - } - - @Test("A sampled string column still reads typed JSON, because sampling is a majority vote") - func sampledStringKeepsItsSpelling() throws { - let stmt = try #require(try update(column: "meta", to: .text(#"{"a":1}"#), kinds: ["meta": .string])) - #expect(stmt.contains(#""meta": {"a":1}"#)) - } - - @Test("A declared type outranks what the sampled documents held") - func declaredKindWins() throws { - let stmt = try #require(try update( - column: "rate", to: .text("3"), kinds: ["rate": .int32], declared: ["rate": .double] - )) - #expect(stmt.contains(#"{"$numberDouble": "3"}"#)) - } - - @Test("An int field takes a bare number that fits in 32 bits") - func int32ColumnWritesABareNumber() throws { - let stmt = try #require(try update(column: "n", to: .text("42"), kinds: [:], declared: ["n": .int32])) - #expect(stmt.contains(#""n": 42"#)) - } - - // MARK: - Numbers past a double - - /// Measured with JavaScriptCore: `JSON.stringify({a: 9007199254740993})` gives - /// `9007199254740992`, so the bare literal reached the server one off. - @Test("An integer past 2^53 is written as a 64-bit integer so JavaScript cannot round it") - func hugeIntegerIsNotRounded() throws { - let stmt = try #require(try update(column: "big", to: .text("9007199254740993"), kinds: [:])) - #expect(stmt.contains(#""big": {"$numberLong": "9007199254740993"}"#)) - } - - @Test("An integer JavaScript holds exactly stays bare") - func exactIntegerStaysBare() throws { - let stmt = try #require(try update(column: "n", to: .text("9007199254740992"), kinds: [:])) - #expect(stmt.contains(#""n": 9007199254740992"#)) - } - - // MARK: - Statements are code - - /// The statement is JavaScript the shell evaluates, so text that only looks like an array - /// used to be pasted in as code. - @Test("Stored text shaped like an array but not JSON is written as a string, never run") - func arrayShapedCodeIsAString() throws { - let hostile = #"[db.getCollection("audit").drop()]"# - let stmt = try #require(try update(column: "note", to: .text(hostile), kinds: [:])) - #expect(stmt.contains("\"note\": \(MongoScriptJson.jsonString(hostile))")) - #expect(!stmt.contains(#""note": [db"#)) - } - - @Test("Stored text shaped like a document but not JSON is written as a string, never run") - func documentShapedCodeIsAString() throws { - let hostile = #"{a: db.dropDatabase()}"# - let stmt = try #require(try update(column: "note", to: .text(hostile), kinds: [:])) - #expect(stmt.contains("\"note\": \(MongoScriptJson.jsonString(hostile))")) - } - - /// U+0600 is a Unicode Prepend character, so it and the quote after it are one `Character`, which - /// matched no escape case and went into the statement raw, closing the string literal early. - @Test("A quote joined to a Prepend character is still escaped, so the value cannot close its string") - func quoteInsideAGraphemeClusterIsEscaped() throws { - let hostile = "x\u{0600}\"}}); db.getCollection(\"victim\").drop(); db.x.find({\"a\": {\"b\": \"" - let stmt = try #require(try update(column: "note", to: .text(hostile), kinds: [:], declared: ["note": .string])) - let start = try #require(stmt.range(of: "\"$set\": ")).upperBound - let setDocument = String(stmt[start...].dropLast(2)) - let data = try #require(setDocument.data(using: .utf8)) - let parsed = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - #expect(parsed["note"] as? String == hostile) - } - - /// Measured on macOS 27: `JSONSerialization` refuses each of these, so none reaches the statement - /// as source. This pins it, because a parser that stopped at the first complete value would let - /// the rest run. - @Test("JSON followed by more text is written as a string, never pasted as source") - func jsonPrefixWithTrailingCodeIsAString() throws { - let payloads = [ - "{}, injected: db.dropDatabase(), tail: {}", - #"{"a":1}, db.dropDatabase(), {}"#, - "[1], db.dropDatabase(), [2]" - ] - for hostile in payloads { - let stmt = try #require(try update(column: "note", to: .text(hostile), kinds: [:])) - #expect(stmt.contains("\"note\": \(MongoScriptJson.jsonString(hostile))"), "\(hostile)") - let gen = MongoDBStatementGenerator(collectionName: "notes", columns: ["_id", "note"]) - let restored = try #require(gen.generateRestore(rows: [["507f1f77bcf86cd799439011", .text(hostile)]])?.first) - #expect(restored.statement.contains("\"note\": \(MongoScriptJson.jsonString(hostile))"), "\(hostile)") - } - } - - @Test("Restoring a deleted row writes a hostile stored string back as a string") - func restoreDoesNotRunStoredText() throws { - let gen = MongoDBStatementGenerator(collectionName: "notes", columns: ["_id", "body"]) - let hostile = #"[db.getCollection("audit").drop()]"# - let statement = try #require(gen.generateRestore(rows: [["507f1f77bcf86cd799439011", .text(hostile)]])?.first) - #expect(statement.statement.contains("\"body\": \(MongoScriptJson.jsonString(hostile))")) - } - - // MARK: - _id - - private func updateFilter( - id: String, - idKind: BsonValueKind?, - sampledKinds: [String: BsonValueKind] = [:] - ) throws -> String? { - let gen = MongoDBStatementGenerator( - collectionName: "codes", - columns: ["_id", "label"], - columnKinds: sampledKinds, - identityKind: idKind - ) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "label", oldValue: .text("a"), newValue: .text("b"))], - originalRow: [.text(id), .text("a")] - ) - return try gen.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ).first?.statement - } - - @Test("A string _id that looks like a number is matched as a string") - func stringIdIsMatchedAsAString() throws { - let stmt = try #require(try updateFilter(id: "1001", idKind: .string)) - #expect(stmt.contains(#"{"_id": "1001"}"#)) - } - - @Test("A string _id that looks like an ObjectId is matched as a string") - func hexStringIdIsMatchedAsAString() throws { - let stmt = try #require(try updateFilter(id: "65a1b2c3d4e5f60718293a4b", idKind: .string)) - #expect(stmt.contains(#"{"_id": "65a1b2c3d4e5f60718293a4b"}"#)) - } - - @Test("A 64-bit _id is matched as one") - func longIdIsMatchedAsALong() throws { - let stmt = try #require(try updateFilter(id: "42", idKind: .int64)) - #expect(stmt.contains(#"{"_id": {"$numberLong": "42"}}"#)) - } - - @Test("A mostly-string _id column does not quote a row whose _id kinds differ") - func mixedIdKindsFallBackToTheSpelling() throws { - let stmt = try #require(try updateFilter( - id: "65a1b2c3d4e5f60718293a4b", idKind: nil, sampledKinds: ["_id": .string] - )) - #expect(stmt.contains(#"{"_id": {"$oid": "65a1b2c3d4e5f60718293a4b"}}"#)) - } - - /// JavaScriptCore evaluates a bare `010` in a statement as the octal number 8. - @Test("An int written with a leading zero is sent as its decimal value") - func int32IsCanonicalised() throws { - let stmt = try #require(try update(column: "n", to: .text("010"), kinds: [:], declared: ["n": .int32])) - #expect(stmt.contains(#""n": 10"#)) - let negative = try #require(try update(column: "n", to: .text("-010"), kinds: ["n": .int32])) - #expect(negative.contains(#""n": -10"#)) - } - - @Test("An _id of unknown kind is still read from its spelling") - func unknownIdFallsBackToItsSpelling() throws { - let stmt = try #require(try updateFilter(id: "65a1b2c3d4e5f60718293a4b", idKind: nil)) - #expect(stmt.contains(#"{"_id": {"$oid": "65a1b2c3d4e5f60718293a4b"}}"#)) - } -} diff --git a/TableProTests/Plugins/MongoDocumentGuardTests.swift b/TableProTests/Plugins/MongoDocumentGuardTests.swift deleted file mode 100644 index d4174afce9..0000000000 --- a/TableProTests/Plugins/MongoDocumentGuardTests.swift +++ /dev/null @@ -1,185 +0,0 @@ -// -// MongoDocumentGuardTests.swift -// TableProTests -// -// What the server answers for these filters is checked against a live server by -// scripts/probes/check-mongodb-document-guard.sh; these pin the text the driver builds. -// - -import Foundation -import Testing - -struct MongoDocumentGuardTests { - private func filter(_ canonical: String) throws -> String { - try MongoDocumentGuard.filter(for: MongoDocumentText(parsing: canonical)) - } - - /// The `$literal` the type signature is compared with, which is the last one in the filter. - private func expectedSignature(_ canonical: String) throws -> String { - let text = try filter(canonical) - let marker = #"{"$literal":"# - let start = try #require(text.range(of: marker, options: .backwards)).upperBound - return String(text[start...].dropLast(6)) - } - - @Test("A flat document's filter, in full") - func goldenFilter() throws { - let children = #"{"$cond":[{"$isArray":"$$ROOT"},"$$ROOT",{"$map":{"input":{"$objectToArray":"# - + #"{"$cond":[{"$eq":[{"$type":"$$ROOT"},"object"]},"$$ROOT",{}]}},"in":"$$this.v"}}]}"# - let leaf = #"{"$cond":[{"$or":[{"$eq":[{"$type":"$$this"},"decimal"]},{"$and":[{"$eq":[{"$type":"$$this"},"# - + #""double"]},{"$eq":["$$this",0]}]}]},[{"$type":"$$this"},{"$convert":{"input":"$$this","to":"string","# - + #""onError":"","onNull":""}}],{"$type":"$$this"}]}"# - let signature = #"{"$map":{"input":\#(children),"in":\#(leaf)}}"# - let document = #"{"_id":{"$numberInt":"1"},"n":{"$numberInt":"5"}}"# - let expected = #"{"_id":{"$numberInt":"1"},"$expr":{"$and":[{"$eq":["$$ROOT",{"$literal":\#(document)}]},"# - + #"{"$eq":[\#(signature),{"$literal":["int","int"]}]}]}}"# - #expect(try filter(#"{ "_id" : { "$numberInt" : "1" }, "n" : { "$numberInt" : "5" } }"#) == expected) - } - - @Test("The whole-document comparison comes first, so the signature is only asked about a document that matched") - func wholeDocumentFirst() throws { - let text = try filter(#"{"_id":{"$numberInt":"1"}}"#) - let root = try #require(text.range(of: #""$and":[{"$eq":["$$ROOT",{"$literal":"#)) - let map = try #require(text.range(of: #""$map""#)) - #expect(root.lowerBound < map.lowerBound) - } - - @Test("Every type marker is compared by its $type name") - func typeNames() throws { - let cases: [(String, String)] = [ - (#"{"$oid":"65f0a1b2c3d4e5f607182930"}"#, #""objectId""#), - (#"{"$numberInt":"5"}"#, #""int""#), - (#"{"$numberLong":"5"}"#, #""long""#), - (#"{"$numberDouble":"5.0"}"#, #""double""#), - (#"{"$date":{"$numberLong":"0"}}"#, #""date""#), - (#"{"$binary":{"base64":"AQID","subType":"00"}}"#, #""binData""#), - (#"{"$regularExpression":{"pattern":"a","options":""}}"#, #""regex""#), - (#"{"$timestamp":{"t":5,"i":1}}"#, #""timestamp""#), - (#"{"$minKey":1}"#, #""minKey""#), - (#"{"$maxKey":1}"#, #""maxKey""#), - (#"{"$symbol":"s"}"#, #""symbol""#), - (#"{"$undefined":true}"#, #""undefined""#), - (#"{"$dbPointer":{"$ref":"c","$id":{"$oid":"65f0a1b2c3d4e5f607182930"}}}"#, #""dbPointer""#), - (#"{"$code":"x"}"#, #""javascript""#), - (#""text""#, #""string""#), - ("true", #""bool""#), - ("null", #""null""#) - ] - for (value, type) in cases { - let signature = try expectedSignature(#"{"_id":{"$numberInt":"1"},"v":\#(value)}"#) - #expect(signature == #"["int",\#(type)]"#, "\(value)") - } - } - - @Test("A decimal carries its text and a zero double its sign, and nothing else does") - func textWhereEqualityIsLenient() throws { - let signature = try expectedSignature( - #"{"_id":{"$numberInt":"1"},"a":{"$numberDecimal":"1.00"},"b":{"$numberDouble":"-0.0"},"# - + #""c":{"$numberDouble":"0.0"},"d":{"$numberDouble":"2.5"},"e":{"$numberDecimal":"Infinity"}}"# - ) - #expect(signature == #"["int",["decimal","1.00"],["double","-0"],["double","0"],"double",["decimal","Infinity"]]"#) - } - - /// The server's `$eq` answers yes for two NaNs whatever their bits, and `$type` and `$convert` - /// cannot tell them apart either, so no filter can notice one NaN replaced by another. - @Test("A NaN anywhere in the document is refused, double or decimal, at any depth") - func notANumberIsRefused() { - let documents = [ - #"{"_id":{"$numberInt":"1"},"n":{"$numberDouble":"NaN"}}"#, - #"{"_id":{"$numberInt":"1"},"n":{"$numberDecimal":"NaN"}}"#, - #"{"_id":{"$numberDouble":"NaN"}}"#, - #"{"_id":{"$numberInt":"1"},"o":{"a":[{"$numberInt":"1"},{"$numberDouble":"NaN"}]}}"# - ] - for document in documents { - #expect(throws: MongoDocumentGuard.Refusal.notANumber, "\(document)") { - try filter(document) - } - } - } - - /// A scope is compared the way a query compares, so `1` and `NumberLong(1)` inside it are equal, - /// and no expression can open it to compare its types instead. - @Test("JavaScript code with a scope is refused anywhere, even with an empty scope") - func codeWithScopeIsRefused() { - let documents = [ - #"{"_id":{"$numberInt":"1"},"c":{"$code":"x","$scope":{"a":{"$numberInt":"1"}}}}"#, - #"{"_id":{"$numberInt":"1"},"c":{"$code":"x","$scope":{}}}"#, - #"{"_id":{"$numberInt":"1"},"a":[{"o":{"$code":"x","$scope":{}}}]}"# - ] - for document in documents { - #expect(throws: MongoDocumentGuard.Refusal.codeWithScope, "\(document)") { - try filter(document) - } - } - } - - @Test("Code without a scope, and a field named NaN, are guarded as usual") - func nearMissesAreGuarded() throws { - let signature = try expectedSignature( - #"{"_id":{"$numberInt":"1"},"c":{"$code":"x"},"NaN":"NaN","d":{"$numberDouble":"Infinity"}}"# - ) - #expect(signature == #"["int","javascript","string","double"]"#) - } - - @Test("Above the last level each child pairs its own name with its children's signature") - func nestedSignature() throws { - let signature = try expectedSignature( - #"{"_id":{"$numberInt":"1"},"o":{"x":{"$numberLong":"2"}},"a":[{"$numberInt":"3"},"s"]}"# - ) - #expect(signature == #"[["int",[]],["object",["long"]],["array",["int","string"]]]"#) - } - - @Test("A container is read by position and never through a field path, whatever its field names") - func namesStayInsideTheLiteral() throws { - let document = #"{"_id":{"$numberInt":"1"},"a.b":{"$numberInt":"1"},"":"e","x":{"$w":{"$numberInt":"1"}}}"# - let text = try filter(document) - let outsideLiteral = text.replacingOccurrences(of: document, with: "") - #expect(!outsideLiteral.contains(#""a.b""#)) - #expect(!outsideLiteral.contains(#""$w""#)) - #expect(!outsideLiteral.contains(#""$a.b""#)) - } - - @Test("Every operator that could raise on what the field holds now is guarded") - func totality() throws { - let text = try filter(#"{"_id":{"$numberInt":"1"},"o":{"a":[{"b":{"$numberDecimal":"1"}}]}}"#) - #expect(!text.contains("$toString")) - #expect(!text.contains("$arrayElemAt")) - let objectToArray = text.components(separatedBy: #"{"$objectToArray":"#).dropFirst() - #expect(!objectToArray.isEmpty) - #expect(objectToArray.allSatisfy { $0.hasPrefix(#"{"$cond":[{"$eq":[{"$type":"#) }) - let conversions = text.components(separatedBy: #"{"$convert":"#).dropFirst() - #expect(conversions.allSatisfy { $0.hasPrefix(#"{"input":"$$this","to":"string","onError":"","onNull":""}"#) }) - } - - @Test("A document as deep as libbson can read the filter of builds, and one level more is refused") - func depthLimit() throws { - func nested(_ depth: Int) -> String { - var value = #"{"$numberInt":"1"}"# - for _ in 0 ..< depth - 1 { - value = #"{"k":\#(value)}"# - } - return #"{"_id":{"$numberInt":"1"},"v":\#(value)}"# - } - let deepest = try MongoDocumentText(parsing: nested(MongoDocumentGuard.maximumDepth)) - #expect(try MongoDocumentGuard.depth(of: .object(deepest.members)) == MongoDocumentGuard.maximumDepth) - _ = try MongoDocumentGuard.filter(for: deepest) - #expect(throws: MongoDocumentGuard.Refusal.tooDeep) { - try filter(nested(MongoDocumentGuard.maximumDepth + 1)) - } - } - - @Test("A type marker is one value, not a level of nesting") - func markersAreScalars() throws { - let document = try MongoDocumentText( - parsing: #"{"_id":{"$oid":"65f0a1b2c3d4e5f607182930"},"d":{"$date":{"$numberLong":"0"}}}"# - ) - #expect(try MongoDocumentGuard.depth(of: .object(document.members)) == 1) - } - - @Test("A document with no _id has nothing to guard") - func missingIdentity() { - #expect(throws: MongoDBDocumentEditingError.missingIdentity) { - try filter(#"{"a":{"$numberInt":"1"}}"#) - } - } -} diff --git a/TableProTests/Plugins/MongoDocumentIdentityTests.swift b/TableProTests/Plugins/MongoDocumentIdentityTests.swift deleted file mode 100644 index d4c719b378..0000000000 --- a/TableProTests/Plugins/MongoDocumentIdentityTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// MongoDocumentIdentityTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoDocumentIdentityTests { - private let objectIdHex = "65f0a1b2c3d4e5f607182930" - - @Test("The locator is the compact canonical _id of libbson's spaced output") - func readsLibbsonShapedText() { - let cases: [(String, String)] = [ - (#"{ "$oid" : "65f0a1b2c3d4e5f607182930" }"#, #"{"$oid":"65f0a1b2c3d4e5f607182930"}"#), - (#""plain""#, #""plain""#), - (#"{ "$numberInt" : "1" }"#, #"{"$numberInt":"1"}"#), - (#"{ "$numberLong" : "1" }"#, #"{"$numberLong":"1"}"#), - (#"{ "$numberDouble" : "1.0" }"#, #"{"$numberDouble":"1.0"}"#), - (#"{ "$date" : { "$numberLong" : "1714557600000" } }"#, #"{"$date":{"$numberLong":"1714557600000"}}"#), - ("true", "true"), - (#"{ "b" : { "$numberInt" : "2" }, "a" : { "$numberInt" : "1" } }"#, #"{"b":{"$numberInt":"2"},"a":{"$numberInt":"1"}}"#), - (#"{ "$binary" : { "base64" : "AQID", "subType" : "00" } }"#, #"{"$binary":{"base64":"AQID","subType":"00"}}"#), - (#"{ "$binary" : { "base64" : "OyQRAeK7QlWMr0E2xWapYg==", "subType" : "04" } }"#, - #"{"$binary":{"base64":"OyQRAeK7QlWMr0E2xWapYg==","subType":"04"}}"#) - ] - for (stored, locator) in cases { - let document = #"{ "_id" : \#(stored), "n" : { "$numberInt" : "1" } }"# - #expect(MongoDocumentIdentity.locator(inDocument: document) == locator) - } - } - - @Test("An _id after other fields is found without being confused by braces inside strings") - func idNotFirst() { - let document = #"{ "a" : [ { "b" : "}]\"{" }, [ 1, 2 ] ], "c" : true, "_id" : "x" }"# - #expect(MongoDocumentIdentity.locator(inDocument: document) == #""x""#) - } - - @Test("A document with no _id has no locator") - func noIdentity() { - #expect(MongoDocumentIdentity.locator(inDocument: #"{ "a" : 1 }"#) == nil) - #expect(MongoDocumentIdentity.locator(inDocument: "{}") == nil) - #expect(MongoDocumentIdentity.locator(inDocument: "not json") == nil) - } - - @Test("An ObjectId and a string with the same hex are different documents") - func objectIdAndSameHexString() throws { - let objectId = try #require(MongoDocumentIdentity.locator( - inDocument: #"{ "_id" : { "$oid" : "\#(objectIdHex)" } }"# - )) - let string = try #require(MongoDocumentIdentity.locator(inDocument: #"{ "_id" : "\#(objectIdHex)" }"#)) - #expect(objectId != string) - let identity = try MongoDocumentIdentity(locator: objectId) - #expect(!identity.identifies(#"{ "_id" : "\#(objectIdHex)", "n" : 2 }"#)) - #expect(identity.identifies(#"{ "_id" : { "$oid" : "\#(objectIdHex)" }, "n" : 1 }"#)) - } - - @Test("A locator is read as one value, and anything more is refused") - func strictLocator() { - for text in [ - "1}); db.x.drop(); ({", - #"1, "a": 2"#, - #"1, "$where": "x""#, - #"{"$ne": null}"#, - #"{"$gt": ""}"#, - "", - #"1} {"_id": 2"# - ] { - #expect(throws: MongoDBDocumentEditingError.unknownDocument) { - try MongoDocumentIdentity(locator: text) - } - } - } - - @Test("A locator is kept compact, and its filter names only _id") - func compactFilter() throws { - let identity = try MongoDocumentIdentity(locator: #"{ "$oid" : "\#(objectIdHex)" }"#) - #expect(identity.locator == #"{"$oid":"\#(objectIdHex)"}"#) - #expect(identity.filter == #"{"_id":{"$oid":"\#(objectIdHex)"}}"#) - } - - @Test("A query's numeric and field-order leniency does not identify a document") - func noNumericLeniency() throws { - let int32 = try MongoDocumentIdentity(locator: #"{"$numberInt":"1"}"#) - #expect(!int32.identifies(#"{ "_id" : { "$numberDouble" : "1.0" } }"#)) - #expect(!int32.identifies(#"{ "_id" : { "$numberLong" : "1" } }"#)) - let document = try MongoDocumentIdentity(locator: #"{"a":{"$numberInt":"1"}}"#) - #expect(!document.identifies(#"{ "_id" : { "a" : { "$numberLong" : "1" } } }"#)) - let ordered = try MongoDocumentIdentity(locator: #"{"a":{"$numberInt":"1"},"b":{"$numberInt":"2"}}"#) - #expect(!ordered.identifies(#"{ "_id" : { "b" : { "$numberInt" : "2" }, "a" : { "$numberInt" : "1" } } }"#)) - } - - @Test("Two Unicode spellings of one string are two identities, as they are to the server") - func unicodeSpellings() throws { - let composed = try MongoDocumentIdentity(locator: "\"caf\u{E9}\"") - #expect(composed.identifies("{ \"_id\" : \"caf\u{E9}\" }")) - #expect(!composed.identifies("{ \"_id\" : \"cafe\u{301}\" }")) - } -} diff --git a/TableProTests/Plugins/MongoDocumentPresentationTests.swift b/TableProTests/Plugins/MongoDocumentPresentationTests.swift deleted file mode 100644 index 9b55688306..0000000000 --- a/TableProTests/Plugins/MongoDocumentPresentationTests.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// MongoDocumentPresentationTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoDocumentPresentationTests { - private func shown(_ canonicalValue: String) throws -> String { - let document = try MongoDocumentText(parsing: #"{"v":\#(canonicalValue)}"#) - let value = try #require(document.value(of: "v")) - return MongoDocumentPresentation.readable(value).compactText - } - - @Test("A 32-bit integer reads as a bare number") - func int32() throws { - #expect(try shown(#"{"$numberInt":"42"}"#) == "42") - } - - @Test("A 64-bit integer inside the 32-bit range keeps its wrapper, since bare it would read back as 32-bit") - func smallInt64() throws { - #expect(try shown(#"{"$numberLong":"5"}"#) == #"{"$numberLong":"5"}"#) - #expect(try shown(#"{"$numberLong":"2147483647"}"#) == #"{"$numberLong":"2147483647"}"#) - } - - @Test("A 64-bit integer outside the 32-bit range reads as a bare number") - func largeInt64() throws { - #expect(try shown(#"{"$numberLong":"9007199254740993"}"#) == "9007199254740993") - #expect(try shown(#"{"$numberLong":"-2147483649"}"#) == "-2147483649") - } - - @Test("A double reads in its shortest exact form and always shows it is a double") - func doubles() throws { - #expect(try shown(#"{"$numberDouble":"0.10000000000000000555"}"#) == "0.1") - #expect(try shown(#"{"$numberDouble":"3.0"}"#) == "3.0") - #expect(try shown(#"{"$numberDouble":"-0.0"}"#) == "-0.0") - #expect(try shown(#"{"$numberDouble":"1e+300"}"#) == "1e+300") - } - - @Test("A double JSON cannot spell keeps its wrapper") - func specialDoubles() throws { - #expect(try shown(#"{"$numberDouble":"Infinity"}"#) == #"{"$numberDouble":"Infinity"}"#) - #expect(try shown(#"{"$numberDouble":"NaN"}"#) == #"{"$numberDouble":"NaN"}"#) - } - - @Test("A date from 1970 through 9999 reads as ISO text, keeping its milliseconds") - func relaxedDates() throws { - #expect(try shown(#"{"$date":{"$numberLong":"1714557600123"}}"#) == #"{"$date":"2024-05-01T10:00:00.123Z"}"#) - #expect(try shown(#"{"$date":{"$numberLong":"0"}}"#) == #"{"$date":"1970-01-01T00:00:00Z"}"#) - #expect(try shown(#"{"$date":{"$numberLong":"253402300799999"}}"#) == #"{"$date":"9999-12-31T23:59:59.999Z"}"#) - } - - @Test("A date outside the range libbson reads back as ISO keeps its canonical form") - func canonicalDates() throws { - #expect(try shown(#"{"$date":{"$numberLong":"-1000"}}"#) == #"{"$date":{"$numberLong":"-1000"}}"#) - #expect(try shown(#"{"$date":{"$numberLong":"253402300800000"}}"#) == #"{"$date":{"$numberLong":"253402300800000"}}"#) - } - - @Test("Other wrappers are left exactly as they are") - func otherWrappers() throws { - for wrapper in [ - #"{"$oid":"507f1f77bcf86cd799439011"}"#, - #"{"$numberDecimal":"1.10"}"#, - #"{"$binary":{"base64":"AAAA","subType":"04"}}"#, - #"{"$timestamp":{"t":5,"i":1}}"# - ] { - #expect(try shown(wrapper) == wrapper) - } - } - - @Test("Numbers inside arrays and subdocuments are made readable too") - func nested() throws { - #expect(try shown(#"[{"$numberInt":"1"},{"a":{"$numberInt":"2"}}]"#) == #"[1,{"a":2}]"#) - } - - @Test("The editable text is indented, keeps field order and puts a wrapper on one line") - func editableText() throws { - let document = try MongoDocumentText( - parsing: #"{"_id":{"$oid":"507f1f77bcf86cd799439011"},"n":{"$numberInt":"1"},"tags":[],"o":{"k":"v"}}"# - ) - #expect(MongoDocumentPresentation.editableText(document) == """ - { - "_id": {"$oid":"507f1f77bcf86cd799439011"}, - "n": 1, - "tags": [], - "o": { - "k": "v" - } - } - """) - } - - @Test("The canonical fallback keeps every wrapper and the same layout") - func prettyCanonical() throws { - let document = try MongoDocumentText( - parsing: #"{"_id":{"$numberInt":"1"},"n":{"$numberLong":"5"},"o":{"k":{"$numberDouble":"0.1"}}}"# - ) - #expect(MongoDocumentPresentation.prettyCanonical(document) == """ - { - "_id": {"$numberInt":"1"}, - "n": {"$numberLong":"5"}, - "o": { - "k": {"$numberDouble":"0.1"} - } - } - """) - } - - @Test("The editable text reads back as the same document") - func editableTextRereads() throws { - let document = try MongoDocumentText(parsing: #"{"a":[1,{"b":[]}],"s":"x\ny"}"#) - let reread = try MongoDocumentText(parsing: MongoDocumentPresentation.editableText(document)) - #expect(reread == document) - } -} diff --git a/TableProTests/Plugins/MongoDocumentReplacementTests.swift b/TableProTests/Plugins/MongoDocumentReplacementTests.swift deleted file mode 100644 index 877bd2070b..0000000000 --- a/TableProTests/Plugins/MongoDocumentReplacementTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// MongoDocumentReplacementTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoDocumentReplacementTests { - private func replacement(original: String, edited: String) throws -> MongoDocumentReplacement { - try MongoDocumentReplacement( - original: MongoDocumentText(parsing: original), - edited: MongoDocumentText(parsing: edited) - ) - } - - private let original = #"{"_id":{"$numberInt":"1"},"a":{"$numberInt":"1"},"b":"x"}"# - - @Test("The stored _id goes first, then the fields in the order they were written") - func idFirstThenWrittenOrder() throws { - let result = try replacement( - original: original, - edited: #"{"z":{"$numberInt":"1"},"_id":{"$numberInt":"1"},"a":{"$numberInt":"2"}}"# - ) - #expect(result.document.compactText == #"{"_id":{"$numberInt":"1"},"z":{"$numberInt":"1"},"a":{"$numberInt":"2"}}"#) - #expect(result.changesDocument) - } - - @Test("An _id the text leaves out is put back rather than dropped") - func omittedIdIsRestored() throws { - let result = try replacement(original: original, edited: #"{"a":{"$numberInt":"1"},"b":"x"}"#) - #expect(result.document.compactText == original) - #expect(!result.changesDocument) - } - - @Test("A changed _id is refused, a reordered subdocument _id included") - func changedIdIsRefused() { - #expect(throws: MongoDBDocumentEditingError.identityChanged) { - try replacement(original: original, edited: #"{"_id":{"$numberInt":"2"}}"#) - } - #expect(throws: MongoDBDocumentEditingError.identityChanged) { - try replacement(original: original, edited: #"{"_id":{"$numberLong":"1"}}"#) - } - #expect(throws: MongoDBDocumentEditingError.identityChanged) { - try replacement( - original: #"{"_id":{"a":{"$numberInt":"1"},"b":{"$numberInt":"2"}}}"#, - edited: #"{"_id":{"b":{"$numberInt":"2"},"a":{"$numberInt":"1"}}}"# - ) - } - } - - @Test("An unchanged document changes nothing") - func unchanged() throws { - #expect(try !replacement(original: original, edited: original).changesDocument) - } - - @Test("Dotted, empty, integer-like and nested $ names are kept verbatim and in order") - func oddNames() throws { - let edited = #"{"_id":{"$numberInt":"1"},"2":"two","price.usd":{"$numberInt":"9"},"":"e","x":{"$w":{"$numberInt":"1"}}}"# - #expect(try replacement(original: original, edited: edited).document.compactText == edited) - } - - @Test("A top-level $ field is refused when the text is read") - func topLevelOperator() { - #expect(throws: MongoDBDocumentEditingError.operatorField("$p")) { - try replacement(original: original, edited: #"{"$p":{"$numberInt":"1"}}"#) - } - } - - @Test("A value respelled in another Unicode form is a change") - func unicodeRespelling() throws { - let composed = "{\"_id\":{\"$numberInt\":\"1\"},\"s\":\"caf\u{E9}\"}" - let decomposed = "{\"_id\":{\"$numberInt\":\"1\"},\"s\":\"cafe\u{301}\"}" - #expect(try replacement(original: composed, edited: decomposed).changesDocument) - } - - @Test("Timestamp(0, 0) in a top-level field is refused, and nested it is kept") - func emptyTimestamp() throws { - #expect(throws: MongoDBDocumentEditingError.emptyTimestamp("ts")) { - try replacement(original: original, edited: #"{"ts":{"$timestamp":{"t":0,"i":0}}}"#) - } - let nested = #"{"_id":{"$numberInt":"1"},"o":{"ts":{"$timestamp":{"t":0,"i":0}}}}"# - #expect(try replacement(original: original, edited: nested).document.compactText == nested) - let stamped = #"{"_id":{"$numberInt":"1"},"ts":{"$timestamp":{"t":5,"i":1}}}"# - #expect(try replacement(original: original, edited: stamped).changesDocument) - } - - @Test("A document with no _id cannot be replaced") - func missingIdentity() { - #expect(throws: MongoDBDocumentEditingError.missingIdentity) { - try replacement(original: #"{"a":{"$numberInt":"1"}}"#, edited: #"{"a":{"$numberInt":"2"}}"#) - } - } -} diff --git a/TableProTests/Plugins/MongoDocumentTextTests.swift b/TableProTests/Plugins/MongoDocumentTextTests.swift deleted file mode 100644 index b2c6505be6..0000000000 --- a/TableProTests/Plugins/MongoDocumentTextTests.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// MongoDocumentTextTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoDocumentTextTests { - @Test("A document keeps its fields in the order they were written") - func keepsOrder() throws { - let document = try MongoDocumentText(parsing: #"{"zeta": 1, "2": "two", "alpha": {"b": 1, "a": 2}}"#) - #expect(document.members.map(\.key) == ["zeta", "2", "alpha"]) - #expect(document.compactText == #"{"zeta":1,"2":"two","alpha":{"b":1,"a":2}}"#) - } - - @Test("Every JSON value kind reads back as the same text") - func valueKinds() throws { - let text = #"{"s":"a\"b\\c\né","n":-1.5e+10,"z":0,"t":true,"f":false,"x":null,"a":[1,[],{}],"o":{}}"# - let document = try MongoDocumentText(parsing: text) - let reread = try MongoDocumentText(parsing: document.compactText) - #expect(reread == document) - #expect(document.members.first { $0.key == "s" }?.value == .string("a\"b\\c\né")) - } - - @Test("A string escape for a character outside the BMP reads as one character") - func surrogatePair() throws { - let document = try MongoDocumentText(parsing: #"{"emoji": "😀"}"#) - #expect(document.members.first { $0.key == "emoji" }?.value == .string("😀")) - } - - @Test("Empty text is refused") - func empty() { - #expect(throws: MongoDocumentText.Refusal.empty) { try MongoDocumentText(parsing: " \n ") } - } - - @Test("An array or a scalar is not a document") - func notAnObject() { - #expect(throws: MongoDocumentText.Refusal.notAnObject) { try MongoDocumentText(parsing: "[{\"a\": 1}]") } - #expect(throws: MongoDocumentText.Refusal.notAnObject) { try MongoDocumentText(parsing: "42") } - } - - @Test("A second document after the first is refused rather than dropped") - func trailingDocument() { - #expect(throws: MongoDocumentText.Refusal.trailingContent) { - try MongoDocumentText(parsing: #"{"a": 1} {"b": 2}"#) - } - } - - @Test("A repeated field is refused at any depth") - func duplicateFields() { - #expect(throws: MongoDocumentText.Refusal.duplicateField("a")) { - try MongoDocumentText(parsing: #"{"a": 1, "a": 2}"#) - } - #expect(throws: MongoDocumentText.Refusal.duplicateField("k")) { - try MongoDocumentText(parsing: #"{"outer": [{"k": 1, "k": 2}]}"#) - } - } - - @Test("An empty, blank, dotted or $-prefixed name reads as written, since an insert stores each one") - func namesAreReadAsWritten() throws { - let text = #"{"":1," ":2,"a.b":3,"$set":{"x":4},"$oid":"507f1f77bcf86cd799439011","n":{"":5}}"# - let document = try MongoDocumentText(parsing: text) - #expect(document.members.map(\.key) == ["", " ", "a.b", "$set", "$oid", "n"]) - #expect(document.compactText == text) - - let wrapped = try MongoDocumentText(parsing: #"{"_id": {"$oid": "507f1f77bcf86cd799439011"}}"#) - #expect(wrapped.members.count == 1) - } - - @Test("A field named \"\" is held to the rule against repeated fields") - func duplicateEmptyName() { - #expect(throws: MongoDocumentText.Refusal.duplicateField("")) { - try MongoDocumentText(parsing: #"{"": 1, "": 2}"#) - } - } - - @Test("Nesting deeper than MongoDB allows is refused without exhausting the stack") - func depthLimit() throws { - let allowed = String(repeating: "{\"a\":", count: MongoDocumentText.maximumDepth - 1) + "1" - + String(repeating: "}", count: MongoDocumentText.maximumDepth - 1) - _ = try MongoDocumentText(parsing: allowed) - - let hostile = "{\"a\":" + String(repeating: "[", count: 100_000) + String(repeating: "]", count: 100_000) + "}" - #expect(throws: MongoDocumentText.Refusal.tooDeep) { try MongoDocumentText(parsing: hostile) } - } - - @Test("Text that is not JSON is refused with its line and column") - func malformed() { - #expect(throws: MongoDocumentText.Refusal.malformed(line: 2, column: 3)) { - try MongoDocumentText(parsing: "{\n name: \"x\"\n}") - } - #expect(throws: MongoDocumentText.Refusal.malformed(line: 1, column: 9)) { - try MongoDocumentText(parsing: #"{"a": 1,}"#) - } - } - - @Test("Syntax JSON does not have is refused", arguments: [ - #"{"a": 'single'}"#, - #"{"a": 01}"#, - #"{"a": 1.}"#, - #"{"a": NaN}"#, - #"{"a": ObjectId("507f1f77bcf86cd799439011")}"#, - "{\"a\": \"raw\u{0001}control\"}", - #"{"a": "\x41"}"#, - #"{"a": 1 // comment"# - ]) - func refusedSyntax(_ text: String) { - #expect(throws: MongoDocumentText.Refusal.self) { try MongoDocumentText(parsing: text) } - } - - @Test("Quoting escapes what a JSON string cannot hold raw") - func quoting() { - #expect(MongoDocumentText.quoted("a\"b\\c\n\u{0001}\u{2028}") == #""a\"b\\c\n\u0001\u2028""#) - } -} diff --git a/TableProTests/Plugins/MongoDocumentWritePlanTests.swift b/TableProTests/Plugins/MongoDocumentWritePlanTests.swift deleted file mode 100644 index 1e173756bb..0000000000 --- a/TableProTests/Plugins/MongoDocumentWritePlanTests.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// MongoDocumentWritePlanTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoDocumentWritePlanTests { - /// Stands in for libbson, which the test target cannot link: every input here is already - /// canonical, which is the one case where libbson hands the text back unchanged. - private let canonical: (String) throws -> String = { $0 } - - @Test("An insert sends the document and shows it as insertOne") - func insert() throws { - let plan = try MongoDocumentWritePlan.make( - collection: "events", - operation: .insert(document: "{\n \"name\": \"launch\"\n}"), - canonicalize: canonical - ) - #expect(plan?.write == .insert(document: #"{"name":"launch"}"#)) - #expect(plan?.statement == #"db.events.insertOne({"name":"launch"})"#) - } - - @Test("An empty document is a valid insert") - func emptyInsert() throws { - let plan = try MongoDocumentWritePlan.make( - collection: "events", operation: .insert(document: "{}"), canonicalize: canonical - ) - #expect(plan?.write == .insert(document: "{}")) - } - - @Test("Field order is kept, integer-like names included, which a JavaScript object would move") - func fieldOrder() throws { - let plan = try MongoDocumentWritePlan.make( - collection: "events", operation: .insert(document: #"{"b":1,"2":2,"a":3}"#), canonicalize: canonical - ) - #expect(plan?.write == .insert(document: #"{"b":1,"2":2,"a":3}"#)) - } - - @Test("Text libbson would misread is refused before it is asked") - func strictBeforeLibbson() { - var asked = false - let spy: (String) throws -> String = { text in - asked = true - return text - } - #expect(throws: MongoDocumentText.Refusal.self) { - try MongoDocumentWritePlan.make( - collection: "events", operation: .insert(document: #"{"a":1} {"b":2}"#), canonicalize: spy - ) - } - #expect(!asked) - } - - @Test("Every name the server stores on insert reaches libbson and is sent as written") - func namesTheServerStores() throws { - let text = #"{"":1,"a":{"":2},"$set":{"x":1},"a.b":3,"$ref":"c","$id":1}"# - var asked = false - let spy: (String) throws -> String = { text in - asked = true - return text - } - let plan = try MongoDocumentWritePlan.make( - collection: "events", operation: .insert(document: text), canonicalize: spy - ) - #expect(asked) - #expect(plan?.write == .insert(document: text)) - #expect(plan?.statement == "db.events.insertOne(\(text))") - } - - @Test("A wrapper libbson cannot read is refused with libbson's reason") - func libbsonRefusal() { - struct Unreadable: Error {} - #expect(throws: Unreadable.self) { - try MongoDocumentWritePlan.make( - collection: "events", - operation: .insert(document: #"{"_id":{"$oid":"nothex"}}"#), - canonicalize: { _ in throw Unreadable() } - ) - } - } - - @Test("A collection name is quoted into the accessor, never spliced as code") - func collectionNameIsQuoted() throws { - let plan = try MongoDocumentWritePlan.make( - collection: #"x").drop(); db.getCollection("y"#, - operation: .insert(document: "{}"), - canonicalize: canonical - ) - #expect(plan?.statement == #"db.getCollection("x\").drop(); db.getCollection(\"y").insertOne({})"#) - } - - private let stored = #"{"_id":{"$numberInt":"1"},"n":{"$numberInt":"5"}}"# - - @Test("An edit replaces the whole document under the guard, and says so with the collation it runs under") - func replaceStatement() throws { - let plan = try #require(try MongoDocumentWritePlan.make( - collection: "events", - operation: .replace(original: stored, edited: #"{"_id":{"$numberInt":"1"},"n":{"$numberInt":"6"}}"#), - canonicalize: canonical - )) - let filter = try MongoDocumentGuard.filter(for: MongoDocumentText(parsing: stored)) - let replacement = #"{"_id":{"$numberInt":"1"},"n":{"$numberInt":"6"}}"# - #expect(plan.write == .replace(filter: filter, replacement: replacement)) - #expect(plan.statement == #"db.events.replaceOne(\#(filter), \#(replacement), {"collation":{"locale":"simple"}})"#) - } - - @Test("An edit that changes nothing writes nothing") - func unchangedEdit() throws { - let plan = try MongoDocumentWritePlan.make( - collection: "events", - operation: .replace(original: stored, edited: "{\n \"n\": {\"$numberInt\": \"5\"}\n}"), - canonicalize: { try MongoDocumentText(parsing: $0).compactText } - ) - #expect(plan == nil) - } - - @Test("The guard is built from the document as it was opened, not as it was edited") - func guardFollowsTheOriginal() throws { - let plan = try #require(try MongoDocumentWritePlan.make( - collection: "events", - operation: .replace(original: stored, edited: #"{"n":{"$numberLong":"5"}}"#), - canonicalize: canonical - )) - guard case .replace(let filter, let replacement) = plan.write else { - Issue.record("Expected a replace") - return - } - #expect(filter.contains(#"{"$literal":\#(stored)}"#)) - #expect(replacement == #"{"_id":{"$numberInt":"1"},"n":{"$numberLong":"5"}}"#) - } - - @Test("Both texts are read strictly before libbson sees either") - func replaceReadsStrictly() { - #expect(throws: MongoDocumentText.Refusal.self) { - try MongoDocumentWritePlan.make( - collection: "events", - operation: .replace(original: stored, edited: #"{"n":1,"n":2}"#), - canonicalize: canonical - ) - } - } -} diff --git a/TableProTests/Plugins/MongoEditableDocumentTests.swift b/TableProTests/Plugins/MongoEditableDocumentTests.swift deleted file mode 100644 index e43b344447..0000000000 --- a/TableProTests/Plugins/MongoEditableDocumentTests.swift +++ /dev/null @@ -1,277 +0,0 @@ -// -// MongoEditableDocumentTests.swift -// TableProTests -// - -import Foundation -import Testing - -private struct FakeLibbson: MongoDocumentCodec { - var readsBackExactly = true - var filterSize: Int? = 100 - var documentSize = 100 - - func isSameDocument(_ text: String, asCanonical canonical: String) -> Bool { - readsBackExactly - } - - func bsonSize(of json: String) -> Int? { - json.contains(#""$expr""#) ? filterSize : documentSize - } -} - -/// The steps of one read in the order they ran, with a cancel that lands during one of them. -private final class ReadSteps { - var serverVersion: String? = "7.0.43" - var listCollectionsReply: () throws -> [String: Any]? = { nil } - var cancelDuring: String? - private(set) var ran: [String] = [] - private var isCancelled = false - - func read() throws -> [MongoStoredDocument] { - try MongoEditableDocument.readStored( - serverVersion: { - run("version") - return serverVersion - }, - listCollectionsReply: { - run("listCollections") - return try listCollectionsReply() - }, - storedDocuments: { - run("find") - return [] - }, - checkCancelled: { - if isCancelled { throw CancellationError() } - } - ) - } - - private func run(_ step: String) { - ran.append(step) - if step == cancelDuring { isCancelled = true } - } -} - -struct MongoEditableDocumentTests { - private let identity: MongoDocumentIdentity - private let stored = #"{ "_id" : { "$numberInt" : "1" }, "n" : { "$numberLong" : "5" }, "d" : { "$numberDouble" : "1.0" } }"# - - init() throws { - identity = try MongoDocumentIdentity(locator: #"{"$numberInt":"1"}"#) - } - - private func open( - _ documents: [MongoStoredDocument], - codec: MongoDocumentCodec = FakeLibbson() - ) throws -> String? { - try MongoEditableDocument.text(for: identity, among: documents, codec: codec) - } - - @Test("A document that no longer exists opens as nil, even when the query matched another one") - func gone() throws { - #expect(try open([]) == nil) - let lenientMatch = MongoStoredDocument(canonical: #"{ "_id" : { "$numberDouble" : "1.0" } }"#, isRepresentable: true) - #expect(try open([lenientMatch]) == nil) - } - - @Test("Two documents with the locator are refused rather than guessed between") - func ambiguous() { - let document = MongoStoredDocument(canonical: stored, isRepresentable: true) - #expect(throws: MongoDBDocumentEditingError.ambiguousIdentity) { - try open([document, document]) - } - } - - @Test("The readable form is shown when libbson reads it back as the same document") - func readable() throws { - let text = try open([MongoStoredDocument(canonical: stored, isRepresentable: true)]) - #expect(text == """ - { - "_id": 1, - "n": {"$numberLong":"5"}, - "d": 1.0 - } - """) - } - - @Test("The canonical form is shown when the readable one would not read back the same") - func canonicalFallback() throws { - let text = try open( - [MongoStoredDocument(canonical: stored, isRepresentable: true)], - codec: FakeLibbson(readsBackExactly: false) - ) - #expect(text?.contains(#""_id": {"$numberInt":"1"}"#) == true) - #expect(text?.contains(#""d": {"$numberDouble":"1.0"}"#) == true) - } - - @Test("A document libbson cannot write back exactly is refused when it opens") - func inexact() { - #expect(throws: MongoDBDocumentEditingError.inexactAsText) { - try open([MongoStoredDocument(canonical: stored, isRepresentable: false)]) - } - } - - @Test("A stored top-level $ field and a repeated field are refused when the document opens") - func unreadableAsText() { - #expect(throws: MongoDBDocumentEditingError.operatorField("$p")) { - try open([MongoStoredDocument(canonical: #"{ "_id" : { "$numberInt" : "1" }, "$p" : 1 }"#, isRepresentable: true)]) - } - #expect(throws: MongoDocumentText.Refusal.duplicateField("a")) { - try open([MongoStoredDocument( - canonical: #"{ "_id" : { "$numberInt" : "1" }, "a" : 1, "a" : 2 }"#, - isRepresentable: true - )]) - } - } - - @Test("Timestamp(0, 0) in a top-level field is refused when the document opens") - func emptyTimestamp() { - let document = #"{ "_id" : { "$numberInt" : "1" }, "ts" : { "$timestamp" : { "t" : 0, "i" : 0 } } }"# - #expect(throws: MongoDBDocumentEditingError.emptyTimestamp("ts")) { - try open([MongoStoredDocument(canonical: document, isRepresentable: true)]) - } - } - - @Test("A guard libbson cannot read is refused as too deep") - func unreadableGuard() { - #expect(throws: MongoDocumentGuard.Refusal.tooDeep) { - try open([MongoStoredDocument(canonical: stored, isRepresentable: true)], codec: FakeLibbson(filterSize: nil)) - } - } - - @Test("A document whose guarded replace would pass 16 MB is refused when it opens") - func tooLarge() throws { - let limit = MongoEditableDocument.commandSizeLimit - let document = MongoStoredDocument(canonical: stored, isRepresentable: true) - #expect(throws: MongoDBDocumentEditingError.tooLarge) { - try open([document], codec: FakeLibbson(filterSize: limit / 2 + 1, documentSize: limit / 2)) - } - #expect(try open([document], codec: FakeLibbson(filterSize: limit / 2, documentSize: limit / 2)) != nil) - } - - @Test("Only a plain collection is edited: a view, a time-series collection and an unknown kind are refused") - func namespaceKinds() { - func refusal(_ type: String?) -> MongoDBDocumentEditingError? { - var entry: [String: Any] = ["name": "c"] - entry["type"] = type - return MongoEditableDocument.namespaceRefusal(listCollectionsReply: ["cursor": ["firstBatch": [entry]]]) - } - #expect(refusal("collection") == nil) - #expect(refusal("view") == .view) - #expect(refusal("timeseries") == .timeSeries) - #expect(refusal("ledger") == .notACollection("ledger")) - #expect(refusal(nil) == nil) - #expect(MongoEditableDocument.namespaceRefusal(listCollectionsReply: [:]) == nil) - #expect(MongoEditableDocument.namespaceTypeCommand(for: #"a"b"#) == #"{"listCollections":1,"filter":{"name":"a\"b"}}"#) - } - - @Test("A NaN libbson cannot write back is refused for being a NaN") - func notANumberBeforeInexact() { - let document = #"{ "_id" : { "$numberInt" : "1" }, "n" : { "$numberDouble" : "NaN" } }"# - #expect(throws: MongoDocumentGuard.Refusal.notANumber) { - try open([MongoStoredDocument(canonical: document, isRepresentable: false)]) - } - #expect(throws: MongoDocumentGuard.Refusal.notANumber) { - try open([MongoStoredDocument(canonical: document, isRepresentable: true)]) - } - } - - @Test("The read asks for no limit, so documents the collation also finds cannot use it up") - func readOptions() { - #expect(MongoEditableDocument.readOptions(maxTimeMS: nil) == "{}") - #expect(MongoEditableDocument.readOptions(maxTimeMS: 30_000) == #"{"maxTimeMS":30000}"#) - } - - @Test("A read goes on past documents the collation found and keeps only exact matches") - func lenientMatchesAreReadPast() { - var matches = MongoExactMatches(identity: identity) - let lenient = [ - #"{ "_id" : { "$numberDouble" : "1.0" } }"#, - #"{ "_id" : { "$numberLong" : "1" } }"#, - #"{ "_id" : { "$numberDecimal" : "1.00" } }"# - ] - var asked = 0 - for canonical in lenient { - matches.consider(canonical) { - asked += 1 - return true - } - } - #expect(matches.documents.isEmpty) - #expect(!matches.isDecided) - #expect(asked == 0, "Only an exact match is read back through libbson") - - matches.consider(stored) { true } - #expect(matches.documents == [MongoStoredDocument(canonical: stored, isRepresentable: true)]) - #expect(!matches.isDecided) - } - - @Test("A second exact match decides the read, and what it gathered is refused as ambiguous") - func secondExactMatchDecides() { - var matches = MongoExactMatches(identity: identity) - matches.consider(#"{ "_id" : "1" }"#) { true } - matches.consider(stored) { true } - matches.consider(#"{ "_id" : { "$numberLong" : "1" } }"#) { true } - #expect(!matches.isDecided) - matches.consider(stored) { false } - #expect(matches.isDecided) - matches.consider(stored) { true } - #expect(matches.documents.count == 2) - #expect(throws: MongoDBDocumentEditingError.ambiguousIdentity) { - try open(matches.documents) - } - } - - @Test("A cancel that lands during one step of the read stops the next one") - func cancelStopsTheNextStep() throws { - let duringVersion = ReadSteps() - duringVersion.cancelDuring = "version" - #expect(throws: CancellationError.self) { try duringVersion.read() } - #expect(duringVersion.ran == ["version"]) - - let duringListing = ReadSteps() - duringListing.cancelDuring = "listCollections" - #expect(throws: CancellationError.self) { try duringListing.read() } - #expect(duringListing.ran == ["version", "listCollections"]) - - let uncancelled = ReadSteps() - _ = try uncancelled.read() - #expect(uncancelled.ran == ["version", "listCollections", "find"]) - } - - @Test("A collection the user cannot list is still read, but a cancel while listing it stops the read") - func listingFailure() throws { - let unauthorized = ReadSteps() - unauthorized.listCollectionsReply = { throw NSError(domain: "MongoDB", code: 13) } - _ = try unauthorized.read() - #expect(unauthorized.ran == ["version", "listCollections", "find"]) - - let cancelled = ReadSteps() - cancelled.listCollectionsReply = { throw CancellationError() } - #expect(throws: CancellationError.self) { try cancelled.read() } - #expect(cancelled.ran == ["version", "listCollections"]) - } - - @Test("An old server and a view are refused before the documents are read") - func refusedBeforeTheRead() { - let old = ReadSteps() - old.serverVersion = "3.6.23" - #expect(throws: MongoDBDocumentEditingError.serverTooOld) { try old.read() } - #expect(old.ran == ["version"]) - - let view = ReadSteps() - view.listCollectionsReply = { ["cursor": ["firstBatch": [["name": "v", "type": "view"]]]] } - #expect(throws: MongoDBDocumentEditingError.view) { try view.read() } - #expect(view.ran == ["version", "listCollections"]) - } - - @Test("Editing needs MongoDB 4.0, and a server whose version is unknown is let through") - func serverFloor() { - #expect(!MongoDBCapabilities.parse("3.6.23").supportsDocumentReplaceGuard) - #expect(MongoDBCapabilities.parse("4.0.0").supportsDocumentReplaceGuard) - #expect(MongoDBCapabilities.parse("7.0.43").supportsDocumentReplaceGuard) - #expect(MongoDBCapabilities.parse(nil).supportsDocumentReplaceGuard) - } -} diff --git a/TableProTests/Plugins/MongoFieldCensusTests.swift b/TableProTests/Plugins/MongoFieldCensusTests.swift deleted file mode 100644 index cc469078ec..0000000000 --- a/TableProTests/Plugins/MongoFieldCensusTests.swift +++ /dev/null @@ -1,168 +0,0 @@ -import Foundation -import TableProPluginKit -import Testing - -struct MongoFieldCensusTests { - private func findPlan(filter: String = "{}", options: MongoScriptCursorOptions = .none) -> MongoScriptCursorPlan { - MongoScriptCursorPlan( - database: "shop", collection: "orders", isFind: true, filter: filter, pipeline: "[]", options: options - ) - } - - private func aggregatePlan(_ pipeline: String, options: MongoScriptCursorOptions = .none) -> MongoScriptCursorPlan { - MongoScriptCursorPlan( - database: "shop", collection: "orders", isFind: false, filter: "{}", pipeline: pipeline, options: options - ) - } - - private func stages(of request: MongoFieldCensus.Request?) throws -> [[String: Any]] { - let pipeline = try #require(request?.pipeline) - let data = try #require(pipeline.data(using: .utf8)) - return try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]]) - } - - private func stageNames(of request: MongoFieldCensus.Request?) throws -> [String] { - try stages(of: request).compactMap { $0.keys.first } - } - - private let tallyStageNames = ["$project", "$unwind", "$group"] - - @Test("A find is tallied over the documents its filter matches, up to the stream's ceiling") - func findTalliesItsFilter() throws { - let request = MongoFieldCensus.request( - for: findPlan(filter: "{\"status\": \"open\"}"), limit: 5_000_000, timeoutMS: 0 - ) - #expect(try stageNames(of: request) == ["$match", "$limit"] + tallyStageNames) - let all = try stages(of: request) - let match = try #require(all.first?["$match"] as? [String: Any]) - #expect(match["status"] as? String == "open") - #expect(all[1]["$limit"] as? Int == 5_000_000) - } - - @Test("A paged find keeps its sort, skip and limit so the tally covers the same documents") - func pagedFindKeepsItsPaging() throws { - var options = MongoScriptCursorOptions.none - options.sort = "{\"createdAt\": -1}" - options.skip = 20 - options.limit = 50 - let request = MongoFieldCensus.request(for: findPlan(options: options), limit: 5_000_000, timeoutMS: 0) - - #expect(try stageNames(of: request) == ["$match", "$sort", "$skip", "$limit"] + tallyStageNames) - let all = try stages(of: request) - #expect(all[2]["$skip"] as? Int == 20) - #expect(all[3]["$limit"] as? Int == 50) - } - - @Test("A sort without paging is left out, since it cannot change which fields exist") - func unpagedSortIsDropped() throws { - var options = MongoScriptCursorOptions.none - options.sort = "{\"createdAt\": -1}" - let request = MongoFieldCensus.request(for: findPlan(options: options), limit: 5_000_000, timeoutMS: 0) - #expect(try stageNames(of: request) == ["$match", "$limit"] + tallyStageNames) - } - - @Test("A find limit above the ceiling is held to the ceiling, as the stream's own find is") - func findLimitIsHeldToTheCeiling() throws { - var options = MongoScriptCursorOptions.none - options.limit = 9_000_000 - let request = MongoFieldCensus.request(for: findPlan(options: options), limit: 5_000_000, timeoutMS: 0) - #expect(try stageNames(of: request) == ["$match", "$limit"] + tallyStageNames) - #expect(try stages(of: request)[1]["$limit"] as? Int == 5_000_000) - } - - @Test("A find projection is applied before the tally, and an empty one is ignored") - func findProjectionShapesTheTally() throws { - var options = MongoScriptCursorOptions.none - options.projection = "{\"name\": 1}" - let projected = MongoFieldCensus.request(for: findPlan(options: options), limit: 5_000_000, timeoutMS: 0) - #expect(try stageNames(of: projected) == ["$match", "$limit", "$project"] + tallyStageNames) - let userProjection = try #require(try stages(of: projected)[2]["$project"] as? [String: Any]) - #expect(userProjection["name"] as? Int == 1) - - options.projection = "{ }" - let empty = MongoFieldCensus.request(for: findPlan(options: options), limit: 5_000_000, timeoutMS: 0) - #expect(try stageNames(of: empty) == ["$match", "$limit"] + tallyStageNames) - } - - @Test("An aggregation is tallied over its own output, up to the stream's ceiling") - func aggregateTalliesItsOutput() throws { - let request = MongoFieldCensus.request( - for: aggregatePlan("[{\"$match\": {}}, {\"$addFields\": {\"total\": 1}}]"), - limit: 5_000_000, timeoutMS: 0 - ) - #expect(try stageNames(of: request) == ["$match", "$addFields", "$limit"] + tallyStageNames) - #expect(try stages(of: request)[2]["$limit"] as? Int == 5_000_000) - } - - @Test("An empty pipeline is tallied over the collection, up to the stream's ceiling") - func emptyPipelineTalliesTheCollection() throws { - let request = MongoFieldCensus.request(for: aggregatePlan("[]"), limit: 5_000_000, timeoutMS: 0) - #expect(try stageNames(of: request) == ["$limit"] + tallyStageNames) - } - - @Test("A pipeline that writes is never run a second time for a tally", arguments: [ - "[{\"$match\": {}}, {\"$out\": \"archive\"}]", - "[{\"$merge\": {\"into\": \"archive\"}}]" - ]) - func writingPipelineHasNoTally(pipeline: String) { - #expect(MongoFieldCensus.request(for: aggregatePlan(pipeline), limit: 5_000_000, timeoutMS: 0) == nil) - } - - @Test("The tally runs with the statement's hint, collation and time limit") - func tallyKeepsTheStatementOptions() throws { - var options = MongoScriptCursorOptions.none - options.hint = "{\"status\": 1}" - options.collation = "{\"locale\": \"fr\"}" - let request = MongoFieldCensus.request(for: findPlan(options: options), limit: 5_000_000, timeoutMS: 3_000) - let optionsJson = try #require(request?.optionsJson) - let data = try #require(optionsJson.data(using: .utf8)) - let parsed = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - #expect((parsed["hint"] as? [String: Any])?["status"] as? Int == 1) - #expect((parsed["collation"] as? [String: Any])?["locale"] as? String == "fr") - #expect(parsed["maxTimeMS"] as? Int == 3_000) - } - - @Test("Field names are grouped as object keys, which the server compares byte for byte under any collation") - func fieldNamesAreGroupedAsObjectKeys() throws { - let request = MongoFieldCensus.request(for: findPlan(), limit: 5_000_000, timeoutMS: 0) - let group = try #require(try stages(of: request).last?["$group"] as? [String: Any]) - let key = try #require(group["_id"] as? [String: Any]) - #expect(Array(key.keys) == ["$arrayToObject"]) - let argument = try #require(key["$arrayToObject"] as? [Any]) - let pairs = try #require(argument.first as? [Any]) - let pair = try #require(pairs.first as? [Any]) - #expect(pair.first as? String == "$pair.k") - #expect((pair.last as? [String: Any])?["$type"] as? String == "$pair.v") - #expect((group["name"] as? [String: Any])?["$first"] as? String == "$pair.k") - } - - @Test("Group replies become tallies, and one without a field name is skipped") - func groupRepliesBecomeTallies() { - let seen = Date(timeIntervalSince1970: 1_700_000_000) - let census = MongoFieldCensus(groups: [ - ["_id": ["late": "date"], "name": "late", "documents": Int32(3), "example": seen], - ["_id": ["big": "long"], "name": "big", "documents": Int64(4), "example": Int64(1)], - ["_id": ["lost": "int"], "documents": Int32(1)] - ]) - #expect(census.tallies.map(\.field) == ["late", "big"]) - #expect(census.tallies.map(\.documents) == [3, 4]) - #expect(census.tallies.first?.example as? Date == seen) - } - - @Test("A group whose example is missing counts as null") - func missingExampleIsNull() { - let census = MongoFieldCensus(groups: [["_id": ["gone": "null"], "name": "gone", "documents": Int32(2)]]) - #expect(census.fields == ["gone"]) - #expect(census.kinds(representation: .unspecified).isEmpty) - } - - @Test("Every tallied field is listed once, in name order") - func fieldsAreListedOnceInNameOrder() { - let census = MongoFieldCensus(tallies: [ - MongoFieldCensus.Tally(field: "zeta", documents: 1, example: "z"), - MongoFieldCensus.Tally(field: "alpha", documents: 2, example: Int32(1)), - MongoFieldCensus.Tally(field: "zeta", documents: 3, example: NSNull()) - ]) - #expect(census.fields == ["alpha", "zeta"]) - } -} diff --git a/TableProTests/Plugins/MongoFieldChangeAssessmentTests.swift b/TableProTests/Plugins/MongoFieldChangeAssessmentTests.swift deleted file mode 100644 index 755e82cd60..0000000000 --- a/TableProTests/Plugins/MongoFieldChangeAssessmentTests.swift +++ /dev/null @@ -1,403 +0,0 @@ -// -// MongoFieldChangeAssessmentTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -/// Every `listCollections` and `listIndexes` fixture is the canonical Extended JSON MongoDB 7.0.43 -/// returned, byte for byte. `made` is a collection New Table created. -struct MongoFieldChangeAssessmentTests { - private enum Fixture { - static let made = #"{ "name" : "made", "type" : "collection", "# - + #""options" : { "validator" : { "$jsonSchema" : { "bsonType" : "object", "required" : [ "title" ], "# - + #""properties" : { "_id" : { "bsonType" : "objectId" }, "title" : { "bsonType" : "string" }, "# - + #""qty" : { "bsonType" : [ "int", "null" ] }, "note" : { "bsonType" : [ "string", "null" ] } } } } }, "# - + #""info" : { "readOnly" : false, "uuid" : { "$binary" : { "base64" : "uO9RAKO/Q++gkdBY5B0Y3w==", "# - + #""subType" : "04" } } }, "idIndex" : { "v" : { "$numberInt" : "2" }, "# - + #""key" : { "_id" : { "$numberInt" : "1" } }, "name" : "_id_" } }"# - static let plain = #"{ "name" : "fx", "type" : "collection", "options" : { }, "info" : { "readOnly" : false, "# - + #""uuid" : { "$binary" : { "base64" : "NmEwJ/xDRUGIfXfL+Gqw/A==", "subType" : "04" } } }, "# - + #""idIndex" : { "v" : { "$numberInt" : "2" }, "key" : { "_id" : { "$numberInt" : "1" } }, "# - + #""name" : "_id_" } }"# - static let moderateWarn = #"{ "name" : "fxv", "type" : "collection", "# - + #""options" : { "validator" : { "$jsonSchema" : { "bsonType" : "object", "required" : [ "name" ], "# - + #""properties" : { "name" : { "bsonType" : "string" }, "status" : { "enum" : [ "active", "gone" ] }, "# - + #""active" : { "bsonType" : "bool" } } } }, "validationLevel" : "moderate", "# - + #""validationAction" : "warn" }, "info" : { "readOnly" : false, "# - + #""uuid" : { "$binary" : { "base64" : "k3km2BjsQWqHkYR1Le6t9g==", "subType" : "04" } } }, "# - + #""idIndex" : { "v" : { "$numberInt" : "2" }, "key" : { "_id" : { "$numberInt" : "1" } }, "# - + #""name" : "_id_" } }"# - static let timeseries = #"{ "name" : "fxts", "type" : "timeseries", "options" : { "timeseries" : { "timeField" : "t", "# - + #""metaField" : "meta", "granularity" : "seconds", "# - + #""bucketMaxSpanSeconds" : { "$numberInt" : "3600" } } }, "info" : { "readOnly" : false } }"# - static let view = #"{ "name" : "activeOrders", "type" : "view", "options" : { "viewOn" : "fxo", "# - + #""pipeline" : [ { "$match" : { "status" : "active" } }, "# - + #"{ "$project" : { "total" : { "$numberInt" : "1" } } } ] }, "info" : { "readOnly" : true } }"# - static let byTotal = #"{ "name" : "byTotal", "type" : "view", "options" : { "viewOn" : "activeOrders", "# - + #""pipeline" : [ { "$sort" : { "total" : { "$numberInt" : "-1" } } } ] }, "# - + #""info" : { "readOnly" : true } }"# - static let joined = #"{ "name" : "joined", "type" : "view", "options" : { "viewOn" : "products", "# - + #""pipeline" : [ { "$lookup" : { "from" : "fxo", "localField" : "sku", "foreignField" : "sku", "# - + #""as" : "o" } } ] }, "info" : { "readOnly" : true } }"# - static let emailIndex = #"{ "v" : { "$numberInt" : "2" }, "key" : { "email" : { "$numberInt" : "1" } }, "name" : "email_1", "unique" : true }"# - static let encrypted = #"{ "name" : "sec", "type" : "collection", "# - + #""options" : { "encryptedFields" : { "fields" : [ { "path" : "ssn", "bsonType" : "string" } ] } } }"# - static let capped = #"{ "name" : "fxcap", "type" : "collection", "# - + #""options" : { "capped" : true, "size" : { "$numberInt" : "4096" } }, "info" : { "readOnly" : false, "# - + #""uuid" : { "$binary" : { "base64" : "atlITu49RhecdL/EpQ54nw==", "subType" : "04" } } }, "# - + #""idIndex" : { "v" : { "$numberInt" : "2" }, "key" : { "_id" : { "$numberInt" : "1" } }, "# - + #""name" : "_id_" } }"# - static let madeWithExtra = made.replacingOccurrences( - of: #""note" : "#, - with: #""extra" : { "bsonType" : "string" }, "note" : "# - ) - } - - private func info(_ json: String?, _ name: String = "c") -> MongoCollectionInfo { - MongoCollectionInfo(collection: name, infoJson: json) - } - - private func assess( - _ changes: [MongoFieldChange], - info: MongoCollectionInfo, - indexes: [String] = [], - searchIndexes: [String] = [], - views: [String] = [] - ) -> MongoFieldChangeAssessment { - MongoFieldChangeAssessment.assess( - changes, - info: info, - indexes: indexes.compactMap(MongoIndexSpec.init(json:)), - searchIndexes: searchIndexes.compactMap(MongoSearchIndex.init(json:)), - views: views.compactMap(MongoViewDefinition.init(json:)) - ) - } - - // MARK: - Catalog - - @Test("A collection's kind, validator, level and action are read from listCollections") - func readsCollectionInfo() { - let validated = info(Fixture.moderateWarn, "fxv") - #expect(validated.kind == .collection) - #expect(validated.validationLevel == "moderate") - #expect(validated.validationAction == "warn") - #expect(validated.validatorJson?.hasPrefix(#"{ "$jsonSchema" : { "bsonType" : "object""#) == true) - #expect(!validated.enforcesValidator) - - let plain = info(Fixture.plain, "fx") - #expect(plain.validatorJson == nil) - #expect(plain.validationLevel == "strict") - #expect(plain.validationAction == "error") - - #expect(info(Fixture.made, "made").enforcesValidator) - #expect(info(Fixture.timeseries, "fxts").kind == .timeseries) - #expect(info(Fixture.view, "activeOrders").kind == .view) - #expect(info(nil, "gone").kind == .missing) - #expect(info(Fixture.encrypted, "sec").encryptedFieldPaths == ["ssn"]) - #expect(info(Fixture.capped, "fxcap").isCapped) - #expect(!plain.isCapped) - #expect(!info(nil, "gone").isCapped) - } - - @Test("Views that read a collection are found through viewOn chains and joins, in any listing order") - func viewDependents() throws { - let views = [Fixture.byTotal, Fixture.view, Fixture.joined].compactMap(MongoViewDefinition.init(json:)) - let dependents = MongoViewDefinition.dependents(of: "fxo", among: views).map(\.name) - #expect(Set(dependents) == ["activeOrders", "byTotal", "joined"]) - #expect(MongoViewDefinition.dependents(of: "unrelated", among: views).isEmpty) - } - - @Test("A union and a graph lookup make a view depend on the collection they name") - func unionAndGraphLookup() throws { - let union = MongoViewDefinition(name: "u", viewOn: "a", pipeline: [["$unionWith": "c"]]) - let unionSpec = MongoViewDefinition(name: "us", viewOn: "a", pipeline: [["$unionWith": ["coll": "c", "pipeline": []]]]) - let graph = MongoViewDefinition(name: "g", viewOn: "a", pipeline: [["$facet": ["x": [["$graphLookup": ["from": "c"]]]]]]) - let dependents = MongoViewDefinition.dependents(of: "c", among: [union, unionSpec, graph]).map(\.name) - #expect(Set(dependents) == ["u", "us", "g"]) - } - - // MARK: - Kinds - - @Test("Only a plain collection has fields to rename") - func kindsAreRefused() { - let change = [MongoFieldChange.rename(from: "v", to: "w")] - #expect(assess(change, info: info(Fixture.timeseries, "fxts")).refusal != nil) - #expect(assess(change, info: info(Fixture.view, "activeOrders")).refusal != nil) - #expect(assess(change, info: info(nil, "gone")).refusal == String(format: String(localized: "Collection %@ no longer exists."), "gone")) - #expect(assess(change, info: info(Fixture.plain, "system.buckets.ts")).refusal != nil) - #expect(assess(change, info: info(Fixture.plain, "fx")).refusal == nil) - } - - // MARK: - Capped collections - - @Test("A capped collection refuses a rename that makes the name longer in bytes, and nothing else") - func cappedCollectionsRefuseGrowth() throws { - let capped = info(Fixture.capped, "fxcap") - let grown = try #require(assess([.rename(from: "abc", to: "abcd")], info: capped).refusal) - #expect(grown == String( - format: String(localized: "%1$@ is a capped collection, and a longer field name makes MongoDB delete its oldest documents. Choose a name no longer than %2$@."), - "fxcap", "abc" - )) - #expect(assess([.rename(from: "ab", to: "\u{1EC5}")], info: capped).refusal != nil) - #expect(assess([.rename(from: "\u{1EC5}", to: "abc")], info: capped).refusal == nil) - #expect(assess([.rename(from: "abc", to: "xyz")], info: capped).refusal == nil) - #expect(assess([.rename(from: "longname", to: "ln")], info: capped).refusal == nil) - #expect(assess([.remove("abc")], info: capped).refusal == nil) - #expect(assess([.remove("r"), .rename(from: "a", to: "abc")], info: capped).refusal != nil) - #expect(assess([.rename(from: "abc", to: "abcd")], info: info(Fixture.plain, "fx")).refusal == nil) - } - - // MARK: - Indexes and encryption - - @Test("An index refusal names the index, the field and the statement that drops it, for either name") - func indexRefusal() throws { - let source = try #require(assess([.rename(from: "email", to: "mail")], info: info(Fixture.plain, "fx"), indexes: [Fixture.emailIndex]).refusal) - #expect(source.contains("email_1")) - #expect(source.contains(#"db.fx.dropIndex("email_1")"#)) - let target = try #require(assess([.rename(from: "x", to: "email")], info: info(Fixture.plain, "fx"), indexes: [Fixture.emailIndex]).refusal) - #expect(target.contains("email_1")) - #expect(assess([.remove("email")], info: info(Fixture.plain, "fx"), indexes: [Fixture.emailIndex]).refusal != nil) - #expect(assess([.rename(from: "x", to: "y")], info: info(Fixture.plain, "fx"), indexes: [Fixture.emailIndex]).refusal == nil) - } - - /// `listIndexes` never lists a search index, so a rename that read it alone left an Atlas Search - /// or Vector Search definition pointing at a path no document has. - @Test("A search index that mentions either name refuses the change and is named in the refusal") - func searchIndexRefusal() throws { - let searchIndexes = [MongoSearchIndexTests.Fixture.synonymMappings, MongoSearchIndexTests.Fixture.vectorSearch] - let source = try #require( - assess([.rename(from: "fullplot", to: "plot")], info: info(Fixture.plain, "movies"), searchIndexes: searchIndexes).refusal - ) - #expect(source.contains("synonym_mappings")) - #expect(source.contains("fullplot")) - let target = try #require( - assess([.rename(from: "tags", to: "genres")], info: info(Fixture.plain, "movies"), searchIndexes: searchIndexes).refusal - ) - #expect(target.contains("vector_index")) - #expect(assess([.remove("plot_embedding")], info: info(Fixture.plain, "movies"), searchIndexes: searchIndexes).refusal != nil) - #expect(assess([.rename(from: "title", to: "heading")], info: info(Fixture.plain, "movies"), searchIndexes: searchIndexes).refusal == nil) - #expect(assess([.rename(from: "fullplot", to: "plot")], info: info(Fixture.plain, "movies")).refusal == nil) - } - - @Test("An encrypted field is refused under either name") - func encryptedFields() { - #expect(assess([.rename(from: "ssn", to: "id2")], info: info(Fixture.encrypted, "sec")).refusal != nil) - #expect(assess([.rename(from: "a", to: "ssn")], info: info(Fixture.encrypted, "sec")).refusal != nil) - #expect(assess([.remove("a")], info: info(Fixture.encrypted, "sec")).refusal == nil) - } - - // MARK: - Views - - @Test("A view that reads the old name refuses, and one that reads only the new name does not") - func viewsReadTheOldNameOnly() throws { - let views = [Fixture.view, Fixture.byTotal, Fixture.joined] - let status = try #require(assess([.rename(from: "status", to: "state")], info: info(Fixture.plain, "fxo"), views: views).refusal) - #expect(status.contains("activeOrders")) - #expect(assess([.rename(from: "sku", to: "code")], info: info(Fixture.plain, "fxo"), views: views).refusal?.contains("joined") == true) - #expect(assess([.rename(from: "free", to: "status")], info: info(Fixture.plain, "fxo"), views: views).refusal == nil) - #expect(assess([.rename(from: "free", to: "gratis")], info: info(Fixture.plain, "fxo"), views: views).refusal == nil) - } - - // MARK: - Validator rewrite - - @Test("Renaming a field New Table declared renames it in properties and required, in place") - func renameRewritesNewTableValidator() throws { - let assessment = assess([.rename(from: "title", to: "name")], info: info(Fixture.made, "made")) - #expect(assessment.refusal == nil) - let rewritten = try #require(assessment.rewrittenValidatorJson) - #expect(rewritten == #"{"$jsonSchema": {"bsonType": "object", "required": ["name"], "# - + #""properties": {"_id": { "bsonType" : "objectId" }, "name": { "bsonType" : "string" }, "# - + #""qty": { "bsonType" : [ "int", "null" ] }, "note": { "bsonType" : [ "string", "null" ] }}}}"#) - #expect(assessment.effectiveValidatorJson == rewritten) - let schema = MongoDBCollectionSchema.parse(jsonSchema: try #require(MongoScriptJson.member(of: rewritten, key: "$jsonSchema"))) - #expect(schema.fields.map(\.name) == ["_id", "name", "qty", "note"]) - #expect(schema.field(named: "name")?.isRequired == true) - } - - @Test("Removing a declared field takes it out of properties, and an emptied required list goes too") - func removalRewritesNewTableValidator() throws { - let note = try #require(assess([.remove("note")], info: info(Fixture.made, "made")).rewrittenValidatorJson) - #expect(!note.contains("note")) - #expect(note.contains(#""required": [ "title" ]"#)) - let title = try #require(assess([.remove("title")], info: info(Fixture.made, "made")).rewrittenValidatorJson) - #expect(!title.contains("required")) - #expect(!title.contains("title")) - } - - @Test("The validator statement is a collMod that leaves level and action alone") - func validatorStatement() { - #expect(MongoFieldChangeAssessment.validatorStatement( - collection: "made", validatorJson: #"{"$jsonSchema": {}}"#, writeConcern: .serverDefault - ) == #"db.runCommand({"collMod": "made", "validator": {"$jsonSchema": {}}})"#) - } - - /// `db.runCommand` passes its document on as written, and `mongoc_client_command_simple` adds no - /// write concern, so a `majority` connection's `collMod` went out with the server's default. - @Test("The validator statement carries the connection's write concern") - func validatorStatementCarriesWriteConcern() { - let majority = MongoWriteConcern(acknowledgement: .majority, journal: nil, timeoutMS: 2_000) - #expect(MongoFieldChangeAssessment.validatorStatement( - collection: "made", validatorJson: #"{"$jsonSchema": {}}"#, writeConcern: majority - ) == #"db.runCommand({"collMod": "made", "validator": {"$jsonSchema": {}}, "writeConcern": {"w": "majority", "wtimeout": 2000}})"#) - let rename = [MongoFieldChange.rename(from: "title", to: "name")] - let composed = assess(rename, info: info(Fixture.made, "made")).leadingStatements(collection: "made", writeConcern: majority) - #expect(composed.count == 1) - #expect(composed.first?.hasSuffix(#""writeConcern": {"w": "majority", "wtimeout": 2000}})"#) == true) - } - - /// The composed `collMod` and the checks after writing both come from the entry the save was - /// composed from, so any change to it since, the validation level included, refuses the save. - @Test("A save composed from one catalog entry is refused before writing once the entry differs") - func catalogChangedSinceComposed() { - let changed = String( - format: String(localized: "%@ changed after this save was prepared, so nothing was changed. Review the save and save again."), - "made" - ) - func refusal(_ current: String?) -> String? { - MongoFieldChangeAssessment.changedSinceComposedRefusal(composedFrom: Fixture.made, current: current, collection: "made") - } - #expect(refusal(Fixture.made) == nil) - #expect(refusal(Fixture.madeWithExtra) == changed) - #expect(refusal(Fixture.plain) == changed) - #expect(refusal(nil) == changed) - let moderate = Fixture.made.replacingOccurrences( - of: #""options" : { "validator""#, with: #""options" : { "validationLevel" : "moderate", "validator""# - ) - #expect(refusal(moderate) == changed) - #expect(MongoFieldChangeAssessment.changedSinceComposedRefusal(composedFrom: nil, current: Fixture.made, collection: "made") == changed) - } - - private func catalogRead( - _ json: String?, - _ name: String, - changes: [MongoFieldChange], - indexes: [String] = [] - ) -> MongoCatalogRead { - MongoCatalogRead(infoJson: json, info: info(json, name), assessment: assess(changes, info: info(json, name), indexes: indexes)) - } - - /// The scans before writing can each take up to the query timeout, so the catalog is read a - /// last time once they end, and the save writes only if it still matches what they checked. - @Test("A catalog that changed while the documents were checked stops the save before its first write") - func catalogChangedDuringChecks() { - let rename = [MongoFieldChange.rename(from: "title", to: "name")] - let checked = catalogRead(Fixture.made, "made", changes: rename) - func refusal(_ current: MongoCatalogRead) -> String? { - MongoFieldChangeAssessment.changedDuringChecksRefusal(checked: checked, current: current, collection: "made") - } - - #expect(refusal(catalogRead(Fixture.made, "made", changes: rename)) == nil) - #expect(refusal(catalogRead(Fixture.madeWithExtra, "made", changes: rename)) == String( - format: String(localized: "%@ changed while its documents were being checked, so nothing was changed. Save again."), - "made" - )) - let moderate = Fixture.made.replacingOccurrences( - of: #""options" : { "validator""#, with: #""options" : { "validationLevel" : "moderate", "validator""# - ) - #expect(moderate != Fixture.made) - #expect(refusal(catalogRead(moderate, "made", changes: rename)) == String( - format: String(localized: "%@ changed while its documents were being checked, so nothing was changed. Save again."), - "made" - )) - let titleIndex = #"{ "v" : { "$numberInt" : "2" }, "key" : { "title" : { "$numberInt" : "1" } }, "name" : "title_1" }"# - #expect(refusal(catalogRead(Fixture.made, "made", changes: rename, indexes: [titleIndex]))?.contains("title_1") == true) - #expect(refusal(catalogRead(nil, "made", changes: rename)) == String(format: String(localized: "Collection %@ no longer exists."), "made")) - } - - /// Measured on 7.0.43 before this check: under `validationAction: "warn"` the rename of `status` - /// applied and left this validator matching a key no document has. - @Test("A validator that reads the whole document refuses a change to any field") - func wholeDocumentValidator() { - let validator = #"{ "$expr" : { "$in" : [ "status", { "$map" : { "input" : { "$objectToArray" : "$$ROOT" }, "# - + #""in" : "$$this.k" } } ] } }"# - let keyed = info(#"{ "name" : "k", "type" : "collection", "options" : { "validator" : "# + validator + " } }", "k") - #expect(assess([.rename(from: "status", to: "state")], info: keyed).refusal != nil) - #expect(assess([.rename(from: "qty", to: "quantity")], info: keyed).refusal != nil) - #expect(assess([.remove("note")], info: keyed).refusal != nil) - } - - @Test("A name patternProperties or additionalProperties applies to refuses, and a declared name does not") - func rulesByNameRefuse() throws { - let closed = info(#"{ "name" : "c", "type" : "collection", "options" : { "validator" : { "$jsonSchema" : { "# - + #""required" : [ "a" ], "properties" : { "_id" : { "bsonType" : "objectId" }, "a" : { "bsonType" : "int" } }, "# - + #""additionalProperties" : false } } } }"#, "c") - let carried = assess([.rename(from: "a", to: "b")], info: closed) - #expect(carried.refusal == nil) - #expect(try #require(carried.rewrittenValidatorJson).contains(#""b": { "bsonType" : "int" }"#)) - #expect(assess([.remove("a")], info: closed).refusal == nil) - #expect(assess([.rename(from: "x", to: "y")], info: closed).refusal != nil) - #expect(assess([.remove("x")], info: closed).refusal != nil) - - let patterned = info(#"{ "name" : "p", "type" : "collection", "options" : { "validator" : { "$jsonSchema" : { "# - + #""properties" : { "a" : { "bsonType" : "int" } }, "patternProperties" : { "^tmp_" : { "bsonType" : "string" } } } } } }"#, "p") - #expect(assess([.rename(from: "a", to: "tmp_a")], info: patterned).refusal != nil) - #expect(assess([.rename(from: "tmp_a", to: "b")], info: patterned).refusal != nil) - #expect(assess([.rename(from: "a", to: "b")], info: patterned).refusal == nil) - } - - @Test("A field the validator does not name needs no collMod") - func undeclaredFieldKeepsTheValidator() { - let assessment = assess([.rename(from: "extra", to: "more")], info: info(Fixture.made, "made")) - #expect(assessment.refusal == nil) - #expect(assessment.rewrittenValidatorJson == nil) - #expect(assessment.effectiveValidatorJson == info(Fixture.made, "made").validatorJson) - } - - @Test("A rename onto a name the validator already declares is refused") - func targetAlreadyDeclared() { - #expect(assess([.rename(from: "qty", to: "note")], info: info(Fixture.made, "made")).refusal != nil) - } - - @Test("Property dependencies are renamed, and an emptied dependency is dropped") - func dependenciesAreRewritten() throws { - let validator = #"{ "$jsonSchema" : { "dependencies" : { "card" : [ "billing" ], "billing" : [ "card", "zip" ] } } }"# - let json = #"{ "name" : "d", "type" : "collection", "options" : { "validator" : "# + validator + " } }" - let renamed = try #require(assess([.rename(from: "billing", to: "address")], info: info(json, "d")).rewrittenValidatorJson) - #expect(renamed == #"{"$jsonSchema": {"dependencies": {"card": ["address"], "address": [ "card", "zip" ]}}}"#) - let removed = try #require(assess([.remove("card")], info: info(json, "d")).rewrittenValidatorJson) - #expect(removed == #"{"$jsonSchema": {"dependencies": {"billing": ["zip"]}}}"#) - } - - @Test("A validator that names the field outside what can be rewritten refuses the save") - func unrewritableValidators() { - func collection(_ validator: String) -> MongoCollectionInfo { - info(#"{ "name" : "v", "type" : "collection", "options" : { "validator" : "# + validator + " } }", "v") - } - let query = collection(#"{ "status" : { "$in" : [ "active" ] } }"#) - #expect(assess([.rename(from: "status", to: "state")], info: query).refusal != nil) - #expect(assess([.rename(from: "active", to: "live")], info: query).refusal == nil) - let expression = collection(#"{ "$expr" : { "$gt" : [ "$qty", { "$numberInt" : "0" } ] } }"#) - #expect(assess([.rename(from: "qty", to: "quantity")], info: expression).refusal != nil) - let pattern = collection(#"{ "$jsonSchema" : { "patternProperties" : { "^tmp_" : { "bsonType" : "int" } } } }"#) - #expect(assess([.remove("tmp_a")], info: pattern).refusal != nil) - #expect(assess([.remove("a")], info: pattern).refusal == nil) - let mixed = collection(#"{ "$jsonSchema" : { "required" : [ "a" ] }, "b" : { "$exists" : true } }"#) - #expect(assess([.rename(from: "a", to: "c")], info: mixed).refusal != nil) - } - - @Test("A rewrite that would carry a key the shell reorders or drops is refused") - func shellUnsafeKeys() { - let json = #"{ "name" : "k", "type" : "collection", "# - + #""options" : { "validator" : { "$jsonSchema" : { "required" : [ "a" ], "# - + #""properties" : { "a" : { "bsonType" : "int" }, "7" : { "bsonType" : "int" } } } } } }"# - #expect(assess([.rename(from: "a", to: "b")], info: info(json, "k")).refusal != nil) - #expect(assess([.rename(from: "x", to: "y")], info: info(json, "k")).refusal == nil) - } - - @Test("A dependent found after the save says the save ran and names what reads the field, without asking to save again") - func dependentAfterTheSave() throws { - let index = try #require(MongoIndexSpec(json: Fixture.emailIndex)) - let changes: [MongoFieldChange] = [.rename(from: "email", to: "mail")] - let found = try #require(MongoFieldDependent.dependent(of: changes, indexes: [index], searchIndexes: [], views: [], collection: "people")) - - #expect(found == .index(name: "email_1", field: "email")) - #expect(found.refusal(collection: "people").hasSuffix("then save again.")) - let after = found.appearedDuringSave(collection: "people") - #expect(after.hasPrefix("The save ran, but index email_1 on people was created while it did and uses email.")) - #expect(!after.contains("save again")) - #expect(MongoFieldDependent.dependent(of: [.rename(from: "name", to: "title")], indexes: [index], searchIndexes: [], views: [], collection: "people") == nil) - } -} diff --git a/TableProTests/Plugins/MongoFieldChangeTests.swift b/TableProTests/Plugins/MongoFieldChangeTests.swift deleted file mode 100644 index bab39ae8c4..0000000000 --- a/TableProTests/Plugins/MongoFieldChangeTests.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// MongoFieldChangeTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoFieldChangeTests { - private func column(_ name: String, _ type: String = "string", nullable: Bool = true) -> PluginColumnDefinition { - PluginColumnDefinition(name: name, dataType: type, isNullable: nullable) - } - - private func rename(_ from: String, _ to: String) -> PluginSchemaOperation { - .modifyColumn(old: column(from), new: column(to)) - } - - private func remove(_ name: String) -> PluginSchemaOperation { - .dropColumn(column(name)) - } - - @Test("A rename moves the field only where it exists and the new name does not") - func renameStatement() throws { - let change = try #require(MongoFieldChange(rename("status", "state"))) - #expect(change.statement(collection: "people", writeConcern: .serverDefault) == """ - db.people.updateMany({"status": {"$exists": true}, "state": {"$exists": false}}, {"$rename": {"status": "state"}}) - """) - } - - @Test("A removal unsets the field only where it exists") - func removalStatement() throws { - let change = try #require(MongoFieldChange(remove("tmp"))) - #expect(change.statement(collection: "people", writeConcern: .serverDefault) == """ - db.people.updateMany({"tmp": {"$exists": true}}, {"$unset": {"tmp": ""}}) - """) - } - - @Test("A collection a method would shadow is reached through getCollection") - func shadowedCollectionName() { - #expect(MongoFieldChange.remove("a").statement(collection: "stats", writeConcern: .serverDefault).hasPrefix("db.getCollection(\"stats\").updateMany(")) - #expect(MongoFieldChange.remove("a").statement(collection: "my orders", writeConcern: .serverDefault).hasPrefix("db.getCollection(\"my orders\")")) - } - - @Test("Quotes, backslashes, newlines and line separators in a name stay inside their string") - func namesAreEscaped() { - let statement = MongoFieldChange.rename(from: "q\"uote", to: "back\\slash\nline\u{2028}").statement(collection: "c", writeConcern: .serverDefault) - #expect(statement == #"db.c.updateMany({"q\"uote": {"$exists": true}, "back\\slash\nline\u2028": {"$exists": false}}, "# - + #"{"$rename": {"q\"uote": "back\\slash\nline\u2028"}})"#) - } - - /// `mongoc_client_command_simple` sends a command with no write concern of its own, so a - /// connection set to `majority` saved with the server's default until the statement named it. - @Test("The statement carries the connection's write concern, and none when the connection sets none") - func statementCarriesWriteConcern() { - let majority = MongoWriteConcern(acknowledgement: .majority, journal: true, timeoutMS: 5_000) - #expect(MongoFieldChange.rename(from: "a", to: "b").statement(collection: "people", writeConcern: majority) == """ - db.people.updateMany({"a": {"$exists": true}, "b": {"$exists": false}}, {"$rename": {"a": "b"}}, \ - {"writeConcern": {"w": "majority", "j": true, "wtimeout": 5000}}) - """) - let two = MongoWriteConcern(acknowledgement: .members(2), journal: nil, timeoutMS: nil) - #expect(MongoFieldChange.remove("tmp").statement(collection: "people", writeConcern: two) == """ - db.people.updateMany({"tmp": {"$exists": true}}, {"$unset": {"tmp": ""}}, {"writeConcern": {"w": 2}}) - """) - #expect(!MongoFieldChange.remove("tmp").statement(collection: "people", writeConcern: .serverDefault).contains("writeConcern")) - } - - /// Measured on 7.0.43: an update sent with `w: 0` is answered `n: 0` with no error whatever it - /// changed, so a save could not tell a finished rename from one that failed. - @Test("A write concern that asks for no answer is raised to one acknowledgement, and the rest is kept") - func unacknowledgedConcernIsRaised() { - #expect(MongoWriteConcern(acknowledgement: .members(0), journal: nil, timeoutMS: 100).schemaChangeJson - == #"{"w": 1, "wtimeout": 100}"#) - #expect(MongoWriteConcern(acknowledgement: .members(0), journal: true, timeoutMS: nil).schemaChangeJson - == #"{"w": 0, "j": true}"#) - #expect(MongoWriteConcern(acknowledgement: .tag("dc"), journal: false, timeoutMS: 0).schemaChangeJson - == #"{"w": "dc", "j": false}"#) - #expect(MongoWriteConcern(acknowledgement: nil, journal: true, timeoutMS: nil).schemaChangeJson == #"{"j": true}"#) - #expect(MongoWriteConcern.serverDefault.schemaChangeJson == nil) - } - - /// The session driver keeps a collection's inferred and declared field types by database and - /// collection, and a Structure save runs on another connection, so the save names the - /// collection and the driver drops it wherever it keeps it. - @Test("A collection's cache keys are found in every database, and no other collection's") - func collectionCacheKeys() { - let key = MongoCollectionCacheKey.key(database: "shop", collection: "orders") - #expect(MongoCollectionCacheKey.names(key, collection: "orders")) - #expect(MongoCollectionCacheKey.names(MongoCollectionCacheKey.key(database: "archive", collection: "orders"), collection: "orders")) - #expect(!MongoCollectionCacheKey.names(key, collection: "rders")) - #expect(!MongoCollectionCacheKey.names(MongoCollectionCacheKey.key(database: "shop", collection: "old_orders"), collection: "orders")) - #expect(!MongoCollectionCacheKey.names(MongoCollectionCacheKey.key(database: "orders", collection: "items"), collection: "orders")) - } - - @Test("_id cannot be renamed, removed or taken as a new name") - func identifierIsRefused() { - #expect(MongoFieldChange.refusal(for: rename("_id", "id")) != nil) - #expect(MongoFieldChange.refusal(for: remove("_id")) != nil) - #expect(MongoFieldChange.refusal(for: rename("id", "_id")) != nil) - } - - @Test("A name an update cannot address is refused on either side of a rename and for a removal") - func unaddressableNamesAreRefused() { - for name in ["", "$x", "a.b", "a\u{0}b", "__proto__"] { - #expect(MongoFieldChange.refusal(for: rename("a", name)) != nil, "rename to \(name.debugDescription)") - #expect(MongoFieldChange.refusal(for: remove(name)) != nil, "remove \(name.debugDescription)") - } - #expect(MongoFieldChange.refusal(for: rename("price.usd", "price")) != nil) - #expect(MongoFieldChange.refusal(for: rename("a", "1")) == nil) - #expect(MongoFieldChange.refusal(for: rename("a", "tên mới")) == nil) - } - - @Test("The $ and dot refusal reads the same as the one New Table gives") - func sharedAddressingWording() { - #expect(MongoFieldName.addressingRefusal("a.b") == String( - format: String(localized: "MongoDB cannot address a field named %@. A field name cannot start with $ or contain a dot."), - "a.b" - )) - } - - @Test("A change to anything but the name is refused, with or without a rename") - func attributeChangesAreRefused() { - #expect(MongoFieldChange.refusal(for: .modifyColumn(old: column("a", "string"), new: column("a", "int"))) != nil) - #expect(MongoFieldChange.refusal(for: .modifyColumn(old: column("a"), new: column("b", "int"))) != nil) - #expect(MongoFieldChange.refusal(for: .modifyColumn(old: column("a"), new: column("b", nullable: false))) != nil) - #expect(MongoFieldChange.refusal(for: rename("a", "b")) == nil) - } - - @Test("An operation that is not a field edit is left to the collection's own refusal") - func otherOperationsPassThrough() { - let index = PluginIndexDefinition(name: "ix", columns: ["a"], isUnique: false) - #expect(MongoFieldChange.refusal(for: .addIndex(index)) == nil) - #expect(MongoFieldChange.refusal(for: .addColumn(column("_id"))) == nil) - #expect(MongoFieldChange(.addColumn(column("a"))) == nil) - } - - @Test("A name used by two edits of one save is refused before anything runs") - func sharedNamesAreRefused() { - #expect(MongoFieldChangePlan(operations: [rename("a", "b"), rename("b", "c")]).refusal != nil) - #expect(MongoFieldChangePlan(operations: [rename("a", "b"), rename("b", "a")]).refusal != nil) - #expect(MongoFieldChangePlan(operations: [remove("b"), rename("a", "b")]).refusal != nil) - #expect(MongoFieldChangePlan(operations: [rename("a", "c"), rename("b", "c")]).refusal != nil) - } - - @Test("Edits that share no name keep both steps in the order their statements run") - func disjointEditsAreKept() { - let plan = MongoFieldChangePlan(operations: [remove("c"), rename("a", "b")]) - #expect(plan.refusal == nil) - #expect(plan.changes == [.remove("c"), .rename(from: "a", to: "b")]) - #expect(plan.renames.map(\.from) == ["a"]) - } - - @Test("A save with no field edit has nothing to plan") - func noFieldEdits() { - let index = PluginIndexDefinition(name: "ix", columns: ["a"], isUnique: false) - #expect(MongoFieldChangePlan(operations: [.addIndex(index)]).isEmpty) - #expect(MongoFieldChangePlan(operations: [.modifyColumn(old: column("a"), new: column("a", "int"))]).isEmpty) - } -} diff --git a/TableProTests/Plugins/MongoFieldDataProbeTests.swift b/TableProTests/Plugins/MongoFieldDataProbeTests.swift deleted file mode 100644 index ae92cb3403..0000000000 --- a/TableProTests/Plugins/MongoFieldDataProbeTests.swift +++ /dev/null @@ -1,282 +0,0 @@ -// -// MongoFieldDataProbeTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// The pipelines here were run against MongoDB 7.0.43 in the pull request's live checks; these tests -/// pin their text and their shape so a refactor cannot quietly change what the server is asked. -struct MongoFieldDataProbeTests { - private func stages(_ pipeline: String) throws -> [[String: Any]] { - try #require(MongoJsonValue.parse(pipeline) as? [[String: Any]]) - } - - @Test("The both-names pass matches documents holding both names of one rename, never one of each") - func bothNamesMatchesPairs() throws { - let pipeline = try #require(MongoFieldDataProbe.bothNamesPipeline([("a", "b"), ("c", "d")])) - #expect(pipeline == #"[{"$match": {"$or": [{"a": {"$exists": true}, "b": {"$exists": true}}, {"c": {"$exists": true}, "# - + #""d": {"$exists": true}}]}}, {"$limit": 1}, {"$project": {"_id": 0, "# - + #""p0": {"$and": [{"$ne": [{"$type": "$a"}, "missing"]}, {"$ne": [{"$type": "$b"}, "missing"]}]}, "# - + #""p1": {"$and": [{"$ne": [{"$type": "$c"}, "missing"]}, {"$ne": [{"$type": "$d"}, "missing"]}]}}}]"#) - let match = try #require(try stages(pipeline).first?["$match"] as? [String: Any]) - let alternatives = try #require(match["$or"] as? [[String: Any]]) - #expect(alternatives.map { Set($0.keys) } == [["a", "b"], ["c", "d"]]) - #expect(MongoFieldDataProbe.bothNamesPipeline([]) == nil) - } - - @Test("The size pass adds each longer name's growth to documents holding the old name, and skips when nothing grows") - func oversizePassCountsGrowth() throws { - let pipeline = try #require(MongoFieldDataProbe.oversizePipeline([("a", "abc"), ("long", "l"), ("x", "xyz9")])) - #expect(pipeline == #"[{"$match": {"$or": [{"a": {"$exists": true}}, {"x": {"$exists": true}}]}}, "# - + #"{"$match": {"$expr": {"$gt": [{"$add": [{"$bsonSize": "$$ROOT"}, "# - + #"{"$cond": [{"$ne": [{"$type": "$a"}, "missing"]}, 2, 0]}, "# - + #"{"$cond": [{"$ne": [{"$type": "$x"}, "missing"]}, 3, 0]}]}, 16777216]}}}, "# - + #"{"$limit": 1}, {"$project": {"_id": 1}}]"#) - #expect(try stages(pipeline).count == 4) - #expect(MongoFieldDataProbe.oversizePipeline([("long", "l"), ("same", "SAME")]) == nil) - #expect(MongoFieldDataProbe.oversizePipeline([("é", "ee")]) == nil) - } - - @Test("The pass says which rename a document holds both names of") - func namesThePair() throws { - let renames = [(from: "a", to: "b"), (from: "c", to: "d")] - let found = try #require(MongoFieldDataProbe.renameHoldingBothNames(in: #"{ "p0" : false, "p1" : true }"#, renames: renames)) - #expect(found.from == "c") - #expect(found.to == "d") - #expect(MongoFieldDataProbe.renameHoldingBothNames(in: nil, renames: renames) == nil) - } - - @Test("One validator pass per change, each checking the documents its statement changes") - func oneStepPipeline() throws { - let pipelines = MongoFieldDataProbe.validatorPipelines( - [.rename(from: "p", to: "x")], validatorJson: #"{"$jsonSchema": {"required": ["y"]}}"#, onlyValidDocuments: false - ) - #expect(pipelines == [#"[{"$match": {"p": {"$exists": true}, "x": {"$exists": false}}}, "# - + #"{"$addFields": {"x": "$p", "p": "$$REMOVE"}}, {"$match": {"$nor": [{"$jsonSchema": {"required": ["y"]}}]}}, "# - + #"{"$limit": 1}, {"$project": {"_id": 1}}]"#]) - } - - @Test("A later step replays the earlier ones with their guard, and a removal replays as a removed field") - func laterStepsReplayEarlierOnes() throws { - let pipelines = MongoFieldDataProbe.validatorPipelines( - [.remove("r"), .rename(from: "p", to: "x"), .rename(from: "q", to: "y")], - validatorJson: "{}", - onlyValidDocuments: false - ) - #expect(pipelines.count == 3) - let third = try stages(pipelines[2]) - let removal = try #require(third[0]["$addFields"] as? [String: Any]) - #expect(removal.count == 1) - #expect(removal["r"] as? String == "$$REMOVE") - let replay = try #require(third[1]["$addFields"] as? [String: Any]) - #expect(Set(replay.keys) == ["x", "p"]) - #expect(pipelines[2].contains(#""x": {"$cond": [{"$and": [{"$ne": [{"$type": "$p"}, "missing"]}, {"$eq": [{"$type": "$x"}, "# - + #""missing"]}]}, "$p", "$x"]}"#)) - #expect(pipelines[2].contains(#""p": {"$cond": [{"$and": [{"$ne": [{"$type": "$p"}, "missing"]}, {"$eq": [{"$type": "$x"}, "# - + #""missing"]}]}, "$$REMOVE", "$p"]}"#)) - let match = try #require(third[2]["$match"] as? [String: Any]) - #expect(Set(match.keys) == ["q", "y"]) - } - - @Test("A moderate validator sets aside documents it already rejects before the step") - func moderateSetsAsideInvalidDocuments() throws { - let validator = #"{"$jsonSchema": {"required": ["k"]}}"# - let strict = try stages(MongoFieldDataProbe.validatorPipelines([.remove("p")], validatorJson: validator, onlyValidDocuments: false)[0]) - let moderate = try stages(MongoFieldDataProbe.validatorPipelines([.remove("p")], validatorJson: validator, onlyValidDocuments: true)[0]) - #expect(strict.count == 5) - #expect(moderate.count == 6) - let preFilter = try #require(moderate[1]["$match"] as? [String: Any]) - #expect(preFilter["$jsonSchema"] != nil) - } - - /// The reviewer's case, measured on 7.0.43. Validator `{old: string}`, documents `{_id: 1, old: - /// "x"}` and `{_id: 2, new: 42}`, rename `old` to `new`. The step pass checks only `_id 1`, the - /// one document the rename changes, so the committed code ran the `collMod` and the rename and - /// reported success, and the next `updateOne` on `_id 2` failed with 121. This pass, run against - /// the same collection, returned `_id 2` and the save was refused with nothing changed. - @Test("The rewritten-rule pass checks documents holding only the new name against the rewritten validator") - func rewrittenRuleReachesTargetOnlyDocuments() throws { - let original = #"{"$jsonSchema": {"properties": {"old": {"bsonType": "string"}}}}"# - let rewritten = #"{"$jsonSchema": {"properties": {"new": {"bsonType": "string"}}}}"# - let pipeline = MongoFieldDataProbe.rewrittenRulePipeline( - [.rename(from: "old", to: "new")], - originalValidatorJson: original, - rewrittenValidatorJson: rewritten, - onlyValidDocuments: false - ) - #expect(pipeline == #"[{"$match": {"$or": [{"old": {"$exists": true}}, {"new": {"$exists": true}}]}}, "# - + #"{"$addFields": {"new": {"$cond": [{"$and": [{"$ne": [{"$type": "$old"}, "missing"]}, "# - + #"{"$eq": [{"$type": "$new"}, "missing"]}]}, "$old", "$new"]}, "# - + #""old": {"$cond": [{"$and": [{"$ne": [{"$type": "$old"}, "missing"]}, "# - + #"{"$eq": [{"$type": "$new"}, "missing"]}]}, "$$REMOVE", "$old"]}}}, "# - + #"{"$match": {"$nor": [{"$jsonSchema": {"properties": {"new": {"bsonType": "string"}}}}]}}, "# - + #"{"$limit": 1}, {"$project": {"_id": 1}}]"#) - - let stepPass = try stages( - MongoFieldDataProbe.validatorPipelines([.rename(from: "old", to: "new")], validatorJson: rewritten, onlyValidDocuments: false)[0] - ) - let stepFilter = try #require(stepPass[0]["$match"] as? [String: Any]) - #expect(stepFilter["new"] as? [String: Bool] == ["$exists": false]) - } - - /// Under `moderate` the server stops checking a document that fails, so the question is whether - /// the save takes a document the old validator accepted and leaves it failing the new one. - /// Measured on 7.0.43: `{new: 42}` accepted before is refused, and `{new: 42, k: "bad"}`, which - /// the old validator already rejected, is set aside and the save applies. - @Test("Under moderate the rewritten-rule pass counts only documents the old validator accepted") - func rewrittenRuleUnderModerate() throws { - let original = #"{"$jsonSchema": {"properties": {"old": {"bsonType": "string"}}}}"# - let rewritten = #"{"$jsonSchema": {"properties": {"new": {"bsonType": "string"}}}}"# - let moderate = try stages(MongoFieldDataProbe.rewrittenRulePipeline( - [.rename(from: "old", to: "new")], - originalValidatorJson: original, - rewrittenValidatorJson: rewritten, - onlyValidDocuments: true - )) - let accepted = try #require(moderate[1]["$match"] as? [String: Any]) - let schema = try #require(accepted["$jsonSchema"] as? [String: Any]) - let properties = try #require(schema["properties"] as? [String: Any]) - #expect(Set(properties.keys) == ["old"]) - #expect(moderate[2]["$addFields"] != nil) - } - - @Test("The rewritten-rule pass covers every name the save changes once, and replays every step in order") - func rewrittenRuleCoversEveryName() throws { - let changes: [MongoFieldChange] = [.remove("r"), .rename(from: "p", to: "x"), .rename(from: "q", to: "y")] - let pipeline = try stages(MongoFieldDataProbe.rewrittenRulePipeline( - changes, originalValidatorJson: "{}", rewrittenValidatorJson: #"{"a": 1}"#, onlyValidDocuments: false - )) - let names = try #require((pipeline[0]["$match"] as? [String: Any])?["$or"] as? [[String: Any]]) - #expect(names.flatMap(\.keys) == ["r", "p", "x", "q", "y"]) - #expect(pipeline.count == 7) - let removal = try #require(pipeline[1]["$addFields"] as? [String: Any]) - #expect(removal["r"] as? String == "$$REMOVE") - #expect(Set(try #require(pipeline[2]["$addFields"] as? [String: Any]).keys) == ["x", "p"]) - #expect(Set(try #require(pipeline[3]["$addFields"] as? [String: Any]).keys) == ["y", "q"]) - let rejected = try #require(pipeline[4]["$match"] as? [String: Any]) - #expect(rejected["$nor"] != nil) - } - - /// The target-only race: a document another client gives only the new name after the last check - /// before writing is accepted by the old validator, which does not read that name, and the - /// `collMod` checks no document. Every statement then succeeds and skips it. - @Test("The pass after writing checks every document holding a changed name against the rewritten validator, replaying nothing") - func violationAfterWriting() throws { - let original = #"{"$jsonSchema": {"properties": {"old": {"bsonType": "string"}}}}"# - let rewritten = #"{"$jsonSchema": {"properties": {"new": {"bsonType": "string"}}}}"# - let pipeline = MongoFieldDataProbe.violationAfterWritingPipeline( - [.rename(from: "old", to: "new")], - originalValidatorJson: original, - rewrittenValidatorJson: rewritten, - onlyValidDocuments: false - ) - #expect(pipeline == #"[{"$match": {"$or": [{"old": {"$exists": true}}, {"new": {"$exists": true}}]}}, "# - + #"{"$match": {"$nor": [{"$jsonSchema": {"properties": {"new": {"bsonType": "string"}}}}]}}, "# - + #"{"$limit": 1}, {"$project": {"_id": 1}}]"#) - let moderate = try stages(MongoFieldDataProbe.violationAfterWritingPipeline( - [.rename(from: "old", to: "new")], - originalValidatorJson: original, - rewrittenValidatorJson: rewritten, - onlyValidDocuments: true - )) - #expect(moderate.count == 5) - let accepted = try #require(moderate[1]["$match"] as? [String: Any]) - #expect(accepted["$jsonSchema"] != nil) - #expect(!moderate.contains { $0["$addFields"] != nil }) - - let message = MongoFieldDataProbe.violationAfterWriting(identifier: "7", collection: "people") - #expect(message.hasPrefix("The save did not finish: the updated validator of people rejects the document with _id 7, ")) - #expect(message.hasSuffix(" Fix that document, then save again.")) - } - - @Test("Every stage of every pass is one MongoDB 4.0 has, so none is $set or $unset") - func stagesExistOnMongoDB40() throws { - let stagesOn40: Set = ["$match", "$addFields", "$limit", "$project"] - let changes: [MongoFieldChange] = [.remove("r"), .rename(from: "p", to: "x"), .rename(from: "q", to: "y")] - let validator = #"{"$jsonSchema": {"required": ["y"]}}"# - let bothNames = try #require(MongoFieldDataProbe.bothNamesPipeline([("p", "x"), ("q", "y")])) - let rewrittenRule = [true, false].map { onlyValid in - MongoFieldDataProbe.rewrittenRulePipeline( - changes, originalValidatorJson: validator, rewrittenValidatorJson: validator, onlyValidDocuments: onlyValid - ) - } - let pipelines = MongoFieldDataProbe.validatorPipelines(changes, validatorJson: validator, onlyValidDocuments: true) - + MongoFieldDataProbe.validatorPipelines(changes, validatorJson: validator, onlyValidDocuments: false) - + [bothNames] + rewrittenRule - + [MongoFieldDataProbe.violationAfterWritingPipeline( - changes, originalValidatorJson: validator, rewrittenValidatorJson: validator, onlyValidDocuments: true - )] - for pipeline in pipelines { - for stage in try stages(pipeline) { - #expect(stage.count == 1) - #expect(Set(stage.keys).isSubset(of: stagesOn40), "\(stage.keys) in \(pipeline)") - } - } - } - - @Test("Each pass reads at local, whatever read concern the connection string sets, within its time bound") - func passOptions() throws { - let options = try #require(MongoJsonValue.parse(MongoFieldDataProbe.aggregateOptionsJson(maxTimeMS: 5_000)) as? [String: Any]) - #expect(Set(options.keys) == ["maxTimeMS", "readConcern"]) - #expect(options["maxTimeMS"] as? Int == 5_000) - #expect(options["readConcern"] as? [String: String] == ["level": "local"]) - } - - @Test("Each pass is bounded, by the query timeout or by a ceiling when there is none") - func timeBound() { - #expect(MongoFieldDataProbe.maxTimeMS(queryTimeoutMS: 60_000) == 60_000) - #expect(MongoFieldDataProbe.maxTimeMS(queryTimeoutMS: 0) == MongoFieldDataProbe.unlimitedTimeoutCeilingMS) - #expect(MongoFieldDataProbe.unlimitedTimeoutCeilingMS > 0) - } - - @Test("The count after writing matches every document still holding the old name, for a rename and a removal") - func remainderPipelines() throws { - #expect(MongoFieldDataProbe.remainderPipeline(.rename(from: "old", to: "new")) - == #"[{"$match": {"old": {"$exists": true}}}, {"$count": "n"}]"#) - #expect(MongoFieldDataProbe.remainderPipeline(.remove("tmp")) - == #"[{"$match": {"tmp": {"$exists": true}}}, {"$count": "n"}]"#) - let stageNames = try stages(MongoFieldDataProbe.remainderPipeline(.remove("tmp"))).flatMap(\.keys) - #expect(stageNames == ["$match", "$count"]) - } - - @Test("No document back from $count is zero, and an answer that is not a count is unknown") - func remainderCounts() { - #expect(MongoFieldDataProbe.remainderCount(in: nil) == 0) - #expect(MongoFieldDataProbe.remainderCount(in: #"{ "n" : { "$numberInt" : "3" } }"#) == 3) - #expect(MongoFieldDataProbe.remainderCount(in: #"{ "n" : { "$numberLong" : "5000000000" } }"#) == 5_000_000_000) - #expect(MongoFieldDataProbe.remainderCount(in: #"{ "n" : 7 }"#) == 7) - #expect(MongoFieldDataProbe.remainderCount(in: #"{ "x" : 1 }"#) == nil) - #expect(MongoFieldDataProbe.remainderCount(in: "not json") == nil) - } - - /// Measured on 7.0.43 with a second connection inserting `{old: 1, new: 2}` between the check - /// before writing and the rename: every statement succeeded, the rename skipped that document, - /// and this count read 1. - @Test("A save that left the old name in some documents is not finished, and says how many hold it") - func shortfalls() { - let rename = MongoFieldChange.rename(from: "old", to: "new") - let removal = MongoFieldChange.remove("tmp") - #expect(MongoFieldDataProbe.shortfall([]) == nil) - #expect(MongoFieldDataProbe.shortfall([(rename, 0), (removal, 0)]) == nil) - #expect(MongoFieldDataProbe.shortfall([(rename, 1), (removal, 0)]) == String( - format: String(localized: "The save did not finish: one document still holds %@, most likely written by another client while the save ran. Save again to finish."), - "old" - )) - #expect(MongoFieldDataProbe.shortfall([(rename, 0), (removal, 12)]) == String( - format: String(localized: "The save did not finish: %1$lld documents still hold %2$@, most likely written by another client while the save ran. Save again to finish."), - Int64(12), "tmp" - )) - #expect(MongoFieldDataProbe.shortfall([(rename, 2), (removal, 12)])?.contains("old") == true) - } - - @Test("A found document's _id is written the way a person types it") - func identifiers() { - #expect(MongoFieldDataProbe.identifier(in: #"{ "_id" : { "$oid" : "6ab746850efe77e864860657" } }"#) - == #"ObjectId("6ab746850efe77e864860657")"#) - #expect(MongoFieldDataProbe.identifier(in: #"{ "_id" : { "$numberInt" : "2" } }"#) == "2") - #expect(MongoFieldDataProbe.identifier(in: #"{ "_id" : "abc" }"#) == #""abc""#) - #expect(MongoFieldDataProbe.identifier(in: nil) == nil) - } -} diff --git a/TableProTests/Plugins/MongoFieldReferencesTests.swift b/TableProTests/Plugins/MongoFieldReferencesTests.swift deleted file mode 100644 index 79b165ea00..0000000000 --- a/TableProTests/Plugins/MongoFieldReferencesTests.swift +++ /dev/null @@ -1,364 +0,0 @@ -// -// MongoFieldReferencesTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// The index and view fixtures are the canonical Extended JSON `listIndexes` and `listCollections` -/// returned on MongoDB 7.0.43, byte for byte. -struct MongoFieldReferencesTests { - private enum Fixture { - static let idIndex = #"{ "v" : { "$numberInt" : "2" }, "key" : { "_id" : { "$numberInt" : "1" } }, "name" : "_id_" }"# - static let textIndex = #"{ "v" : { "$numberInt" : "2" }, "key" : { "_fts" : "text", "_ftsx" : { "$numberInt" : "1" } }, "# - + #""name" : "t_text_sub.x_text", "weights" : { "sub.x" : { "$numberInt" : "1" }, "# - + #""t" : { "$numberInt" : "5" } }, "default_language" : "english", "language_override" : "language", "# - + #""textIndexVersion" : { "$numberInt" : "3" } }"# - static let wildcardIndex = #"{"v":{"$numberInt":"2"},"key":{"$**":{"$numberInt":"1"}},"name":"$**_1","wildcardProjection":{"a":{"$numberInt":"1"}}}"# - static let partialIndex = #"{ "v" : { "$numberInt" : "2" }, "key" : { "b" : { "$numberInt" : "1" } }, "name" : "b_1", "# - + #""unique" : true, "partialFilterExpression" : { "a" : { "$gt" : { "$numberInt" : "0" } } } }"# - static let subtreeIndex = #"{ "v" : { "$numberInt" : "2" }, "key" : { "d.$**" : { "$numberInt" : "1" } }, "name" : "d.$**_1" }"# - } - - private func index(_ json: String) throws -> MongoIndexSpec { - try #require(MongoIndexSpec(json: json)) - } - - private func query(_ json: String, reaches field: String) throws -> Bool { - MongoQueryFieldReferences.reaches(try #require(MongoJsonValue.parse(json)), field: field) - } - - private func pipeline(_ json: String, reaches field: String) throws -> Bool { - MongoPipelineFieldReferences.reaches(try #require(MongoJsonValue.parse(json)), field: field) - } - - @Test("A path reaches a field when it is the field or a path under it") - func pathReach() { - #expect(MongoFieldPath.reaches("a", field: "a")) - #expect(MongoFieldPath.reaches("a.b", field: "a")) - #expect(!MongoFieldPath.reaches("ab", field: "a")) - #expect(!MongoFieldPath.reaches("b.a", field: "a")) - } - - @Test("The _id index names _id and nothing else") - func idIndex() throws { - let spec = try index(Fixture.idIndex) - #expect(spec.name == "_id_") - #expect(spec.reaches("_id")) - #expect(!spec.reaches("id")) - } - - @Test("A text index names its weighted fields and its language override, not what sits under them") - func textIndex() throws { - let spec = try index(Fixture.textIndex) - #expect(spec.reaches("t")) - #expect(spec.reaches("sub")) - #expect(spec.reaches("language")) - #expect(!spec.reaches("x")) - #expect(!spec.reaches("english")) - } - - @Test("A wildcard index names what its projection names, and a subtree index names its root") - func wildcardIndexes() throws { - #expect(try index(Fixture.wildcardIndex).reaches("a")) - #expect(try index(Fixture.wildcardIndex).reaches("b") == false) - #expect(try index(Fixture.subtreeIndex).reaches("d")) - #expect(try index(Fixture.subtreeIndex).reaches("e") == false) - } - - @Test("A partial index names both its key and the fields of its filter, and b does not name bb") - func partialIndex() throws { - let spec = try index(Fixture.partialIndex) - #expect(spec.reaches("a")) - #expect(spec.reaches("b")) - #expect(!spec.reaches("bb")) - } - - @Test("A query names the keys it matches on and never the values it matches") - func queryKeysNotValues() throws { - #expect(try query(#"{"status": {"$in": ["active", "gone"]}}"#, reaches: "status")) - #expect(try query(#"{"status": {"$in": ["active", "gone"]}}"#, reaches: "active") == false) - #expect(try query(#"{"$or": [{"a": 1}, {"$and": [{"b.c": 2}]}]}"#, reaches: "b")) - #expect(try query(#"{"$nor": [{"a": 1}]}"#, reaches: "a")) - #expect(try query(#"{"items": {"$elemMatch": {"qty": {"$gt": 1}}}}"#, reaches: "qty") == false) - } - - @Test("$expr names a field by a $-prefixed string or a document variable, never by a literal") - func expressions() throws { - #expect(try query(#"{"$expr": {"$gt": ["$qty", {"$numberInt": "0"}]}}"#, reaches: "qty")) - #expect(try query(#"{"$expr": {"$eq": ["$status", "active"]}}"#, reaches: "active") == false) - #expect(try query(#"{"$expr": {"$eq": [{"$literal": "$qty"}, "x"]}}"#, reaches: "qty") == false) - #expect(try query(#"{"$expr": {"$gt": ["$$ROOT.qty", 1]}}"#, reaches: "qty")) - #expect(try query(#"{"$expr": {"$gt": ["$$CURRENT.qty.n", 1]}}"#, reaches: "qty")) - #expect(try query(#"{"$expr": {"$gt": [{"$getField": "qty"}, 1]}}"#, reaches: "qty")) - #expect(try query(#"{"$expr": {"$gt": [{"$getField": {"field": {"$literal": "qty"}, "input": "$$ROOT"}}, 1]}}"#, reaches: "qty")) - #expect(try query(#"{"$expr": {"$lt": ["$$NOW", "$at"]}}"#, reaches: "qty") == false) - #expect(try query(#"{"$expr": {"$lt": ["$$NOW", "$at"]}}"#, reaches: "at")) - #expect(try query(#"{"$expr": {"$function": {"body": "function() {}", "args": [], "lang": "js"}}}"#, reaches: "qty")) - } - - /// The reviewer's case: every key of the document becomes a value, and a literal matches one. - /// Nothing in the validator names `status` the way a field is named, and a rename of `status` - /// would leave it checking a key no document has. - @Test("An expression handed the whole document reads every field") - func wholeDocumentReadsEveryField() throws { - let keys = #"{"$expr": {"$in": ["status", {"$map": {"input": {"$objectToArray": "$$ROOT"}, "in": "$$this.k"}}]}}"# - #expect(try query(keys, reaches: "status")) - #expect(try query(keys, reaches: "qty")) - #expect(try query(#"{"$expr": {"$gt": [{"$size": {"$objectToArray": "$$CURRENT"}}, 3]}}"#, reaches: "qty")) - #expect(try query(#"{"$expr": {"$eq": ["$$ROOT", "x"]}}"#, reaches: "qty")) - let merged = #"{"$expr": {"$eq": [{"$mergeObjects": ["$$ROOT", {"a": 1}]}, {"a": 1}]}}"# - #expect(try query(merged, reaches: "qty")) - let unset = #"{"$expr": {"$eq": [{"$unsetField": {"field": "a", "input": "$$CURRENT"}}, {}]}}"# - #expect(try query(unset, reaches: "qty")) - let set = #"{"$expr": {"$eq": [{"$setField": {"field": "a", "input": "$$ROOT", "value": 1}}, {"a": 1}]}}"# - #expect(try query(set, reaches: "qty")) - #expect(try query(#"{"$where": "this.a > 1"}"#, reaches: "qty")) - let elements = #"{"$expr": {"$anyElementTrue": {"$map": {"input": "$items", "in": {"$eq": ["$$this", 1]}}}}}"# - #expect(try query(elements, reaches: "items")) - #expect(try query(elements, reaches: "qty") == false) - } - - @Test("patternProperties and additionalProperties apply to a name the schema does not declare") - func rulesByName() throws { - func applies(_ json: String, to name: String) throws -> Bool { - MongoQueryFieldReferences.appliesByName(try #require(MongoJsonValue.parse(json)), to: name) - } - let closed = #"{"$jsonSchema": {"properties": {"a": {"bsonType": "int"}}, "additionalProperties": false}}"# - #expect(try applies(closed, to: "a") == false) - #expect(try applies(closed, to: "b")) - #expect(try applies(#"{"$jsonSchema": {"additionalProperties": {"bsonType": "string"}}}"#, to: "b")) - #expect(try applies(#"{"$jsonSchema": {"additionalProperties": true}}"#, to: "b") == false) - #expect(try applies(#"{"$jsonSchema": {"additionalProperties": {}}}"#, to: "b") == false) - let patterned = #"{"$jsonSchema": {"patternProperties": {"^tmp_": {"bsonType": "int"}}}}"# - #expect(try applies(patterned, to: "tmp_x")) - #expect(try applies(patterned, to: "x") == false) - #expect(try applies(#"{"$and": [{"$jsonSchema": {"allOf": [{"additionalProperties": false}]}}]}"#, to: "x")) - #expect(try applies(#"{"$jsonSchema": {"not": {"patternProperties": {"^x$": {}}}}}"#, to: "x")) - #expect(try applies(#"{"a": {"$exists": true}}"#, to: "a") == false) - } - - @Test("A field name $getField, $setField or $unsetField computes at run time reaches every field") - func computedFieldNames() throws { - let concat = #"{"$getField": {"field": {"$concat": ["sta", "tus"]}, "input": "$$ROOT"}}"# - #expect(try query(#"{"$expr": {"$eq": ["# + concat + #", 1]}}"#, reaches: "status")) - #expect(try query(#"{"$expr": {"$eq": ["# + concat + #", 1]}}"#, reaches: "qty")) - #expect(try pipeline(#"[{"$project": {"s": "# + concat + #"}}]"#, reaches: "status")) - #expect(try pipeline(#"[{"$project": {"s": {"$getField": "$name"}}}]"#, reaches: "qty")) - #expect(try pipeline(#"[{"$project": {"s": {"$getField": {"field": "$$key", "input": "$$ROOT"}}}}]"#, reaches: "qty")) - let unset = #"[{"$replaceWith": {"$unsetField": {"field": {"$toString": "$k"}, "input": "$$ROOT"}}}]"# - #expect(try pipeline(unset, reaches: "qty")) - - #expect(try pipeline(#"[{"$replaceWith": {"$setField": {"field": "a", "input": "$$ROOT", "value": 1}}}]"#, reaches: "a")) - #expect(try pipeline(#"[{"$replaceWith": {"$setField": {"field": "a", "input": "$$ROOT", "value": 1}}}]"#, reaches: "b") == false) - #expect(try query(#"{"$expr": {"$eq": [{"$getField": {"field": {"$literal": "qty"}, "input": "$sub"}}, 1]}}"#, reaches: "note") == false) - #expect(try query(#"{"$expr": {"$eq": [{"$getField": {"field": {"$literal": "qty"}, "input": "$$ROOT"}}, 1]}}"#, reaches: "note") == false) - #expect(try query(#"{"$expr": {"$eq": [{"$getField": {"field": {"$literal": "qty"}, "input": "$$ROOT"}}, 1]}}"#, reaches: "qty")) - } - - private func view(_ json: String, reads field: String) throws -> Bool { - let stages = try #require(MongoJsonValue.parse(json) as? [Any]) - return MongoViewDefinition(name: "v", viewOn: "c", pipeline: stages).readsField(field) - } - - /// The reviewer's case in a view: `{$objectToArray: "$$ROOT"}` turns every key into a value, so - /// a rename changes what the view emits while nothing in it names the field. - @Test("A view that hands the whole document to something reading its names reads every field") - func viewReadsEveryFieldThroughTheWholeDocument() throws { - #expect(try view(#"[{"$project": {"kv": {"$objectToArray": "$$ROOT"}}}]"#, reads: "status")) - #expect(try view(#"[{"$addFields": {"n": {"$size": {"$objectToArray": "$$CURRENT"}}}}]"#, reads: "qty")) - #expect(try view(#"[{"$match": {"$expr": {"$eq": ["$$ROOT", {"a": 1}]}}}]"#, reads: "qty")) - #expect(try view(#"[{"$group": {"_id": null, "docs": {"$push": "$$ROOT"}}}]"#, reads: "qty")) - #expect(try view(#"[{"$project": {"copy": "$$ROOT"}}]"#, reads: "qty")) - #expect(try view(#"[{"$lookup": {"from": "o", "let": {"d": "$$ROOT"}, "pipeline": [], "as": "x"}}]"#, reads: "qty")) - #expect(try view(#"[{"$facet": {"f": [{"$project": {"kv": {"$objectToArray": "$$CURRENT"}}}]}}]"#, reads: "qty")) - #expect(try view(#"[{"$unionWith": {"coll": "o", "pipeline": [{"$replaceWith": {"$objectToArray": "$$ROOT"}}]}}]"#, reads: "qty")) - #expect(try view(#"[{"$replaceWith": {"$cond": [{"$eq": ["$$ROOT", {}]}, "$$ROOT", {}]}}]"#, reads: "qty")) - #expect(try view(#"[{"$replaceWith": {"$mergeObjects": [{"$objectToArray": "$$ROOT"}]}}]"#, reads: "qty")) - } - - /// A view that passes the document on as the document is the view `[{$match: ...}]` already is: - /// it reads the fields it names, and a later stage can take the document apart only as `$$ROOT`, - /// which is read where it stands. - @Test("A view that keeps the whole document as the document reads only the fields it names") - func viewPassingTheDocumentOn() throws { - #expect(try view(#"[{"$replaceRoot": {"newRoot": "$$ROOT"}}]"#, reads: "qty") == false) - #expect(try view(#"[{"$replaceWith": {"$mergeObjects": [{"note": ""}, "$$ROOT"]}}]"#, reads: "qty") == false) - #expect(try view(#"[{"$replaceWith": {"$mergeObjects": [{"note": ""}, "$$ROOT"]}}]"#, reads: "note")) - #expect(try view(#"[{"$replaceWith": {"$setField": {"field": "a", "input": "$$ROOT", "value": 1}}}]"#, reads: "b") == false) - #expect(try view(#"[{"$replaceWith": {"$unsetField": {"field": "a", "input": "$$CURRENT"}}}]"#, reads: "b") == false) - let branch = #"[{"$replaceWith": {"$cond": {"if": {"$eq": ["$a", 1]}, "then": "$$ROOT", "else": {"$ifNull": ["$$ROOT", {}]}}}}]"# - #expect(try view(branch, reads: "qty") == false) - let switched = #"[{"$replaceWith": {"$switch": {"branches": [{"case": "$flag", "then": "$$ROOT"}], "default": "$$CURRENT"}}}]"# - #expect(try view(switched, reads: "qty") == false) - #expect(try view(#"[{"$replaceWith": {"$let": {"vars": {}, "in": "$$ROOT"}}}]"#, reads: "qty") == false) - #expect(try view(#"[{"$project": {"v": {"$getField": {"field": "a", "input": "$$ROOT"}}}}]"#, reads: "qty") == false) - #expect(try view(#"[{"$project": {"v": {"$getField": {"field": "a", "input": "$$ROOT"}}}}]"#, reads: "a")) - #expect(try view(#"[{"$replaceWith": {"$setField": {"field": "a", "input": "$$ROOT", "value": "$$ROOT"}}}]"#, reads: "b")) - } - - /// One rule, so the same expression reads the same fields whether a validator's `$expr` holds it - /// or a view computes it. - @Test("A validator and a view holding the same expression answer the same for a field neither names") - func validatorAndViewAgree() throws { - let expressions = [ - #"{"$objectToArray": "$$ROOT"}"#, - #"{"$size": {"$objectToArray": "$$CURRENT"}}"#, - #"{"$eq": ["$$ROOT", {"a": 1}]}"#, - #"{"$mergeObjects": ["$$ROOT", {"a": 1}]}"#, - #"{"$setField": {"field": "a", "input": "$$ROOT", "value": 1}}"#, - #"{"$unsetField": {"field": "a", "input": "$$CURRENT"}}"#, - #"{"$getField": {"field": "a", "input": "$$ROOT"}}"#, - #"{"$getField": {"field": "a", "input": {"$mergeObjects": ["$$ROOT", {}]}}}"#, - #"{"$let": {"vars": {"d": "$$ROOT"}, "in": "$$d.a"}}"#, - #"{"$cond": [true, "$$ROOT", {}]}"#, - #"{"$gt": ["$a", 1]}"# - ] - for text in expressions { - let expression = try #require(MongoJsonValue.parse(text)) - let validator = MongoQueryFieldReferences.reaches(["$expr": expression], field: "unnamed") - let pipeline = try #require(MongoJsonValue.parse(#"[{"$project": {"x": "# + text + "}}]") as? [Any]) - let viewReads = MongoViewDefinition(name: "v", viewOn: "c", pipeline: pipeline).readsField("unnamed") - #expect(validator == viewReads, "\(text)") - } - #expect(MongoQueryFieldReferences.reaches(["$expr": ["$getField": ["field": "a", "input": "$$ROOT"]]], field: "unnamed") == false) - #expect(MongoQueryFieldReferences.reaches(["$expr": ["$objectToArray": "$$ROOT"]], field: "unnamed")) - } - - /// A document-level `enum` lists whole documents, and a document matches an entry only with - /// exactly its names. A rename carried only through `properties` left this validator matching - /// no renamed document. - @Test("A document listed in a schema's enum names the fields it holds, at the document's level only") - func documentEnumEntriesNameFields() throws { - let listed = #"{"$jsonSchema": {"enum": [{"_id": 1, "old": "a"}, {"_id": 2, "old": "b"}]}}"# - #expect(try query(listed, reaches: "old")) - #expect(try query(listed, reaches: "new") == false) - #expect(try query(#"{"$jsonSchema": {"anyOf": [{"enum": [{"old": 1}]}]}}"#, reaches: "old")) - #expect(try query(#"{"$jsonSchema": {"properties": {"tags": {"enum": [{"old": 1}]}}}}"#, reaches: "old") == false) - #expect(try query(#"{"$jsonSchema": {"enum": ["old", {"$numberInt": "1"}]}}"#, reaches: "old") == false) - func applies(_ json: String, to name: String) throws -> Bool { - MongoQueryFieldReferences.appliesByName(try #require(MongoJsonValue.parse(json)), to: name) - } - #expect(try applies(listed, to: "old")) - #expect(try applies(#"{"$jsonSchema": {"not": {"enum": [{"new": 1}]}}}"#, to: "new")) - #expect(try applies(listed, to: "other") == false) - } - - @Test("A variable bound to the document with $let is read as the document") - func letBoundDocument() throws { - #expect(try query(#"{"$expr": {"$let": {"vars": {"doc": "$$ROOT"}, "in": {"$gt": ["$$doc.qty", 0]}}}}"#, reaches: "qty")) - } - - @Test("$jsonSchema names its required, properties and dependencies at the document's level only") - func jsonSchemaLevels() throws { - let schema = #"{"$jsonSchema": {"bsonType": "object", "required": ["name"], "# - + #""properties": {"name": {"bsonType": "string"}, "status": {"enum": ["active", "gone"]}, "# - + #""active": {"bsonType": "bool"}, "address": {"bsonType": "object", "# - + #""properties": {"zip": {"bsonType": "string"}}}}, "description": "city"}}"# - #expect(try query(schema, reaches: "name")) - #expect(try query(schema, reaches: "status")) - #expect(try query(schema, reaches: "active")) - #expect(try query(schema, reaches: "zip") == false) - #expect(try query(schema, reaches: "gone") == false) - #expect(try query(schema, reaches: "city") == false) - #expect(try query(#"{"$jsonSchema": {"dependencies": {"a": ["b"]}}}"#, reaches: "a")) - #expect(try query(#"{"$jsonSchema": {"dependencies": {"a": ["b"]}}}"#, reaches: "b")) - #expect(try query(#"{"$jsonSchema": {"allOf": [{"required": ["x"]}]}}"#, reaches: "x")) - #expect(try query(#"{"$jsonSchema": {"not": {"required": ["x"]}}}"#, reaches: "x")) - } - - @Test("A pattern names the fields it matches, and a pattern that does not compile names every field") - func patternProperties() throws { - let schema = #"{"$jsonSchema": {"patternProperties": {"^tmp_": {"bsonType": "int"}}}}"# - #expect(try query(schema, reaches: "tmp_x")) - #expect(try query(schema, reaches: "x") == false) - #expect(try query(#"{"$jsonSchema": {"patternProperties": {"([": {}}}}"#, reaches: "x")) - } - - @Test("A view's pipeline names match and projection keys, $-strings and literal field parameters") - func pipelines() throws { - let activeOrders = #"[ { "$match" : { "status" : "active" } }, { "$project" : { "total" : { "$numberInt" : "1" } } } ]"# - #expect(try pipeline(activeOrders, reaches: "status")) - #expect(try pipeline(activeOrders, reaches: "total")) - #expect(try pipeline(activeOrders, reaches: "stat") == false) - let joined = #"[ { "$lookup" : { "from" : "orders", "localField" : "sku", "foreignField" : "code", "as" : "o" } } ]"# - #expect(try pipeline(joined, reaches: "sku")) - #expect(try pipeline(joined, reaches: "code")) - #expect(try pipeline(#"[{"$unset": ["a", "b"]}]"#, reaches: "b")) - #expect(try pipeline(#"[{"$unset": "a"}]"#, reaches: "a")) - #expect(try pipeline(#"[{"$group": {"_id": "$$CURRENT.note"}}]"#, reaches: "note")) - #expect(try pipeline(#"[{"$facet": {"x": [{"$sortByCount": "$tag"}]}}]"#, reaches: "tag")) - #expect(try pipeline(#"[{"$match": {"at": {"$date": {"$numberLong": "0"}}}}]"#, reaches: "numberLong") == false) - #expect(try pipeline(#"[{"$addFields": {"k": {"$getField": "tag"}}}]"#, reaches: "tag")) - #expect(try pipeline(#"[{"$addFields": {"k": {"$function": {"body": "", "args": [], "lang": "js"}}}}]"#, reaches: "x")) - } - - /// A view's grammar keeps growing, so any string in it that could be the field counts, values - /// and `$literal` included. A value that only looks like the field is a refused save; a stage - /// that names the field in a way a list of parameters does not know is a view left reading a - /// field that is gone. - @Test("Any string in a view that could be the field counts, values and $literal included") - func pipelineStringsCountWherever() throws { - let activeOrders = #"[ { "$match" : { "status" : "active" } } ]"# - #expect(try pipeline(activeOrders, reaches: "active")) - #expect(try pipeline(#"[{"$lookup": {"from": "orders", "localField": "a", "foreignField": "b", "as": "o"}}]"#, reaches: "orders")) - #expect(try pipeline(#"[{"$project": {"k": {"$literal": "$tag"}}}]"#, reaches: "tag")) - #expect(try pipeline(#"[{"$project": {"p": {"$getField": {"$literal": "$price"}}}}]"#, reaches: "price")) - #expect(try pipeline(#"[{"$project": {"k": {"$literal": "tag.x"}}}]"#, reaches: "tag")) - } - - @Test("$densify and $fill name the fields of their partitionByFields list") - func partitionByFieldsLists() throws { - let densify = #"[ { "$densify" : { "field" : "ts", "partitionByFields" : [ "tenant" ], "# - + #""range" : { "step" : { "$numberInt" : "1" }, "unit" : "hour", "bounds" : "partition" } } } ]"# - #expect(try pipeline(densify, reaches: "tenant")) - #expect(try pipeline(densify, reaches: "ts")) - #expect(try pipeline(densify, reaches: "ten") == false) - let fill = #"[ { "$fill" : { "partitionByFields" : [ "region", "tenant" ], "sortBy" : { "ts" : { "$numberInt" : "1" } }, "# - + #""output" : { "qty" : { "method" : "linear" } } } } ]"# - #expect(try pipeline(fill, reaches: "tenant")) - #expect(try pipeline(fill, reaches: "region")) - #expect(try pipeline(fill, reaches: "qty")) - #expect(try pipeline(fill, reaches: "price") == false) - } - - @Test("A path given as a list names every field in it, and a path under the field names the field") - func arrayValuedPath() throws { - let search = #"[{"$search": {"text": {"query": "x", "path": ["title", "tenant.name"]}}}]"# - #expect(try pipeline(search, reaches: "tenant")) - #expect(try pipeline(search, reaches: "title")) - #expect(try pipeline(search, reaches: "x")) - #expect(try pipeline(search, reaches: "titles") == false) - #expect(try pipeline(#"[{"$search": {"text": {"query": "x", "path": {"wildcard": "tenant.*"}}}}]"#, reaches: "tenant")) - } - - @Test("A stage this build has never heard of still names the field wherever a string does") - func unknownStage() throws { - #expect(try pipeline(#"[{"$futureStage": {"by": "tenant"}}]"#, reaches: "tenant")) - #expect(try pipeline(#"[{"$futureStage": ["region", "tenant"]}]"#, reaches: "tenant")) - #expect(try pipeline(#"[{"$futureStage": {"keys": [{"on": "$tenant.id"}]}}]"#, reaches: "tenant")) - #expect(try pipeline(#"[{"$futureStage": {"tenant": true}}]"#, reaches: "tenant")) - #expect(try pipeline(#"[{"$futureStage": {"by": "tenants"}}]"#, reaches: "tenant") == false) - } - - /// Measured on 7.0.43: this view reads `[{"_id": 1, "s": [{"tenant": "a"}]}]`, and after the - /// collection's `tenant` was renamed it read `[{"_id": 1, "s": [{}]}]` with no error. - @Test("A field read under a name an earlier stage gave the document counts") - func fieldUnderAnotherName() throws { - let joined = #"[{"$lookup": {"from": "src", "localField": "sid", "foreignField": "sid", "as": "s"}}, "# - + #"{"$project": {"s.tenant": {"$numberInt": "1"}}}]"# - #expect(try pipeline(joined, reaches: "tenant")) - #expect(try pipeline(#"[{"$group": {"_id": null, "docs": {"$push": "$$ROOT"}}}, {"$match": {"docs.tenant": "a"}}]"#, reaches: "tenant")) - #expect(try pipeline(#"[{"$project": {"t": "$doc.tenant"}}]"#, reaches: "tenant")) - } - - @Test("A number, a date or an id names no field, a symbol names one like a string, and code reads every field") - func wrappedValues() throws { - #expect(try pipeline(#"[{"$limit": {"$numberInt": "5"}}]"#, reaches: "5") == false) - #expect(try pipeline(#"[{"$match": {"_id": {"$oid": "6ab746850efe77e864860657"}}}]"#, reaches: "6ab746850efe77e864860657") == false) - #expect(try pipeline(#"[{"$project": {"s": {"$literal": {"$symbol": "tenant"}}}}]"#, reaches: "tenant")) - #expect(try pipeline(#"[{"$match": {"$where": {"$code": "this.x"}}}]"#, reaches: "tenant")) - #expect(try pipeline(#"[{"$project": {"c": {"$literal": {"$code": "1"}}}}]"#, reaches: "tenant")) - } -} diff --git a/TableProTests/Plugins/MongoInsertOptionsTests.swift b/TableProTests/Plugins/MongoInsertOptionsTests.swift deleted file mode 100644 index 86908becf8..0000000000 --- a/TableProTests/Plugins/MongoInsertOptionsTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// MongoInsertOptionsTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoInsertOptionsTests { - /// libbson 1.28.1's `bson_validate_flags_t`, which the test target cannot import. - private enum LibbsonValidation { - static let utf8 = 1 << 0 - static let dollarKeys = 1 << 1 - static let dotKeys = 1 << 2 - static let utf8AllowNull = 1 << 3 - static let emptyKeys = 1 << 4 - } - - /// `_mongoc_default_insert_vflags` in libmongoc 1.28.1, used when an insert passes no options. - private let libmongocInsertDefault = LibbsonValidation.utf8 | LibbsonValidation.utf8AllowNull - | LibbsonValidation.emptyKeys - - private func validate(in json: String) throws -> Int { - let data = try #require(json.data(using: .utf8)) - let options = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - #expect(options.keys.sorted() == ["validate"]) - let number = try #require(options["validate"] as? NSNumber) - #expect(CFGetTypeID(number) != CFBooleanGetTypeID()) - return number.intValue - } - - @Test("An insert asks for libmongoc's default check without the one that refuses a field named \"\"") - func dropsOnlyTheEmptyNameCheck() throws { - let validate = try validate(in: MongoInsertOptions.json) - #expect(validate & LibbsonValidation.emptyKeys == 0) - #expect(validate == libmongocInsertDefault & ~LibbsonValidation.emptyKeys) - } - - @Test("An insert keeps the UTF-8 check and adds no key check of its own") - func keepsTheRestOfTheDefault() throws { - let validate = try validate(in: MongoInsertOptions.json) - #expect(validate != 0) - #expect(validate & LibbsonValidation.utf8 != 0) - #expect(validate & LibbsonValidation.utf8AllowNull != 0) - #expect(validate & (LibbsonValidation.dollarKeys | LibbsonValidation.dotKeys) == 0) - } - - @Test("The options text carries the value the type names") - func textMatchesValue() throws { - #expect(try validate(in: MongoInsertOptions.json) == MongoInsertOptions.validation) - } -} diff --git a/TableProTests/Plugins/MongoResultRebuildTests.swift b/TableProTests/Plugins/MongoResultRebuildTests.swift deleted file mode 100644 index 009a0873dd..0000000000 --- a/TableProTests/Plugins/MongoResultRebuildTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// MongoResultRebuildTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoResultRebuildTests { - private func result() -> PluginQueryResult { - var result = PluginQueryResult( - columns: ["_id"], - columnTypeNames: ["Int32"], - rows: [["1"], ["2"], ["3"]], - rowsAffected: 0, - timing: PluginQueryTiming(total: 2, firstRow: 1, server: 0.5), - columnMeta: [PluginColumnInfo(name: "_id", dataType: "Int32", isPrimaryKey: true)] - ) - result.rowLocators = [#"{"$numberInt":"1"}"#, nil, #"{"$numberInt":"3"}"#] - return result - } - - private func expectCarried(_ rebuilt: PluginQueryResult, from original: PluginQueryResult) { - #expect(rebuilt.timing == original.timing) - #expect(rebuilt.columnMeta?.map(\.name) == original.columnMeta?.map(\.name)) - } - - @Test("Setting the affected count or a status keeps the locators, the column metadata and the timing") - func rebuildsKeepEverything() { - let original = result() - let affected = original.withRowsAffected(3) - #expect(affected.rowsAffected == 3) - #expect(affected.rowLocators == original.rowLocators) - expectCarried(affected, from: original) - let status = original.withStatus("printed") - #expect(status.statusMessage == "printed") - #expect(status.rowLocators == original.rowLocators) - expectCarried(status, from: original) - } - - @Test("Capping to the row cap cuts the locators with the rows") - func cappingSlicesLocators() { - let original = result() - let capped = original.capped(to: 2) - #expect(capped.rows.count == 2) - #expect(capped.isTruncated) - #expect(capped.rowLocators == [#"{"$numberInt":"1"}"#, nil]) - expectCarried(capped, from: original) - #expect(original.capped(to: 5).rowLocators == original.rowLocators) - } - - @Test("Locators that do not pair one to one with the rows are dropped") - func mismatchedLocators() { - #expect(result().withRowLocators(["a"]).rowLocators == nil) - #expect(result().withRowLocators(nil).rowLocators == nil) - #expect(result().withRowLocators(["a", nil, "c"]).rowLocators == ["a", nil, "c"]) - } - - @Test("Printed lines in a status are told apart even when a line holds a comma") - func printedSummarySeparatesLines() { - #expect(MongoScriptText.printedSummary(["inserted 2, skipped 1", "done"]) == "inserted 2, skipped 1; done") - #expect(MongoScriptText.printedSummary(["only"]) == "only") - let long = MongoScriptText.printedSummary([String(repeating: "a", count: 300), String(repeating: "b", count: 300)]) - #expect(long.count == 401) - #expect(long.hasSuffix("…")) - } -} diff --git a/TableProTests/Plugins/MongoScriptAccessPolicyTests.swift b/TableProTests/Plugins/MongoScriptAccessPolicyTests.swift deleted file mode 100644 index d9572af96b..0000000000 --- a/TableProTests/Plugins/MongoScriptAccessPolicyTests.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// MongoScriptAccessPolicyTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoScriptAccessPolicyTests { - private static let readOperations = [ - "currentDatabase", "useDatabase", "listCollections", "listIndexes", "countDocuments", - "estimatedDocumentCount", "distinct", "collectionStats", "openCursor", "cursorConfigure", - "cursorFetch", "cursorCount", "cursorExplain", "cursorClose", "newObjectId", "hexToBase64", - "encodeUuid", "sleep" - ] - - private static let writeOperations = [ - "insertOne", "insertMany", "update", "replace", "delete", "findAndModify", "bulkWrite", - "createIndex", "dropIndex", "dropCollection", "renameCollection" - ] - - private func command(_ document: String) -> [String: Any] { - ["op": "command", "command": document] - } - - private func allowsRead(_ op: String, _ request: [String: Any] = [:]) -> Bool { - MongoScriptAccessPolicy.allows(op: op, request: request, access: .read) - } - - @Test("A read may make every call the prelude's reads make") - func readOperationsAreAllowed() { - for op in Self.readOperations { - #expect(allowsRead(op), "\(op)") - } - #expect(MongoScriptAccessPolicy.readOperations == Set(Self.readOperations)) - } - - @Test("A read may not write, nor make a call the policy does not know") - func writeOperationsAreRefused() { - for op in Self.writeOperations + ["mapReduce", "", "COUNTDOCUMENTS"] { - #expect(!allowsRead(op), "\(op)") - } - } - - @Test("A read may send the seven commands the prelude's reads send, in any case") - func readCommandsAreAllowed() { - for document in [ - #"{"listCollections": 1}"#, - #"{"dbStats": 1}"#, - #"{"buildInfo": 1}"#, - #"{"serverStatus": 1}"#, - #"{"hostInfo": 1}"#, - #"{"currentOp": 1}"#, - #"{"listDatabases": 1}"#, - #"{"listCollections":{"$numberInt":"1"}}"#, - #" {"DBSTATS": 1}"#, - #"{"buildinfo": 1}"#, - #"{"dbStats": 1, "drop": "users"}"# - ] { - #expect(allowsRead("command", command(document)), "\(document)") - } - } - - @Test("A read may not send any other command, explain included") - func otherCommandsAreRefused() { - for document in [ - #"{"drop": "users"}"#, - #"{"dropDatabase": 1}"#, - #"{"explain": {"find": "users"}, "verbosity": "executionStats"}"#, - #"{"explain": {"mapReduce": "users", "out": "copy"}, "verbosity": "executionStats"}"#, - #"{"count": "users"}"#, - #"{"distinct": "users", "key": "a"}"#, - #"{"collStats": "users"}"#, - #"{"validate": "users"}"#, - #"{"killOp": 1, "op": 7}"#, - #"{"ping": 1}"#, - #"{"aggregate": "users", "pipeline": [], "cursor": {}}"#, - #"{"drop": "users", "dbStats": 1}"# - ] { - #expect(!allowsRead("command", command(document)), "\(document)") - } - } - - @Test("A command's name is its first key with escapes decoded, as libbson reads it") - func commandNameIsTheDecodedFirstKey() { - #expect(MongoScriptAccessPolicy.commandName(of: #"{"\u0064rop": "users"}"#) == "drop") - #expect(!allowsRead("command", command(#"{"\u0064rop": "users"}"#))) - #expect(allowsRead("command", command(#"{"\u0064bStats": 1}"#))) - #expect(MongoScriptAccessPolicy.commandName(of: " \n{ \"hostInfo\" : 1 }") == "hostInfo") - #expect(MongoScriptAccessPolicy.refusedName(op: "command", request: command(#"{"\u0064rop": "users"}"#)) == "drop") - #expect(MongoScriptAccessPolicy.refusedName(op: "delete", request: [:]) == "delete") - } - - @Test("A command that is missing, not text, or does not open with a readable key is refused") - func unreadableCommandsAreRefused() { - for document in [ - "", - "{}", - #"[{"dbStats": 1}]"#, - "{dbStats: 1}", - "{'dbStats': 1}", - #"{"\ud800": 1}"#, - #"{"\udc00dbStats": 1}"#, - #"{"db\Stats": 1}"#, - "{\"db\tStats\": 1}", - "\u{FEFF}{\"dbStats\": 1}", - "{\"dbStats" - ] { - #expect(!allowsRead("command", command(document)), "\(document)") - } - #expect(!allowsRead("command", [:])) - #expect(!allowsRead("command", ["command": ["dbStats": 1]])) - } - - @Test("Read-write access allows every call and every command") - func readWriteAllowsEverything() { - for op in Self.writeOperations + Self.readOperations + ["mapReduce"] { - #expect(MongoScriptAccessPolicy.allows(op: op, request: [:], access: .readWrite), "\(op)") - } - #expect(MongoScriptAccessPolicy.allows(op: "command", request: command(#"{"drop": "users"}"#), access: .readWrite)) - #expect(MongoScriptAccessPolicy.allows(op: "command", request: [:], access: .readWrite)) - } - - @Test("$out and $merge write as keys at any depth, escaped or not, and in any shape") - func writeStagesAreFound() { - for text in [ - #"[{"$out": "copy"}]"#, - #"[{"$match": {}}, {"$merge": {"into": "copy"}}]"#, - #"[{"$lookup": {"from": "a", "pipeline": [{"$out": "copy"}], "as": "b"}}]"#, - #"[{"$facet": {"x": [{"$match": {"y": [{"z": {"$merge": "c"}}]}}]}}]"#, - #"{"pipeline": [{"$out": "copy"}]}"#, - #"[{"\u0024out": "copy"}]"#, - #"[{"$o\u0075t": "copy"}]"#, - #"[{"$merg\u0065": {"into": "copy"}}]"#, - #"{"pipeline": [], "pipeline": [{"$out": "copy"}]}"#, - #"{"pipeline": [{"$out": "copy"}], "pipeline": []}"#, - #"{"hint": {}, "pipeline": [{"$out": "copy"}]}"# - ] { - #expect(MongoScriptAccessPolicy.writesThroughPipeline(text), "\(text)") - } - } - - @Test("A string value spelled like a write stage is a field path, not a stage") - func stringValuesAreNotStages() { - for text in [ - #"[{"$group": {"_id": "$out", "n": {"$sum": {"$numberInt": "1"}}}}]"#, - #"[{"$project": {"m": "$merge"}}]"#, - #"[{"$match": {"f": "\u0024out"}}]"#, - #"[{"$match": {"k": ["$out", "$merge"]}}]"#, - #"[{"$project": {"o": "$outcome"}}]"#, - #"[{"$project": {"m": {"$mergeObjects": [{"a": 1}, {"b": 2}]}}}]"#, - #"[{"$match": {"$OUT": 1}}]"#, - #"[{"$match": {"a": -1.5e+3, "b": true, "c": false, "d": null, "e": 0, "f": 2E-2}}]"#, - "[]", - " [ ] ", - #"{"hint": {"a": 1}, "collation": {"locale": "fr"}, "batchSize": 10, "allowDiskUse": true, "maxTimeMS": 5}"#, - String(repeating: "[", count: 120) + String(repeating: "]", count: 120) - ] { - #expect(!MongoScriptAccessPolicy.writesThroughPipeline(text), "\(text.prefix(80))") - } - } - - @Test("Text that is not strict JSON counts as writing") - func unreadableTextWrites() { - for text in [ - "", - " ", - "{", - "\"$out\"", - "1", - #"[{"$match": }]"#, - "[{'$out': 'copy'}]", - "[{$out: 'copy'}]", - #"[{"a": NaN}]"#, - #"[{"a": 01}]"#, - #"[{"a": 1.}]"#, - #"[{"a": "\x24out"}]"#, - #"[{"\ud800": 1}]"#, - #"[{"\udc00": 1}]"#, - #"[] {"$out": "copy"}"#, - "[{\"a\": \"tab\tinside\"}]", - String(repeating: "[", count: 1_000) + String(repeating: "]", count: 1_000) - ] { - #expect(MongoScriptAccessPolicy.writesThroughPipeline(text), "\(text.prefix(80))") - } - } - - @Test("Aggregate options are checked beside the pipeline") - func aggregateOptionsAreChecked() { - #expect(MongoScriptAccessPolicy.writes(pipeline: "[]", options: #"{"hint": {}, "pipeline": [{"$out": "copy"}]}"#)) - #expect(MongoScriptAccessPolicy.writes(pipeline: #"[{"$out": "copy"}]"#, options: nil)) - #expect(!MongoScriptAccessPolicy.writes(pipeline: "[]", options: nil)) - #expect(!MongoScriptAccessPolicy.writes(pipeline: "[]", options: #"{"hint": {"a": 1}}"#)) - } - - @Test("A stage spliced in through a cursor's sort is in the pipeline the host checks") - func spliceThroughSortIsSeen() throws { - var options = MongoScriptCursorOptions.none - try options.apply(key: "sort", value: #"{"a": 1}}, {"$out": "copy"#) - #expect(MongoScriptAccessPolicy.writesThroughPipeline(options.decoratedPipeline(#"[{"$match": {}}]"#))) - } -} diff --git a/TableProTests/Plugins/MongoSearchIndexTests.swift b/TableProTests/Plugins/MongoSearchIndexTests.swift deleted file mode 100644 index ab9d43c435..0000000000 --- a/TableProTests/Plugins/MongoSearchIndexTests.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// MongoSearchIndexTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// The listings are the `$listSearchIndexes` output the MongoDB manual documents for Atlas, written -/// in the canonical Extended JSON libmongoc hands back, one host kept from each `statusDetail`. No -/// Atlas cluster was reachable from the live checks, and MongoDB 7.0.43 refuses the stage. -struct MongoSearchIndexTests { - enum Fixture { - static let synonymMappings = #"{ "id" : "65240be420da840844a4d077", "name" : "synonym_mappings", "status" : "READY", "# - + #""queryable" : true, "latestDefinitionVersion" : { "version" : { "$numberInt" : "0" }, "# - + #""createdAt" : { "$date" : { "$numberLong" : "1696861156305" } } }, "# - + #""latestDefinition" : { "mappings" : { "dynamic" : true, "fields" : { "fullplot" : { "type" : "string" } } }, "# - + #""synonyms" : [ { "name" : "synonym_mapping", "analyzer" : "lucene.english", "# - + #""source" : { "collection" : "synonyms" } } ] }, "synonymMappingStatus" : "READY", "# - + #""synonymMappingStatusDetail" : [ { "synonym_mapping" : { "status" : "READY", "queryable" : true } } ], "# - + #""statusDetail" : [ { "hostname" : "atlas-n1cm1j-shard-00-02", "status" : "READY", "queryable" : true, "# - + #""mainIndex" : { "status" : "READY", "queryable" : true, "definitionVersion" : { "# - + #""version" : { "$numberInt" : "0" }, "createdAt" : { "$date" : { "$numberLong" : "1696861156000" } } }, "# - + #""definition" : { "mappings" : { "dynamic" : true, "fields" : { "fullplot" : { "type" : "string", "# - + #""indexOptions" : "offsets", "store" : true, "norms" : "include" } } }, "# - + #""synonyms" : [ { "name" : "synonym_mapping", "analyzer" : "lucene.english", "# - + #""source" : { "collection" : "synonyms" } } ] } } } ] }"# - - static let storedSource = #"{ "id" : "6524096020da840844a4c4a7", "name" : "default", "status" : "BUILDING", "# - + #""queryable" : true, "latestDefinitionVersion" : { "version" : { "$numberInt" : "2" }, "# - + #""createdAt" : { "$date" : { "$numberLong" : "1696863117355" } } }, "# - + #""latestDefinition" : { "mappings" : { "dynamic" : true }, "storedSource" : { "include" : [ "awards.text" ] } }, "# - + #""statusDetail" : [ { "hostname" : "atlas-n1cm1j-shard-00-02", "status" : "BUILDING", "queryable" : true, "# - + #""mainIndex" : { "status" : "READY", "queryable" : true, "definitionVersion" : { "# - + #""version" : { "$numberInt" : "0" }, "createdAt" : { "$date" : { "$numberLong" : "1696860512000" } } }, "# - + #""definition" : { "mappings" : { "dynamic" : true, "fields" : { } } } }, "# - + #""stagedIndex" : { "status" : "PENDING", "queryable" : false, "definitionVersion" : { "# - + #""version" : { "$numberInt" : "1" }, "createdAt" : { "$date" : { "$numberLong" : "1696863089000" } } }, "# - + #""definition" : { "mappings" : { "dynamic" : true, "fields" : { } }, "storedSource" : true } } } ] }"# - - static let vectorSearch = #"{ "id" : "6524096020da840844a4c4b1", "name" : "vector_index", "type" : "vectorSearch", "# - + #""status" : "READY", "queryable" : true, "latestDefinitionVersion" : { "version" : { "$numberInt" : "0" }, "# - + #""createdAt" : { "$date" : { "$numberLong" : "1696861156305" } } }, "# - + #""latestDefinition" : { "fields" : [ { "type" : "vector", "numDimensions" : { "$numberInt" : "1536" }, "# - + #""path" : "plot_embedding", "similarity" : "dotProduct" }, { "type" : "filter", "path" : "genres" } ] } }"# - - /// An update in progress: the definition the host still serves names `tenant`, the new one - /// does not yet. - static let updating = #"{ "id" : "1", "name" : "catalog", "status" : "BUILDING", "queryable" : true, "# - + #""latestDefinition" : { "mappings" : { "dynamic" : false, "fields" : { "title" : { "type" : "string" } } } }, "# - + #""statusDetail" : [ { "hostname" : "h", "mainIndex" : { "definition" : { "mappings" : { "dynamic" : false, "# - + #""fields" : { "title" : { "type" : "string" }, "tenant" : { "type" : "token" } } } } } } ] }"# - } - - private func index(_ json: String) throws -> MongoSearchIndex { - try #require(MongoSearchIndex(json: json)) - } - - @Test("A mapping names its field as a key, in the latest definition and in each host's") - func mappingKeys() throws { - let synonyms = try index(Fixture.synonymMappings) - #expect(synonyms.name == "synonym_mappings") - #expect(synonyms.mentions("fullplot")) - #expect(!synonyms.mentions("plot")) - #expect(!synonyms.mentions("full")) - } - - @Test("A vector index names its fields by path, and storedSource by a list of paths") - func paths() throws { - let vector = try index(Fixture.vectorSearch) - #expect(vector.mentions("plot_embedding")) - #expect(vector.mentions("genres")) - #expect(!vector.mentions("plot")) - let stored = try index(Fixture.storedSource) - #expect(stored.mentions("awards")) - #expect(stored.mentions("text")) - #expect(!stored.mentions("award")) - } - - @Test("The definition a host still serves counts while a new one is being built") - func servedDefinitionCounts() throws { - let updating = try index(Fixture.updating) - #expect(updating.mentions("tenant")) - #expect(updating.mentions("title")) - } - - /// The listing around a definition is status, not definition: `status`, `queryable` and - /// `hostname` are the server's words about the index. Inside a definition every key and every - /// string counts, grammar included, because a false refusal costs one save and a missed one - /// leaves an index on a field that is gone. - @Test("Only definitions are read, and every key and string inside one counts") - func onlyDefinitionsCount() throws { - let synonyms = try index(Fixture.synonymMappings) - #expect(!synonyms.mentions("status")) - #expect(!synonyms.mentions("queryable")) - #expect(!synonyms.mentions("hostname")) - #expect(!synonyms.mentions("READY")) - #expect(synonyms.mentions("mappings")) - #expect(synonyms.mentions("synonyms")) - } - - /// Measured: `$listSearchIndexes` and `$search` both fail with 6047401 on MongoDB 6.0.28 and - /// 7.0.43 community, and both with 31082 on 8.2.12 community with no `mongot`. MongoDB 5.0.33 - /// community fails both with 40324, the code for a stage the server does not know. - @Test("A server with no search says so for every search stage, and has no search index") - func serverWithoutSearch() { - #expect(MongoSearchIndex.listingFailure(code: 6_047_401) == .serverWithoutSearch) - #expect(MongoSearchIndex.listingFailure(code: 31_082) == .serverWithoutSearch) - for code: UInt32 in [0, 6, 13, 50, 59, 89, 91, 11_600, 13_435] { - #expect(MongoSearchIndex.listingFailure(code: code) == .unknown, "\(code)") - } - #expect(MongoSearchIndex.listingPipelineJson == #"[{"$listSearchIndexes": {}}]"#) - } - - /// An Atlas cluster older than `$listSearchIndexes` answers it with 40324 and still runs - /// `$search` over search indexes it cannot list. Reading 40324 as "no indexes" let a rename - /// leave such an index mapping a field no document has. - @Test("A server that does not know the listing stage has search indexes unless it does not know $search either") - func listingStageUnknown() { - #expect(MongoSearchIndex.listingFailure(code: 40_324) == .listingStageUnknown) - #expect(MongoSearchIndex.searchIsUnavailable(probeErrorCode: nil) == false) - #expect(MongoSearchIndex.searchIsUnavailable(probeErrorCode: 40_324)) - #expect(MongoSearchIndex.searchIsUnavailable(probeErrorCode: 6_047_401)) - #expect(MongoSearchIndex.searchIsUnavailable(probeErrorCode: 31_082)) - #expect(MongoSearchIndex.searchIsUnavailable(probeErrorCode: 8) == false) - #expect(MongoSearchIndex.searchIsUnavailable(probeErrorCode: 13) == false) - #expect(MongoSearchIndex.searchProbePipelineJson.hasPrefix(#"[{"$search": "#)) - } - - @Test("A listing that is not a document is no index") - func unreadableListing() { - #expect(MongoSearchIndex(json: "[]") == nil) - #expect(MongoSearchIndex(json: "not json") == nil) - } -} diff --git a/TableProTests/Plugins/MongoStreamProjectionTests.swift b/TableProTests/Plugins/MongoStreamProjectionTests.swift deleted file mode 100644 index e623acd31c..0000000000 --- a/TableProTests/Plugins/MongoStreamProjectionTests.swift +++ /dev/null @@ -1,186 +0,0 @@ -// -// MongoStreamProjectionTests.swift -// TableProTests -// -// Tests for MongoStreamProjection (compiled via symlink from MongoDBDriverPlugin). -// - -import Foundation -import TableProPluginKit -import Testing - -struct MongoStreamProjectionTests { - private func text(_ value: Any, _ kind: BsonValueKind) -> PluginCellValue { - PluginCellValue.fromOptional("\(value)") - } - - @Test("Header carries the announced columns and type names") - func headerCarriesColumns() { - let projection = MongoStreamProjection( - columns: ["_id", "name"], - columnTypeNames: ["ObjectId", "VARCHAR"] - ) - #expect(projection.header.columns == ["_id", "name"]) - #expect(projection.header.columnTypeNames == ["ObjectId", "VARCHAR"]) - } - - @Test("An empty column set falls back to _id") - func emptyColumnsFallBackToId() { - let projection = MongoStreamProjection(columns: [], columnTypeNames: []) - #expect(projection.columns == ["_id"]) - #expect(projection.columnTypeNames == ["VARCHAR"]) - } - - @Test("Missing type names are padded so the header stays balanced") - func missingTypeNamesArePadded() { - let projection = MongoStreamProjection(columns: ["a", "b", "c"], columnTypeNames: ["INTEGER"]) - #expect(projection.columnTypeNames == ["INTEGER", "VARCHAR", "VARCHAR"]) - } - - @Test("A document missing a column yields null in that slot") - func missingFieldBecomesNull() { - let projection = MongoStreamProjection(columns: ["_id", "name", "email"], columnTypeNames: []) - let row = projection.row(for: ["_id": 1, "email": "a@b.c"], convert: text) - #expect(row == [.text("1"), .null, .text("a@b.c")]) - } - - @Test("Fields outside the column plan never widen a row") - func extraFieldsDoNotWidenTheRow() { - let projection = MongoStreamProjection(columns: ["_id", "name"], columnTypeNames: []) - let row = projection.row(for: ["_id": 1, "name": "Ada", "addedLater": true], convert: text) - #expect(row.count == projection.columns.count) - #expect(row == [.text("1"), .text("Ada")]) - } - - @Test("Every row in a heterogeneous batch matches the header width") - func heterogeneousDocumentsStayAligned() { - let documents: [[String: Any]] = [ - ["_id": 1, "name": "Ada"], - ["_id": 2, "nickname": "Grace"], - ["_id": 3] - ] - let projection = MongoStreamProjection(columns: ["_id", "name", "nickname"], columnTypeNames: []) - - for document in documents { - #expect(projection.row(for: document, convert: text).count == projection.header.columns.count) - } - } - - @Test("Column order drives the row, not the document key order") - func columnOrderDrivesTheRow() { - let projection = MongoStreamProjection(columns: ["z", "a"], columnTypeNames: []) - let row = projection.row(for: ["a": "first", "z": "last"], convert: text) - #expect(row == [.text("last"), .text("first")]) - } - - private var fullSample: [[String: Any]] { - (1...MongoStreamProjection.sampleSize).map { ["_id": Int32($0), "name": "user \($0)"] } - } - - private func census(_ tallies: [(String, Int64, Any)]) -> MongoFieldCensus { - MongoFieldCensus(tallies: tallies.map { MongoFieldCensus.Tally(field: $0.0, documents: $0.1, example: $0.2) }) - } - - private func cell(_ value: Any, _ kind: BsonValueKind) -> PluginCellValue { - BsonDocumentFlattener.cellValue(for: value, kind: kind, representation: .unspecified) - } - - @Test("A field the census reports after the sample joins the header with its own type") - func censusFieldJoinsTheHeader() throws { - let seen = Date(timeIntervalSince1970: 1_700_000_000) - let projection = MongoStreamProjection( - sample: fullSample, - census: census([ - ("_id", 201, Int32(1)), - ("name", 201, "user 1"), - ("late", 1, seen) - ]), - representation: .unspecified - ) - - #expect(projection.columns == ["_id", "name", "late"]) - let lateIndex = try #require(projection.columns.firstIndex(of: "late")) - #expect(projection.columnTypeNames[lateIndex] == "TIMESTAMP") - - let row = projection.row(for: ["_id": Int32(201), "name": "user 201", "late": seen], convert: cell) - #expect(row[lateIndex] != .null) - } - - @Test("Without a census the header is the sample's columns") - func missingCensusKeepsTheSampledColumns() { - let projection = MongoStreamProjection(sample: fullSample, census: nil, representation: .unspecified) - #expect(projection.columns == ["_id", "name"]) - #expect(projection.columnTypeNames == ["INTEGER", "VARCHAR"]) - } - - @Test("Sampled columns keep their order and census fields follow in name order") - func censusFieldsFollowTheSampledColumns() { - let projection = MongoStreamProjection( - sample: fullSample, - census: census([("zeta", 4, "z"), ("name", 201, "user 1"), ("alpha", 2, true)]), - representation: .unspecified - ) - #expect(projection.columns == ["_id", "name", "alpha", "zeta"]) - #expect(projection.columnTypeNames == ["INTEGER", "VARCHAR", "BOOLEAN", "VARCHAR"]) - } - - @Test("A census field takes the type most of its documents hold, and nulls do not vote") - func censusFieldTakesTheMajorityType() { - let projection = MongoStreamProjection( - sample: fullSample, - census: census([ - ("mixed", 3, "text"), - ("mixed", 10, Int64(5_000_000_000)), - ("dated", 500, NSNull()), - ("dated", 1, Date(timeIntervalSince1970: 0)), - ("empty", 7, NSNull()) - ]), - representation: .unspecified - ) - #expect(projection.columns == ["_id", "name", "dated", "empty", "mixed"]) - #expect(projection.columnTypeNames == ["INTEGER", "VARCHAR", "TIMESTAMP", "VARCHAR", "BIGINT"]) - } - - @Test("A sampled column that held only nulls takes the type the census found for it") - func censusTypesAColumnTheSampleHeldOnlyNullsIn() { - let sample: [[String: Any]] = (1...MongoStreamProjection.sampleSize).map { - ["_id": Int32($0), "deletedAt": NSNull()] - } - let deleted = Date(timeIntervalSince1970: 1_700_000_000) - let projection = MongoStreamProjection( - sample: sample, - census: census([("deletedAt", 200, NSNull()), ("deletedAt", 40, deleted)]), - representation: .unspecified - ) - - #expect(projection.columns == ["_id", "deletedAt"]) - #expect(projection.columnTypeNames == ["INTEGER", "TIMESTAMP"]) - let row = projection.row(for: ["_id": Int32(201), "deletedAt": deleted], convert: cell) - #expect(row[1] != .null) - } - - @Test("The census never changes the type the sample chose for a sampled column") - func censusLeavesSampledTypesAlone() { - let projection = MongoStreamProjection( - sample: fullSample, - census: census([("name", 10_000, Int32(3))]), - representation: .unspecified - ) - #expect(projection.columns == ["_id", "name"]) - #expect(projection.columnTypeNames == ["INTEGER", "VARCHAR"]) - } - - @Test("A field the header left out is reported, and an announced one is not") - func fieldsOutsideTheHeaderAreReported() { - let projection = MongoStreamProjection(sample: fullSample, census: nil, representation: .unspecified) - let late = projection.unannouncedFields(in: ["_id": Int32(201), "name": "user 201", "late": true]) - #expect(late == ["late"]) - #expect(projection.unannouncedFields(in: ["_id": Int32(202), "name": "user 202"]).isEmpty) - } - - @Test("The fallback header announces only _id") - func fallbackHeaderAnnouncesOnlyTheId() { - let projection = MongoStreamProjection(columns: [], columnTypeNames: []) - #expect(projection.unannouncedFields(in: ["_id": 1, "name": "Ada"]) == ["name"]) - } -} diff --git a/TableProTests/Plugins/MongoWriteFailureTests.swift b/TableProTests/Plugins/MongoWriteFailureTests.swift deleted file mode 100644 index dbaaad2e26..0000000000 --- a/TableProTests/Plugins/MongoWriteFailureTests.swift +++ /dev/null @@ -1,242 +0,0 @@ -// -// MongoWriteFailureTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct MongoWriteFailureTests { - @Test("A validator rejection under ok: 1 is a failure") - func validatorRejection() { - let reply = """ - {"n":{"$numberInt":"0"},"writeErrors":[{"index":{"$numberInt":"0"},"code":{"$numberInt":"121"},\ - "errmsg":"Document failed validation","errInfo":{"failingDocumentId":{"$oid":"6ab6cea9cc5310bead7fd537"},\ - "details":{"operatorName":"$jsonSchema"}}}],"nModified":{"$numberInt":"0"},"ok":{"$numberInt":"1"}} - """ - #expect(MongoWriteFailure.read(fromReply: reply) == MongoWriteFailure( - code: 121, message: "Document failed validation", stage: .document - )) - } - - @Test("An update that would change _id is a failure") - func immutableId() { - let reply = """ - {"n":{"$numberInt":"0"},"writeErrors":[{"index":{"$numberInt":"0"},"code":{"$numberInt":"66"},\ - "errmsg":"Performing an update on the path '_id' would modify the immutable field '_id'"}],\ - "nModified":{"$numberInt":"0"},"ok":{"$numberInt":"1"}} - """ - let failure = MongoWriteFailure.read(fromReply: reply) - #expect(failure?.code == 66) - #expect(failure?.message == "Performing an update on the path '_id' would modify the immutable field '_id'") - } - - @Test("A duplicate key is a failure") - func duplicateKey() { - let reply = """ - {"n":{"$numberInt":"0"},"writeErrors":[{"index":{"$numberInt":"0"},"code":{"$numberInt":"11000"},\ - "errmsg":"E11000 duplicate key error collection: v.u index: k_1 dup key: { k: 1 }",\ - "keyPattern":{"k":{"$numberInt":"1"}},"keyValue":{"k":{"$numberInt":"1"}}}],\ - "nModified":{"$numberInt":"0"},"ok":{"$numberInt":"1"}} - """ - #expect(MongoWriteFailure.read(fromReply: reply)?.code == 11_000) - } - - @Test("A write concern error is a failure that says the write was applied") - func writeConcernError() { - let reply = """ - {"n":{"$numberInt":"1"},"nModified":{"$numberInt":"1"},"writeConcernError":{"code":{"$numberInt":"64"},\ - "codeName":"WriteConcernFailed","errmsg":"waiting for replication timed out"},"ok":{"$numberInt":"1"}} - """ - #expect(MongoWriteFailure.read(fromReply: reply) == MongoWriteFailure( - code: 64, - message: MongoScriptText.writeNotAcknowledged(reason: "waiting for replication timed out"), - stage: .unconfirmed - )) - } - - @Test("The insert call's plural writeConcernErrors also says the write was applied") - func pluralWriteConcernErrors() { - let reply = """ - {"insertedCount":{"$numberInt":"1"},"writeConcernErrors":[{"code":{"$numberInt":"64"},\ - "errmsg":"waiting for replication timed out"}]} - """ - #expect(MongoWriteFailure.read(fromReply: reply) == MongoWriteFailure( - code: 64, - message: MongoScriptText.writeNotAcknowledged(reason: "waiting for replication timed out"), - stage: .unconfirmed - )) - } - - @Test("A command the server stopped carries the server's own code and message") - func commandStoppedByTimeout() { - let reply = """ - { "ok" : { "$numberDouble" : "0.0" }, "errmsg" : "operation exceeded time limit", \ - "code" : { "$numberInt" : "50" }, "codeName" : "MaxTimeMSExpired" } - """ - let failure = MongoWriteFailure.read(fromReply: reply) - #expect(failure == MongoWriteFailure(code: 50, message: "operation exceeded time limit", stage: .command)) - #expect(failure?.stoppedWhileRunning == true) - } - - @Test("A command the server refused outright did not stop while running") - func commandRefused() { - let reply = """ - { "ok" : { "$numberDouble" : "0.0" }, "errmsg" : "cannot use 'w' > 1 when a host is not replicated", \ - "code" : { "$numberInt" : "2" }, "codeName" : "BadValue" } - """ - let failure = MongoWriteFailure.read(fromReply: reply) - #expect(failure?.stage == .command) - #expect(failure?.code == 2) - #expect(failure?.message == "cannot use 'w' > 1 when a host is not replicated") - #expect(failure?.stoppedWhileRunning == false) - } - - @Test("The insert call's errorReplies carry the server's refusal") - func errorReplies() { - let reply = """ - { "insertedCount" : { "$numberInt" : "0" }, "errorReplies" : [ { "ok" : { "$numberDouble" : "0.0" }, \ - "errmsg" : "cannot use 'w' > 1 when a host is not replicated", "code" : { "$numberInt" : "2" }, \ - "codeName" : "BadValue" } ] } - """ - #expect(MongoWriteFailure.read(fromReply: reply) == MongoWriteFailure( - code: 2, message: "cannot use 'w' > 1 when a host is not replicated", stage: .command - )) - } - - @Test("A reply with no server answer is left to the caller's error domain") - func noServerAnswer() { - #expect(MongoWriteFailure.read(fromReply: "{}") == nil) - #expect(MongoWriteFailure.read(fromReply: "{\"errorLabels\":[\"RetryableWriteError\"]}") == nil) - } - - @Test("The first write error wins over a write concern error") - func writeErrorsBeforeConcern() { - let reply = """ - {"writeErrors":[{"index":0,"code":121,"errmsg":"Document failed validation"}],\ - "writeConcernError":{"code":64,"errmsg":"waiting for replication timed out"},"ok":1} - """ - #expect(MongoWriteFailure.read(fromReply: reply)?.code == 121) - } - - @Test("A clean reply, including one that matched nothing, is not a failure") - func cleanReplies() { - #expect(MongoWriteFailure.read(fromReply: """ - {"n":{"$numberInt":"2"},"nModified":{"$numberInt":"2"},"ok":{"$numberInt":"1"}} - """) == nil) - #expect(MongoWriteFailure.read(fromReply: """ - {"n":{"$numberInt":"0"},"ok":{"$numberInt":"1"}} - """) == nil) - #expect(MongoWriteFailure.read(fromReply: "{\"writeErrors\":[],\"ok\":1}") == nil) - } - - @Test("An entry without a message still names its code") - func missingMessage() { - let failure = MongoWriteFailure.read(fromReply: "{\"writeErrors\":[{\"index\":0,\"code\":2}],\"ok\":1}") - #expect(failure?.code == 2) - #expect(failure?.message == MongoScriptText.writeRefused(code: 2)) - } - - @Test("A command reply carrying a write concern error is a failure, and its write errors are not") - func commandConcernFailure() { - let collMod = #"{"ok":1,"writeConcernError":{"code":64,"errmsg":"waiting for replication timed out"}}"# - let failure = MongoWriteFailure.concernFailure(fromReply: collMod) - #expect(failure?.code == 64) - #expect(failure?.message == MongoScriptText.writeNotAcknowledged(reason: "waiting for replication timed out")) - #expect(MongoWriteFailure.concernFailure(fromReply: #"{"ok":1}"#) == nil) - #expect(MongoWriteFailure.concernFailure(fromReply: """ - {"writeErrors":[{"index":0,"code":121,"errmsg":"Document failed validation"}],"ok":1} - """) == nil) - } - - @Test("Text that is not a reply reads as no failure") - func unreadableReply() { - #expect(MongoWriteFailure.read(fromReply: "") == nil) - #expect(MongoWriteFailure.read(fromReply: "[1, 2]") == nil) - } - - @Test("A write concern error in a CRUD reply's array says the write was applied") - func crudWriteConcernErrors() { - let reply = """ - {"insertedCount":{"$numberInt":"0"},"matchedCount":{"$numberInt":"1"},"modifiedCount":{"$numberInt":"1"},\ - "upsertedCount":{"$numberInt":"0"},"writeConcernErrors":[{"code":{"$numberInt":"64"},\ - "errmsg":"waiting for replication timed out"}]} - """ - #expect(MongoWriteFailure.read(fromReply: reply) == MongoWriteFailure( - code: 64, - message: MongoScriptText.writeNotAcknowledged(reason: "waiting for replication timed out"), - stage: .unconfirmed - )) - } - - @Test("A CRUD reply's writeErrors array gives the server's own message") - func crudWriteErrors() { - let reply = """ - {"insertedCount":{"$numberInt":"0"},"matchedCount":{"$numberInt":"0"},"modifiedCount":{"$numberInt":"0"},\ - "upsertedCount":{"$numberInt":"0"},"writeErrors":[{"index":{"$numberInt":"0"},"code":{"$numberInt":"121"},\ - "errmsg":"Document failed validation"}]} - """ - #expect( - MongoWriteFailure.read(fromReply: reply) - == MongoWriteFailure(code: 121, message: "Document failed validation", stage: .document) - ) - } -} - -struct MongoScriptStatementFailureTests { - @Test("A failure with no database switch and no writes is passed on unchanged") - func noSwitch() { - let error = MongoScriptError("boom") - let carried = MongoScriptStatementFailure.carrying(error, databaseSwitch: nil, writes: MongoWriteLedger()) - #expect(carried as? MongoScriptError == error) - } - - @Test("A failure after a database switch carries the switch and keeps its message") - func carriesSwitch() throws { - let carried = MongoScriptStatementFailure.carrying( - MongoScriptError("boom"), databaseSwitch: "reports", writes: MongoWriteLedger() - ) - let failure = try #require(carried as? MongoScriptStatementFailure) - #expect(failure.databaseSwitch == "reports") - #expect(failure.localizedDescription == "boom") - } - - @Test("A failure after a write carries the ledger and names what was written") - func carriesWrites() throws { - var writes = MongoWriteLedger() - writes.record( - .insert, touchesMany: false, acknowledged: true, - outcome: MongoWriteOutcome(replyJson: "{\"n\": 1}", failure: nil) - ) - let carried = MongoScriptStatementFailure.carrying(MongoScriptError("boom"), databaseSwitch: nil, writes: writes) - let failure = try #require(carried as? MongoScriptStatementFailure) - #expect(failure.databaseSwitch == nil) - #expect(failure.writes == writes) - #expect(failure.localizedDescription == "boom\n\n\(MongoScriptText.writesChanged(1))") - } - - @Test("A write that failed and changed nothing is passed on as itself, its stage with it") - func failedWriteWithNoChangeKeepsItsStage() throws { - var writes = MongoWriteLedger() - let stopped = MongoWriteFailure(code: 50, message: "operation exceeded time limit", stage: .command) - writes.record( - .update, touchesMany: false, acknowledged: true, - outcome: MongoWriteOutcome(replyJson: "{}", failure: stopped) - ) - let carried = MongoScriptStatementFailure.carrying(stopped, databaseSwitch: nil, writes: writes) - #expect(carried as? MongoWriteFailure == stopped) - #expect(carried.localizedDescription == "operation exceeded time limit") - } - - @Test("A failed write crosses the bridge with its stage") - func bridgeCarriesStage() throws { - let failure = MongoWriteFailure(code: 50, message: "operation \"exceeded\" time limit", stage: .unconfirmed) - let data = try #require(failure.scriptReplyJson.data(using: .utf8)) - let response = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - let error = try #require(response["e"] as? [String: Any]) - #expect(response["ok"] as? Bool == false) - #expect(error["m"] as? String == failure.message) - #expect(error["c"] as? Int == 50) - #expect(error["s"] as? String == "unconfirmed") - } -} diff --git a/TableProTests/Plugins/MongoWriteLedgerTests.swift b/TableProTests/Plugins/MongoWriteLedgerTests.swift deleted file mode 100644 index a8084c98fa..0000000000 --- a/TableProTests/Plugins/MongoWriteLedgerTests.swift +++ /dev/null @@ -1,354 +0,0 @@ -// -// MongoWriteLedgerTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// The replies below are the canonical Extended JSON libmongoc 1.28.1 handed back from MongoDB -/// 7.0.43, so the ledger is tested against shapes the host actually sees. -struct MongoWriteLedgerTests { - private static let validatorRejection = """ - { "n" : { "$numberInt" : "0" }, "writeErrors" : [ { "index" : { "$numberInt" : "0" }, \ - "code" : { "$numberInt" : "121" }, "errmsg" : "Document failed validation" } ], \ - "nModified" : { "$numberInt" : "0" }, "ok" : { "$numberDouble" : "1.0" } } - """ - - private static let rawInsertDuplicateAtTwo = """ - { "n" : { "$numberInt" : "2" }, "writeErrors" : [ { "index" : { "$numberInt" : "2" }, \ - "code" : { "$numberInt" : "11000" }, "errmsg" : "E11000 duplicate key error" } ], \ - "ok" : { "$numberDouble" : "1.0" } } - """ - - private static let crudInsertDuplicateAtTwo = """ - { "insertedCount" : { "$numberInt" : "2" }, "writeErrors" : [ { "index" : { "$numberInt" : "2" }, \ - "code" : { "$numberInt" : "11000" }, "errmsg" : "E11000 duplicate key error" } ] } - """ - - private static let maxTimeExpired = """ - { "ok" : { "$numberDouble" : "0.0" }, "errmsg" : "operation exceeded time limit", \ - "code" : { "$numberInt" : "50" }, "codeName" : "MaxTimeMSExpired" } - """ - - private static let interrupted = """ - { "ok" : { "$numberDouble" : "0.0" }, "errmsg" : "operation was interrupted", \ - "code" : { "$numberInt" : "11601" }, "codeName" : "Interrupted" } - """ - - private static let replicationRefused = """ - { "ok" : { "$numberDouble" : "0.0" }, "errmsg" : "cannot use 'w' > 1 when a host is not replicated", \ - "code" : { "$numberInt" : "2" }, "codeName" : "BadValue" } - """ - - private static func outcome(_ reply: String) -> MongoWriteOutcome { - MongoWriteOutcome(replyJson: reply, failure: MongoWriteFailure.read(fromReply: reply)) - } - - private static func ledger(_ operation: MongoWriteOperation, touchesMany: Bool, reply: String) -> MongoWriteLedger { - var ledger = MongoWriteLedger() - ledger.record(operation, touchesMany: touchesMany, acknowledged: true, outcome: outcome(reply)) - return ledger - } - - @Test("An updateMany that fails part-way may have changed documents its reply does not count") - func updateManyDocumentErrorMayHaveChanged() { - let ledger = Self.ledger(.update, touchesMany: true, reply: Self.validatorRejection) - #expect(ledger.changed == 0) - #expect(ledger.mayHaveChangedMore) - #expect(ledger.note == MongoScriptText.writesMayHaveChanged) - } - - @Test("An updateOne that fails changed nothing, so there is no note") - func updateOneDocumentErrorChangedNothing() { - let ledger = Self.ledger(.update, touchesMany: false, reply: Self.validatorRejection) - #expect(ledger.changed == 0) - #expect(!ledger.mayHaveChangedMore) - #expect(ledger.note == nil) - } - - @Test("An ordered insert counts the documents before the duplicate, from either reply shape") - func insertCountsWhatLanded() { - for reply in [Self.rawInsertDuplicateAtTwo, Self.crudInsertDuplicateAtTwo] { - let ledger = Self.ledger(.insert, touchesMany: true, reply: reply) - #expect(ledger.changed == 2) - #expect(!ledger.mayHaveChangedMore) - #expect(ledger.note == MongoScriptText.writesChanged(2)) - } - } - - @Test("A multi-document write stopped by a timeout or a kill may have changed documents") - func interruptedMultiWriteMayHaveChanged() { - for operation in [MongoWriteOperation.update, .delete] { - for reply in [Self.maxTimeExpired, Self.interrupted] { - let ledger = Self.ledger(operation, touchesMany: true, reply: reply) - #expect(ledger.mayHaveChangedMore) - #expect(MongoWriteFailure.read(fromReply: reply)?.stoppedWhileRunning == true) - #expect(ledger.note == MongoScriptText.writesMayHaveChanged) - } - } - } - - @Test("A single-document write stopped by a timeout changed nothing and leaves nothing to report") - func interruptedSingleWriteChangedNothing() { - let ledger = Self.ledger(.update, touchesMany: false, reply: Self.maxTimeExpired) - #expect(!ledger.mayHaveChangedMore) - #expect(ledger.note == nil) - #expect(ledger.isEmpty) - } - - @Test("A command the server refused outright changed nothing") - func refusedCommandChangedNothing() { - let ledger = Self.ledger(.update, touchesMany: true, reply: Self.replicationRefused) - #expect(ledger.changed == 0) - #expect(!ledger.mayHaveChangedMore) - #expect(ledger.note == nil) - } - - @Test("A bulkWrite counts every operation that finished before the one that failed") - func bulkSequenceMatchesMongosh() { - var ledger = MongoWriteLedger() - ledger.record(.insert, touchesMany: false, acknowledged: true, outcome: Self.outcome(""" - { "n" : { "$numberInt" : "1" }, "ok" : { "$numberDouble" : "1.0" } } - """)) - ledger.record(.update, touchesMany: true, acknowledged: true, outcome: Self.outcome(""" - { "n" : { "$numberInt" : "4" }, "nModified" : { "$numberInt" : "4" }, "ok" : { "$numberDouble" : "1.0" } } - """)) - ledger.record(.insert, touchesMany: false, acknowledged: true, outcome: Self.outcome(""" - { "n" : { "$numberInt" : "0" }, "writeErrors" : [ { "index" : { "$numberInt" : "0" }, \ - "code" : { "$numberInt" : "11000" }, "errmsg" : "E11000 duplicate key error" } ], \ - "ok" : { "$numberDouble" : "1.0" } } - """)) - #expect(ledger.changed == 5) - #expect(ledger.note == MongoScriptText.writesChanged(5)) - } - - @Test("A known count followed by an updateMany that failed part-way says both") - func knownCountAndPossiblyMore() { - var ledger = MongoWriteLedger() - ledger.record(.delete, touchesMany: true, acknowledged: true, outcome: Self.outcome(""" - { "n" : { "$numberInt" : "3" }, "ok" : { "$numberDouble" : "1.0" } } - """)) - ledger.record(.update, touchesMany: true, acknowledged: true, outcome: Self.outcome(Self.validatorRejection)) - #expect(ledger.note == MongoScriptText.writesChangedAndMaybeMore(3)) - } - - @Test("A write the servers did not confirm was still applied and counts") - func unconfirmedWriteCounts() { - let ledger = Self.ledger(.update, touchesMany: false, reply: """ - {"n":{"$numberInt":"1"},"nModified":{"$numberInt":"1"},"writeConcernError":{"code":{"$numberInt":"64"},\ - "errmsg":"waiting for replication timed out"},"ok":{"$numberDouble":"1.0"}} - """) - #expect(ledger.changed == 1) - #expect(ledger.note == MongoScriptText.writesChanged(1)) - } - - @Test("Upserts count as changes and a findAndModify counts the document it reached") - func upsertAndFindAndModifyCounts() { - var ledger = MongoWriteLedger() - ledger.record(.update, touchesMany: false, acknowledged: true, outcome: Self.outcome(""" - { "n" : { "$numberInt" : "1" }, "upserted" : [ { "index" : { "$numberInt" : "0" }, \ - "_id" : { "$numberInt" : "100" } } ], "nModified" : { "$numberInt" : "0" }, "ok" : { "$numberDouble" : "1.0" } } - """)) - ledger.record(.findAndModify, touchesMany: false, acknowledged: true, outcome: Self.outcome(""" - { "lastErrorObject" : { "n" : { "$numberInt" : "1" }, "updatedExisting" : true }, \ - "value" : { "_id" : { "$numberInt" : "1" } }, "ok" : { "$numberDouble" : "1.0" } } - """)) - #expect(ledger.changed == 2) - } - - @Test("A write whose connection broke may have been applied, and one never sent was not") - func unansweredAndNotSent() { - var unanswered = MongoWriteLedger() - unanswered.record(.update, touchesMany: false, acknowledged: true, outcome: MongoWriteOutcome( - replyJson: "{}", failure: MongoWriteFailure(code: 6, message: "socket error", stage: .unanswered) - )) - #expect(unanswered.note == MongoScriptText.writesMayHaveChanged) - - var notSent = MongoWriteLedger() - notSent.record(.update, touchesMany: true, acknowledged: true, outcome: MongoWriteOutcome( - replyJson: "{}", failure: MongoWriteFailure(code: 13_053, message: "No suitable servers", stage: .notSent) - )) - #expect(notSent.changed == 0) - #expect(notSent.note == nil) - } - - @Test("An insert that stopped before sending every document still counts the batches that went") - func notSentKeepsEarlierBatches() { - var ledger = MongoWriteLedger() - ledger.record(.insert, touchesMany: true, acknowledged: true, outcome: MongoWriteOutcome( - replyJson: "{ \"insertedCount\" : { \"$numberInt\" : \"3\" } }", - failure: MongoWriteFailure( - code: 0, message: MongoScriptText.insertStoppedAtOversizedDocument, stage: .stoppedBetweenBatches - ) - )) - #expect(ledger.changed == 3) - #expect(!ledger.mayHaveChangedMore) - #expect(ledger.note == MongoScriptText.writesChanged(3)) - } - - @Test("An unacknowledged insert that stopped between batches may have written the batches that went") - func unacknowledgedStopBetweenBatchesMayHaveChanged() { - var ledger = MongoWriteLedger() - ledger.record(.insert, touchesMany: true, acknowledged: false, outcome: MongoWriteOutcome( - replyJson: "{ }", - failure: MongoWriteFailure( - code: 0, message: MongoScriptText.insertStoppedAtOversizedDocument, stage: .stoppedBetweenBatches - ) - )) - #expect(ledger.changed == 0) - #expect(ledger.mayHaveChangedMore) - #expect(ledger.note == MongoScriptText.writesMayHaveChanged) - } - - @Test("A multi-document write stopped by an interruption code from MongoDB 8.0 on may have changed documents") - func newerInterruptionsMayHaveChanged() { - let codes: [UInt32] = [91_331, 10_045_600, 453, 471, 473, 485, 509] - for code in codes { - var ledger = MongoWriteLedger() - ledger.record(.update, touchesMany: true, acknowledged: true, outcome: Self.outcome(""" - { "ok" : { "$numberDouble" : "0.0" }, "errmsg" : "interrupted", "code" : { "$numberInt" : "\(code)" } } - """)) - #expect(ledger.mayHaveChangedMore, "\(code)") - #expect(ledger.note == MongoScriptText.writesMayHaveChanged, "\(code)") - } - } - - @Test("A write sent with w: 0 may have changed documents whatever its reply counts") - func unacknowledgedWriteMayHaveChanged() { - let commandReply = """ - { "n" : { "$numberInt" : "0" }, "nModified" : { "$numberInt" : "0" }, "ok" : { "$numberDouble" : "1.0" } } - """ - for (operation, reply) in [(MongoWriteOperation.update, commandReply), (.delete, commandReply), (.insert, "{ }")] { - var ledger = MongoWriteLedger() - ledger.record(operation, touchesMany: false, acknowledged: false, outcome: Self.outcome(reply)) - #expect(ledger.changed == 0) - #expect(ledger.mayHaveChangedMore) - #expect(ledger.note == MongoScriptText.writesMayHaveChanged) - } - } - - @Test("An unacknowledged write refused on this side changed nothing") - func unacknowledgedWriteNotSent() { - var ledger = MongoWriteLedger() - ledger.record(.insert, touchesMany: true, acknowledged: false, outcome: MongoWriteOutcome( - replyJson: "{ }", failure: MongoWriteFailure(code: 22, message: "Invalid writeConcern", stage: .notSent) - )) - #expect(ledger.note == nil) - #expect(ledger.isEmpty) - } - - @Test("A write still waiting on the server when the statement is abandoned may have been applied") - func writeInFlight() { - var ledger = MongoWriteLedger() - ledger.beginWrite() - #expect(ledger.note == MongoScriptText.writesMayHaveChanged) - ledger.abandonWrite() - #expect(ledger.note == nil) - #expect(ledger.isEmpty) - } -} - -struct MongoWriteLedgerMessageTests { - private static let timeoutText = "operation exceeded time limit" - - private static func ledger(recording failure: MongoWriteFailure, touchesMany: Bool) -> MongoWriteLedger { - var ledger = MongoWriteLedger() - ledger.record( - .update, touchesMany: touchesMany, acknowledged: true, - outcome: MongoWriteOutcome(replyJson: "{}", failure: failure) - ) - return ledger - } - - @Test("A write stopped by the timeout reads as a write, with the note after the timeout text") - func writeTimeoutKeepsNote() { - let ledger = Self.ledger( - recording: MongoWriteFailure(code: 50, message: Self.timeoutText, stage: .command), touchesMany: true - ) - #expect(ledger.reportedMessage( - code: 50, message: Self.timeoutText, failedWrite: .command, maxTimeMS: 30_000 - ) == """ - \(MongoDBTimeoutPolicy.writeTimeoutMessage(maxTimeMS: 30_000)) - - \(MongoScriptText.writesMayHaveChanged) - """) - } - - @Test("A read that times out after the script caught a write's timeout reads as a query, not a write") - func readTimeoutAfterCaughtWriteTimeout() { - let ledger = Self.ledger( - recording: MongoWriteFailure(code: 50, message: Self.timeoutText, stage: .command), touchesMany: true - ) - #expect(ledger.reportedMessage( - code: 50, message: Self.timeoutText, failedWrite: nil, maxTimeMS: 1_000 - ) == """ - \(MongoDBTimeoutPolicy.timeoutMessage(maxTimeMS: 1_000)) - - \(MongoScriptText.writesMayHaveChanged) - """) - } - - @Test("A single write stopped by the timeout reads as a write with nothing recorded before it") - func singleWriteTimeout() { - #expect(MongoWriteLedger().reportedMessage( - code: 50, message: Self.timeoutText, failedWrite: .command, maxTimeMS: 1_000 - ) == MongoDBTimeoutPolicy.writeTimeoutMessage(maxTimeMS: 1_000)) - } - - @Test("A read timeout with nothing written keeps the read wording and no note") - func readTimeout() { - #expect(MongoWriteLedger().reportedMessage( - code: 50, message: Self.timeoutText, failedWrite: nil, maxTimeMS: 30_000 - ) == MongoDBTimeoutPolicy.timeoutMessage(maxTimeMS: 30_000)) - } - - @Test("A read that times out after an earlier write keeps the read wording and adds the note") - func readTimeoutAfterWrite() { - var ledger = MongoWriteLedger() - ledger.record(.delete, touchesMany: true, acknowledged: true, outcome: MongoWriteOutcome(replyJson: "{\"n\": 2}", failure: nil)) - #expect(ledger.reportedMessage(code: 50, message: Self.timeoutText, failedWrite: nil, maxTimeMS: 1_000) == """ - \(MongoDBTimeoutPolicy.timeoutMessage(maxTimeMS: 1_000)) - - \(MongoScriptText.writesChanged(2)) - """) - } - - @Test("A write-concern error with the timeout's code keeps the text that says the write was applied") - func unconfirmedTimeoutKeepsItsText() { - let applied = MongoScriptText.writeNotAcknowledged(reason: "waiting for replication timed out") - var ledger = MongoWriteLedger() - ledger.record(.update, touchesMany: false, acknowledged: true, outcome: MongoWriteOutcome( - replyJson: "{\"n\": 1, \"nModified\": 1}", - failure: MongoWriteFailure(code: 50, message: applied, stage: .unconfirmed) - )) - #expect(ledger.reportedMessage(code: 50, message: applied, failedWrite: .unconfirmed, maxTimeMS: 30_000) == """ - \(applied) - - \(MongoScriptText.writesChanged(1)) - """) - } - - @Test("A server refusal keeps its message and gets the count of what was already written") - func documentErrorWithCount() { - var ledger = MongoWriteLedger() - ledger.record(.insert, touchesMany: true, acknowledged: true, outcome: MongoWriteOutcome( - replyJson: "{\"n\": 2}", - failure: MongoWriteFailure(code: 121, message: "Document failed validation", stage: .document) - )) - #expect(ledger.reportedMessage( - code: 121, message: "Document failed validation", failedWrite: .document, maxTimeMS: 30_000 - ) == """ - Document failed validation - - \(MongoScriptText.writesChanged(2)) - """) - } - - @Test("With no query timeout set, a timeout code is left as the server wrote it") - func noTimeoutConfigured() { - #expect(MongoWriteLedger().reportedMessage( - code: 50, message: Self.timeoutText, failedWrite: .command, maxTimeMS: nil - ) == Self.timeoutText) - } -} diff --git a/TableProTests/Plugins/MySQLConnectionEncodingTests.swift b/TableProTests/Plugins/MySQLConnectionEncodingTests.swift index 090060f339..553d382379 100644 --- a/TableProTests/Plugins/MySQLConnectionEncodingTests.swift +++ b/TableProTests/Plugins/MySQLConnectionEncodingTests.swift @@ -61,28 +61,9 @@ struct MySQLConnectionEncodingTests { } } - @Test("The MySQL plugin declares the shared encoding field") - func pluginDeclaresTheField() throws { - let source = try String(contentsOf: Self.repositoryRoot().appendingPathComponent( - "Plugins/MySQLDriverPlugin/MySQLPlugin.swift" - ), encoding: .utf8) - #expect(source.contains("MySQLConnectionEncoding.connectionField")) - } - private static func encoded(_ field: ConnectionField) throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] return try encoder.encode(field) } - - private static func repositoryRoot(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } } diff --git a/TableProTests/Plugins/MySQLKillLatchTests.swift b/TableProTests/Plugins/MySQLKillLatchTests.swift index 245dc0dfc5..263e1ba3d5 100644 --- a/TableProTests/Plugins/MySQLKillLatchTests.swift +++ b/TableProTests/Plugins/MySQLKillLatchTests.swift @@ -2,12 +2,10 @@ // MySQLKillLatchTests.swift // TableProTests // -// `MariaDBPluginConnection` is not compiled into the test target, so the ordering is pinned here on -// the pure latch the connection asks. The live behaviour it encodes was measured with the app's own -// libmariadb against seven servers; see MySQLKillLatch.swift. +// The live behaviour this latch encodes was measured with the app's own libmariadb against seven +// servers; see MySQLKillLatch.swift. // -import Foundation import Testing struct MySQLKillLatchTests { @@ -75,50 +73,3 @@ struct MySQLKillLatchTests { #expect(MySQLKillLatch.absorbsLatchedKill(flavor: .oceanbase(version: nil)) == false) } } - -/// The absorb step has to sit where every statement passes, not beside one of them. `streamQuery` -/// was the gap the design left: an export right after a Stop collected the kill instead. -struct MySQLStatementEntryPointGuardTests { - @Test("Every statement entry point goes through the wrapper that absorbs a latched kill") - func everyStatementEntryPointUsesTheWrapper() throws { - let source = try Self.connectionSource() - let wrapped = Self.lines(of: source).filter { $0.contains("try runStatement(") } - #expect( - wrapped.count == 3, - """ - executeQuerySync, executeParameterizedQuerySync and streamQuery each wrap their body in \ - runStatement, which is the one place absorbLatchedKillIfNeeded runs. A fourth statement \ - path needs the same wrapper: \(wrapped) - """ - ) - #expect(source.contains("absorbLatchedKillIfNeeded()")) - } - - /// The absorb is useless if the kill has not gone out yet, so the drain is part of it. - @Test("Absorbing drains the cancel queue before reading the latch") - func absorbDrainsTheCancelQueueFirst() throws { - let source = try Self.connectionSource() - let body = try #require(source.range(of: "func absorbLatchedKillIfNeeded() {")) - let tail = source[body.upperBound...].prefix(400) - let drain = try #require(tail.range(of: "cancelQueue.sync {}")) - let read = try #require(tail.range(of: "takeKillAbsorption()")) - #expect(drain.lowerBound < read.lowerBound) - } - - private static func lines(of source: String) -> [String] { - source.components(separatedBy: .newlines) - } - - private static func connectionSource() throws -> String { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - let candidate = directory - .appendingPathComponent("Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift") - if FileManager.default.fileExists(atPath: candidate.path) { - return try String(contentsOf: candidate, encoding: .utf8) - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } -} diff --git a/TableProTests/Plugins/MySQLLocalSocketTests.swift b/TableProTests/Plugins/MySQLLocalSocketTests.swift index 6f7b3d7687..7011144855 100644 --- a/TableProTests/Plugins/MySQLLocalSocketTests.swift +++ b/TableProTests/Plugins/MySQLLocalSocketTests.swift @@ -9,13 +9,6 @@ import TableProPluginKit import Testing struct MySQLLocalSocketTests { - @Test("The field key and default path the app and driver share") - func constants() { - #expect(MySQLLocalSocket.fieldKey == "localSocketPath") - #expect(MySQLLocalSocket.defaultPath == "/tmp/mysql.sock") - #expect(MySQLLocalSocket.maximumPathBytes == 103) - } - @Test("A missing, empty or blank field means no socket, and the path is trimmed") func pathFromFields() { #expect(MySQLLocalSocket.path(in: [:]) == nil) diff --git a/TableProTests/Plugins/MySQLResultSynthesisSourceScanTests.swift b/TableProTests/Plugins/MySQLResultSynthesisSourceScanTests.swift deleted file mode 100644 index 6f0174bb73..0000000000 --- a/TableProTests/Plugins/MySQLResultSynthesisSourceScanTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// MySQLResultSynthesisSourceScanTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// The MySQL driver used to answer a `SELECT` that produced no result set by scraping a name out of -/// the statement text and describing it, so `SELECT COUNT(*) INTO @c FROM users` came back carrying -/// the columns of `users`, and a catalog read a proxy declined came back as -/// `1146 Table 'db.information_schema' doesn't exist`. A result set is the server's answer, so the -/// driver never sends a statement of its own to invent one. The plugin imports CMariaDB, which this -/// target cannot, so the guard is a source scan. -struct MySQLResultSynthesisSourceScanTests { - private static let pluginDirectory: URL = { - var directory = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { directory.deleteLastPathComponent() } - return directory - .appendingPathComponent("Plugins") - .appendingPathComponent("MySQLDriverPlugin") - }() - - private static func pluginSources() throws -> [(name: String, text: String)] { - try FileManager.default - .contentsOfDirectory(at: pluginDirectory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "swift" } - .sorted { $0.lastPathComponent < $1.lastPathComponent } - .map { ($0.lastPathComponent, try String(contentsOf: $0, encoding: .utf8)) } - } - - @Test("The driver reads no table name out of a statement it was handed") - func noStatementTextIsScrapedForATableName() throws { - let sources = try Self.pluginSources() - #expect(!sources.isEmpty, "The scan found no plugin sources, so it checked nothing") - - /// `MySQLSelectLimitStatement` matches a bare `\bFROM\b` to reconcile `SQL_SELECT_LIMIT`, - /// which reads nothing out of the statement. What is banned is capturing the name after it. - let offenders = sources.filter { source in - source.text.contains("bFROM\\s+[") || source.text.contains("extractTableName") - } - #expect( - offenders.map(\.name).isEmpty, - "A statement's text is not a catalog: \(offenders.map(\.name))" - ) - } - - @Test("An empty result is never filled in with columns the server did not send") - func emptyResultsAreNotSynthesized() throws { - let sources = try Self.pluginSources() - - let offenders = sources.filter { source in - source.text.contains("columns.isEmpty && result.rows.isEmpty") - || source.text.contains("fetchColumnNames") - } - #expect( - offenders.map(\.name).isEmpty, - "A result set with no columns means the statement produced none: \(offenders.map(\.name))" - ) - } -} diff --git a/TableProTests/Plugins/ObjectCatalogQueryTests.swift b/TableProTests/Plugins/ObjectCatalogQueryTests.swift index 69c2f340bc..af1d18af04 100644 --- a/TableProTests/Plugins/ObjectCatalogQueryTests.swift +++ b/TableProTests/Plugins/ObjectCatalogQueryTests.swift @@ -341,409 +341,3 @@ struct MySQLObjectQueryTests { #expect(MySQLObjectQueries.routineList(schema: "app", includesParameters: true).contains("information_schema.PARAMETERS")) } } - -struct MSSQLObjectQueryTests { - /// INFORMATION_SCHEMA.ROUTINES.ROUTINE_DEFINITION is nvarchar(4000) and silently truncates, - /// which looks like a procedure that ends mid-statement. - @Test("Routine source comes from sys.sql_modules, never ROUTINE_DEFINITION") - func routineSourceAvoidsInformationSchema() { - let list = MSSQLObjectQueries.routineList(schema: "dbo") - let definition = MSSQLObjectQueries.moduleDefinition(schema: "dbo", name: "p") - #expect(list.contains("sys.sql_modules")) - #expect(definition.contains("sys.sql_modules")) - #expect(!list.contains("ROUTINE_DEFINITION")) - #expect(!definition.contains("ROUTINE_DEFINITION")) - } - - /// `OBJECT_ID(N'sales.v.1')` parses `v.1` as two parts and finds nothing, so a view is matched by - /// its schema and name as literals rather than through a name the server re-parses. - @Test("A module is found by its schema and name, never through OBJECT_ID text") - func moduleIsMatchedByLiteralNames() { - let sql = MSSQLObjectQueries.moduleDefinition(schema: "sales", name: "v.1") - #expect(sql.contains("s.name = N'sales' AND o.name = N'v.1'")) - #expect(sql.contains("LEFT JOIN sys.sql_modules")) - #expect(!sql.contains("OBJECT_ID(")) - } - - @Test("Only the view type code reads as a view") - func viewTypeCode() { - #expect(MSSQLObjectQueries.isView(objectType: "V ")) - #expect(MSSQLObjectQueries.isView(objectType: "v")) - #expect(!MSSQLObjectQueries.isView(objectType: "U ")) - #expect(!MSSQLObjectQueries.isView(objectType: "P ")) - #expect(!MSSQLObjectQueries.isView(objectType: "")) - } - - /// Measured on Azure SQL Edge: the stored text keeps its comments, and an encrypted view's - /// definition is NULL with `IsEncrypted` 1. - @Test("A view's stored text is its source, its header naming the view as it is named now") - func viewSourceIsTheStoredText() throws { - let stored = "-- owner: finance\r\ncreate view [dbo].[v_dscp1]\r\nAS\r\nSELECT id AS idxr FROM dbo.t -- note\r\n" - #expect(try MSSQLObjectQueries.viewSource(row: [stored, "0", "V "], schema: "dbo", name: "v_dscp1") == stored) - - let renamed = try MSSQLObjectQueries.viewSource( - row: ["CREATE VIEW dbo.v_old AS SELECT 2 AS y", "0", "V "], schema: "dbo", name: "v_new" - ) - #expect(renamed == "CREATE VIEW dbo.[v_new] AS SELECT 2 AS y") - } - - @Test("An encrypted view, a withheld definition and a missing view are three different errors") - func viewSourceFailures() { - func failure(_ row: [String?]?, _ name: String) -> PluginObjectSourceError? { - do { - _ = try MSSQLObjectQueries.viewSource(row: row, schema: "dbo", name: name) - return nil - } catch { - return error as? PluginObjectSourceError - } - } - guard case .unsupported("v")? = failure([nil, "1", "V "], "v") else { - Issue.record("An encrypted view must say its source cannot be shown") - return - } - guard case .insufficientPrivilege("v")? = failure([nil, "0", "V "], "v") else { - Issue.record("A withheld definition must say the account may not read it") - return - } - guard case .notFound("v")? = failure(nil, "v") else { - Issue.record("A missing view must say it no longer exists") - return - } - guard case .notFound("t")? = failure([nil, "0", "U "], "t") else { - Issue.record("A table is not a view") - return - } - } - - /// A tab's schema is not a search path on SQL Server, so an unqualified template for `sales.v` - /// created `dbo.v` when run. - @Test("The Edit View template names the view's schema") - func editViewTemplateIsQualified() { - let template = MSSQLObjectQueries.editViewTemplate(schema: "sales", view: "v", createOrAlter: true) - #expect(template == "CREATE OR ALTER VIEW [sales].[v] AS\nSELECT * FROM table_name;") - } - - @Test("Before CREATE OR ALTER the template drops in its own batch") - func legacyEditViewTemplateSeparatesBatches() { - let template = MSSQLObjectQueries.editViewTemplate(schema: "we]ird", view: "o'v", createOrAlter: false) - #expect(template == """ - IF OBJECT_ID(N'[we]]ird].[o''v]', 'V') IS NOT NULL DROP VIEW [we]]ird].[o'v]; - GO - CREATE VIEW [we]]ird].[o'v] AS - SELECT * FROM table_name; - """) - } - - @Test("Both trigger scopes come from one builder") - func triggerScopesShareOneQuery() { - let all = MSSQLObjectQueries.triggerList(schema: "dbo", table: nil) - let one = MSSQLObjectQueries.triggerList(schema: "dbo", table: "Orders") - #expect(!all.contains("parent.name =")) - #expect(one.contains("parent.name = N'Orders'")) - #expect(all.contains("sys.trigger_events")) - } - - @Test("Object types map to the two routine kinds") - func objectTypeMapping() { - #expect(MSSQLObjectQueries.routineKind(forObjectType: "P ") == "PROCEDURE") - #expect(MSSQLObjectQueries.routineKind(forObjectType: "FN") == "FUNCTION") - #expect(MSSQLObjectQueries.routineKind(forObjectType: "IF") == "FUNCTION") - #expect(MSSQLObjectQueries.routineKind(forObjectType: "TF") == "FUNCTION") - } - - @Test("A quote in a schema or table is escaped") - func literalsAreEscaped() { - #expect(MSSQLObjectQueries.routineList(schema: "it's").contains("N'it''s'")) - #expect(MSSQLObjectQueries.triggerList(schema: "dbo", table: "o'brien").contains("N'o''brien'")) - } - - @Test("A non-ASCII schema, routine or table name is an nvarchar literal") - func catalogNamesAreNationalLiterals() { - #expect(MSSQLObjectQueries.routineList(schema: "販売").contains("s.name = N'販売'")) - let definition = MSSQLObjectQueries.moduleDefinition(schema: "販売", name: "集計") - #expect(definition.contains("s.name = N'販売' AND o.name = N'集計'")) - let triggers = MSSQLObjectQueries.triggerList(schema: "販売", table: "注文") - #expect(triggers.contains("s.name = N'販売'")) - #expect(triggers.contains("parent.name = N'注文'")) - } - - @Test("Fixed catalog type codes stay plain literals") - func catalogTypeCodesStayPlain() { - #expect(MSSQLObjectQueries.routineList(schema: "dbo") - .contains("o.type IN ('P', 'PC', 'X', 'FN', 'IF', 'TF', 'FS', 'FT', 'AF')")) - } - - /// A database with CLR routines listed fewer than it held, with nothing saying so. - @Test("CLR and extended routines are listed alongside the T-SQL ones") - func clrRoutinesAreListed() { - for code in ["PC", "FS", "FT", "AF", "X"] { - #expect(MSSQLObjectQueries.routineObjectTypes.contains(code)) - } - } - - @Test("Every procedure code reads as a procedure and every function code as a function") - func objectTypeMappingCoversClr() { - for code in ["P ", "PC", "X "] { - #expect(MSSQLObjectQueries.routineKind(forObjectType: code) == "PROCEDURE") - } - for code in ["FN", "IF", "TF", "FS", "FT", "AF"] { - #expect(MSSQLObjectQueries.routineKind(forObjectType: code) == "FUNCTION") - } - } - - /// Measured on SQL Server 2022: a caller with only SELECT and EXECUTE still sees the - /// sys.sql_modules row with a NULL definition, exactly as WITH ENCRYPTION does. Only - /// OBJECTPROPERTY tells the two apart, and it answers for that caller too. - @Test("Encryption is read from OBJECTPROPERTY, not from a missing definition") - func encryptionIsNotInferredFromNullDefinition() { - let list = MSSQLObjectQueries.routineList(schema: "dbo") - #expect(list.contains("OBJECTPROPERTY(o.object_id, 'IsEncrypted') AS is_encrypted")) - #expect(list.contains("AS definition_withheld")) - #expect(MSSQLObjectQueries.moduleDefinition(schema: "dbo", name: "p") - .contains("OBJECTPROPERTY(o.object_id, 'IsEncrypted')")) - } - - /// Selecting every body to draw a list of names pulled a whole schema's source over the wire - /// and dropped it; the reader's own open re-queries the one they asked for. - @Test("The listing carries no routine bodies") - func listingOmitsBodies() { - let list = MSSQLObjectQueries.routineList(schema: "dbo") - #expect(!list.contains("m.definition,")) - #expect(MSSQLObjectQueries.moduleDefinition(schema: "dbo", name: "p").contains("m.definition")) - } - - /// A CLR routine has no sys.sql_modules row at all, so the inner join this replaced returned - /// zero rows and the caller reported a routine sitting in the list as no longer existing. - /// Measured against SQL Server 2022 with an object that has no module row: inner join 0 rows, - /// left join 1 row. - @Test("The definition query survives a routine with no SQL module row") - func definitionQueryUsesLeftJoin() { - let sql = MSSQLObjectQueries.moduleDefinition(schema: "dbo", name: "p") - #expect(sql.contains("FROM sys.objects o")) - #expect(sql.contains("LEFT JOIN sys.sql_modules m")) - #expect(!sql.contains("FROM sys.sql_modules")) - #expect(sql.contains("o.type")) - } - - @Test("Only T-SQL routines are expected to have a SQL source") - func sqlSourceIsPerObjectType() { - for code in ["P ", "FN", "IF", "TF"] { - #expect(MSSQLObjectQueries.routineHasSQLSource(forObjectType: code)) - } - for code in ["PC", "FS", "FT", "AF", "X "] { - #expect(!MSSQLObjectQueries.routineHasSQLSource(forObjectType: code)) - } - } - - /// Reporting a CLR routine's language as T-SQL is a claim about a body that is not there. - @Test("Language names the runtime the routine actually runs on") - func languageFollowsObjectType() { - #expect(MSSQLObjectQueries.routineLanguage(forObjectType: "P ") == "T-SQL") - #expect(MSSQLObjectQueries.routineLanguage(forObjectType: "TF") == "T-SQL") - #expect(MSSQLObjectQueries.routineLanguage(forObjectType: "PC") == "CLR") - #expect(MSSQLObjectQueries.routineLanguage(forObjectType: "AF") == "CLR") - #expect(MSSQLObjectQueries.routineLanguage(forObjectType: "X ") == "Extended") - } -} - -struct OracleObjectQueryTests { - @Test("The trigger list selects the body the old query never asked for") - func triggerListSelectsBody() { - let sql = OracleObjectQueries.triggerList(schema: "HR", table: nil) - #expect(sql.contains("TRIGGER_BODY")) - #expect(sql.contains("DESCRIPTION")) - #expect(!sql.contains("TABLE_NAME = ")) - } - - @Test("Both trigger scopes come from one builder") - func triggerScopesShareOneQuery() { - let one = OracleObjectQueries.triggerList(schema: "HR", table: "EMPLOYEES") - #expect(one.contains("TABLE_NAME = 'EMPLOYEES'")) - } - - private static func trigger( - name: String = "CS_TRG", - owner: String? = "PROBE", - tableOwner: String? = "PROBE", - description: String?, - whenClause: String? = nil, - actionType: String? = "PL/SQL ", - status: String? = "ENABLED", - body: String? - ) -> OracleTriggerSource { - OracleTriggerSource( - name: name, owner: owner, tableOwner: tableOwner, description: description, whenClause: whenClause, - actionType: actionType, status: status, body: body - ) - } - - /// The shapes measured on Oracle 23ai: DESCRIPTION ends before the WHEN clause and holds no - /// DISABLE, so both come from their own columns. - @Test("A trigger definition writes back its WHEN clause and its disabled state") - func triggerDefinitionKeepsWhenAndDisable() { - let definition = OracleObjectQueries.triggerDefinition(Self.trigger( - description: "trg_b BEFORE INSERT ON t1 FOR EACH ROW FOLLOWS trg_a ", - whenClause: "NEW.id > 0", - status: "DISABLED", - body: "BEGIN :NEW.v := 'b'; END;" - )) - - #expect(definition == """ - CREATE OR REPLACE TRIGGER trg_b BEFORE INSERT ON t1 FOR EACH ROW FOLLOWS trg_a - DISABLE - WHEN (NEW.id > 0) - BEGIN :NEW.v := 'b'; END; - """) - } - - /// TRIGGER_BODY holds only the call's target and a `;` Oracle added. Written back without CALL - /// the create failed with ORA-04079; with the `;` it is stored INVALID. - @Test("A CALL trigger is written with CALL and without Oracle's ;") - func callTriggerDefinition() { - let definition = OracleObjectQueries.triggerDefinition(Self.trigger( - description: "trg_call BEFORE INSERT ON t1 FOR EACH ROW\n", - actionType: "CALL", - body: "log_it(:NEW.id);" - )) - - #expect(definition == "CREATE OR REPLACE TRIGGER trg_call BEFORE INSERT ON t1 FOR EACH ROW\nCALL log_it(:NEW.id)") - } - - @Test("A compound trigger keeps its body and takes its clauses before it") - func compoundTriggerDefinition() { - let definition = OracleObjectQueries.triggerDefinition(Self.trigger( - description: "trg_c FOR INSERT ON t1 ", - status: "DISABLED", - body: "COMPOUND TRIGGER\n BEFORE EACH ROW IS BEGIN NULL; END BEFORE EACH ROW;\nEND trg_c;" - )) - - #expect(definition.hasPrefix("CREATE OR REPLACE TRIGGER trg_c FOR INSERT ON t1\nDISABLE\nCOMPOUND TRIGGER")) - } - - /// Written as the source spelled it, a sync into another schema created the trigger back in the - /// source schema. ALL_SOURCE already gives a procedure without its schema. - @Test("The owner's schema is taken out of the header, any other schema stays") - func ownerSchemaIsStripped() { - let cases: [(header: String, expected: String)] = [ - ("cmp_src.trg_b BEFORE INSERT ON cmp_src.t1 FOR EACH ROW", "trg_b BEFORE INSERT ON t1 FOR EACH ROW"), - (#""CMP_SRC"."TRG_Q" BEFORE UPDATE OF v ON "CMP_SRC"."T1""#, #""TRG_Q" BEFORE UPDATE OF v ON "T1""#), - ("trg_logon AFTER LOGON ON cmp_src.SCHEMA", "trg_logon AFTER LOGON ON SCHEMA"), - ("trg_x BEFORE INSERT ON other.t1 FOR EACH ROW", "trg_x BEFORE INSERT ON other.t1 FOR EACH ROW"), - (#"trg_y BEFORE INSERT ON "cmp_src".t1"#, #"trg_y BEFORE INSERT ON "cmp_src".t1"#), - ( - "trg_z BEFORE INSERT ON other.cmp_src REFERENCING NEW AS cmp_src", - "trg_z BEFORE INSERT ON other.cmp_src REFERENCING NEW AS cmp_src" - ), - ("trg_w -- on cmp_src.t1\n BEFORE DELETE ON CMP_SRC . t1", "trg_w -- on cmp_src.t1\n BEFORE DELETE ON t1"), - ] - for example in cases { - #expect(OracleObjectQueries.strippingSchema("CMP_SRC", from: example.header) == example.expected, "\(example.header)") - } - } - - @Test("A definition read from another schema lands in the schema it runs in") - func triggerDefinitionDropsTheOwner() { - let definition = OracleObjectQueries.triggerDefinition(Self.trigger( - owner: "CMP_SRC", - tableOwner: "CMP_SRC", - description: "cmp_src.trg_b BEFORE INSERT ON cmp_src.t1 FOR EACH ROW", - body: "BEGIN NULL; END;" - )) - - #expect(definition == "CREATE OR REPLACE TRIGGER trg_b BEFORE INSERT ON t1 FOR EACH ROW\nBEGIN NULL; END;") - } - - /// Listed with HR's table and replayed from HR, an unqualified header would create HR.TRG and - /// leave AUDIT.TRG, the trigger that was opened, as it was. - @Test("A trigger one schema owns on another schema's table keeps its header as written") - func crossSchemaTriggerKeepsItsQualifiers() { - let definition = OracleObjectQueries.triggerDefinition(Self.trigger( - owner: "AUDIT", - tableOwner: "HR", - description: "audit.trg BEFORE INSERT ON hr.t FOR EACH ROW", - body: "BEGIN NULL; END;" - )) - - #expect(definition == "CREATE OR REPLACE TRIGGER audit.trg BEFORE INSERT ON hr.t FOR EACH ROW\nBEGIN NULL; END;") - } - - @Test("A missing description still produces a runnable header") - func triggerDefinitionFallsBackToName() { - let definition = OracleObjectQueries.triggerDefinition(Self.trigger( - name: "AUDIT_EMP", description: nil, body: "BEGIN NULL; END;" - )) - #expect(definition.hasPrefix("CREATE OR REPLACE TRIGGER \"AUDIT_EMP\"")) - } - - @Test("The trigger list reads ACTION_TYPE, and TRIGGER_BODY stays the last column because it is a LONG") - func triggerListSelectsActionType() { - let sql = OracleObjectQueries.triggerList(schema: "HR", table: nil) - #expect(sql.contains("ACTION_TYPE,\n TABLE_OWNER,\n TRIGGER_BODY\nFROM SYS.ALL_TRIGGERS")) - } - - /// Unqualified, a trigger another schema owns is looked up in the current schema instead. - @Test("A trigger drop names its schema only when that is not the current one") - func dropTriggerQualification() { - #expect(OracleObjectQueries.dropTrigger(name: "T", schema: "HR", currentSchema: "HR") == #"DROP TRIGGER "T""#) - #expect(OracleObjectQueries.dropTrigger(name: "T", schema: "APP", currentSchema: "HR") == #"DROP TRIGGER "APP"."T""#) - #expect(OracleObjectQueries.dropTrigger(name: "T", schema: nil, currentSchema: "HR") == #"DROP TRIGGER "T""#) - } - - @Test("Timing and orientation are read out of the trigger type") - func timingAndOrientation() { - #expect(OracleObjectQueries.timing(fromTriggerType: "BEFORE EACH ROW") == "BEFORE") - #expect(OracleObjectQueries.timing(fromTriggerType: "AFTER STATEMENT") == "AFTER") - #expect(OracleObjectQueries.timing(fromTriggerType: "INSTEAD OF") == "INSTEAD OF") - #expect(OracleObjectQueries.orientation(fromTriggerType: "BEFORE EACH ROW") == "ROW") - #expect(OracleObjectQueries.orientation(fromTriggerType: "AFTER STATEMENT") == "STATEMENT") - } - - /// A packaged routine is an OBJECT_TYPE of PACKAGE, so listing only PROCEDURE and FUNCTION - /// keeps it out. It is addressed through its package and has a different DDL call, so a row - /// for it here would be a row whose source cannot be fetched. - @Test("Packaged routines stay out of the standalone list") - func packagedRoutinesExcluded() { - let sql = OracleObjectQueries.routineList(schema: "HR") - #expect(sql.contains("OBJECT_TYPE IN ('PROCEDURE', 'FUNCTION')")) - #expect(sql.contains("ALL_OBJECTS")) - } - - /// LISTAGG caps at 4000 bytes and raises ORA-01489 past it, which fails the whole SELECT and - /// loses every routine in the schema over one wide signature. Oracle only overloads inside a - /// package, so a standalone routine needs no argument list to be identified. - @Test("The routine list builds no argument signature") - func routineListAvoidsListagg() { - let sql = OracleObjectQueries.routineList(schema: "HR") - #expect(!sql.contains("LISTAGG")) - #expect(!sql.contains("ALL_ARGUMENTS")) - } - - /// A schema browse asks for the triggers this schema owns; a per-table fetch asks for the - /// triggers on that table. The two columns differ for a cross-schema trigger. - @Test("Schema scope reads OWNER and table scope reads TABLE_OWNER") - func triggerScopeColumns() { - let schemaWide = OracleObjectQueries.triggerList(schema: "HR", table: nil) - #expect(schemaWide.contains("WHERE OWNER = 'HR'")) - #expect(!schemaWide.contains("TABLE_OWNER = 'HR'")) - - let perTable = OracleObjectQueries.triggerList(schema: "HR", table: "EMPLOYEES") - #expect(perTable.contains("TABLE_OWNER = 'HR'")) - #expect(perTable.contains("TABLE_NAME = 'EMPLOYEES'")) - } - - @Test("ALL_SOURCE is read in line order") - func routineSourceOrdersByLine() { - let sql = OracleObjectQueries.routineSource(schema: "HR", name: "P", type: "PROCEDURE") - #expect(sql.contains("ORDER BY LINE")) - #expect(sql.contains("ALL_SOURCE")) - } - - @Test("A quote in a schema or name is escaped") - func literalsAreEscaped() { - #expect(OracleObjectQueries.routineList(schema: "IT'S").contains("'IT''S'")) - #expect( - OracleObjectQueries.routineSource(schema: "HR", name: "X'; DROP", type: "PROCEDURE") - .contains("'X''; DROP'") - ) - } -} diff --git a/TableProTests/Plugins/OracleBindPlaceholdersTests.swift b/TableProTests/Plugins/OracleBindPlaceholdersTests.swift deleted file mode 100644 index 783d3c43d4..0000000000 --- a/TableProTests/Plugins/OracleBindPlaceholdersTests.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// OracleBindPlaceholdersTests.swift -// TableProTests -// -// The host's `?` placeholders on Oracle: a value a literal can carry is the literal the PluginKit default writes, and -// only what no literal can carry is bound as `:n`. A `?` that is text stays text. The literal limit and the binds -// were measured on Oracle 23ai. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct OracleBindPlaceholdersTests { - private static let bytes = PluginCellValue.bytes(Data([0xDE, 0xAD])) - - private func substituted(_ sql: String, _ parameters: [PluginCellValue]) -> String { - OracleBindPlaceholders.substituting(sql, parameters: parameters).sql - } - - @Test("Text, numbers and NULL are the literals the PluginKit default writes") - func literals() { - let statement = OracleBindPlaceholders.substituting( - "INSERT INTO \"T\" (\"A\", \"B\", \"C\", \"D\") VALUES (?, ?, ?, ?)", - parameters: [.text("it's"), .text("12.5"), .null, .text("00123")] - ) - #expect(statement.sql == "INSERT INTO \"T\" (\"A\", \"B\", \"C\", \"D\") VALUES ('it''s', 12.5, NULL, 00123)") - #expect(statement.binds.isEmpty) - } - - /// Oracle has no `X'..'` literal (ORA-03046). - @Test("Bytes are bound, numbered densely among the literals") - func bytesAreBound() { - let other = PluginCellValue.bytes(Data([0x01])) - let statement = OracleBindPlaceholders.substituting( - "UPDATE \"T\" SET \"R\" = ?, \"V\" = ?, \"B\" = ? WHERE \"ID\" = ?", - parameters: [Self.bytes, .text("x"), other, .text("7")] - ) - #expect(statement.sql == "UPDATE \"T\" SET \"R\" = :1, \"V\" = 'x', \"B\" = :2 WHERE \"ID\" = 7") - #expect(statement.binds == [Self.bytes, other]) - } - - /// 4,000 bytes parse and 4,001 fail with ORA-01704, counted on the value and not on its doubled quotes. - @Test("Text over the 4,000-byte literal limit is bound") - func longTextIsBound() { - let atLimit = String(repeating: "a", count: 3_999) + "'" - #expect(OracleBindPlaceholders.literal(for: .text(atLimit)) == "'" + String(repeating: "a", count: 3_999) + "'''") - #expect(OracleBindPlaceholders.literal(for: .text(String(repeating: "a", count: 4_001))) == nil) - #expect(OracleBindPlaceholders.literal(for: .text(String(repeating: "\u{1EBF}", count: 1_333))) != nil) - #expect(OracleBindPlaceholders.literal(for: .text(String(repeating: "\u{1EBF}", count: 1_334))) == nil) - - let long = PluginCellValue.text(String(repeating: "x", count: 5_000)) - let statement = OracleBindPlaceholders.substituting("INSERT INTO \"T\" (\"C\", \"A\") VALUES (?, ?)", parameters: [long, .text("1")]) - #expect(statement.sql == "INSERT INTO \"T\" (\"C\", \"A\") VALUES (:1, 1)") - #expect(statement.binds == [long]) - } - - @Test("A quote carrying a combining mark is still doubled, and NUL is dropped") - func quoteEscaping() { - #expect(OracleBindPlaceholders.literal(for: .text("a'\u{0301}b")) == "'a''\u{0301}b'") - #expect(OracleBindPlaceholders.literal(for: .text("a\u{0}b")) == "'ab'") - } - - @Test("A question mark inside a string literal is text") - func stringLiteral() { - #expect(substituted("SELECT '?', 'it''s ?' FROM dual WHERE a = ?", [Self.bytes]) - == "SELECT '?', 'it''s ?' FROM dual WHERE a = :1") - } - - @Test("A question mark inside an alternative-quoted literal is text, whatever the delimiter") - func alternativeQuotes() { - #expect(substituted("SELECT q'[?]' FROM dual WHERE a = ?", [Self.bytes]) == "SELECT q'[?]' FROM dual WHERE a = :1") - #expect(substituted("SELECT Q'{?}', q'(?)', q'', q'!?!' FROM dual WHERE a = ?", [Self.bytes]) - == "SELECT Q'{?}', q'(?)', q'', q'!?!' FROM dual WHERE a = :1") - #expect(substituted("SELECT nq'[?]', Nq'[?]' FROM dual WHERE a = ?", [Self.bytes]) - == "SELECT nq'[?]', Nq'[?]' FROM dual WHERE a = :1") - } - - /// Read as a plain literal, `q'[it's]'` would close at `it'` and swallow the placeholder after it. - @Test("An alternative-quoted literal takes no escapes, so a quote inside it does not end it") - func alternativeQuoteHoldsAQuote() { - #expect(substituted("SELECT q'[it's]' FROM dual WHERE a = ?", [.text("v")]) - == "SELECT q'[it's]' FROM dual WHERE a = 'v'") - } - - @Test("A question mark in a quoted name is part of the name") - func quotedIdentifier() { - #expect(substituted("UPDATE \"T\" SET \"Paid?\" = ? WHERE \"A?\"\"b\" = ?", [Self.bytes, .text("k")]) - == "UPDATE \"T\" SET \"Paid?\" = :1 WHERE \"A?\"\"b\" = 'k'") - } - - @Test("A question mark in a comment is text") - func comments() { - #expect(substituted("SELECT 1 -- why?\nFROM dual WHERE a = ?", [.text("5")]) == "SELECT 1 -- why?\nFROM dual WHERE a = 5") - #expect(substituted("SELECT /* ? */ 1 FROM dual WHERE a = ?", [.null]) == "SELECT /* ? */ 1 FROM dual WHERE a = NULL") - } - - @Test("A replaced placeholder glued to a word is kept apart from it") - func gluedPlaceholder() { - #expect(substituted("WHERE a=?and b=?", [.text("1"), Self.bytes]) == "WHERE a=1 and b=:1") - } - - @Test("A placeholder with no parameter left stays as it is") - func missingParameter() { - let statement = OracleBindPlaceholders.substituting("WHERE a = ? AND b = ?", parameters: [Self.bytes]) - #expect(statement.sql == "WHERE a = :1 AND b = ?") - #expect(statement.binds == [Self.bytes]) - } - - @Test("Text outside the BMP survives the rewrite") - func nonBMPText() { - #expect(substituted("SELECT '👍🏽?' FROM dual WHERE a = ? AND b = '🇻🇳'", [.text("🇻🇳")]) - == "SELECT '👍🏽?' FROM dual WHERE a = '🇻🇳' AND b = '🇻🇳'") - } - - @Test("A statement with no placeholder comes back unchanged") - func noPlaceholder() { - let statement = OracleBindPlaceholders.substituting("SELECT * FROM \"T\"", parameters: [.text("unused")]) - #expect(statement.sql == "SELECT * FROM \"T\"") - #expect(statement.binds.isEmpty) - } -} diff --git a/TableProTests/Plugins/OracleBrowseSQLTests.swift b/TableProTests/Plugins/OracleBrowseSQLTests.swift deleted file mode 100644 index 34adce8b0b..0000000000 --- a/TableProTests/Plugins/OracleBrowseSQLTests.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// OracleBrowseSQLTests.swift -// TableProTests -// -// The Oracle plugin's table paging and filter SQL. Every query below was run against Oracle 23ai under the default -// NLS_DATE_FORMAT DD-MON-RR and found the row it filters for. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct OracleBrowseSQLTests { - private func filtered(_ column: String, _ op: String, _ value: String, kind: PluginColumnKind?) -> String { - OracleBrowseSQL.filteredQuery( - qualifiedTable: "\"T\"", - filters: [PluginQueryFilter(column: column, op: op, value: value)], - logicMode: "and", - sortColumns: [], - columns: [column], - limit: 10, - offset: 0, - columnKinds: kind.map { [column: $0] } ?? [:] - ) - } - - /// `ORDER BY 1` fails with ORA-22848 when the first column is a LOB. - @Test("A page with no sort has no ORDER BY") - func browseWithoutSort() { - let sql = OracleBrowseSQL.browseQuery( - qualifiedTable: "\"HR\".\"T\"", sortColumns: [], columns: ["NOTE", "ID"], limit: 100, offset: 200 - ) - #expect(sql == "SELECT * FROM \"HR\".\"T\" OFFSET 200 ROWS FETCH NEXT 100 ROWS ONLY") - } - - @Test("A sorted page orders by the sorted column") - func browseWithSort() { - let sql = OracleBrowseSQL.browseQuery( - qualifiedTable: "\"T\"", sortColumns: [(columnIndex: 1, ascending: false)], columns: ["NOTE", "ID"], - limit: 10, offset: 0 - ) - #expect(sql == "SELECT * FROM \"T\" ORDER BY \"ID\" DESC OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY") - } - - @Test("A filtered page with no sort has no ORDER BY either") - func filteredWithoutSort() { - #expect(filtered("N", "=", "5", kind: .integer) == "SELECT * FROM \"T\" WHERE \"N\" = 5 OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY") - } - - /// A quoted `'2026-10-06'` is read through NLS_DATE_FORMAT and fails with ORA-01861. - @Test("A date or timestamp value against a temporal column is an ANSI literal") - func temporalLiterals() { - let cases: [(value: String, literal: String)] = [ - ("2026-10-06", "DATE '2026-10-06'"), - ("2026-10-06 15:22:20", "TIMESTAMP '2026-10-06 15:22:20'"), - ("2026-10-06 15:22:20.050", "TIMESTAMP '2026-10-06 15:22:20.050'"), - ("2026-10-06 15:22:20.000001+02:00", "TIMESTAMP '2026-10-06 15:22:20.000001+02:00'"), - ("2026-10-06 15:22:20 -05:30", "TIMESTAMP '2026-10-06 15:22:20 -05:30'"), - ("2026-10-06T15:22:20", "TIMESTAMP '2026-10-06 15:22:20'"), - (" 2026-10-06 ", "DATE '2026-10-06'") - ] - for testCase in cases { - #expect(OracleBrowseSQL.filterValue(testCase.value, kind: .other) == testCase.literal, "\(testCase.value)") - } - } - - /// Measured on 23ai with the session at `+07:00` against a `+00:00` database: a TIMESTAMP WITH LOCAL TIME ZONE - /// column matches the text it reads as, with its offset or without one in the session's zone. - @Test("A local-time-zone value with or without its offset is a timestamp literal") - func localTimeZoneLiterals() { - #expect(filtered("TL", "=", "2026-10-06 08:04:05.123456+07:00", kind: .other) - == "SELECT * FROM \"T\" WHERE \"TL\" = TIMESTAMP '2026-10-06 08:04:05.123456+07:00' " - + "OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY") - #expect(OracleBrowseSQL.filterValue("2026-10-06 08:04:05.123456", kind: .other) - == "TIMESTAMP '2026-10-06 08:04:05.123456'") - } - - /// Each of these is an error as an ANSI literal (ORA-01861, ORA-01873), so it keeps today's quoted text. - @Test("Text that is not a whole date or timestamp stays a quoted string") - func nonTemporalShapes() { - for value in ["2026-10-06 15:22", "26-10-06", "2026-10-06 15:22:20.1234567891", "06-OCT-26", "x' OR 1=1 --"] { - let quoted = "'\(value.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "'", with: "''"))'" - #expect(OracleBrowseSQL.filterValue(value, kind: .other) == quoted, "\(value)") - } - } - - /// A date literal against a text column would convert the column, which fails on any row that is not a date. - @Test("A text, numeric or unknown column keeps its own literal") - func otherKindsKeepTheirLiterals() { - #expect(OracleBrowseSQL.filterValue("2026-10-06", kind: .text) == "'2026-10-06'") - #expect(OracleBrowseSQL.filterValue("2026-10-06", kind: nil) == "'2026-10-06'") - #expect(OracleBrowseSQL.filterValue("12.5", kind: .decimal) == "12.5") - } - - @Test("Every comparison operator on a temporal column uses the literal") - func temporalOperators() { - #expect(filtered("D", "BETWEEN", "2026-10-06,2026-10-06 23:59:59", kind: .other) - == "SELECT * FROM \"T\" WHERE \"D\" BETWEEN DATE '2026-10-06' AND TIMESTAMP '2026-10-06 23:59:59' " - + "OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY") - #expect(filtered("D", "IN", "2026-10-06 15:22:20, 2026-01-01", kind: .other) - == "SELECT * FROM \"T\" WHERE \"D\" IN (TIMESTAMP '2026-10-06 15:22:20', DATE '2026-01-01') " - + "OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY") - #expect(filtered("D", ">=", "2026-10-06", kind: .other) - == "SELECT * FROM \"T\" WHERE \"D\" >= DATE '2026-10-06' OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY") - } - - @Test("Names are quoted and qualified") - func qualifiedName() { - #expect(OracleBrowseSQL.qualifiedName(schema: "HR", table: "Order \"Lines\"") == "\"HR\".\"Order \"\"Lines\"\"\"") - #expect(OracleBrowseSQL.qualifiedName(schema: nil, table: "T") == "\"T\"") - #expect(OracleBrowseSQL.qualifiedName(schema: "", table: "T") == "\"T\"") - } -} diff --git a/TableProTests/Plugins/OracleColumnStatementsTests.swift b/TableProTests/Plugins/OracleColumnStatementsTests.swift deleted file mode 100644 index eeb7e0d9fb..0000000000 --- a/TableProTests/Plugins/OracleColumnStatementsTests.swift +++ /dev/null @@ -1,200 +0,0 @@ -// -// OracleColumnStatementsTests.swift -// TableProTests -// -// The Oracle plugin's column MODIFY names only what changed. Every statement below was run against Oracle 23ai with -// the outcome its test names. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct OracleColumnStatementsTests { - private static let table = "\"HR\".\"T\"" - - private func quote(_ name: String) -> String { - "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" - } - - private func modify(_ old: PluginColumnDefinition, _ new: PluginColumnDefinition) -> String? { - OracleColumnStatements.modify(qualifiedTable: Self.table, oldColumn: old, newColumn: new, quote: quote) - } - - private func column( - _ name: String = "A", - type: String = "NUMBER(10,2)", - nullable: Bool = true, - defaultValue: String? = nil, - comment: String? = nil - ) -> PluginColumnDefinition { - PluginColumnDefinition( - name: name, dataType: type, isNullable: nullable, defaultValue: defaultValue, comment: comment - ) - } - - /// Restating `NOT NULL` on a column that has it fails the statement with ORA-01442. - @Test("A default changed on a NOT NULL column names the default alone") - func defaultOnANotNullColumn() { - let sql = modify( - column(nullable: false, defaultValue: "1"), - column(nullable: false, defaultValue: "2") - ) - #expect(sql == "ALTER TABLE \"HR\".\"T\" MODIFY (\"A\" DEFAULT 2)") - } - - /// Restating the displayed type turned a `VARCHAR2(20 CHAR)` column into `VARCHAR2(80)` bytes. - @Test("A nullability change names neither the type nor the default") - func nullabilityAlone() { - let sql = modify( - column("B", type: "VARCHAR2(80)", nullable: true, defaultValue: "'q'"), - column("B", type: "VARCHAR2(80)", nullable: false, defaultValue: "'q'") - ) - #expect(sql == "ALTER TABLE \"HR\".\"T\" MODIFY (\"B\" NOT NULL)") - } - - @Test("A column made nullable says NULL") - func madeNullable() { - let sql = modify(column(nullable: false), column(nullable: true)) - #expect(sql == "ALTER TABLE \"HR\".\"T\" MODIFY (\"A\" NULL)") - } - - @Test("A type change names the type alone") - func typeAlone() { - let sql = modify( - column(nullable: false, defaultValue: "1"), - column(type: "NUMBER(12,2)", nullable: false, defaultValue: "1") - ) - #expect(sql == "ALTER TABLE \"HR\".\"T\" MODIFY (\"A\" NUMBER(12,2))") - } - - /// `"Hunt_Mixed"` folded to `"HUNT_MIXED"` names no type (ORA-00902), and a lowercase built-in type is valid as typed. - @Test("A changed type is written as given") - func typeWrittenVerbatim() { - let object = modify(column("O", type: "\"Old_Type\""), column("O", type: "\"Hunt_Mixed\"")) - #expect(object == "ALTER TABLE \"HR\".\"T\" MODIFY (\"O\" \"Hunt_Mixed\")") - let builtIn = modify(column("N", type: "NVARCHAR2(10)"), column("N", type: "nvarchar2(20)")) - #expect(builtIn == "ALTER TABLE \"HR\".\"T\" MODIFY (\"N\" nvarchar2(20))") - } - - @Test("A type that differs only in case is not a change") - func typeCaseIsNotAChange() { - #expect(modify(column(type: "NUMBER(10,2)"), column(type: "number(10,2)")) == nil) - } - - /// Oracle cannot take a default away, so no default is `DEFAULT NULL`. - @Test("Removing a default writes DEFAULT NULL") - func removedDefault() { - let sql = modify(column(defaultValue: "sysdate"), column(defaultValue: nil)) - #expect(sql == "ALTER TABLE \"HR\".\"T\" MODIFY (\"A\" DEFAULT NULL)") - } - - /// `DEFAULT ON NULL 'y'` keeps the semantics; `DEFAULT 'y'` alone drops them and makes the column nullable. - @Test("A DEFAULT ON NULL value is written with its ON NULL") - func defaultOnNull() { - let sql = modify( - column("S", type: "VARCHAR2(10)", nullable: false, defaultValue: "ON NULL 'x'"), - column("S", type: "VARCHAR2(10)", nullable: false, defaultValue: "ON NULL 'y'") - ) - #expect(sql == "ALTER TABLE \"HR\".\"T\" MODIFY (\"S\" DEFAULT ON NULL 'y')") - } - - @Test("A change to the comment alone modifies nothing") - func commentAlone() { - #expect(modify(column(defaultValue: "1", comment: "old"), column(defaultValue: "1", comment: "new")) == nil) - #expect(modify(column(defaultValue: "1"), column(defaultValue: "1")) == nil) - } - - @Test("Every attribute that changed is named, in Oracle's order") - func everythingChanged() { - let sql = modify( - column(type: "NUMBER(10,2)", nullable: true, defaultValue: nil), - column(type: "NUMBER(12,2)", nullable: false, defaultValue: "0") - ) - #expect(sql == "ALTER TABLE \"HR\".\"T\" MODIFY (\"A\" NUMBER(12,2) DEFAULT 0 NOT NULL)") - } - - @Test("A rename comes first, and the modify uses the new name") - func renameThenModify() { - let sql = modify(column("OLD", defaultValue: "1"), column("NEW", defaultValue: "2")) - #expect(sql == """ - ALTER TABLE "HR"."T" RENAME COLUMN "OLD" TO "NEW"; - ALTER TABLE "HR"."T" MODIFY ("NEW" DEFAULT 2) - """) - } - - // MARK: - Column definition - - private func definition( - _ column: PluginColumnDefinition, - inlinePrimaryKey: Bool = false, - supportsIdentity: Bool = true - ) -> String { - OracleColumnStatements.definition( - column, inlinePrimaryKey: inlinePrimaryKey, supportsIdentity: supportsIdentity, quote: quote - ) - } - - private func autoIncrement( - _ name: String = "ID", - nullable: Bool = false, - primaryKey: Bool = true, - defaultValue: String? = nil - ) -> PluginColumnDefinition { - PluginColumnDefinition( - name: name, dataType: "NUMBER", isNullable: nullable, defaultValue: defaultValue, - isPrimaryKey: primaryKey, autoIncrement: true - ) - } - - @Test("An auto-increment column is an identity that still takes an explicit key") - func autoIncrementIsIdentity() { - #expect(definition(autoIncrement(), inlinePrimaryKey: true) - == "\"ID\" NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY PRIMARY KEY") - } - - /// `ON NULL` already makes the column NOT NULL, and an identity takes no DEFAULT. - @Test("An identity writes neither a default nor NOT NULL") - func identityWritesNoDefaultOrNullability() { - #expect(definition(autoIncrement(nullable: false, primaryKey: false, defaultValue: "0")) - == "\"ID\" NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY") - } - - @Test("Without identity columns the column is written as before") - func noIdentityBefore12c() { - #expect(definition(autoIncrement(nullable: false), inlinePrimaryKey: true, supportsIdentity: false) - == "\"ID\" NUMBER NOT NULL PRIMARY KEY") - } - - @Test("A plain column keeps Oracle's order: type, DEFAULT, NOT NULL, key") - func plainColumnOrder() { - let column = PluginColumnDefinition( - name: "S", dataType: "VARCHAR2(10)", isNullable: false, defaultValue: "'x'", isPrimaryKey: true - ) - #expect(definition(column, inlinePrimaryKey: true) == "\"S\" VARCHAR2(10) DEFAULT 'x' NOT NULL PRIMARY KEY") - #expect(definition(column, inlinePrimaryKey: false) == "\"S\" VARCHAR2(10) DEFAULT 'x' NOT NULL") - } - - // MARK: - Column comments - - @Test("Create Table comments each column that has a comment, and only those") - func createTableColumnComments() { - let statements = OracleColumnStatements.commentStatements( - qualifiedTable: Self.table, - columns: [column("A", comment: "It's A"), column("B"), column("C", comment: "")] - ) - #expect(statements == ["COMMENT ON COLUMN \"HR\".\"T\".\"A\" IS 'It''s A'"]) - } - - /// Every value the Default menu offers for Oracle reaches the server as the text after `DEFAULT`. - @Test("Every Oracle menu value survives the modify writer") - func menuValuesSurvive() { - let values = ColumnDefaultVocabulary.options(for: .oracle).compactMap(\.sql) - #expect(!values.isEmpty) - for value in values { - let sql = modify(column(type: "DATE", defaultValue: nil), column(type: "DATE", defaultValue: value)) - #expect(sql == "ALTER TABLE \"HR\".\"T\" MODIFY (\"A\" DEFAULT \(value))", "\(value)") - } - } -} diff --git a/TableProTests/Plugins/OracleCommentStatementTests.swift b/TableProTests/Plugins/OracleCommentStatementTests.swift deleted file mode 100644 index 562826498f..0000000000 --- a/TableProTests/Plugins/OracleCommentStatementTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// OracleCommentStatementTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -struct OracleCommentStatementTests { - private static let name = "\"HR\".\"T\"" - - private func statement(_ objectType: String, _ comment: String?) -> String? { - OracleObjectQueries.commentStatement(qualifiedName: Self.name, objectType: objectType, comment: comment) - } - - @Test("A table, a partitioned table and a view take the TABLE keyword", arguments: ["TABLE", "PARTITIONED TABLE", "VIEW"]) - func tableKeyword(kind: String) { - #expect(statement(kind, "c") == "COMMENT ON TABLE \"HR\".\"T\" IS 'c'") - } - - @Test("A materialized view takes its own keyword") - func materializedViewKeyword() { - #expect(statement("MATERIALIZED VIEW", "c") == "COMMENT ON MATERIALIZED VIEW \"HR\".\"T\" IS 'c'") - } - - @Test("Other kinds cannot be commented", arguments: ["SEQUENCE", "SYSTEM TABLE", "FOREIGN TABLE", ""]) - func otherKinds(kind: String) { - #expect(statement(kind, nil) == nil) - } - - /// Oracle removes a comment only when it is set to the empty string. - @Test("A nil or empty comment is written as the empty string") - func emptyCommentClears() { - #expect(statement("TABLE", nil) == "COMMENT ON TABLE \"HR\".\"T\" IS ''") - #expect(statement("TABLE", "") == "COMMENT ON TABLE \"HR\".\"T\" IS ''") - } - - @Test("A comment doubles its quotes and drops NUL") - func commentEscaping() { - #expect(statement("TABLE", "it's\0 here") == "COMMENT ON TABLE \"HR\".\"T\" IS 'it''s here'") - } - - @Test("A quote followed by a combining mark is still doubled") - func quoteBeforeCombiningMark() { - #expect(statement("TABLE", "a'\u{301}b") == "COMMENT ON TABLE \"HR\".\"T\" IS 'a''\u{301}b'") - #expect(OracleObjectQueries.quoteIdentifier("x\"\u{301}") == "\"x\"\"\u{301}\"") - } - - @Test("A column comment names the column inside the table") - func columnComment() { - #expect(OracleObjectQueries.columnCommentStatement(qualifiedTable: Self.name, column: "c\"x", comment: "d") - == "COMMENT ON COLUMN \"HR\".\"T\".\"c\"\"x\" IS 'd'") - } -} diff --git a/TableProTests/Plugins/OracleConnectionErrorTests.swift b/TableProTests/Plugins/OracleConnectionErrorTests.swift deleted file mode 100644 index d2aa59d6d0..0000000000 --- a/TableProTests/Plugins/OracleConnectionErrorTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import TableProPluginKit -import Testing - -/// These cover the copies in `Plugins/TableProPluginKit/`, which no driver calls. The Oracle -/// driver runs the ones in `Packages/TableProOracle/Sources/TableProOracleCore/`, whose own suite -/// is `Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleConnectErrorClassifierTests` -/// and is where the classification that ships is pinned. The two sets have already drifted and -/// nothing forces them to agree; a green run here says nothing about the driver. -struct OracleConnectionErrorTests { - @Test("Decode and connection failures are treated as channel-fatal") - func channelFatalCodes() { - #expect(OracleChannelFatalCode.isChannelFatal("connectionError")) - #expect(OracleChannelFatalCode.isChannelFatal("messageDecodingFailure")) - #expect(OracleChannelFatalCode.isChannelFatal("unexpectedBackendMessage")) - } - - @Test("Server-side SQL errors keep the connection alive") - func nonFatalCodes() { - #expect(!OracleChannelFatalCode.isChannelFatal("server")) - #expect(!OracleChannelFatalCode.isChannelFatal("statementCancelled")) - #expect(!OracleChannelFatalCode.isChannelFatal("malformedStatement")) - } -} - -struct OracleConnectErrorClassifierTests { - @Test("An unclean shutdown is a dropped handshake") - func uncleanShutdownIsDropped() { - #expect(OracleConnectErrorClassifier.classify("uncleanShutdown") == .connectionDropped) - } - - @Test("An unsupported server version is reported as such") - func serverVersionNotSupported() { - #expect(OracleConnectErrorClassifier.classify("serverVersionNotSupported") == .versionNotSupported) - } - - @Test("An unsupported verifier carries its flag through") - func verifierCarriesFlag() { - #expect( - OracleConnectErrorClassifier.classify("unsupportedVerifierType(0x939)") - == .verifierUnsupported(flag: "unsupportedVerifierType(0x939)") - ) - } - - @Test("A failed advanced negotiation is a native encryption failure") - func advancedNegotiationIsEncryptionFailure() { - #expect(OracleConnectErrorClassifier.classify("advancedNegotiationFailed") == .advancedNegotiationFailed) - #expect(OracleConnectErrorClassifier.isLikelyNativeEncryptionFailure( - failure: .advancedNegotiationFailed, nativeNetworkEncryptionEnabled: true, timedOut: false - )) - } - - @Test("Any other code falls back to a generic connection failure") - func unknownIsConnectionFailed() { - #expect(OracleConnectErrorClassifier.classify("connectionError") == .connectionFailed) - #expect(OracleConnectErrorClassifier.classify("server") == .connectionFailed) - } -} diff --git a/TableProTests/Plugins/OracleNationalTextTests.swift b/TableProTests/Plugins/OracleNationalTextTests.swift deleted file mode 100644 index 2382bcfa9d..0000000000 --- a/TableProTests/Plugins/OracleNationalTextTests.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// OracleNationalTextTests.swift -// TableProTests -// -// Text for NCHAR, NVARCHAR2 and NCLOB columns. Every statement shape below was run against Oracle 23ai with a -// WE8MSWIN1252 database character set (national AL16UTF16), an AL32UTF8 one with a UTF8 national character set, and -// AL32UTF8 with AL16UTF16: each stored and matched the text, where a plain literal stored ¿¿¿ on the first. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct OracleNationalTextTests { - @Test("ASCII text keeps its plain literal") - func asciiTextIsLeftAlone() { - #expect(OracleNationalText.sql(for: "abc 123 'q' \\", asLOB: false) == nil) - #expect(OracleNationalText.sql(for: "", asLOB: true) == nil) - } - - @Test("Other characters are UTF-16 escapes, a quote is doubled and a backslash is escaped") - func escapes() { - #expect(OracleNationalText.sql(for: "ລາວ", asLOB: false) == "UNISTR('\\0EA5\\0EB2\\0EA7')") - #expect( - OracleNationalText.sql(for: "é a\\b 'q'", asLOB: false) == "UNISTR('\\00E9 a\\\\b ''q''')" - ) - #expect(OracleNationalText.sql(for: "😀\n", asLOB: false) == "UNISTR('\\D83D\\DE00\\000A')") - } - - @Test("NUL is dropped, as the plain literal drops it") - func nulIsDropped() { - #expect(OracleNationalText.sql(for: "é\0", asLOB: false) == "UNISTR('\\00E9')") - } - - @Test("Long text is split into calls of at most 4,000 bytes, never inside a surrogate pair") - func chunking() throws { - let text = String(repeating: "ດ", count: 799) + "😀" + "ດ" - let sql = try #require(OracleNationalText.sql(for: text, asLOB: false)) - let calls = sql.components(separatedBy: " || ") - #expect(calls.count == 2) - #expect(calls[0] == "UNISTR('" + String(repeating: "\\0E94", count: 799) + "')") - #expect(calls[1] == "UNISTR('\\D83D\\DE00\\0E94')") - for call in calls { - #expect(call.utf8.count - "UNISTR('')".utf8.count <= OracleNationalText.maxChunkBytes) - } - } - - /// One UNISTR of 3,995 `a` and an `é` is 3,996 characters, 7,992 bytes in AL16UTF16, and an AL16UTF16 database kept - /// 2,000 of them with no error. - @Test("Mostly ASCII text is split by its size in the national character set, not only by its escapes") - func chunkingBoundsTheDecodedSize() throws { - let text = String(repeating: "a", count: 3_995) + "é" - let sql = try #require(OracleNationalText.sql(for: text, asLOB: true)) - let calls = sql.components(separatedBy: " || ") - #expect(calls.count == 2) - #expect(calls[0] == "TO_NCLOB(UNISTR('" + String(repeating: "a", count: 2_000) + "'))") - #expect(calls[1] == "TO_NCLOB(UNISTR('" + String(repeating: "a", count: 1_995) + "\\00E9'))") - } - - @Test("Each call is wrapped in TO_NCLOB for an NCLOB, so the joined value has no 4,000-byte limit") - func lobChunks() throws { - let sql = try #require(OracleNationalText.sql(for: String(repeating: "ດ", count: 801), asLOB: true)) - #expect(sql.hasPrefix("TO_NCLOB(UNISTR('\\0E94")) - #expect(sql.components(separatedBy: " || ").allSatisfy { $0.hasPrefix("TO_NCLOB(UNISTR('") && $0.hasSuffix("'))") }) - #expect(sql.components(separatedBy: " || ").count == 2) - } -} - -struct OracleRowWriterNationalTextTests { - private static let columns = ["ID", "NV", "NC", "NCL", "V"] - private static let typeNames = [ - "ID": "NVARCHAR2(20)", "NV": "nvarchar2", "NC": "NCHAR(5)", "NCL": "nclob", "V": "VARCHAR2(20)" - ] - private static let original: [PluginCellValue] = [.text("ກ"), .text("ລາວ"), .text("ລາວ "), .null, .text("ລາວ")] - - private func writer(keys: [String]) -> OracleRowWriter { - var context = PluginRowWriteContext() - context.columnTypeNames = Self.typeNames - var writer = OracleRowWriter(qualifiedTable: "\"T1\"", columns: Self.columns, primaryKeyColumns: keys) - writer.context = context - return writer - } - - private func update(_ column: String, to value: PluginCellValue) -> PluginRowChange { - let index = Self.columns.firstIndex(of: column) ?? 0 - return PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: index, columnName: column, oldValue: Self.original[index], newValue: value)], - originalRow: Self.original - ) - } - - private func write(_ writer: OracleRowWriter, _ change: PluginRowChange) throws -> PluginRowWrite { - let writes = try writer.rowWrites( - for: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - return try #require(writes.first) - } - - @Test("A national column is set and matched by its key through UNISTR") - func keyedUpdate() throws { - let write = try write(writer(keys: ["ID"]), update("NV", to: .text("ລາວ😀"))) - #expect(write.statement == "UPDATE \"T1\" SET \"NV\" = UNISTR('\\0EA5\\0EB2\\0EA7\\D83D\\DE00') " - + "WHERE \"ID\" = UNISTR('\\0E81')") - #expect(write.parameters.isEmpty) - } - - @Test("A keyless match writes every national value through UNISTR, NCHAR as the padded value it read") - func keylessMatch() throws { - let write = try write(writer(keys: []), update("V", to: .text("x"))) - #expect(write.statement == "UPDATE \"T1\" SET \"V\" = 'x' WHERE \"ID\" = UNISTR('\\0E81') " - + "AND \"NV\" = UNISTR('\\0EA5\\0EB2\\0EA7') AND \"NC\" = UNISTR('\\0EA5\\0EB2\\0EA7 ') " - + "AND \"NCL\" IS NULL AND \"V\" = 'ລາວ' AND ROWNUM = 1") - } - - @Test("An NCLOB value is written through TO_NCLOB, however long, instead of a bind") - func nclobIsNotBound() throws { - let long = String(repeating: "ດ", count: 3_000) - let write = try write(writer(keys: ["ID"]), update("NCL", to: .text(long))) - #expect(write.statement.hasPrefix("UPDATE \"T1\" SET \"NCL\" = TO_NCLOB(UNISTR('\\0E94")) - #expect(write.parameters.isEmpty) - } - - @Test("ASCII text in a national column keeps the plain literal, numeric-looking text quoted") - func asciiStaysPlain() throws { - let write = try write(writer(keys: ["ID"]), update("NV", to: .text("00123"))) - #expect(write.statement == "UPDATE \"T1\" SET \"NV\" = '00123' WHERE \"ID\" = UNISTR('\\0E81')") - } - - @Test("ASCII text over the literal limit in an NCLOB is still bound") - func longAsciiLOBIsBound() throws { - let long = String(repeating: "a", count: 5_000) - let write = try write(writer(keys: ["ID"]), update("NCL", to: .text(long))) - #expect(write.statement.hasPrefix("UPDATE \"T1\" SET \"NCL\" = ? WHERE")) - #expect(write.parameters == [.text(long)]) - } - - @Test("An insert writes national values through UNISTR and other text as before") - func insert() throws { - let writes = try writer(keys: ["ID"]).rowWrites( - for: [PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil)], - insertedRowData: [0: [.text("1"), .text("é"), .null, .text("ລ"), .text("é")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - let write = try #require(writes.first) - #expect(write.statement == "INSERT INTO \"T1\" (\"ID\", \"NV\", \"NC\", \"NCL\", \"V\") " - + "VALUES ('1', UNISTR('\\00E9'), NULL, TO_NCLOB(UNISTR('\\0EA5')), 'é')") - } -} diff --git a/TableProTests/Plugins/OracleObjectQueriesQualificationTests.swift b/TableProTests/Plugins/OracleObjectQueriesQualificationTests.swift deleted file mode 100644 index 2b1933329c..0000000000 --- a/TableProTests/Plugins/OracleObjectQueriesQualificationTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// OracleObjectQueriesQualificationTests.swift -// TableProTests -// -// The Oracle plugin's routine, trigger and index catalog SQL names every dictionary view with its SYS owner, so a -// same-named object planted in the current schema cannot shadow it. Measured on Oracle 23ai. -// - -import Foundation -import Testing - -@testable import TablePro - -struct OracleObjectQueriesQualificationTests { - private static let bareDictionary = try! NSRegularExpression( - pattern: #"\b(ALL|DBA|USER)_[A-Z_]+\b|\bDUAL\b|V\$[A-Z_]+"# - ) - - private func bareToken(in sql: String) -> String? { - let stripped = sql.replacingOccurrences(of: #"SYS\.[A-Z_$0-9]+"#, with: " ", options: .regularExpression) - let range = NSRange(stripped.startIndex..., in: stripped) - guard let match = Self.bareDictionary.firstMatch(in: stripped, range: range), - let matchRange = Range(match.range, in: stripped) else { - return nil - } - return String(stripped[matchRange]) - } - - @Test("Routine and trigger catalog SQL is owner-qualified") - func routineAndTriggerQueriesAreQualified() { - let queries = [ - OracleObjectQueries.routineList(schema: "HR"), - OracleObjectQueries.routineSource(schema: "HR", name: "P", type: "PROCEDURE"), - OracleObjectQueries.triggerList(schema: "HR", table: nil), - OracleObjectQueries.triggerList(schema: "HR", table: "EMPLOYEES") - ] - for sql in queries { - #expect(bareToken(in: sql) == nil, "unqualified dictionary name in:\n\(sql)") - } - } - - @Test("Index dump SQL is owner-qualified") - func indexQueryIsQualified() { - let sql = OracleIndexStatements.query(schema: "HR", table: "EMPLOYEES") - #expect(bareToken(in: sql) == nil, "unqualified dictionary name in:\n\(sql)") - #expect(sql.contains("SYS.ALL_INDEXES")) - #expect(sql.contains("SYS.ALL_IND_COLUMNS")) - } -} diff --git a/TableProTests/Plugins/OraclePartitionMappingTests.swift b/TableProTests/Plugins/OraclePartitionMappingTests.swift deleted file mode 100644 index 7e05798d9d..0000000000 --- a/TableProTests/Plugins/OraclePartitionMappingTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// OraclePartitionMappingTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct OraclePartitionMappingTests { - @Test("A partition states no bound, because HIGH_VALUE is a LONG column the driver cannot read") - func partitionsCarryNoBound() { - let mapped = OraclePartitionMapping.partitions(from: [ - OracleCatalogPartition(name: "P2024", position: 1, rowCount: 500), - OracleCatalogPartition(name: "P2025", position: 2, rowCount: nil) - ]) - - #expect(mapped.map(\.name) == ["P2024", "P2025"]) - #expect(mapped.allSatisfy { $0.bound == nil }) - #expect(mapped.map(\.ordinalPosition) == [1, 2]) - #expect(mapped.first?.rowCount == 500) - } - - @Test("No Oracle partition is a relation, so none offers to be opened or dropped by name") - func partitionsAreNeverRelations() { - let mapped = OraclePartitionMapping.partitions(from: [ - OracleCatalogPartition(name: "P1", position: 1, rowCount: nil) - ]) - - #expect(mapped.allSatisfy { !$0.isSeparateRelation }) - #expect(mapped.allSatisfy { $0.schema == nil }) - } - - @Test("A subpartition follows the partition it subdivides and names it") - func subpartitionsNestUnderTheirPartition() { - let mapped = OraclePartitionMapping.partitions(from: [ - OracleCatalogPartition( - name: "P1", - position: 1, - rowCount: nil, - subpartitions: [ - OracleCatalogPartition(name: "P1_SP1", position: 1, rowCount: 10), - OracleCatalogPartition(name: "P1_SP2", position: 2, rowCount: 20) - ] - ), - OracleCatalogPartition(name: "P2", position: 2, rowCount: nil) - ]) - - #expect(mapped.map(\.name) == ["P1", "P1_SP1", "P1_SP2", "P2"]) - #expect(mapped[0].isSubpartitioned) - #expect(mapped[1].parentPartitionName == "P1") - #expect(mapped[2].parentPartitionName == "P1") - #expect(mapped[3].parentPartitionName == nil) - #expect(!mapped[3].isSubpartitioned) - } - - @Test("A table with no partitions maps to nothing rather than an empty placeholder row") - func emptyCatalogMapsToNothing() { - #expect(OraclePartitionMapping.partitions(from: []).isEmpty) - } -} diff --git a/TableProTests/Plugins/OracleRowWriterTests.swift b/TableProTests/Plugins/OracleRowWriterTests.swift deleted file mode 100644 index e5840d7f93..0000000000 --- a/TableProTests/Plugins/OracleRowWriterTests.swift +++ /dev/null @@ -1,485 +0,0 @@ -// -// OracleRowWriterTests.swift -// TableProTests -// -// The Oracle plugin's grid save statements. Every shape below was run against Oracle 23ai, with real VARCHAR2 and -// RAW binds, under the default NLS_DATE_FORMAT DD-MON-RR: the masked dates and timestamps, the suffixed binary -// literals, the NULL literal into an object column and the keyless match all write or find the row. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct OracleRowWriterTests { - private static let columns = ["ID", "D", "T", "TZ", "TL", "N", "BF", "BD", "R", "V", "O", "CL"] - private static let typeNames = [ - "ID": "NUMBER", "D": "DATE", "T": "TIMESTAMP(3)", "TZ": "TIMESTAMP(6) WITH TIME ZONE", - "TL": "TIMESTAMP(6) WITH LOCAL TIME ZONE", "N": "NUMBER(10,2)", "BF": "BINARY_FLOAT", "BD": "BINARY_DOUBLE", - "R": "RAW(16)", "V": "VARCHAR2(20 CHAR)", "O": "\"HR\".\"ADDR\"", "CL": "CLOB" - ] - - private static let original: [PluginCellValue] = [ - .text("7"), .text("2026-10-06 15:22:20"), .text("2026-10-06 15:22:20.050"), - .text("2026-10-06 15:22:20.000001+02:00"), .text("2026-10-06 23:15:03.444429"), .text("123.45"), - .text("1.5"), .text("inf"), .bytes(Data([0xDE, 0xAD])), .text("abc"), .null, .null - ] - - private func writer( - keys: [String] = ["ID"], - columns: [String] = OracleRowWriterTests.columns, - typeNames: [String: String] = OracleRowWriterTests.typeNames, - configure: (inout PluginRowWriteContext) -> Void = { _ in } - ) -> OracleRowWriter { - var context = PluginRowWriteContext() - context.columnTypeNames = typeNames - configure(&context) - var writer = OracleRowWriter(qualifiedTable: "\"T1\"", columns: columns, primaryKeyColumns: keys) - writer.context = context - return writer - } - - private func update( - _ column: String, - to value: PluginCellValue, - row: Int = 0, - original: [PluginCellValue]? = OracleRowWriterTests.original - ) -> PluginRowChange { - let index = Self.columns.firstIndex(of: column) ?? 0 - let old = original.map { $0[index] } ?? .null - return PluginRowChange( - rowIndex: row, - type: .update, - cellChanges: [(columnIndex: index, columnName: column, oldValue: old, newValue: value)], - originalRow: original - ) - } - - private func updates(_ writer: OracleRowWriter, _ changes: [PluginRowChange]) throws -> [PluginRowWrite] { - try writer.rowWrites(for: changes, insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: []) - } - - private func insert(_ writer: OracleRowWriter, _ values: [PluginCellValue]) throws -> PluginRowWrite? { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - return try writer.rowWrites( - for: [change], insertedRowData: [0: values], deletedRowIndices: [], insertedRowIndices: [0] - ).first - } - - private func deletes(_ writer: OracleRowWriter, rows: [[PluginCellValue]]) throws -> [PluginRowWrite] { - let changes = rows.enumerated().map { index, row in - PluginRowChange(rowIndex: index, type: .delete, cellChanges: [], originalRow: row) - } - return try writer.rowWrites( - for: changes, insertedRowData: [:], deletedRowIndices: Set(changes.map { $0.rowIndex }), - insertedRowIndices: [] - ) - } - - // MARK: - Row match - - @Test("A keyed row is found by its primary key alone") - func keyedMatch() throws { - let write = try #require(try updates(writer(), [update("V", to: .text("new"))]).first) - #expect(write.statement == "UPDATE \"T1\" SET \"V\" = 'new' WHERE \"ID\" = 7") - #expect(write.parameters.isEmpty) - #expect(write.rowIndices == [0]) - } - - @Test("A keyless row is found by every column, NULLs with IS NULL, and one row at most") - func keylessMatch() throws { - let columns = ["A", "B", "C"] - let keyless = writer(keys: [], columns: columns, typeNames: ["A": "NUMBER", "B": "VARCHAR2(5 BYTE)", "C": "DATE"]) - let change = PluginRowChange( - rowIndex: 3, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "B", oldValue: .text("x"), newValue: .text("y"))], - originalRow: [.text("1"), .text("x"), .null] - ) - let write = try #require(try updates(keyless, [change]).first) - #expect(write.statement == "UPDATE \"T1\" SET \"B\" = 'y' WHERE \"A\" = 1 AND \"B\" = 'x' AND \"C\" IS NULL AND ROWNUM = 1") - #expect(write.parameters.isEmpty) - } - - @Test("A keyless match that needs an excluded column is refused, and a NULL one is matched") - func keylessExcludedColumn() throws { - let columns = ["A", "NOTE"] - let keyless = writer(keys: [], columns: columns, typeNames: ["A": "NUMBER"]) { $0.rowMatchExcludedColumns = ["NOTE"] } - let needed = PluginRowChange( - rowIndex: 4, - type: .update, - cellChanges: [(columnIndex: 0, columnName: "A", oldValue: .text("1"), newValue: .text("2"))], - originalRow: [.text("1"), .text("long text")] - ) - #expect(throws: PluginRowWriteRefusal.self) { try updates(keyless, [needed]) } - do { - _ = try updates(keyless, [needed]) - } catch let refusal as PluginRowWriteRefusal { - #expect(refusal.rowIndex == 4) - } - - let nullNote = PluginRowChange( - rowIndex: 5, - type: .update, - cellChanges: [(columnIndex: 0, columnName: "A", oldValue: .text("1"), newValue: .text("2"))], - originalRow: [.text("1"), .null] - ) - let write = try #require(try updates(keyless, [nullNote]).first) - #expect(write.statement == "UPDATE \"T1\" SET \"A\" = 2 WHERE \"A\" = 1 AND \"NOTE\" IS NULL AND ROWNUM = 1") - } - - /// CLOB fails `=` with ORA-22848 and a JSON column compared with its own text matches nothing. - @Test("A keyless match refuses a LOB, JSON or object value even when the host excluded nothing") - func keylessUncomparableTypes() { - for typeName in ["CLOB", "JSON", "BLOB", "LONG", "VECTOR(3, FLOAT32)", "\"HR\".\"ADDR\"", "object"] { - let keyless = writer(keys: [], columns: ["A", "X"], typeNames: ["A": "NUMBER", "X": typeName]) - let change = PluginRowChange( - rowIndex: 0, - type: .delete, - cellChanges: [], - originalRow: [.text("1"), .text("value")] - ) - #expect(throws: PluginRowWriteRefusal.self, "\(typeName)") { - try keyless.rowWrites(for: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: []) - } - } - } - - @Test("A keyed row whose key is not loaded is refused") - func keyedMissingKey() { - var original = Self.original - original[0] = .null - #expect(throws: PluginRowWriteRefusal.self) { - try updates(writer(), [update("V", to: .text("x"), original: original)]) - } - } - - @Test("An update with no cell changes writes nothing and refuses nothing") - func emptyUpdate() throws { - let probe = PluginRowChange(rowIndex: 0, type: .update, cellChanges: [], originalRow: nil) - #expect(try updates(writer(), [probe]).isEmpty) - #expect(try updates(OracleRowWriter(qualifiedTable: "\"T1\"", columns: [], primaryKeyColumns: []), [probe]).isEmpty) - } - - // MARK: - Values - - /// Bound bare, the same text fails with ORA-01861 under the default DD-MON-RR. - @Test("Dates and timestamps are converted with explicit masks") - func temporalMasks() throws { - let columns = ["D", "T", "TZ", "TL"] - let values: [PluginCellValue] = [ - .text("2026-10-06 15:22:20"), .text("2026-10-06 15:22:20.05"), - .text("2026-10-06 15:22:20.000001+02:00"), .text("2026-10-06 08:04:05.123456+07:00") - ] - let expected = "INSERT INTO \"T1\" (\"D\", \"T\", \"TZ\", \"TL\") VALUES (" - + "TO_DATE('2026-10-06 15:22:20', 'YYYY-MM-DD HH24:MI:SS'), " - + "TO_TIMESTAMP('2026-10-06 15:22:20.05', 'YYYY-MM-DD HH24:MI:SS.FF'), " - + "TO_TIMESTAMP_TZ('2026-10-06 15:22:20.000001+02:00', 'YYYY-MM-DD HH24:MI:SS.FFTZH:TZM'), " - + "TO_TIMESTAMP_TZ('2026-10-06 08:04:05.123456+07:00', 'YYYY-MM-DD HH24:MI:SS.FFTZH:TZM'))" - - let fromSchema = try #require(try insert(writer(keys: [], columns: columns), values)) - #expect(fromSchema.statement == expected) - #expect(fromSchema.parameters.isEmpty) - - let headerNames = [ - "D": "date", "T": "timestamp", "TZ": "timestamp with time zone", "TL": "timestamp with local time zone" - ] - let fromHeaders = try #require(try insert(writer(keys: [], columns: columns, typeNames: headerNames), values)) - #expect(fromHeaders.statement == expected) - } - - /// The value reads as the session's wall clock with that instant's offset, which `TO_TIMESTAMP` rejects with - /// ORA-01830. Measured on 23ai, the read text matches the row again with the session at `+07:00` and at - /// `America/New_York` against a `+00:00` database. - @Test("A TIMESTAMP WITH LOCAL TIME ZONE value keeps its offset in SET and in a keyless match") - func localTimeZoneRoundTrip() throws { - let keyless = writer( - keys: [], columns: ["A", "TL"], - typeNames: ["A": "NUMBER", "TL": "TIMESTAMP(6) WITH LOCAL TIME ZONE"] - ) - let read = PluginCellValue.text("2026-10-06 08:04:05.123456+07:00") - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "TL", oldValue: read, newValue: .text("2026-10-05 21:04:05-04:00"))], - originalRow: [.text("0"), read] - ) - let write = try #require(try updates(keyless, [change]).first) - #expect(write.statement == "UPDATE \"T1\" SET " - + "\"TL\" = TO_TIMESTAMP_TZ('2026-10-05 21:04:05-04:00', 'YYYY-MM-DD HH24:MI:SS.FFTZH:TZM') " - + "WHERE \"A\" = 0 AND " - + "\"TL\" = TO_TIMESTAMP_TZ('2026-10-06 08:04:05.123456+07:00', 'YYYY-MM-DD HH24:MI:SS.FFTZH:TZM') " - + "AND ROWNUM = 1") - #expect(write.parameters.isEmpty) - } - - @Test("A column with no known type is bound as it always was") - func untypedColumnIsBound() throws { - let write = try #require(try insert(writer(keys: [], columns: ["D"], typeNames: [:]), [.text("2026-10-06")])) - #expect(write.statement == "INSERT INTO \"T1\" (\"D\") VALUES (?)") - #expect(write.parameters == [.text("2026-10-06")]) - } - - /// A bound `123.45` fails with ORA-01722 where the decimal separator is a comma. - @Test("A valid number is written as a literal, anything else is bound") - func numbers() throws { - let cases: [(value: String, sql: String)] = [ - ("123.45", "123.45"), ("-0.5", "-0.5"), ("1e-05", "1e-05"), (".5", ".5"), ("+5", "+5"), - ("12,5", "?"), ("1; DROP TABLE T1", "?"), ("\u{FF11}\u{FF12}", "?"), ("1e", "?"), ("", "?"), ("NaN", "?") - ] - for testCase in cases { - let write = try #require(try updates(writer(), [update("N", to: .text(testCase.value))]).first) - #expect(write.statement == "UPDATE \"T1\" SET \"N\" = \(testCase.sql) WHERE \"ID\" = 7", "\(testCase.value)") - } - } - - /// A bare `1.7976931348623157e+308` is read as a NUMBER first and overflows it (ORA-01426). - @Test("Binary floating-point literals carry their suffix, and inf or nan is bound") - func binaryFloatingPoint() throws { - let float = try #require(try updates(writer(), [update("BF", to: .text("3.4028235e+38"))]).first) - #expect(float.statement == "UPDATE \"T1\" SET \"BF\" = 3.4028235e+38f WHERE \"ID\" = 7") - let double = try #require(try updates(writer(), [update("BD", to: .text("1.7976931348623157e+308"))]).first) - #expect(double.statement == "UPDATE \"T1\" SET \"BD\" = 1.7976931348623157e+308d WHERE \"ID\" = 7") - let infinity = try #require(try updates(writer(), [update("BD", to: .text("-inf"))]).first) - #expect(infinity.statement == "UPDATE \"T1\" SET \"BD\" = ? WHERE \"ID\" = 7") - #expect(infinity.parameters == [.text("-inf")]) - } - - /// Left as `?`, `00123` would be written unquoted by the placeholder writer and stored in a VARCHAR2 as `123`. - @Test("Numeric-looking text into a text column stays quoted, in SET and in a keyless match") - func leadingZeroTextStaysQuoted() throws { - let set = try #require(try updates(writer(), [update("V", to: .text("00123"))]).first) - #expect(set.statement == "UPDATE \"T1\" SET \"V\" = '00123' WHERE \"ID\" = 7") - #expect(set.parameters.isEmpty) - - let keyless = writer(keys: [], columns: ["A", "B"], typeNames: ["A": "NUMBER", "B": "VARCHAR2(10 BYTE)"]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 0, columnName: "A", oldValue: .text("1"), newValue: .text("2"))], - originalRow: [.text("1"), .text("00123")] - ) - let match = try #require(try updates(keyless, [change]).first) - #expect(match.statement == "UPDATE \"T1\" SET \"A\" = 2 WHERE \"A\" = 1 AND \"B\" = '00123' AND ROWNUM = 1") - - let row = try #require(try insert(writer(keys: [], columns: ["V"]), [.text("00123")])) - #expect(row.statement == "INSERT INTO \"T1\" (\"V\") VALUES ('00123')") - } - - @Test("Numeric-looking text into a number column is a numeric literal") - func leadingZeroIntoNumber() throws { - let write = try #require(try updates(writer(), [update("N", to: .text("00123"))]).first) - #expect(write.statement == "UPDATE \"T1\" SET \"N\" = 00123 WHERE \"ID\" = 7") - } - - @Test("Text for a column of no known type keeps the placeholder") - func unknownTypeKeepsPlaceholder() throws { - let untyped = writer(keys: ["ID"], columns: ["ID", "V"], typeNames: ["ID": "NUMBER"]) - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "V", oldValue: .null, newValue: .text("00123"))], - originalRow: [.text("7"), .null] - ) - let write = try #require(try updates(untyped, [change]).first) - #expect(write.statement == "UPDATE \"T1\" SET \"V\" = ? WHERE \"ID\" = 7") - #expect(write.parameters == [.text("00123")]) - } - - /// A string literal over 4,000 bytes fails with ORA-01704, so such text is left to be bound. - @Test("Text over the literal limit keeps the placeholder") - func longTextKeepsPlaceholder() throws { - let long = String(repeating: "x", count: 4_001) - let text = try #require(try updates(writer(), [update("V", to: .text(long))]).first) - #expect(text.statement == "UPDATE \"T1\" SET \"V\" = ? WHERE \"ID\" = 7") - #expect(text.parameters == [.text(long)]) - let clob = try #require(try updates(writer(), [update("CL", to: .text(long))]).first) - #expect(clob.statement == "UPDATE \"T1\" SET \"CL\" = ? WHERE \"ID\" = 7") - let date = try #require(try updates(writer(), [update("D", to: .text(long))]).first) - #expect(date.statement == "UPDATE \"T1\" SET \"D\" = TO_DATE(?, 'YYYY-MM-DD HH24:MI:SS') WHERE \"ID\" = 7") - } - - /// A NULL bind into an object-type column fails with ORA-00932. - @Test("NULL is the literal, never a bind") - func nullLiteral() throws { - let write = try #require(try updates(writer(), [update("O", to: .null)]).first) - #expect(write.statement == "UPDATE \"T1\" SET \"O\" = NULL WHERE \"ID\" = 7") - #expect(write.parameters.isEmpty) - } - - @Test("Bytes and text are bound") - func bytesAndText() throws { - let bytes = try #require(try updates(writer(), [update("R", to: .bytes(Data([1, 2])))]).first) - #expect(bytes.statement == "UPDATE \"T1\" SET \"R\" = ? WHERE \"ID\" = 7") - #expect(bytes.parameters == [.bytes(Data([1, 2]))]) - let text = try #require(try updates(writer(), [update("V", to: .text("it's 42"))]).first) - #expect(text.statement == "UPDATE \"T1\" SET \"V\" = 'it''s 42' WHERE \"ID\" = 7") - #expect(text.parameters.isEmpty) - } - - @Test("The server's clock functions are written as keywords on temporal columns only") - func temporalFunctions() throws { - let date = try #require(try updates(writer(), [update("D", to: .text("sysdate"))]).first) - #expect(date.statement == "UPDATE \"T1\" SET \"D\" = SYSDATE WHERE \"ID\" = 7") - let stamp = try #require(try updates(writer(), [update("TZ", to: .text("CURRENT_TIMESTAMP"))]).first) - #expect(stamp.statement == "UPDATE \"T1\" SET \"TZ\" = CURRENT_TIMESTAMP WHERE \"ID\" = 7") - let text = try #require(try updates(writer(), [update("V", to: .text("SYSDATE"))]).first) - #expect(text.statement == "UPDATE \"T1\" SET \"V\" = 'SYSDATE' WHERE \"ID\" = 7") - } - - @Test("The default marker is the DEFAULT keyword") - func defaultMarker() throws { - let write = try #require(try updates(writer(), [update("V", to: OracleRowWriter.defaultMarker)]).first) - #expect(write.statement == "UPDATE \"T1\" SET \"V\" = DEFAULT WHERE \"ID\" = 7") - let row = try #require(try insert( - writer(keys: [], columns: ["A", "B"], typeNames: ["A": "NUMBER"]), [.text("1"), OracleRowWriter.defaultMarker] - )) - #expect(row.statement == "INSERT INTO \"T1\" (\"A\", \"B\") VALUES (1, DEFAULT)") - } - - // MARK: - Server-owned columns - - @Test("An insert leaves out the columns the server owns") - func insertSkipsServerOwned() throws { - let owned = writer(keys: ["ID"], columns: ["ID", "V"], typeNames: ["ID": "NUMBER"]) { $0.serverOwnedColumns = ["ID"] } - let write = try #require(try insert(owned, [.text("99"), .text("x")])) - #expect(write.statement == "INSERT INTO \"T1\" (\"V\") VALUES (?)") - #expect(write.parameters == [.text("x")]) - - let allOwned = writer(keys: ["ID"], columns: ["ID"], typeNames: [:]) { $0.serverOwnedColumns = ["ID"] } - let defaults = try #require(try insert(allOwned, [.text("99")])) - #expect(defaults.statement == "INSERT INTO \"T1\" (\"ID\") VALUES (DEFAULT)") - } - - @Test("An update that gives a server-owned column a value is refused") - func updateRefusesServerOwned() { - let owned = writer { $0.serverOwnedColumns = ["V"] } - #expect(throws: PluginRowWriteRefusal.self) { try updates(owned, [update("V", to: .text("x"))]) } - } - - // MARK: - Deletes - - /// One statement with 32,767 binds took 30 seconds on 23ai; 1,000 took 0.07. - @Test("Keyed deletes are batched at most 1,000 key values a statement") - func keyedDeleteBatches() throws { - let rows = (0..<2_500).map { index in [PluginCellValue.text("\(index)")] } - let writes = try deletes(writer(keys: ["ID"], columns: ["ID"], typeNames: ["ID": "NUMBER"]), rows: rows) - #expect(writes.map { $0.rowIndices.count } == [1_000, 1_000, 500]) - #expect(writes.first?.statement.hasPrefix("DELETE FROM \"T1\" WHERE \"ID\" = 0 OR \"ID\" = 1 OR") == true) - #expect(Set(writes.flatMap { $0.rowIndices }) == Set(0..<2_500)) - - let composite = writer(keys: ["ID", "K"], columns: ["ID", "K"], typeNames: ["ID": "NUMBER", "K": "VARCHAR2(5 BYTE)"]) - let pairs = (0..<1_200).map { index in [PluginCellValue.text("\(index)"), .text("k")] } - let compositeWrites = try deletes(composite, rows: pairs) - #expect(compositeWrites.map { $0.rowIndices.count } == [500, 500, 200]) - #expect(compositeWrites.first?.parameters.isEmpty == true) - #expect(compositeWrites.first?.statement.hasPrefix( - "DELETE FROM \"T1\" WHERE (\"ID\" = 0 AND \"K\" = 'k') OR (\"ID\" = 1 AND \"K\" = 'k')" - ) == true) - } - - @Test("Keyless deletes go one row at a time") - func keylessDeletes() throws { - let keyless = writer(keys: [], columns: ["A", "B"], typeNames: ["A": "NUMBER", "B": "VARCHAR2(5 BYTE)"]) - let writes = try deletes(keyless, rows: [[.text("1"), .text("x")], [.text("2"), .null]]) - #expect(writes.map { $0.statement } == [ - "DELETE FROM \"T1\" WHERE \"A\" = 1 AND \"B\" = 'x' AND ROWNUM = 1", - "DELETE FROM \"T1\" WHERE \"A\" = 2 AND \"B\" IS NULL AND ROWNUM = 1" - ]) - #expect(writes.map { $0.rowIndices } == [[0], [1]]) - } - - /// Moving the delete after the insert fails the insert with ORA-00001 when it reuses the deleted row's key. - @Test("A delete stays ahead of an insert that reuses its key") - func deleteKeepsItsPlace() throws { - let keyed = writer(keys: ["ID"], columns: ["ID", "V"], typeNames: ["ID": "NUMBER", "V": "VARCHAR2(5 BYTE)"]) - let delete = PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: [.text("7"), .text("a")]) - let insert = PluginRowChange(rowIndex: 1, type: .insert, cellChanges: [], originalRow: nil) - let writes = try keyed.rowWrites( - for: [delete, insert], - insertedRowData: [1: [.text("7"), .text("b")]], - deletedRowIndices: [0], - insertedRowIndices: [1] - ) - #expect(writes.map { $0.statement } == [ - "DELETE FROM \"T1\" WHERE \"ID\" = 7", - "INSERT INTO \"T1\" (\"ID\", \"V\") VALUES (7, 'b')" - ]) - } - - @Test("Only consecutive deletes are batched, each run where it stood") - func interleavedDeleteRuns() throws { - let keyed = writer(keys: ["ID"], columns: ["ID", "V"], typeNames: ["ID": "NUMBER", "V": "VARCHAR2(5 BYTE)"]) - func delete(_ row: Int) -> PluginRowChange { - PluginRowChange(rowIndex: row, type: .delete, cellChanges: [], originalRow: [.text("\(row)"), .null]) - } - let update = PluginRowChange( - rowIndex: 3, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "V", oldValue: .null, newValue: .text("x"))], - originalRow: [.text("3"), .null] - ) - let writes = try keyed.rowWrites( - for: [delete(1), delete(2), update, delete(4), delete(5)], - insertedRowData: [:], - deletedRowIndices: [1, 2, 4, 5], - insertedRowIndices: [] - ) - #expect(writes.map { $0.statement } == [ - "DELETE FROM \"T1\" WHERE \"ID\" = 1 OR \"ID\" = 2", - "UPDATE \"T1\" SET \"V\" = 'x' WHERE \"ID\" = 3", - "DELETE FROM \"T1\" WHERE \"ID\" = 4 OR \"ID\" = 5" - ]) - #expect(writes.map { $0.rowIndices } == [[1, 2], [3], [4, 5]]) - } - - // MARK: - BFILE - - /// A BFILE reads as `BFILENAME('DIR', 'file')`; quoted or bound, that text fails with ORA-00932. - @Test("A BFILE value is written back as the BFILENAME call it reads as") - func bfileWrittenAsCall() throws { - let files = writer(keys: [], columns: ["ID", "F"], typeNames: ["ID": "NUMBER", "F": "BFILE"]) - let row = try #require(try insert(files, [.text("2"), .text("BFILENAME('DATA_PUMP_DIR', 'it''s.bin')")])) - #expect(row.statement == "INSERT INTO \"T1\" (\"ID\", \"F\") VALUES (2, BFILENAME('DATA_PUMP_DIR', 'it''s.bin'))") - #expect(row.parameters.isEmpty) - let empty = try #require(try insert(files, [.text("3"), .null])) - #expect(empty.statement == "INSERT INTO \"T1\" (\"ID\", \"F\") VALUES (3, NULL)") - } - - @Test("Anything in a BFILE cell but the exact BFILENAME call is refused") - func bfileRefusesOtherText() { - let files = writer(keys: [], columns: ["F"], typeNames: ["F": "BFILE"]) - let refused = [ - "", "x.bin", "BFILENAME('DIR','x.bin')", "bfilename('DIR', 'x.bin')", - "BFILENAME('DIR', 'x.bin'); DROP TABLE T1", "BFILENAME('DIR', 'x.bin')) --", "BFILENAME('DIR', 'it's')", - "BFILENAME('DIR', 'x.bin'" - ] - for text in refused { - #expect(throws: PluginRowWriteRefusal.self, "\(text)") { try insert(files, [.text(text)]) } - } - #expect(throws: PluginRowWriteRefusal.self) { try insert(files, [.bytes(Data([1]))]) } - } - - @Test("The BFILENAME text is parsed only in its exact shape") - func bfileParsing() { - let names = OracleBFileText.names(in: "BFILENAME('D''IR', 'a''b''.bin')") - #expect(names?.directory == "D'IR") - #expect(names?.fileName == "a'b'.bin") - #expect(OracleBFileText.call(from: "BFILENAME('D''IR', 'a''b''.bin')") == "BFILENAME('D''IR', 'a''b''.bin')") - #expect(OracleBFileText.names(in: "BFILENAME('DIR', 'x') ") == nil) - #expect(OracleBFileText.names(in: "BFILENAME('DIR', x)") == nil) - } - - // MARK: - Numeric literal - - @Test("Only an ASCII number passes as a literal") - func numericLiteral() { - for valid in ["0", "-1", "+1", "1.", ".5", "1.5e10", "1E-3", "00123"] { - #expect(OracleNumericLiteral.isValid(valid), "\(valid)") - } - for invalid in ["", "-", ".", "e5", "1e", "1e+", "1.2.3", "1 ", " 1", "1,5", "0x10", "\u{0663}", "1--1", "inf"] { - #expect(!OracleNumericLiteral.isValid(invalid), "\(invalid)") - } - } -} diff --git a/TableProTests/Plugins/OracleTableDDLTests.swift b/TableProTests/Plugins/OracleTableDDLTests.swift deleted file mode 100644 index 88eef06919..0000000000 --- a/TableProTests/Plugins/OracleTableDDLTests.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// OracleTableDDLTests.swift -// TableProTests -// -// The Oracle plugin's table DDL writes each column in the order Oracle's grammar takes it. Every statement below -// was run against Oracle 23ai and created the table it describes. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct OracleTableDDLTests { - private func quote(_ name: String) -> String { - "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" - } - - private func column( - _ name: String, - _ type: String, - nullable: Bool = true, - defaultValue: String? = nil, - identity: IdentityKind? = nil, - virtualExpression: String? = nil, - ddlSpelling: String? = nil - ) -> PluginColumnInfo { - PluginColumnInfo( - name: name, - dataType: type, - isNullable: nullable, - isPrimaryKey: false, - defaultValue: defaultValue, - extra: nil, - charset: nil, - collation: nil, - comment: nil, - identityKind: identity, - isGenerated: virtualExpression != nil, - allowedValues: nil, - generationExpression: virtualExpression, - generationKind: virtualExpression == nil ? nil : .virtual, - ddlSpelling: ddlSpelling, - ddlDefault: nil, - ddlGenerationExpression: nil, - ddlCollation: nil, - classificationTypeName: nil - ) - } - - private func definition(_ column: PluginColumnInfo) -> String { - OracleTableDDL.columnDefinition(column, quote: quote) - } - - /// `NOT NULL DEFAULT 5` fails with ORA-03076. - @Test("DEFAULT comes before NOT NULL") - func defaultBeforeNotNull() { - #expect(definition(column("A", "NUMBER(10)", nullable: false, defaultValue: "5")) - == "\"A\" NUMBER(10) DEFAULT 5 NOT NULL") - #expect(definition(column("S", "VARCHAR2(10 CHAR)", nullable: false, defaultValue: "ON NULL 'x'")) - == "\"S\" VARCHAR2(10 CHAR) DEFAULT ON NULL 'x' NOT NULL") - } - - /// A SQL export writes every row with its key, which a `GENERATED ALWAYS` identity refuses (ORA-32795). - @Test("An identity column is written as a plain column, so a restore keeps the dump's keys") - func identity() { - #expect(definition(column("ID", "NUMBER", nullable: false, identity: .always)) == "\"ID\" NUMBER NOT NULL") - #expect(definition(column("ID", "NUMBER", nullable: false, identity: .byDefault)) == "\"ID\" NUMBER NOT NULL") - } - - @Test("A virtual column is written with its expression, not as a stored one") - func virtualColumn() { - #expect(definition(column("V", "NUMBER", virtualExpression: "\"A\"*2")) - == "\"V\" NUMBER GENERATED ALWAYS AS (\"A\"*2) VIRTUAL") - #expect(definition(column("V", "NUMBER", nullable: false, virtualExpression: "\"B\"+1")) - == "\"V\" NUMBER GENERATED ALWAYS AS (\"B\"+1) VIRTUAL NOT NULL") - } - - /// `"Hunt_Mixed"` folded to `HUNT_MIXED` names no type (ORA-00902), and `TIMESTAMP(6) WITH TIME ZONE` is a type - /// only as it is spelled. - @Test("Types are written verbatim, and a DDL spelling wins over the declared type") - func verbatimTypes() { - #expect(definition(column("M", "\"Hunt_Mixed\"")) == "\"M\" \"Hunt_Mixed\"") - #expect(definition(column("TZ", "TIMESTAMP(6) WITH TIME ZONE", defaultValue: "SYSTIMESTAMP")) - == "\"TZ\" TIMESTAMP(6) WITH TIME ZONE DEFAULT SYSTIMESTAMP") - #expect(definition(column("X", "XMLTYPE", ddlSpelling: "\"SYS\".\"XMLTYPE\"")) == "\"X\" \"SYS\".\"XMLTYPE\"") - #expect(definition(column("N", "NVARCHAR2(100)")) == "\"N\" NVARCHAR2(100)") - } - - @Test("The table names every column in order and quotes names") - func createTable() { - let ddl = OracleTableDDL.createTable( - qualifiedTable: "\"HR\".\"T\"", - columns: [column("ID", "NUMBER", nullable: false, identity: .always), column("Na\"me", "VARCHAR2(5 BYTE)")], - quote: quote - ) - #expect(ddl == """ - CREATE TABLE "HR"."T" ( - "ID" NUMBER NOT NULL, - "Na""me" VARCHAR2(5 BYTE) - ); - """) - } - - /// Measured on Oracle 23 Free: `v (public_id) AS SELECT internal_id` replayed from `TEXT_VC` alone - /// exposed `INTERNAL_ID`. - @Test("A view names its columns in the header, since its stored text is the query alone") - func viewNamesItsColumns() { - let ddl = OracleTableDDL.createView( - qualifiedView: "\"HR\".\"V\"", columns: ["PUBLIC_ID", "n\"x"], query: "SELECT internal_id, 1 FROM t", quote: quote - ) - #expect(ddl == "CREATE OR REPLACE VIEW \"HR\".\"V\" (\"PUBLIC_ID\", \"n\"\"x\") AS\nSELECT internal_id, 1 FROM t") - } - - @Test("A view whose columns could not be listed keeps the bare header") - func viewWithoutColumnsKeepsTheBareHeader() { - let ddl = OracleTableDDL.createView(qualifiedView: "\"HR\".\"V\"", columns: [], query: "SELECT 1 FROM dual", quote: quote) - #expect(ddl == "CREATE OR REPLACE VIEW \"HR\".\"V\" AS\nSELECT 1 FROM dual") - } -} diff --git a/TableProTests/Plugins/OracleTypeCatalogTests.swift b/TableProTests/Plugins/OracleTypeCatalogTests.swift deleted file mode 100644 index 147a229f1a..0000000000 --- a/TableProTests/Plugins/OracleTypeCatalogTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// OracleTypeCatalogTests.swift -// TableProTests -// -// The Oracle plugin's one type list, and what it knows about a type from its name. Every spelling below was measured -// against Oracle 23ai. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct OracleTypeCatalogTests { - private static let legacyDataTypes = [ - "NUMBER", "INTEGER", "SMALLINT", "FLOAT", "BINARY_FLOAT", "BINARY_DOUBLE", - "CHAR", "VARCHAR2", "NCHAR", "NVARCHAR2", "CLOB", "NCLOB", "LONG", - "BLOB", "RAW", "LONG RAW", "BFILE", - "DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", - "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND", - "BOOLEAN", "ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY" - ] - - @Test("The picker offers BOOLEAN, JSON and VECTOR") - func pickerOffersNewTypes() { - #expect(OracleTypeCatalog.columnTypesByCategory["Boolean"] == ["BOOLEAN"]) - #expect(OracleTypeCatalog.columnTypesByCategory["JSON"] == ["JSON"]) - #expect(OracleTypeCatalog.columnTypesByCategory["Vector"] == ["VECTOR"]) - } - - @Test("The picker keeps every category it had") - func pickerKeepsCategories() { - let categories = OracleTypeCatalog.columnTypesByCategory - #expect(categories["Integer"] == ["NUMBER", "INTEGER", "INT", "SMALLINT"]) - #expect(categories["String"] == ["VARCHAR2", "NVARCHAR2", "CHAR", "NCHAR", "CLOB", "NCLOB", "LONG"]) - #expect(categories["XML"] == ["XMLTYPE"]) - #expect(categories["Spatial"] == ["SDO_GEOMETRY"]) - #expect(categories["Other"] == ["ROWID", "UROWID"]) - } - - @Test("Completion types are the picker's types and keep every earlier entry") - func dataTypesDeriveFromCategories() { - let dataTypes = OracleTypeCatalog.dataTypes - #expect(dataTypes == Set(OracleTypeCatalog.columnTypesByCategory.values.flatMap { $0 })) - #expect(Set(Self.legacyDataTypes).isSubset(of: dataTypes)) - #expect(dataTypes.isSuperset(of: ["INT", "DECIMAL", "NUMERIC", "REAL", "DOUBLE PRECISION", "JSON", "VECTOR"])) - #expect(dataTypes.count == 35) - } - - @Test("A type name matches on its base with every modifier removed") - func baseTypeName() { - #expect(OracleTypeCatalog.baseTypeName("TIMESTAMP(6) WITH TIME ZONE") == "TIMESTAMP WITH TIME ZONE") - #expect(OracleTypeCatalog.baseTypeName("timestamp with local time zone") == "TIMESTAMP WITH LOCAL TIME ZONE") - #expect(OracleTypeCatalog.baseTypeName("INTERVAL DAY(2) TO SECOND(6)") == "INTERVAL DAY TO SECOND") - #expect(OracleTypeCatalog.baseTypeName("VARCHAR2(50 BYTE)") == "VARCHAR2") - #expect(OracleTypeCatalog.baseTypeName("NUMBER(*,2)") == "NUMBER") - #expect(OracleTypeCatalog.baseTypeName("date") == "DATE") - } - - @Test("A DATE result column classifies as TIMESTAMP(0), nothing else gets a hint") - func resultHints() { - #expect(OracleTypeCatalog.resultClassificationTypeName(forHeaderType: "date") == "TIMESTAMP(0)") - #expect(OracleTypeCatalog.resultClassificationTypeName(forHeaderType: "timestamp") == nil) - #expect(OracleTypeCatalog.resultClassificationTypeName(forHeaderType: "varchar2") == nil) - - let meta = OracleTypeCatalog.resultColumnMeta(columns: ["ID", "CREATED"], typeNames: ["number", "date"]) - #expect(meta?.map(\.classificationTypeName) == [nil, "TIMESTAMP(0)"]) - #expect(meta?.map(\.dataType) == ["number", "date"]) - #expect(OracleTypeCatalog.resultColumnMeta(columns: ["ID"], typeNames: ["number"]) == nil) - #expect(OracleTypeCatalog.resultColumnMeta(columns: ["A", "B"], typeNames: ["date"]) == nil) - } - - @Test("A bounded result gains the hints the stream header could not carry") - func boundedResultHints() { - let result = PluginQueryResult( - columns: ["D"], columnTypeNames: ["date"], rows: [[.text("2026-10-06 15:22:20")]], - rowsAffected: 0, timing: PluginQueryTiming(total: 1), isTruncated: true - ) - let hinted = result.withOracleClassificationHints() - #expect(hinted.columnMeta?.first?.classificationTypeName == "TIMESTAMP(0)") - #expect(hinted.isTruncated) - #expect(hinted.rows.count == 1) - - let plain = PluginQueryResult( - columns: ["N"], columnTypeNames: ["number"], rows: [], rowsAffected: 0, timing: PluginQueryTiming(total: 0) - ) - #expect(plain.withOracleClassificationHints().columnMeta == nil) - } - - @Test("Only an owner-qualified, quoted or REF type gets its own DDL spelling") - func ddlSpelling() { - #expect(OracleTypeCatalog.ddlSpelling(forDeclaredType: "\"SYS\".\"XMLTYPE\"") == "\"SYS\".\"XMLTYPE\"") - #expect(OracleTypeCatalog.ddlSpelling(forDeclaredType: "\"Hunt_Mixed\"") == "\"Hunt_Mixed\"") - #expect(OracleTypeCatalog.ddlSpelling(forDeclaredType: "REF HUNT_ADDR") == "REF HUNT_ADDR") - #expect(OracleTypeCatalog.ddlSpelling(forDeclaredType: "VARCHAR2(50 CHAR)") == nil) - #expect(OracleTypeCatalog.ddlSpelling(forDeclaredType: "HUNT_ADDR") == nil) - } -} diff --git a/TableProTests/Plugins/ParquetExportCancellationTests.swift b/TableProTests/Plugins/ParquetExportCancellationTests.swift deleted file mode 100644 index 86a07155d0..0000000000 --- a/TableProTests/Plugins/ParquetExportCancellationTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// ParquetExportCancellationTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct ParquetExportCancellationTests { - @Test("Stopping between tables removes the files already written") - func stopBetweenTablesRemovesWrittenFiles() async throws { - let directory = try makeDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let destination = directory.appendingPathComponent("dump.parquet") - let progress = PluginExportProgress(progress: Progress(totalUnitCount: 0)) - var attempted: [String] = [] - - await #expect(throws: PluginExportCancellationError.self) { - try await ParquetTableFiles.write( - [table("users"), table("orders")], destination: destination, progress: progress - ) { table, fileURL in - attempted.append(table.name) - try Data("PAR1".utf8).write(to: fileURL) - progress.cancel() - } - } - - #expect(attempted == ["users"]) - #expect(!fileExists(for: "users", destination: destination)) - #expect(!fileExists(for: "orders", destination: destination)) - } - - @Test("A table that fails to write removes the files already written") - func failedTableRemovesWrittenFiles() async throws { - let directory = try makeDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let destination = directory.appendingPathComponent("dump.parquet") - let progress = PluginExportProgress(progress: Progress(totalUnitCount: 0)) - - await #expect(throws: PluginExportError.self) { - try await ParquetTableFiles.write( - [table("users"), table("orders")], destination: destination, progress: progress - ) { table, fileURL in - guard table.name == "users" else { throw PluginExportError.exportFailed("orders") } - try Data("PAR1".utf8).write(to: fileURL) - } - } - - #expect(!fileExists(for: "users", destination: destination)) - } - - @Test("A finished multi-table export keeps one file per table") - func finishedExportKeepsEveryFile() async throws { - let directory = try makeDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let destination = directory.appendingPathComponent("dump.parquet") - let progress = PluginExportProgress(progress: Progress(totalUnitCount: 0)) - - try await ParquetTableFiles.write( - [table("users"), table("orders")], destination: destination, progress: progress - ) { _, fileURL in - try Data("PAR1".utf8).write(to: fileURL) - } - - #expect(fileExists(for: "users", destination: destination)) - #expect(fileExists(for: "orders", destination: destination)) - } - - @Test("A single table is written to the destination itself") - func singleTableWritesToDestination() async throws { - let directory = try makeDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let destination = directory.appendingPathComponent("dump.parquet") - let progress = PluginExportProgress(progress: Progress(totalUnitCount: 0)) - var targets: [URL] = [] - - try await ParquetTableFiles.write( - [table("users")], destination: destination, progress: progress - ) { _, fileURL in - targets.append(fileURL) - } - - #expect(targets == [destination]) - } - - private func table(_ name: String) -> PluginExportTable { - PluginExportTable(name: name, databaseName: "main", tableType: "table") - } - - private func makeDirectory() throws -> URL { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("parquet-export-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return directory - } - - private func fileExists(for table: String, destination: URL) -> Bool { - let url = ParquetFileNaming.perTableURL(destination: destination, table: table) - return FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) - } -} diff --git a/TableProTests/Plugins/PluginColumnInfoCodableTests.swift b/TableProTests/Plugins/PluginColumnInfoCodableTests.swift index 21a60a6c63..6f6a7d1c09 100644 --- a/TableProTests/Plugins/PluginColumnInfoCodableTests.swift +++ b/TableProTests/Plugins/PluginColumnInfoCodableTests.swift @@ -3,18 +3,6 @@ import TableProPluginKit import Testing struct PluginColumnInfoCodableTests { - @Test("allowedValues round-trips through JSON encoding") - func allowedValuesRoundTrip() throws { - let original = PluginColumnInfo( - name: "status", - dataType: "ENUM", - allowedValues: ["active", "inactive", "pending"] - ) - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(PluginColumnInfo.self, from: data) - #expect(decoded.allowedValues == ["active", "inactive", "pending"]) - } - @Test("nil allowedValues encodes and decodes back to nil") func nilAllowedValuesRoundTrip() throws { let original = PluginColumnInfo(name: "id", dataType: "INTEGER") diff --git a/TableProTests/Plugins/PluginQueryTimeoutTests.swift b/TableProTests/Plugins/PluginQueryTimeoutTests.swift index ecef70f268..61dbd4bad0 100644 --- a/TableProTests/Plugins/PluginQueryTimeoutTests.swift +++ b/TableProTests/Plugins/PluginQueryTimeoutTests.swift @@ -1,18 +1,6 @@ import Testing struct PluginQueryTimeoutTests { - @Test("MongoDB clamps query timeout conversion before narrowing to Int32") - func mongoDBConversionIsBounded() { - let maximum = PluginQueryTimeout.maximumSeconds - let maximumMilliseconds = Int32(maximum * 1_000) - - #expect(MongoDBTimeoutPolicy.queryTimeoutMilliseconds(seconds: 0) == 0) - #expect(MongoDBTimeoutPolicy.queryTimeoutMilliseconds(seconds: -1) == 0) - #expect(MongoDBTimeoutPolicy.queryTimeoutMilliseconds(seconds: maximum) == maximumMilliseconds) - #expect(MongoDBTimeoutPolicy.queryTimeoutMilliseconds(seconds: maximum + 1) == maximumMilliseconds) - #expect(MongoDBTimeoutPolicy.queryTimeoutMilliseconds(seconds: Int.max) == maximumMilliseconds) - } - @Test("SQLite clamps query timeout conversion before narrowing to Int32") func sqliteConversionIsBounded() { let maximum = PluginQueryTimeout.maximumSeconds diff --git a/TableProTests/Plugins/PluginSSLClassifierTests.swift b/TableProTests/Plugins/PluginSSLClassifierTests.swift index c571b2081c..0a9ec7bc3f 100644 --- a/TableProTests/Plugins/PluginSSLClassifierTests.swift +++ b/TableProTests/Plugins/PluginSSLClassifierTests.swift @@ -92,50 +92,6 @@ struct MariaDBClassifierTests { } } -struct MongoDBClassifierTests { - @Test("Atlas internal-error handshake failure → unknown, not cipherMismatch") - func testAtlasInternalErrorHandshake() { - let message = "No suitable servers found: [TLS handshake failed: internal error (-9838) " - + "calling hello on 'ac-zmho1ul-shard-00-00.dsllzcf.mongodb.net:27017']" - guard case .unknown = MongoDBSSLClassifier.classifySSLError(message) else { - Issue.record("Expected unknown for a generic handshake failure") - return - } - } - - @Test("Genuine cipher/protocol failure → cipherMismatch") - func testGenuineCipherMismatch() { - guard case .cipherMismatch = MongoDBSSLClassifier.classifySSLError("TLS handshake failed: sslv3 alert handshake failure: no shared cipher") else { - Issue.record("Expected cipherMismatch") - return - } - } - - @Test("Certificate verify failure → untrustedCertificate") - func testCertificateVerifyFailed() { - guard case .untrustedCertificate = MongoDBSSLClassifier.classifySSLError("TLS handshake failed: certificate verify failed") else { - Issue.record("Expected untrustedCertificate") - return - } - } - - @Test("Hostname verification failure → hostnameMismatch") - func testHostnameVerification() { - guard case .hostnameMismatch = MongoDBSSLClassifier.classifySSLError("hostname verification failed") else { - Issue.record("Expected hostnameMismatch") - return - } - } - - @Test("TLS required → serverRejectedPlaintext") - func testTLSRequired() { - guard case .serverRejectedPlaintext = MongoDBSSLClassifier.classifySSLError("TLS required by Atlas cluster") else { - Issue.record("Expected serverRejectedPlaintext") - return - } - } -} - struct RedisClassifierTests { @Test("No shared cipher → cipherMismatch") func testNoSharedCipher() { @@ -154,29 +110,6 @@ struct RedisClassifierTests { } } -struct OracleClassifierTests { - @Test("ORA-29024 → cipherMismatch") - func testORA29024() { - guard case .cipherMismatch = OracleSSLClassifier.classifySSLError("ORA-29024: Certificate validation failure") else { - Issue.record("Expected cipherMismatch") - return - } - } - - @Test("Network timeout (ORA-12606) is not classified as SSL") - func testTimeoutNotSSL() { - #expect(OracleSSLClassifier.classifySSLError("ORA-12606: TNS: Application timeout occurred") == nil) - } - - @Test("ORA-28759 → clientCertRequired") - func testORA28759() { - guard case .clientCertRequired = OracleSSLClassifier.classifySSLError("ORA-28759: failure to open file") else { - Issue.record("Expected clientCertRequired") - return - } - } -} - struct ClickHouseClassifierTests { @Test("URLError.secureConnectionFailed → cipherMismatch") func testSecureConnectionFailed() { @@ -244,59 +177,3 @@ struct ClickHouseClassifierTests { } } } - -struct CassandraClassifierTests { - private let encryptedPkcs8 = "-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIIF...\n-----END ENCRYPTED PRIVATE KEY-----" - private let encryptedPkcs1 = """ - -----BEGIN RSA PRIVATE KEY----- - Proc-Type: 4,ENCRYPTED - DEK-Info: AES-256-CBC,1234 - - MIIE... - -----END RSA PRIVATE KEY----- - """ - private let unencryptedPkcs8 = "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----" - - @Test("Detects PKCS#8 and PKCS#1 encrypted keys, not unencrypted ones") - func testEncryptionDetection() { - #expect(CassandraClientKeyClassifier.isEncryptedPrivateKey(encryptedPkcs8)) - #expect(CassandraClientKeyClassifier.isEncryptedPrivateKey(encryptedPkcs1)) - #expect(!CassandraClientKeyClassifier.isEncryptedPrivateKey(unencryptedPkcs8)) - } - - @Test("Encrypted key with no passphrase → clientKeyPassphraseRequired") - func testEncryptedNoPassphrase() { - let error = CassandraClientKeyClassifier.privateKeyLoadError( - keyPEM: encryptedPkcs8, hasPassphrase: false, keyPath: "/k.pem") - guard case .clientKeyPassphraseRequired = error else { - Issue.record("Expected clientKeyPassphraseRequired") - return - } - } - - @Test("Encrypted key with wrong passphrase → clientKeyPassphraseIncorrect") - func testEncryptedWrongPassphrase() { - let error = CassandraClientKeyClassifier.privateKeyLoadError( - keyPEM: encryptedPkcs1, hasPassphrase: true, keyPath: "/k.pem") - guard case .clientKeyPassphraseIncorrect = error else { - Issue.record("Expected clientKeyPassphraseIncorrect") - return - } - } - - @Test("Unencrypted but unreadable key → clientKeyInvalid, never a passphrase error") - func testUnencryptedInvalid() { - let withoutPassphrase = CassandraClientKeyClassifier.privateKeyLoadError( - keyPEM: unencryptedPkcs8, hasPassphrase: false, keyPath: "/k.pem") - let withPassphrase = CassandraClientKeyClassifier.privateKeyLoadError( - keyPEM: unencryptedPkcs8, hasPassphrase: true, keyPath: "/k.pem") - guard case .clientKeyInvalid = withoutPassphrase else { - Issue.record("Expected clientKeyInvalid without passphrase") - return - } - guard case .clientKeyInvalid = withPassphrase else { - Issue.record("Expected clientKeyInvalid with passphrase") - return - } - } -} diff --git a/TableProTests/Plugins/PluginTableInfoTests.swift b/TableProTests/Plugins/PluginTableInfoTests.swift index a62a021834..507d423f03 100644 --- a/TableProTests/Plugins/PluginTableInfoTests.swift +++ b/TableProTests/Plugins/PluginTableInfoTests.swift @@ -15,14 +15,6 @@ struct PluginTableInfoTests { #expect(info.comment == "Account records") } - @Test("comment round-trips through JSON encoding") - func commentRoundTrip() throws { - let original = PluginTableInfo(name: "orders", type: "TABLE", comment: "Customer orders") - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(PluginTableInfo.self, from: data) - #expect(decoded.comment == "Customer orders") - } - @Test("decoding a payload without comment keeps it nil for forward compatibility") func legacyPayloadDecodesToNilComment() throws { let legacyJson = """ diff --git a/TableProTests/Plugins/PluginURLSessionTaskBoxTests.swift b/TableProTests/Plugins/PluginURLSessionTaskBoxTests.swift deleted file mode 100644 index 1c9c2824f2..0000000000 --- a/TableProTests/Plugins/PluginURLSessionTaskBoxTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import Testing - -private final class RecordingURLSessionTask: PluginURLSessionCancellable, @unchecked Sendable { - private let lock = NSLock() - private var cancellations = 0 - - var cancellationCount: Int { - lock.withLock { cancellations } - } - - func cancel() { - lock.withLock { cancellations += 1 } - } -} - -struct PluginURLSessionTaskBoxTests { - @Test("Cancellation delivered before task registration cancels the late task") - func cancellationBeforeRegistration() { - let box = PluginURLSessionTaskBox() - let task = RecordingURLSessionTask() - - box.cancel() - box.set(task) - - #expect(task.cancellationCount == 1) - } - - @Test("Cancellation delivered after task registration cancels the current task") - func cancellationAfterRegistration() { - let box = PluginURLSessionTaskBox() - let task = RecordingURLSessionTask() - box.set(task) - - box.cancel() - box.cancel() - - #expect(task.cancellationCount == 1) - } - - @Test("Cancellation delivered after completion cannot touch the completed task") - func cancellationAfterCompletion() { - let box = PluginURLSessionTaskBox() - let task = RecordingURLSessionTask() - box.set(task) - box.finish() - - box.cancel() - - #expect(task.cancellationCount == 0) - } -} diff --git a/TableProTests/Plugins/PostgreSQLLiteralQuotingTests.swift b/TableProTests/Plugins/PostgreSQLLiteralQuotingTests.swift index d3a51e6b6b..a3f787b2b0 100644 --- a/TableProTests/Plugins/PostgreSQLLiteralQuotingTests.swift +++ b/TableProTests/Plugins/PostgreSQLLiteralQuotingTests.swift @@ -147,101 +147,3 @@ struct PostgreSQLLiteralQuotingTests { #expect(!PostgreSQLSchemaQueries.allTablesMetadata(schema: "a\0b").contains("\0")) } } - -/// Nothing at runtime can see a statement that wraps its own quotes around an already-complete -/// literal: `''E'a\\b''` is valid SQL that matches nothing, and a plain `'\(name)'` only misbehaves -/// on a server running the legacy setting. So the guard is a source scan, the same shape -/// `IndexDDLOwnershipTests` and `SyncMapperFieldAccessTests` use. -struct PostgreSQLLiteralQuotingSourceScanTests { - /// `PostgreSQLObjectQueries` owns the quoting and is the one file allowed to write the quotes - /// itself. `LibPQConnectionString` builds a libpq conninfo string, whose quoting rules are - /// libpq's rather than SQL's. - private static let quotingOwners: Set = [ - "PostgreSQLObjectQueries.swift", - "LibPQConnectionString.swift" - ] - - /// `LibPQDriverCore` carries the `escapeStringLiteral` PluginKit requirement, whose contract is - /// inner text for the app and the export plugins to wrap in their own quotes, and - /// `LibPQStringConformance` implements it. Both read the session's reported - /// `standard_conforming_strings`, so neither may be reached from a statement builder. - private static let escapeHelperOwners: Set = [ - "LibPQDriverCore.swift", - "LibPQStringConformance.swift" - ] - - private static let pluginDirectory: URL? = { - var directory = URL(fileURLWithPath: #filePath) - for _ in 0..<3 { directory.deleteLastPathComponent() } - let plugin = directory - .appendingPathComponent("Plugins") - .appendingPathComponent("PostgreSQLDriverPlugin") - return FileManager.default.fileExists(atPath: plugin.path) ? plugin : nil - }() - - private static func sources() throws -> [(name: String, text: String)] { - guard let pluginDirectory else { return [] } - let files = try FileManager.default.contentsOfDirectory( - at: pluginDirectory, includingPropertiesForKeys: nil) - return try files - .filter { $0.pathExtension == "swift" } - .map { ($0.lastPathComponent, try String(contentsOf: $0, encoding: .utf8)) } - } - - @Test("The scan reaches the plugin sources at all") - func sourcesAreReachable() throws { - let sources = try Self.sources() - #expect(!sources.isEmpty, "No PostgreSQL plugin sources found; the guards below would pass vacuously") - #expect(sources.contains { $0.name == "PostgreSQLObjectQueries.swift" }) - #expect(Self.escapeHelperOwners.isSubset(of: Set(sources.map(\.name)))) - } - - /// A line carrying `message:` is a Swift diagnostic quoting a name for the reader, not SQL. - @Test("No file builds a SQL literal by hand") - func noHandWrittenLiterals() throws { - var offenders: [String] = [] - for source in try Self.sources() where !Self.quotingOwners.contains(source.name) { - for (offset, line) in source.text.components(separatedBy: "\n").enumerated() - where line.contains(#"'\("#) && !line.contains("message:") { - offenders.append("\(source.name):\(offset + 1)") - } - } - - #expect( - offenders.isEmpty, - "These lines quote a literal themselves instead of calling quoteLiteral: \(offenders)" - ) - } - - @Test("No catalog lookup outside the schema queries resolves its schema through current_schema()") - func noCatalogLookupReadsTheSessionSchema() throws { - var offenders: [String] = [] - for source in try Self.sources() where source.name != "PostgreSQLSchemaQueries.swift" { - for (offset, line) in source.text.components(separatedBy: "\n").enumerated() - where line.replacingOccurrences(of: " ", with: "").contains("nspname=current_schema()") { - offenders.append("\(source.name):\(offset + 1)") - } - } - - #expect( - offenders.isEmpty, - "These lines read the session's schema instead of the one the caller named: \(offenders)" - ) - } - - @Test("No file builds catalog SQL through a session-dependent escape helper") - func noSessionDependentEscapeHelpers() throws { - let helpers = ["escapeLiteral(", "escapeStringLiteral(", "LibPQStringConformance.escape("] - var offenders: [String] = [] - for source in try Self.sources() where !Self.escapeHelperOwners.contains(source.name) { - for helper in helpers where source.text.contains(helper) { - offenders.append("\(source.name): \(helper)") - } - } - - #expect( - offenders.isEmpty, - "These files reach for an escape helper instead of quoteLiteral: \(offenders)" - ) - } -} diff --git a/TableProTests/Plugins/RedisConnectProbeTests.swift b/TableProTests/Plugins/RedisConnectProbeTests.swift index 2ad4b1c1fd..091cc63b12 100644 --- a/TableProTests/Plugins/RedisConnectProbeTests.swift +++ b/TableProTests/Plugins/RedisConnectProbeTests.swift @@ -58,19 +58,6 @@ struct RedisConnectProbeTests { } struct RedisConnectTimeoutTests { - private func pluginSource(_ name: String) throws -> String { - let repository = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - return try String( - contentsOf: repository - .appendingPathComponent("Plugins/RedisDriverPlugin") - .appendingPathComponent(name), - encoding: .utf8 - ) - } - @Test("The remaining millisecond budget becomes hiredis wall time") func hiredisBudget() { let timeout = RedisConnectTimeout(additionalFields: ["connectTimeoutMilliseconds": "2750"]) @@ -99,18 +86,4 @@ struct RedisConnectTimeoutTests { == RedisConnectTimeout.maximumMilliseconds ) } - - @Test("INFO probes run before the Redis session timeout replaces the connect deadline") - func bootstrapLifecycleOrder() throws { - let connection = try pluginSource("RedisPluginConnection.swift") - let driver = try pluginSource("RedisPluginDriver.swift") - - #expect(connection.contains("if let deadline = activeConnectDeadline")) - #expect(connection.contains("armConnectWatchdog(deadline: deadline)")) - #expect(connection.contains("Darwin.shutdown(context.pointee.fd, SHUT_RDWR)")) - #expect(connection.components(separatedBy: "timeval(tv_sec: 30, tv_usec: 0)").count == 2) - let modeProbe = try #require(driver.range(of: "try await verifyServerMode(mode, on: channel)")) - let adoption = try #require(driver.range(of: "try await channel.finishConnecting()")) - #expect(modeProbe.lowerBound < adoption.lowerBound) - } } diff --git a/TableProTests/Plugins/RedisQueuedReplyTests.swift b/TableProTests/Plugins/RedisQueuedReplyTests.swift index dd9edc097e..9b9c71491c 100644 --- a/TableProTests/Plugins/RedisQueuedReplyTests.swift +++ b/TableProTests/Plugins/RedisQueuedReplyTests.swift @@ -128,38 +128,6 @@ struct RedisQueuedCommandPolicyTests { } } -/// The paged read and the streamed read both run an operation, and the streamed one used to run it -/// without translating the queued reply: a command sent into the user's open block threw instead of -/// answering `QUEUED`, and nothing recorded it, so `EXEC`'s replies paired with the recorded commands -/// one position out. The translation therefore belongs to the one function that dispatches an -/// operation, not to a route. The plugin imports CRedis, which this target cannot, so the guard is a -/// source scan. -struct RedisQueuedTranslationSourceScanTests { - private static let pluginDirectory: URL = { - var directory = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { directory.deleteLastPathComponent() } - return directory - .appendingPathComponent("Plugins") - .appendingPathComponent("RedisDriverPlugin") - }() - - private static func source(_ name: String) throws -> String { - try String(contentsOf: pluginDirectory.appendingPathComponent(name), encoding: .utf8) - } - - private static let queuedCatch = "catch let queued as RedisQueuedCommand" - - @Test("The operation dispatcher is what translates a queued reply") - func theDispatcherTranslates() throws { - #expect(try Self.source("RedisPluginDriver+Operations.swift").contains(Self.queuedCatch)) - } - - @Test("No route translates a queued reply for itself") - func noRouteTranslatesOnItsOwn() throws { - #expect(!(try Self.source("RedisPluginDriver.swift").contains(Self.queuedCatch))) - } -} - struct RedisCommandChannelScanTests { @Test("A queued SCAN is refused rather than read as an empty keyspace") func queuedScanIsRefused() async throws { diff --git a/TableProTests/Plugins/SQLLexicalFeatureMappingTests.swift b/TableProTests/Plugins/SQLLexicalFeatureMappingTests.swift index c21f8d6894..7a376d3424 100644 --- a/TableProTests/Plugins/SQLLexicalFeatureMappingTests.swift +++ b/TableProTests/Plugins/SQLLexicalFeatureMappingTests.swift @@ -3,7 +3,7 @@ // TableProTests // // The kit carries its own copy of the lexical rules, because a plugin cannot link the app's package. These hold the -// two copies to one another: the feature bits, the grammars two plugins keep for themselves, and where each copy +// two copies to one another: the feature bits, the grammars the MySQL plugin keeps for itself, and where each copy // splits the same text. // @@ -50,13 +50,12 @@ struct SQLLexicalFeatureMappingTests { #expect(SQLLexicalGrammar(pluginFeatures: grammar.pluginFeatures) == grammar) } - @Test("The grammars the MySQL and DuckDB plugins keep are the curated ones") + @Test("The grammars the MySQL plugin keeps are the curated ones") func pluginGrammarsMatchTheCuratedTable() { #expect(SQLLexicalGrammar(pluginFeatures: MySQLLexicalFeatures.mySQL) == DatabaseType.mysql.lexicalGrammar) #expect( SQLLexicalGrammar(pluginFeatures: MySQLLexicalFeatures.databend) == DatabaseType.databend.lexicalGrammar ) - #expect(SQLLexicalGrammar(pluginFeatures: DuckDBLexicalFeatures.features) == DatabaseType.duckdb.lexicalGrammar) } @Test("NO_BACKSLASH_ESCAPES from the server status turns the backslash off in both string quotes") diff --git a/TableProTests/Plugins/SQLiteDefaultValueTests.swift b/TableProTests/Plugins/SQLiteDefaultValueTests.swift index 4a3048a0d7..3372582d2d 100644 --- a/TableProTests/Plugins/SQLiteDefaultValueTests.swift +++ b/TableProTests/Plugins/SQLiteDefaultValueTests.swift @@ -35,15 +35,11 @@ struct SQLiteDefaultValueTests { ) func roundTrip(catalog: String, expected: String) { #expect(sqliteDefaultValueFromCatalog(catalog) == expected) - #expect(libSQLDefaultValueFromCatalog(catalog) == expected) - #expect(cloudflareD1DefaultValueFromCatalog(catalog) == expected) } @Test("No default at all stays absent") func absentDefault() { #expect(sqliteDefaultValueFromCatalog(nil) == nil) #expect(sqliteDefaultValueFromCatalog("") == nil) - #expect(libSQLDefaultValueFromCatalog(nil) == nil) - #expect(cloudflareD1DefaultValueFromCatalog(nil) == nil) } } diff --git a/TableProTests/Plugins/SnowflakeAuthTests.swift b/TableProTests/Plugins/SnowflakeAuthTests.swift deleted file mode 100644 index 7509318149..0000000000 --- a/TableProTests/Plugins/SnowflakeAuthTests.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// SnowflakeAuthTests.swift -// TableProTests -// -// Tests for SnowflakeAccount, SnowflakeConnectionsTOML, and the SPKI -// wrapping used for key-pair JWT fingerprints (compiled via symlink from -// SnowflakeDriverPlugin). -// - -import Foundation -import Testing - -struct SnowflakeAccountTests { - @Test("Plain locator gets the Snowflake domain appended") - func testHostFromLocator() { - #expect(SnowflakeAccount.host(forAccount: "xy12345.us-east-1") == "xy12345.us-east-1.snowflakecomputing.com") - #expect(SnowflakeAccount.host(forAccount: "myorg-myaccount") == "myorg-myaccount.snowflakecomputing.com") - } - - @Test("Full hostnames pass through unchanged, case-insensitively") - func testHostPassthrough() { - #expect( - SnowflakeAccount.host(forAccount: "abc.snowflakecomputing.com") == "abc.snowflakecomputing.com" - ) - #expect( - SnowflakeAccount.host(forAccount: "Abc.SnowflakeComputing.Com") == "Abc.SnowflakeComputing.Com" - ) - } - - @Test("URL forms resolve to their host") - func testHostFromURL() { - #expect( - SnowflakeAccount.host(forAccount: "https://abc.snowflakecomputing.com/console") == - "abc.snowflakecomputing.com" - ) - } - - @Test("Whitespace is trimmed before resolution") - func testHostTrimsWhitespace() { - #expect(SnowflakeAccount.host(forAccount: " abc \n") == "abc.snowflakecomputing.com") - } - - @Test("Issuer account name drops domain and region, then uppercases") - func testIssuerAccountName() { - #expect(SnowflakeAccount.issuerAccountName(forAccount: "xy12345.us-east-1") == "XY12345") - #expect( - SnowflakeAccount.issuerAccountName(forAccount: "xy12345.us-east-1.snowflakecomputing.com") == "XY12345" - ) - #expect(SnowflakeAccount.issuerAccountName(forAccount: "myorg-myaccount") == "MYORG-MYACCOUNT") - } -} - -struct SnowflakeConnectionsTOMLTests { - @Test("Parses sections with key-value pairs") - func testBasicSection() { - let toml = """ - [default] - account = "xy12345" - user = jane - """ - let parsed = SnowflakeConnectionsTOML.parse(toml) - #expect(parsed["default"]?["account"] == "xy12345") - #expect(parsed["default"]?["user"] == "jane") - } - - @Test("Strips the connections. prefix from config.toml sections") - func testConnectionsPrefix() { - let parsed = SnowflakeConnectionsTOML.parse("[connections.dev]\naccount = 'abc'\n") - #expect(parsed["dev"]?["account"] == "abc") - } - - @Test("Quoted section names are unquoted") - func testQuotedSectionName() { - let parsed = SnowflakeConnectionsTOML.parse("[connections.\"my conn\"]\nrole = \"ADMIN\"\n") - #expect(parsed["my conn"]?["role"] == "ADMIN") - } - - @Test("Comments are stripped outside strings and kept inside both quote styles") - func testCommentHandling() { - let toml = """ - [default] - account = "abc" # trailing comment - password = "p#ss" - token = 'a#b' - """ - let parsed = SnowflakeConnectionsTOML.parse(toml) - #expect(parsed["default"]?["account"] == "abc") - #expect(parsed["default"]?["password"] == "p#ss") - #expect(parsed["default"]?["token"] == "a#b") - } - - @Test("Key-value pairs before any section are ignored") - func testKeysOutsideSectionIgnored() { - let parsed = SnowflakeConnectionsTOML.parse("account = \"abc\"\n[dev]\nuser = \"u\"\n") - #expect(parsed.count == 1) - #expect(parsed["dev"]?["user"] == "u") - } -} - -struct SnowflakeSPKIWrappingTests { - private static let rsaAlgorithmID: [UInt8] = [ - 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, - 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00 - ] - - @Test("Short keys use single-byte DER lengths") - func testShortFormLength() { - let pkcs1 = Data((0..<10).map { UInt8($0) }) - let spki = [UInt8](SnowflakeKeyPairAuth.wrapPKCS1IntoSPKI(pkcs1)) - - #expect(spki[0] == 0x30) - #expect(Int(spki[1]) == spki.count - 2) - #expect(Array(spki[2..<17]) == Self.rsaAlgorithmID) - #expect(spki[17] == 0x03) - #expect(Int(spki[18]) == pkcs1.count + 1) - #expect(spki[19] == 0x00) - #expect(Array(spki.suffix(pkcs1.count)) == [UInt8](pkcs1)) - } - - @Test("Keys past 127 bytes use long-form DER lengths") - func testLongFormLength() { - let pkcs1 = Data(repeating: 0xAB, count: 270) - let spki = [UInt8](SnowflakeKeyPairAuth.wrapPKCS1IntoSPKI(pkcs1)) - - #expect(spki[0] == 0x30) - #expect(spki[1] == 0x82) - let bodyLength = (Int(spki[2]) << 8) | Int(spki[3]) - #expect(bodyLength == spki.count - 4) - #expect(Array(spki[4..<19]) == Self.rsaAlgorithmID) - #expect(spki[19] == 0x03) - #expect(spki[20] == 0x82) - let bitStringLength = (Int(spki[21]) << 8) | Int(spki[22]) - #expect(bitStringLength == pkcs1.count + 1) - #expect(spki[23] == 0x00) - #expect(Array(spki.suffix(pkcs1.count)) == [UInt8](pkcs1)) - } -} diff --git a/TableProTests/Plugins/SnowflakeGeneratorTests.swift b/TableProTests/Plugins/SnowflakeGeneratorTests.swift deleted file mode 100644 index 1fb2543ef2..0000000000 --- a/TableProTests/Plugins/SnowflakeGeneratorTests.swift +++ /dev/null @@ -1,328 +0,0 @@ -// -// SnowflakeGeneratorTests.swift -// TableProTests -// -// Tests for the Snowflake DML statement generator, DDL generator, and -// SHOW-based schema query builders (compiled via symlinks from -// SnowflakeDriverPlugin). -// - -import Foundation -import TableProPluginKit -import Testing - -struct SnowflakeStatementGeneratorTests { - private func generator( - columns: [String] = ["id", "name", "payload"], - types: [String] = ["NUMBER", "VARCHAR(100)", "VARIANT"], - primaryKeys: [String] = ["id"] - ) -> SnowflakeStatementGenerator { - SnowflakeStatementGenerator( - qualifiedTable: "\"DB\".\"PUBLIC\".\"T\"", - columns: columns, - columnTypeNames: types, - primaryKeyColumns: primaryKeys - ) - } - - private func update(_ column: String, to value: PluginCellValue, original: [PluginCellValue]) -> PluginRowChange { - PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 0, columnName: column, oldValue: .null, newValue: value)], - originalRow: original - ) - } - - @Test("Plain inserts use VALUES with placeholders") - func testPlainInsert() { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let result = generator(columns: ["id", "name"], types: ["NUMBER", "VARCHAR"], primaryKeys: ["id"]) - .generateStatements( - from: [change], - insertedRowData: [0: [.text("1"), .text("Alice")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - #expect(result.count == 1) - #expect(result[0].statement == "INSERT INTO \"DB\".\"PUBLIC\".\"T\" (\"id\", \"name\") VALUES (?, ?)") - #expect(result[0].parameters == [.text("1"), .text("Alice")]) - } - - @Test - func insertLeavesDefaultMarkedColumnsOut() { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let result = generator(columns: ["id", "status"], types: ["NUMBER", "VARCHAR"], primaryKeys: ["id"]) - .generateStatements( - from: [change], - insertedRowData: [0: [.text("1"), .text("__DEFAULT__")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - #expect(result.map(\.statement) == ["INSERT INTO \"DB\".\"PUBLIC\".\"T\" (\"id\") VALUES (?)"]) - #expect(result.first?.parameters == [.text("1")]) - } - - @Test - func insertOfOnlyDefaultsWritesTheDefaultKeyword() { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let result = generator(columns: ["id", "status"], types: ["NUMBER", "VARCHAR"], primaryKeys: ["id"]) - .generateStatements( - from: [change], - insertedRowData: [0: [.text("__DEFAULT__"), .text("__DEFAULT__")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - #expect(result.map(\.statement) == ["INSERT INTO \"DB\".\"PUBLIC\".\"T\" (\"id\") VALUES (DEFAULT)"]) - #expect(result.first?.parameters.isEmpty == true) - } - - @Test("Inserts touching VARIANT columns use INSERT SELECT with PARSE_JSON") - func testVariantInsertUsesSelect() { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let result = generator().generateStatements( - from: [change], - insertedRowData: [0: [.text("1"), .text("Alice"), .text("{\"a\":1}")]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - #expect(result[0].statement == - "INSERT INTO \"DB\".\"PUBLIC\".\"T\" (\"id\", \"name\", \"payload\") SELECT ?, ?, PARSE_JSON(?)") - } - - @Test("Updates set PARSE_JSON for VARIANT and filter by primary key") - func testVariantUpdate() { - let change = update("payload", to: .text("{\"b\":2}"), original: [.text("7"), .text("Bob"), .null]) - let result = generator().generateStatements( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - #expect(result[0].statement == - "UPDATE \"DB\".\"PUBLIC\".\"T\" SET \"payload\" = PARSE_JSON(?) WHERE \"id\" = ?") - #expect(result[0].parameters == [.text("{\"b\":2}"), .text("7")]) - } - - @Test("Without primary keys the WHERE clause uses all non-variant columns") - func testAllColumnFallbackSkipsVariant() { - let change = update("name", to: .text("New"), original: [.text("7"), .null, .text("{}")]) - let result = generator(primaryKeys: []).generateStatements( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - #expect(result[0].statement == - "UPDATE \"DB\".\"PUBLIC\".\"T\" SET \"name\" = ? WHERE \"id\" = ? AND \"name\" IS NULL") - #expect(result[0].parameters == [.text("New"), .text("7")]) - } - - @Test("Deletes filter by primary key") - func testDelete() { - let change = PluginRowChange( - rowIndex: 3, - type: .delete, - cellChanges: [], - originalRow: [.text("9"), .text("Eve"), .null] - ) - let result = generator().generateStatements( - from: [change], insertedRowData: [:], deletedRowIndices: [3], insertedRowIndices: [] - ) - #expect(result[0].statement == "DELETE FROM \"DB\".\"PUBLIC\".\"T\" WHERE \"id\" = ?") - #expect(result[0].parameters == [.text("9")]) - } - - @Test("Semi-structured detection covers VARIANT, OBJECT, and ARRAY") - func testSemiStructuredDetection() { - #expect(SnowflakeStatementGenerator.isSemiStructured("VARIANT")) - #expect(SnowflakeStatementGenerator.isSemiStructured("object")) - #expect(SnowflakeStatementGenerator.isSemiStructured("ARRAY")) - #expect(!SnowflakeStatementGenerator.isSemiStructured("VARCHAR(10)")) - #expect(!SnowflakeStatementGenerator.isSemiStructured("NUMBER(10,2)")) - } -} - -struct SnowflakeDDLGeneratorTests { - private let generator = SnowflakeDDLGenerator(qualifiedTable: { "\"DB\".\"PUBLIC\".\"\($0)\"" }) - - @Test("Add column renders type, default, nullability, and comment") - func testAddColumn() { - let column = PluginColumnDefinition( - name: "score", dataType: "NUMBER(10,2)", isNullable: false, defaultValue: "0", comment: "points" - ) - #expect(generator.addColumnSQL(table: "T", column: column) == - "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" ADD COLUMN \"score\" NUMBER(10,2) DEFAULT 0 NOT NULL COMMENT 'points'") - } - - @Test("VARCHAR can widen but never shrink") - func testVarcharWidening() { - #expect(SnowflakeDDLGenerator.isSupportedTypeChange(from: "VARCHAR(100)", to: "VARCHAR(500)")) - #expect(!SnowflakeDDLGenerator.isSupportedTypeChange(from: "VARCHAR(500)", to: "VARCHAR(100)")) - } - - @Test("NUMBER precision can change but scale cannot") - func testNumberConstraints() { - #expect(SnowflakeDDLGenerator.isSupportedTypeChange(from: "NUMBER(10,2)", to: "NUMBER(20,2)")) - #expect(!SnowflakeDDLGenerator.isSupportedTypeChange(from: "NUMBER(10,2)", to: "NUMBER(10,4)")) - } - - @Test("Cross-type changes are rejected") - func testCrossTypeRejected() { - #expect(!SnowflakeDDLGenerator.isSupportedTypeChange(from: "VARCHAR(10)", to: "NUMBER(10,0)")) - let old = PluginColumnDefinition(name: "c", dataType: "VARCHAR(10)") - let new = PluginColumnDefinition(name: "c", dataType: "NUMBER(10,0)") - #expect(generator.modifyColumnSQL(table: "T", old: old, new: new) == nil) - } - - @Test("Combined alters render as one statement with comma actions") - func testCombinedAlter() { - let old = PluginColumnDefinition(name: "c", dataType: "VARCHAR(10)", isNullable: true) - let new = PluginColumnDefinition(name: "c", dataType: "VARCHAR(50)", isNullable: false, comment: "x") - let sql = generator.modifyColumnSQL(table: "T", old: old, new: new) - #expect(sql == - "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" ALTER COLUMN \"c\" SET DATA TYPE VARCHAR(50), COLUMN \"c\" SET NOT NULL, COLUMN \"c\" COMMENT 'x'") - } - - @Test("Rename plus alter renders two statements") - func testRenameWithAlter() { - let old = PluginColumnDefinition(name: "a", dataType: "VARCHAR(10)") - let new = PluginColumnDefinition(name: "b", dataType: "VARCHAR(50)") - let sql = generator.modifyColumnSQL(table: "T", old: old, new: new) - #expect(sql == - "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" RENAME COLUMN \"a\" TO \"b\";\n" + - "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" ALTER COLUMN \"b\" SET DATA TYPE VARCHAR(50)") - } - - @Test("Primary key changes drop then add") - func testModifyPrimaryKey() { - let statements = generator.modifyPrimaryKeySQL(table: "T", oldColumns: ["a"], newColumns: ["a", "b"]) - #expect(statements == [ - "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" DROP PRIMARY KEY", - "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" ADD PRIMARY KEY (\"a\", \"b\")" - ]) - } - - @Test("Create table includes a primary key constraint") - func testCreateTable() { - let definition = PluginCreateTableDefinition( - tableName: "T", - columns: [ - PluginColumnDefinition(name: "id", dataType: "NUMBER", isNullable: false, isPrimaryKey: true), - PluginColumnDefinition(name: "name", dataType: "VARCHAR(50)") - ] - ) - let sql = generator.createTableSQL(definition: definition) - #expect(sql?.contains("CREATE TABLE \"DB\".\"PUBLIC\".\"T\"") == true) - #expect(sql?.contains("PRIMARY KEY (\"id\")") == true) - } - - @Test("CREATE TABLE carries the foreign keys it was given") - func testCreateTableForeignKeys() throws { - let definition = PluginCreateTableDefinition( - tableName: "orders", - columns: [ - PluginColumnDefinition(name: "id", dataType: "NUMBER", isNullable: false, isPrimaryKey: true), - PluginColumnDefinition(name: "customer_id", dataType: "NUMBER") - ], - foreignKeys: [ - PluginForeignKeyDefinition( - name: "fk_orders_customer", columns: ["customer_id"], - referencedTable: "customers", referencedColumns: ["id"] - ) - ] - ) - let sql = try #require(generator.createTableSQL(definition: definition)) - #expect(sql.contains( - "CONSTRAINT \"fk_orders_customer\" FOREIGN KEY (\"customer_id\") " - + "REFERENCES \"DB\".\"PUBLIC\".\"customers\" (\"id\")" - )) - } - - @Test("an unnamed foreign key writes no CONSTRAINT clause") - func testUnnamedForeignKey() { - let foreignKey = PluginForeignKeyDefinition( - name: "", columns: ["customer_id"], referencedTable: "customers", referencedColumns: ["id"] - ) - let clause = generator.foreignKeyDefinitionSQL(foreignKey) - #expect(clause.hasPrefix("FOREIGN KEY (\"customer_id\")")) - #expect(!clause.contains("CONSTRAINT")) - } - - @Test("no referenced columns means no empty parentheses") - func testOmittedReferencedColumns() { - let foreignKey = PluginForeignKeyDefinition( - name: "", columns: ["customer_id"], referencedTable: "customers", referencedColumns: [] - ) - #expect(!generator.foreignKeyDefinitionSQL(foreignKey).contains("()")) - } - - @Test("A table, view or materialized view comment is set with its own ALTER keyword") - func objectCommentSetsPerKind() { - let name = "\"DB\".\"PUBLIC\".\"T\"" - #expect(SnowflakeDDLGenerator.objectCommentSQL(qualifiedName: name, objectType: "TABLE", comment: "Orders") == - "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" SET COMMENT = 'Orders'") - #expect(SnowflakeDDLGenerator.objectCommentSQL(qualifiedName: name, objectType: "VIEW", comment: "Orders") == - "ALTER VIEW \"DB\".\"PUBLIC\".\"T\" SET COMMENT = 'Orders'") - #expect(SnowflakeDDLGenerator.objectCommentSQL(qualifiedName: name, objectType: "MATERIALIZED VIEW", comment: "x") == - "ALTER MATERIALIZED VIEW \"DB\".\"PUBLIC\".\"T\" SET COMMENT = 'x'") - #expect(SnowflakeDDLGenerator.objectCommentSQL(qualifiedName: name, objectType: "MATERIALIZED_VIEW", comment: "x") == - "ALTER MATERIALIZED VIEW \"DB\".\"PUBLIC\".\"T\" SET COMMENT = 'x'") - } - - @Test("An apostrophe followed by a combining mark is still doubled in a comment") - func objectCommentEscapesBeforeCombiningMark() { - #expect(SnowflakeDDLGenerator.objectCommentSQL(qualifiedName: "\"T\"", objectType: "TABLE", comment: "a'\u{0301}b") == - "ALTER TABLE \"T\" SET COMMENT = 'a''\u{0301}b'") - } - - @Test("A nil or empty comment unsets the comment") - func objectCommentClears() { - let name = "\"DB\".\"PUBLIC\".\"T\"" - #expect(SnowflakeDDLGenerator.objectCommentSQL(qualifiedName: name, objectType: "TABLE", comment: nil) == - "ALTER TABLE \"DB\".\"PUBLIC\".\"T\" UNSET COMMENT") - #expect(SnowflakeDDLGenerator.objectCommentSQL(qualifiedName: name, objectType: "VIEW", comment: "") == - "ALTER VIEW \"DB\".\"PUBLIC\".\"T\" UNSET COMMENT") - } - - @Test("A comment escapes the backslash before the quote") - func objectCommentEscapes() { - let sql = SnowflakeDDLGenerator.objectCommentSQL(qualifiedName: "\"T\"", objectType: "TABLE", comment: #"it's \ fine"#) - #expect(sql == #"ALTER TABLE "T" SET COMMENT = 'it''s \\ fine'"#) - } - - @Test("Kinds Snowflake cannot comment on through this route return nil") - func objectCommentRefusesOtherKinds() { - for kind in ["SEQUENCE", "FOREIGN TABLE", "SYSTEM TABLE", "PARTITIONED TABLE", "EXTERNAL TABLE"] { - #expect(SnowflakeDDLGenerator.objectCommentSQL(qualifiedName: "\"T\"", objectType: kind, comment: nil) == nil) - } - } -} - -struct SnowflakeSchemaQueriesTests { - @Test("SHOW statements quote identifiers") - func testShowQuoting() { - #expect(SnowflakeSchemaQueries.showObjects(database: "my\"db", schema: "S") == - "SHOW TERSE OBJECTS IN SCHEMA \"my\"\"db\".\"S\"") - } - - @Test("SHOW TABLES LIKE escapes pattern wildcards") - func testLikeEscaping() { - let sql = SnowflakeSchemaQueries.showTablesLike(database: "D", schema: "S", table: "my_table") - #expect(sql.contains("'my\\\\_table'")) - } - - @Test("Object kinds map to app table types") - func testKindMapping() { - #expect(SnowflakeSchemaQueries.objectType(forKind: "TABLE") == "TABLE") - #expect(SnowflakeSchemaQueries.objectType(forKind: "VIEW") == "VIEW") - #expect(SnowflakeSchemaQueries.objectType(forKind: "MATERIALIZED_VIEW") == "MATERIALIZED_VIEW") - } - - @Test("Clustering key expressions parse to column names") - func testClusterByParsing() { - #expect(SnowflakeSchemaQueries.parseClusterBy("LINEAR(col1, col2)") == ["col1", "col2"]) - #expect(SnowflakeSchemaQueries.parseClusterBy("LINEAR(\"a\")") == ["a"]) - } - - @Test("Multi-statement detection requires content after a semicolon") - func testMultiStatementDetection() { - #expect(SnowflakeSchemaQueries.isLikelyMultiStatement("SELECT 1; SELECT 2")) - #expect(!SnowflakeSchemaQueries.isLikelyMultiStatement("SELECT 1;")) - #expect(!SnowflakeSchemaQueries.isLikelyMultiStatement("SELECT 1")) - } -} diff --git a/TableProTests/Plugins/SnowflakeImportedKeysTests.swift b/TableProTests/Plugins/SnowflakeImportedKeysTests.swift deleted file mode 100644 index 15ac7bc099..0000000000 --- a/TableProTests/Plugins/SnowflakeImportedKeysTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// SnowflakeImportedKeysTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -/// `SHOW IMPORTED KEYS` reports `pk_database_name` beside `pk_schema_name`, and the driver read only -/// the second, so a key pointing into another database resolved to the current one's same-named -/// table. Snowflake names objects in three parts, so the database is the half that was missing. -struct SnowflakeImportedKeysTests { - @Test("A key into another database reports that database") - func crossDatabaseKeyReportsItsDatabase() { - #expect( - SnowflakeSchemaQueries.referencedDatabase(reported: "RAW", local: "ANALYTICS") == "RAW" - ) - } - - /// A same-database key must resolve exactly as it did before the fix, or every existing key on - /// every Snowflake account moves. - @Test("A key into the current database reports nothing") - func sameDatabaseKeyReportsNothing() { - #expect( - SnowflakeSchemaQueries.referencedDatabase(reported: "ANALYTICS", local: "ANALYTICS") == nil - ) - } - - /// Snowflake folds an unquoted identifier to upper case and answers in its own spelling, so the - /// two sides can differ in case for one database. - @Test("Case alone does not make it another database") - func caseAloneIsNotAnotherDatabase() { - #expect( - SnowflakeSchemaQueries.referencedDatabase(reported: "ANALYTICS", local: "analytics") == nil - ) - } - - /// An older Snowflake, or a result read through a path that does not carry the column. - @Test("A result with no database column reports nothing", arguments: [String?.none, ""]) - func missingDatabaseColumnReportsNothing(reported: String?) { - #expect(SnowflakeSchemaQueries.referencedDatabase(reported: reported, local: "ANALYTICS") == nil) - } - - @Test("With no current database the reported one is taken at its word") - func withoutALocalDatabaseTheReportedOneStands() { - #expect(SnowflakeSchemaQueries.referencedDatabase(reported: "RAW", local: nil) == "RAW") - #expect(SnowflakeSchemaQueries.referencedDatabase(reported: "RAW", local: "") == "RAW") - } -} diff --git a/TableProTests/Plugins/SnowflakeProtocolTests.swift b/TableProTests/Plugins/SnowflakeProtocolTests.swift deleted file mode 100644 index 7f469388b8..0000000000 --- a/TableProTests/Plugins/SnowflakeProtocolTests.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// SnowflakeProtocolTests.swift -// TableProTests -// -// Tests for the Snowflake binding encoder, HTTP retry policy, re-auth code -// classification, and heartbeat interval (compiled via symlinks from -// SnowflakeDriverPlugin). -// - -import Foundation -import TableProPluginKit -import Testing - -private final class SnowflakeTimeoutProtocol: URLProtocol, @unchecked Sendable { - private static let lock = NSLock() - nonisolated(unsafe) private static var timeout: TimeInterval? - - static var recordedTimeout: TimeInterval? { - lock.withLock { timeout } - } - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - Self.lock.withLock { Self.timeout = request.timeoutInterval } - guard let url = request.url, - let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) - else { return } - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data("{}".utf8)) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} -} - -struct SnowflakeBindingEncoderTests { - @Test("Keys are 1-based string indices") - func testKeysAreOneBased() { - let bindings = SnowflakeBindingEncoder.encode([.text("a"), .text("b")]) - #expect(Set(bindings.keys) == ["1", "2"]) - } - - @Test("Text values bind as TEXT") - func testTextBinding() { - let bindings = SnowflakeBindingEncoder.encode([.text("O'Brien \\ path")]) - #expect(bindings["1"]?["type"] as? String == "TEXT") - #expect(bindings["1"]?["value"] as? String == "O'Brien \\ path") - } - - @Test("Bytes bind as BINARY hex") - func testBinaryBinding() { - let bindings = SnowflakeBindingEncoder.encode([.bytes(Data([0xAB, 0x01, 0xFF]))]) - #expect(bindings["1"]?["type"] as? String == "BINARY") - #expect(bindings["1"]?["value"] as? String == "AB01FF") - } - - @Test("Null binds as a typed null") - func testNullBinding() { - let bindings = SnowflakeBindingEncoder.encode([.null]) - #expect(bindings["1"]?["type"] as? String == "TEXT") - #expect(bindings["1"]?["value"] is NSNull) - } - - @Test("Encoded payload serializes as JSON") - func testJSONSerializable() { - let bindings = SnowflakeBindingEncoder.encode([.text("x"), .null, .bytes(Data([0x00]))]) - #expect(JSONSerialization.isValidJSONObject(bindings)) - } -} - -struct SnowflakeRetryPolicyTests { - @Test("Transient statuses are retried") - func testTransientStatuses() { - #expect(SnowflakeRetryPolicy.isTransient(statusCode: 500)) - #expect(SnowflakeRetryPolicy.isTransient(statusCode: 503)) - #expect(SnowflakeRetryPolicy.isTransient(statusCode: 429)) - #expect(SnowflakeRetryPolicy.isTransient(statusCode: 408)) - } - - @Test("Application errors are not retried") - func testTerminalStatuses() { - #expect(!SnowflakeRetryPolicy.isTransient(statusCode: 200)) - #expect(!SnowflakeRetryPolicy.isTransient(statusCode: 400)) - #expect(!SnowflakeRetryPolicy.isTransient(statusCode: 401)) - #expect(!SnowflakeRetryPolicy.isTransient(statusCode: 403)) - } - - @Test("Retried URL tags the attempt and keeps the request id") - func testRetriedURLTagging() throws { - let url = try #require(URL(string: "https://x.snowflakecomputing.com/queries/v1/query-request?requestId=abc&request_guid=old")) - let retried = SnowflakeRetryPolicy.retriedURL(url, retryCount: 2, retryReason: 503, clientStartTime: 1_700_000) - let components = try #require(URLComponents(url: retried, resolvingAgainstBaseURL: false)) - let items = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) - #expect(items["requestId"] == "abc") - #expect(items["retryCount"] == "2") - #expect(items["retryReason"] == "503") - #expect(items["clientStartTime"] == "1700000") - #expect(items["request_guid"] != "old") - } - - @Test("Delay stays within the jitter bounds") - func testDelayBounds() { - var generator = SystemRandomNumberGenerator() - var delay = SnowflakeRetryPolicy.baseDelay - for _ in 0..<20 { - delay = SnowflakeRetryPolicy.nextDelay(after: delay, using: &generator) - #expect(delay >= SnowflakeRetryPolicy.baseDelay) - #expect(delay <= SnowflakeRetryPolicy.maxDelay) - } - } - - @Test("A connect request uses the remaining deadline instead of the session default") - func testConnectDeadlineReachesURLRequest() async throws { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [SnowflakeTimeoutProtocol.self] - let session = URLSession(configuration: configuration) - let url = try #require(URL(string: "https://account.snowflakecomputing.test/session/v1/login-request")) - var request = URLRequest(url: url) - request.timeoutInterval = 120 - let deadline = PluginConnectDeadline(milliseconds: 2_500) - - _ = try await SnowflakeHTTPClient.send(request, session: session, connectDeadline: deadline) - - let timeout = try #require(SnowflakeTimeoutProtocol.recordedTimeout) - #expect(timeout > 0) - #expect(timeout <= 2.5) - } -} - -struct SnowflakeReAuthTests { - @Test("Session and token expiry codes trigger re-authentication") - func testReauthCodes() { - for code in ["390110", "390112", "390113", "390114", "390115", "390195"] { - #expect(SnowflakeError.isReauthenticationCode(code)) - } - } - - @Test("MFA and credential failures are terminal") - func testTerminalCodes() { - for code in ["394507", "394508", "394512", "390100", ""] { - #expect(!SnowflakeError.isReauthenticationCode(code)) - } - } - - @Test("A rejected MFA passcode is never replayed") - func testRejectedPasscodeGuard() { - let account = "guardtest-\(UUID().uuidString)" - #expect(!SnowflakeMFATokenStore.isPasscodeRejected("123456", account: account, user: "U")) - SnowflakeMFATokenStore.markPasscodeRejected("123456", account: account, user: "U") - #expect(SnowflakeMFATokenStore.isPasscodeRejected("123456", account: account, user: "U")) - #expect(!SnowflakeMFATokenStore.isPasscodeRejected("654321", account: account, user: "U")) - #expect(!SnowflakeMFATokenStore.isPasscodeRejected("", account: account, user: "U")) - } - - @Test("Inaccessible-object codes map to empty listings") - func testInaccessibleObjectCodes() { - #expect(SnowflakeError.isInaccessibleObjectCode("002043")) - #expect(SnowflakeError.isInaccessibleObjectCode("2043")) - #expect(SnowflakeError.queryFailed(code: "002043", message: "x").indicatesInaccessibleObject) - #expect(!SnowflakeError.queryFailed(code: "390112", message: "x").indicatesInaccessibleObject) - #expect(!SnowflakeError.authFailed("x").indicatesInaccessibleObject) - } -} - -struct PluginSessionContextTests { - @Test("Round-trips through Codable") - func testCodableRoundTrip() throws { - let context = PluginSessionContext( - id: "warehouse", - label: "Warehouse", - iconName: "building.columns", - currentValue: "COMPUTE_WH", - availableValues: ["COMPUTE_WH", "LOAD_WH"] - ) - let data = try JSONEncoder().encode(context) - let decoded = try JSONDecoder().decode(PluginSessionContext.self, from: data) - #expect(decoded.id == context.id) - #expect(decoded.currentValue == "COMPUTE_WH") - #expect(decoded.availableValues == ["COMPUTE_WH", "LOAD_WH"]) - } -} - -struct SnowflakeHeartbeatIntervalTests { - @Test("Interval is a quarter of master validity, clamped to 15 to 60 minutes") - func testIntervalClamping() { - #expect(SnowflakeHeartbeat.interval(masterValiditySeconds: 14_400) == 3_600) - #expect(SnowflakeHeartbeat.interval(masterValiditySeconds: 7_200) == 1_800) - #expect(SnowflakeHeartbeat.interval(masterValiditySeconds: 600) == 900) - #expect(SnowflakeHeartbeat.interval(masterValiditySeconds: 100_000) == 3_600) - } -} diff --git a/TableProTests/Plugins/SnowflakeSQLTests.swift b/TableProTests/Plugins/SnowflakeSQLTests.swift deleted file mode 100644 index d9c29a0e1b..0000000000 --- a/TableProTests/Plugins/SnowflakeSQLTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// SnowflakeSQLTests.swift -// TableProTests -// -// Tests for SnowflakeSQL (compiled via symlink from SnowflakeDriverPlugin). -// - -import Foundation -import Testing - -struct SnowflakeSQLTests { - // MARK: - Literals - - @Test("A quote is doubled") - func testQuoteDoubling() { - #expect(SnowflakeSQL.escapeLiteral("O'Brien") == "O''Brien") - #expect(SnowflakeSQL.escapeLiteral("''") == "''''") - } - - /// Snowflake reads `\'` inside a literal as a content quote, so an escaper that doubles the - /// quote and leaves the backslash alone lets a name close the literal and run its own SQL. - @Test("A backslash is doubled so it cannot escape the closing quote") - func testBackslashDoubling() { - #expect(SnowflakeSQL.escapeLiteral("a\\b") == "a\\\\b") - #expect(SnowflakeSQL.escapeLiteral("\\") == "\\\\") - } - - /// The payload works against a quote-only escaper: it becomes `x\'' OR 1=1 --`, Snowflake reads - /// `\'` as a content quote, the next quote closes the literal, and `OR 1=1` runs. Doubling the - /// backslash first leaves the quote with nothing to escape it. - @Test("A backslash-quote injection payload stays inside the literal") - func testInjectionPayloadIsNeutralised() { - let escaped = SnowflakeSQL.escapeLiteral("x\\' OR 1=1 --") - #expect(escaped == "x\\\\'' OR 1=1 --") - - let quoteOnly = "x\\' OR 1=1 --".replacingOccurrences(of: "'", with: "''") - #expect(quoteOnly == "x\\'' OR 1=1 --") - #expect(escaped != quoteOnly) - } - - @Test("The backslash is doubled before the quotes, not after") - func testEscapeOrder() { - #expect(SnowflakeSQL.escapeLiteral("'") == "''") - #expect(SnowflakeSQL.escapeLiteral("\\'") == "\\\\''") - } - - @Test("An ordinary value is untouched") - func testPlainLiteral() { - #expect(SnowflakeSQL.escapeLiteral("PUBLIC") == "PUBLIC") - #expect(SnowflakeSQL.escapeLiteral("") == "") - } - - // MARK: - Identifiers - - @Test("An identifier is wrapped and its quotes doubled") - func testIdentifierQuoting() { - #expect(SnowflakeSQL.quoteIdentifier("orders") == "\"orders\"") - #expect(SnowflakeSQL.quoteIdentifier("we\"ird") == "\"we\"\"ird\"") - #expect(SnowflakeSQL.quoteIdentifier("a\".\"b") == "\"a\"\".\"\"b\"") - } - - @Test("An identifier that tries to close its own quoting cannot") - func testIdentifierInjectionIsNeutralised() { - let quoted = SnowflakeSQL.quoteIdentifier("t\" ; DROP TABLE x; --") - #expect(quoted == "\"t\"\" ; DROP TABLE x; --\"") - } - - // MARK: - LIKE patterns - - @Test("LIKE wildcards in a name are escaped") - func testLikePattern() { - #expect(SnowflakeSQL.escapeLikePattern("my_table") == "my\\\\_table") - #expect(SnowflakeSQL.escapeLikePattern("100%") == "100\\\\%") - } - - @Test("A LIKE pattern escapes its literal first") - func testLikePatternEscapesLiteralToo() { - #expect(SnowflakeSQL.escapeLikePattern("O'Brien") == "O''Brien") - } - - // MARK: - One owner - - /// Every statement builder in the plugin routes here. These pin that the wrappers stayed - /// wrappers, because three copies of this logic drifting apart is what shipped the injection. - @Test("The schema query wrappers delegate to the shared escaper") - func testSchemaQueriesDelegate() { - #expect(SnowflakeSchemaQueries.escapeLiteral("x\\'") == SnowflakeSQL.escapeLiteral("x\\'")) - #expect(SnowflakeSchemaQueries.quote("we\"ird") == SnowflakeSQL.quoteIdentifier("we\"ird")) - #expect( - SnowflakeSchemaQueries.escapeLikePattern("my_t") == SnowflakeSQL.escapeLikePattern("my_t") - ) - } - - @Test("The routine queries escape a schema name that carries a backslash") - func testRoutineListEscapesSchema() { - let sql = SnowflakeObjectQueries.routineList(schema: "x\\' OR 1=1 --") - #expect(sql.contains("x\\\\'' OR 1=1 --")) - #expect(!sql.contains("x\\' OR")) - } -} diff --git a/TableProTests/Plugins/SnowflakeSessionKeyTests.swift b/TableProTests/Plugins/SnowflakeSessionKeyTests.swift deleted file mode 100644 index 7667a2cd62..0000000000 --- a/TableProTests/Plugins/SnowflakeSessionKeyTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// SnowflakeSessionKeyTests.swift -// TableProTests -// -// Tests for SnowflakeSessionKey (compiled via symlink from SnowflakeDriverPlugin). -// - -import Foundation -import Testing - -struct SnowflakeSessionKeyTests { - private func key( - connectionId: String = "A", - host: String = "acct.snowflakecomputing.com", - user: String = "dana", - authMethod: String = "password", - role: String = "analyst" - ) -> String { - SnowflakeSessionKey.fingerprint( - connectionId: connectionId, - host: host, - user: user, - authMethod: authMethod, - role: role - ) - } - - /// The defect: two saved connections to one account, user and role shared a session, so - /// `USE DATABASE` in one moved the other's current database and a save wrote to the wrong one. - @Test("Two saved connections to the same account do not share a session") - func testDistinctConnectionsDoNotShare() { - #expect(key(connectionId: "A") != key(connectionId: "B")) - } - - @Test("The same saved connection always resolves to one session") - func testSameConnectionShares() { - #expect(key(connectionId: "A") == key(connectionId: "A")) - } - - /// The metadata pool rewrites the database on its copy before building a driver, so a key that - /// varied with the database would give that driver its own login and another MFA prompt. - @Test("The database is not part of the key") - func testDatabaseIsNotInTheKey() { - #expect(!key().contains("ANALYTICS")) - #expect(key(connectionId: "A") == key(connectionId: "A")) - } - - @Test("Account identity still separates sessions") - func testAccountIdentitySeparates() { - #expect(key(host: "one.snowflakecomputing.com") != key(host: "two.snowflakecomputing.com")) - #expect(key(user: "dana") != key(user: "sam")) - #expect(key(authMethod: "password") != key(authMethod: "keyPair")) - #expect(key(role: "analyst") != key(role: "admin")) - } - - @Test("User and role compare case-insensitively") - func testCaseFolding() { - #expect(key(user: "dana") == key(user: "DANA")) - #expect(key(role: "analyst") == key(role: "ANALYST")) - } -} diff --git a/TableProTests/Plugins/SnowflakeStatementTypeTests.swift b/TableProTests/Plugins/SnowflakeStatementTypeTests.swift deleted file mode 100644 index 984f3e72e7..0000000000 --- a/TableProTests/Plugins/SnowflakeStatementTypeTests.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// SnowflakeStatementTypeTests.swift -// TableProTests -// -// Tests for SnowflakeStatementType (compiled via symlink from SnowflakeDriverPlugin). -// - -import Foundation -import Testing - -struct SnowflakeStatementTypeTests { - private func counts(_ values: [String]) -> [PluginCellValueBox] { - values.map { .text($0) } - } - - @Test("The DML range is the one the server documents") - func testDMLRange() { - #expect(SnowflakeStatementType.isDML(0x3000)) - #expect(SnowflakeStatementType.isDML(0x3500)) - #expect(SnowflakeStatementType.isDML(0x3200)) - #expect(!SnowflakeStatementType.isDML(0x1000)) - #expect(!SnowflakeStatementType.isDML(0x2FFF)) - #expect(!SnowflakeStatementType.isDML(0x3501)) - #expect(!SnowflakeStatementType.isDML(0xA000)) - } - - @Test("An INSERT or DELETE reports its single count") - func testSingleCount() { - #expect( - SnowflakeStatementType.affectedRows(statementTypeId: 0x3000, row: counts(["7"])) == 7 - ) - } - - /// An `UPDATE` returns rows updated and multi-joined rows updated, and a multi-clause `MERGE` - /// one count per clause. Reading only the first column reported the wrong number, and the guard - /// that demanded exactly one column reported none at all. - @Test("An UPDATE or MERGE sums every count column") - func testMultipleCountsAreSummed() { - #expect( - SnowflakeStatementType.affectedRows(statementTypeId: 0x3000, row: counts(["12", "0"])) == 12 - ) - #expect( - SnowflakeStatementType.affectedRows(statementTypeId: 0x3000, row: counts(["3", "4", "5"])) == 12 - ) - } - - /// The old check keyed on a column named "number of rows", so this statement reported its own - /// result as the number of rows it had changed. - @Test("A SELECT never reports affected rows, whatever its columns are named") - func testSelectReportsNothing() { - #expect( - SnowflakeStatementType.affectedRows(statementTypeId: 0x1000, row: counts(["1523"])) == 0 - ) - #expect( - SnowflakeStatementType.affectedRows(statementTypeId: 0xA000, row: counts(["1523"])) == 0 - ) - } - - @Test("A missing or unreadable count reports nothing rather than a guess") - func testUnreadableCounts() { - #expect(SnowflakeStatementType.affectedRows(statementTypeId: 0x3000, row: nil) == 0) - #expect(SnowflakeStatementType.affectedRows(statementTypeId: 0x3000, row: []) == 0) - #expect( - SnowflakeStatementType.affectedRows(statementTypeId: 0x3000, row: counts(["abc"])) == 0 - ) - #expect( - SnowflakeStatementType.affectedRows(statementTypeId: 0x3000, row: [.null]) == 0 - ) - } -} diff --git a/TableProTests/Plugins/SnowflakeTypeMapperTests.swift b/TableProTests/Plugins/SnowflakeTypeMapperTests.swift deleted file mode 100644 index 3290557e54..0000000000 --- a/TableProTests/Plugins/SnowflakeTypeMapperTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// SnowflakeTypeMapperTests.swift -// TableProTests -// -// Tests for SnowflakeTypeMapper (compiled via symlink from SnowflakeDriverPlugin). -// - -import Foundation -import Testing - -struct SnowflakeTypeMapperTests { - private func column( - _ type: String, - precision: Int? = nil, - scale: Int? = nil, - length: Int? = nil - ) -> SnowflakeColumnMeta { - SnowflakeColumnMeta( - name: "c", - internalType: type, - nullable: true, - precision: precision, - scale: scale, - length: length - ) - } - - @Test("Fixed with scale renders precision and scale") - func testFixedWithScale() { - #expect(SnowflakeTypeMapper.displayType(for: column("fixed", precision: 10, scale: 2)) == "NUMBER(10,2)") - #expect(SnowflakeTypeMapper.displayType(for: column("fixed", scale: 3)) == "NUMBER(38,3)") - } - - @Test("Fixed without scale renders plain NUMBER") - func testFixedWithoutScale() { - #expect(SnowflakeTypeMapper.displayType(for: column("fixed", precision: 38, scale: 0)) == "NUMBER") - #expect(SnowflakeTypeMapper.displayType(for: column("FIXED")) == "NUMBER") - } - - @Test("Text renders VARCHAR with optional length") - func testText() { - #expect(SnowflakeTypeMapper.displayType(for: column("text", length: 255)) == "VARCHAR(255)") - #expect(SnowflakeTypeMapper.displayType(for: column("text")) == "VARCHAR") - } - - @Test("Real maps to FLOAT and timestamps keep their zone variant") - func testRealAndTimestamps() { - #expect(SnowflakeTypeMapper.displayType(for: column("real")) == "FLOAT") - #expect(SnowflakeTypeMapper.displayType(for: column("timestamp_ntz")) == "TIMESTAMP_NTZ") - #expect(SnowflakeTypeMapper.displayType(for: column("timestamp_ltz")) == "TIMESTAMP_LTZ") - #expect(SnowflakeTypeMapper.displayType(for: column("timestamp_tz")) == "TIMESTAMP_TZ") - } - - @Test("Unknown internal types fall back to their uppercased name") - func testUnknownType() { - #expect(SnowflakeTypeMapper.displayType(for: column("vector")) == "VECTOR") - } -} diff --git a/TableProTests/Plugins/SnowflakeValueDecoderTests.swift b/TableProTests/Plugins/SnowflakeValueDecoderTests.swift deleted file mode 100644 index 9d90adbf83..0000000000 --- a/TableProTests/Plugins/SnowflakeValueDecoderTests.swift +++ /dev/null @@ -1,228 +0,0 @@ -// -// SnowflakeValueDecoderTests.swift -// TableProTests -// -// Tests for SnowflakeValueDecoder (compiled via symlink from SnowflakeDriverPlugin). -// - -import Foundation -import Testing - -struct SnowflakeValueDecoderTests { - private func column(_ type: String, scale: Int? = nil) -> SnowflakeColumnMeta { - SnowflakeColumnMeta( - name: "c", - internalType: type, - nullable: true, - precision: nil, - scale: scale, - length: nil - ) - } - - private func decodedText(_ raw: String, _ type: String, scale: Int? = nil) -> String? { - guard case .text(let value) = SnowflakeValueDecoder.decode(raw, as: column(type, scale: scale)) else { - return nil - } - return value - } - - private func decodedBytes(_ raw: String, _ type: String) -> Data? { - guard case .bytes(let data) = SnowflakeValueDecoder.decode(raw, as: column(type)) else { return nil } - return data - } - - // MARK: - DATE - - @Test("Epoch days decode to the dates the issue names") - func testReportedDates() { - #expect(decodedText("18262", "date") == "2020-01-01") - #expect(decodedText("20682", "date") == "2026-08-17") - } - - @Test("Epoch day zero and its neighbours decode") - func testEpochNeighbours() { - #expect(decodedText("0", "date") == "1970-01-01") - #expect(decodedText("1", "date") == "1970-01-02") - #expect(decodedText("-1", "date") == "1969-12-31") - #expect(decodedText("-25567", "date") == "1900-01-01") - } - - /// Foundation renders this epoch day as 0001-01-03, because `Calendar(identifier: .gregorian)`, - /// `Calendar(identifier: .iso8601)`, `ISO8601DateFormatter` and `Date.FormatStyle.iso8601` all - /// apply the 1582 Julian cutover. Snowflake does not, so this case fails the moment the decoder - /// is reimplemented on any of them. - @Test("Dates before 1582 use the proleptic Gregorian calendar") - func testProlepticGregorian() { - #expect(decodedText("-719162", "date") == "0001-01-01") - #expect(decodedText("-141427", "date") == "1582-10-15") - #expect(decodedText("-141428", "date") == "1582-10-14") - } - - @Test("The bounds of Snowflake's date range decode") - func testRangeBounds() { - #expect(decodedText("-719162", "date") == "0001-01-01") - #expect(decodedText("2932896", "date") == "9999-12-31") - } - - @Test("Epoch days outside the date range keep their raw text") - func testOutOfRangeDatesArePreserved() { - #expect(decodedText("2932897", "date") == "2932897") - #expect(decodedText("-719163", "date") == "-719163") - #expect(decodedText("100000000", "date") == "100000000") - } - - @Test("A date value that is not an integer keeps its raw text") - func testUndecodableDatesArePreserved() { - #expect(decodedText("", "date") == "") - #expect(decodedText("abc", "date") == "abc") - #expect(decodedText("20682.5", "date") == "20682.5") - #expect(decodedText("2026-08-17", "date") == "2026-08-17") - } - - @Test("Decoding is keyed on the column, never on the value") - func testIntegerInNonDateColumnIsUntouched() { - #expect(decodedText("20682", "fixed") == "20682") - #expect(decodedText("20682", "text") == "20682") - #expect(decodedText("20682", "variant") == "20682") - #expect(decodedText("20682", "boolean") == "20682") - } - - @Test("An unknown internal type keeps its raw text") - func testUnknownTypeIsUntouched() { - #expect(decodedText("20682", "vector") == "20682") - } - - // MARK: - TIME - - @Test("Seconds since midnight decode to a wall clock") - func testTime() { - #expect(decodedText("3600.000000000", "time", scale: 0) == "01:00:00") - #expect(decodedText("82919.000000000", "time", scale: 0) == "23:01:59") - #expect(decodedText("0.000000000", "time", scale: 0) == "00:00:00") - #expect(decodedText("86399.000000000", "time", scale: 0) == "23:59:59") - } - - @Test("A time column's scale decides how many fractional digits are written") - func testTimeScale() { - #expect(decodedText("3600.123456789", "time", scale: 0) == "01:00:00") - #expect(decodedText("3600.123456789", "time", scale: 3) == "01:00:00.123") - #expect(decodedText("3600.123456789", "time", scale: 9) == "01:00:00.123456789") - #expect(decodedText("3600.5", "time", scale: 3) == "01:00:00.500") - } - - @Test("A time outside a single day keeps its raw text") - func testOutOfRangeTimeIsPreserved() { - #expect(decodedText("86400.000000000", "time", scale: 0) == "86400.000000000") - #expect(decodedText("-1.000000000", "time", scale: 0) == "-1.000000000") - #expect(decodedText("noon", "time", scale: 0) == "noon") - } - - // MARK: - TIMESTAMP - - @Test("TIMESTAMP_NTZ decodes to a bare wall clock") - func testTimestampNtz() { - #expect(decodedText("1755388800.000000000", "timestamp_ntz", scale: 0) == "2025-08-17 00:00:00") - #expect(decodedText("1616173619.000000000", "timestamp_ntz", scale: 0) == "2021-03-19 17:06:59") - } - - /// TIMESTAMP_LTZ is an absolute instant stored in UTC. Without the marker the parser reads the - /// UTC wall clock as if it were the reader's own, which moves the instant by their offset. - @Test("TIMESTAMP_LTZ marks itself as UTC") - func testTimestampLtz() { - #expect(decodedText("1755388800.000000000", "timestamp_ltz", scale: 0) == "2025-08-17 00:00:00Z") - #expect(decodedText("1616173619.000000000", "timestamp_ltz", scale: 3) == "2021-03-19 17:06:59.000Z") - } - - @Test("TIMESTAMP_TZ applies the offset and writes it back") - func testTimestampTz() { - #expect( - decodedText("1616173619.000000000 1500", "timestamp_tz", scale: 0) == "2021-03-19 18:06:59+01:00" - ) - #expect( - decodedText("1616173619.000000000 1140", "timestamp_tz", scale: 0) == "2021-03-19 12:06:59-05:00" - ) - #expect( - decodedText("1616173619.000000000 1440", "timestamp_tz", scale: 0) == "2021-03-19 17:06:59+00:00" - ) - } - - @Test("A malformed TIMESTAMP_TZ keeps its raw text") - func testUndecodableTimestampTzIsPreserved() { - #expect(decodedText("1616173619.000000000", "timestamp_tz", scale: 0) == "1616173619.000000000") - #expect(decodedText("1616173619.000000000 abc", "timestamp_tz", scale: 0) == "1616173619.000000000 abc") - #expect(decodedText("1616173619.000000000 9999", "timestamp_tz", scale: 0) == "1616173619.000000000 9999") - } - - /// `TimeZone(secondsFromGMT:)` is nil beyond eighteen hours and the shared parser reads that nil - /// as GMT, so emitting `+18:30` would move the instant by eighteen and a half hours. Eighteen - /// hours exactly is the last offset that survives the round trip. - @Test("An offset the shared parser cannot represent keeps its raw text") - func testOutOfRangeOffsetIsPreserved() { - #expect(decodedText("0.000000000 2520", "timestamp_tz", scale: 0) == "1970-01-01 18:00:00+18:00") - #expect(decodedText("0.000000000 360", "timestamp_tz", scale: 0) == "1969-12-31 06:00:00-18:00") - #expect(decodedText("0.000000000 2550", "timestamp_tz", scale: 0) == "0.000000000 2550") - #expect(decodedText("0.000000000 330", "timestamp_tz", scale: 0) == "0.000000000 330") - } - - /// The wire writes nine decimal places, so the value carries more significant digits than a - /// `Double` holds. Parsing it as a floating-point number drops the last nanoseconds silently. - @Test("Nanosecond precision survives decoding") - func testNanosecondPrecision() { - #expect( - decodedText("1616173619.123456789", "timestamp_ntz", scale: 9) == "2021-03-19 17:06:59.123456789" - ) - } - - /// A value before the epoch is written as the negated magnitude, so the fraction is subtracted - /// from the whole part rather than added to it. `-0.000000009` is the example Snowflake's own - /// connector documents, and it is the case that proves the sign is not read off the integer - /// half alone: `-0` parses to `0`. - @Test("Timestamps before the epoch subtract their fraction") - func testNegativeTimestamps() { - #expect(decodedText("-1.000000000", "timestamp_ntz", scale: 0) == "1969-12-31 23:59:59") - #expect(decodedText("-86400.000000000", "timestamp_ntz", scale: 0) == "1969-12-31 00:00:00") - #expect(decodedText("-1.500000000", "timestamp_ntz", scale: 1) == "1969-12-31 23:59:58.5") - #expect( - decodedText("-0.000000009", "timestamp_ntz", scale: 9) == "1969-12-31 23:59:59.999999991" - ) - #expect(decodedText("-0.500000000", "timestamp_ntz", scale: 1) == "1969-12-31 23:59:59.5") - } - - @Test("A timestamp outside the date range keeps its raw text") - func testOutOfRangeTimestampIsPreserved() { - #expect(decodedText("999999999999.000000000", "timestamp_ntz", scale: 0) == "999999999999.000000000") - } - - // MARK: - BINARY - - @Test("Binary hex decodes to bytes") - func testBinary() { - #expect(decodedBytes("48656C6C6F", "binary") == Data("Hello".utf8)) - #expect(decodedBytes("48656c6c6f", "binary") == Data("Hello".utf8)) - #expect(decodedBytes("00FF", "binary") == Data([0x00, 0xFF])) - } - - @Test("A zero-length binary decodes to empty bytes, not to text") - func testEmptyBinary() { - #expect(decodedBytes("", "binary") == Data()) - } - - @Test("Binary that is not a whole hex string keeps its raw text") - func testUndecodableBinaryIsPreserved() { - #expect(decodedText("48656C6C6", "binary") == "48656C6C6") - #expect(decodedText("zz", "binary") == "zz") - } - - // MARK: - Type mapping - - @Test("Every logical type still maps to the display name the app classifies") - func testDisplayNamesUnchanged() { - #expect(SnowflakeTypeMapper.displayType(for: column("date")) == "DATE") - #expect(SnowflakeTypeMapper.displayType(for: column("time")) == "TIME") - #expect(SnowflakeTypeMapper.displayType(for: column("timestamp_ntz")) == "TIMESTAMP_NTZ") - #expect(SnowflakeTypeMapper.displayType(for: column("timestamp_ltz")) == "TIMESTAMP_LTZ") - #expect(SnowflakeTypeMapper.displayType(for: column("timestamp_tz")) == "TIMESTAMP_TZ") - #expect(SnowflakeTypeMapper.displayType(for: column("binary")) == "BINARY") - } -} diff --git a/TableProTests/Plugins/SpannerRegistrySnapshotTests.swift b/TableProTests/Plugins/SpannerRegistrySnapshotTests.swift deleted file mode 100644 index 546dd39ac3..0000000000 --- a/TableProTests/Plugins/SpannerRegistrySnapshotTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct SpannerRegistrySnapshotTests { - private func snapshot() throws -> PluginMetadataSnapshot { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - return try #require(defaults.first { $0.typeId == "Spanner" }).snapshot - } - - private func field(_ id: String) throws -> ConnectionField { - try #require(try snapshot().connection.additionalConnectionFields.first { $0.id == id }) - } - - @Test("Spanner is an API-only engine with no port and no built-in password") - func connectionShape() throws { - let snapshot = try snapshot() - #expect(snapshot.connection.hidesBuiltInPassword) - #expect(snapshot.connection.hidesBuiltInDatabase) - #expect(snapshot.connectionMode == .apiOnly) - #expect(snapshot.defaultPort == 0) - #expect(snapshot.schema.containerEntityName == "Schema") - } - - @Test("The nameless GoogleSQL default schema is presented as (default) and written unqualified") - func defaultSchemaIsImplicit() throws { - let schema = try snapshot().schema - #expect(schema.defaultSchemaName == "(default)") - #expect(schema.implicitSchemaName == "(default)") - #expect(DatabaseType.spanner.implicitSchemaName == "(default)") - } - - @Test("Every connection field keeps its id, in order") - func fieldIds() throws { - let ids = try snapshot().connection.additionalConnectionFields.map(\.id) - #expect(ids == [ - "spAuthMethod", - "spServiceAccountJson", - "spProjectId", - "spInstanceId", - "spDatabaseId", - "spOAuthClientId", - "spOAuthClientSecret", - "spOAuthRefreshToken", - "spEndpoint" - ]) - } - - @Test("The auth method offers the emulator beside the three Google credential sources") - func authMethodOptions() throws { - let authMethod = try field("spAuthMethod") - #expect(authMethod.defaultValue == "serviceAccount") - #expect(authMethod.fieldType == .dropdown(options: [ - .init(value: "serviceAccount", label: "Service Account Key"), - .init(value: "adc", label: "Application Default Credentials"), - .init(value: "oauth", label: "Google Account (OAuth)"), - .init(value: "emulator", label: "Emulator (no credentials)") - ])) - } - - @Test("The OAuth refresh token is a secure field shown only for Google Account sign-in") - func refreshTokenField() throws { - let token = try field("spOAuthRefreshToken") - #expect(token.isSecure) - #expect(token.section == .authentication) - #expect(token.visibleWhen == FieldVisibilityRule(fieldId: "spAuthMethod", values: ["oauth"])) - } - - @Test("The endpoint defaults to production Spanner and sits in the advanced section") - func endpointField() throws { - let endpoint = try field("spEndpoint") - #expect(endpoint.placeholder == "https://spanner.googleapis.com") - #expect(endpoint.section == .advanced) - } - - @Test("EXPLAIN renders its plan as indented text") - func explainFormat() throws { - let variants = try snapshot().explainVariants - #expect(variants.map(\.sqlPrefix) == ["EXPLAIN"]) - #expect(variants.map(\.format) == [.indentedText]) - } - - @Test("Every auth method, the emulator included, keeps the built-in password hidden") - @MainActor - func passwordStaysHiddenForEveryAuthMethod() { - for method in ["serviceAccount", "adc", "oauth", "emulator"] { - var connection = DatabaseConnection(name: "Spanner", type: .spanner) - connection.additionalFields = ["spAuthMethod": method] - #expect(PluginManager.shared.hidesPassword(for: connection), "\(method)") - } - } -} diff --git a/TableProTests/Plugins/SurrealDBCBORTests.swift b/TableProTests/Plugins/SurrealDBCBORTests.swift deleted file mode 100644 index 64bf4d3813..0000000000 --- a/TableProTests/Plugins/SurrealDBCBORTests.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// SurrealDBCBORTests.swift -// TableProTests -// - -import Foundation -import Testing - -struct SurrealDBCBORTests { - private func roundTrip(_ value: SurrealValue) throws -> SurrealValue { - try SurrealCBOR.decode(SurrealCBOR.encode(value)) - } - - @Test("Scalars round-trip") - func scalars() throws { - let values: [SurrealValue] = [ - .null, - .none, - .bool(true), - .bool(false), - .int(0), - .int(23), - .int(24), - .int(255), - .int(65_535), - .int(4_294_967_295), - .int(9_223_372_036_854_775_807), - .int(-1), - .int(-1000), - .double(1.5), - .double(-0.25), - .string(""), - .string("hello"), - .string("unicode ✓ 日本語"), - .bytes(Data([0x00, 0x01, 0xFF])), - ] - for value in values { - #expect(try roundTrip(value) == value) - } - } - - @Test("Tagged SurrealDB types round-trip") - func taggedTypes() throws { - let uuid = try #require(UUID(uuidString: "018a6680-0000-7000-8000-000000000000")) - let values: [SurrealValue] = [ - .recordId(SurrealRecordID(table: "person", id: .string("alice"))), - .recordId(SurrealRecordID(table: "person", id: .int(10))), - .decimal("12.345"), - .datetime(seconds: 1_726_403_696, nanoseconds: 789_000_000), - .datetime(seconds: -14_182_940, nanoseconds: 0), - .duration(seconds: 3600, nanoseconds: 0), - .duration(seconds: 0, nanoseconds: 1), - .uuid(uuid), - .table("person"), - .array([.int(1), .string("two")]), - .object([(key: "a", value: .int(1)), (key: "b", value: .none)]), - ] - for value in values { - #expect(try roundTrip(value) == value) - } - } - - @Test("Decodes the byte sequences a live SurrealDB server sends") - func serverBytes() throws { - let taggedRecordIdOfPersonAlice = Data( - [0xC8, 0x82, 0x66, 0x70, 0x65, 0x72, 0x73, 0x6F, 0x6E, 0x65, 0x61, 0x6C, 0x69, 0x63, 0x65] - ) - #expect( - try SurrealCBOR.decode(taggedRecordIdOfPersonAlice) - == .recordId(SurrealRecordID(table: "person", id: .string("alice"))) - ) - - let taggedDecimalOf12Point345 = Data([0xCA, 0x66, 0x31, 0x32, 0x2E, 0x33, 0x34, 0x35]) - #expect(try SurrealCBOR.decode(taggedDecimalOf12Point345) == .decimal("12.345")) - - let taggedNone = Data([0xC6, 0xF6]) - let plainNull = Data([0xF6]) - #expect(try SurrealCBOR.decode(taggedNone) == .none) - #expect(try SurrealCBOR.decode(plainNull) == .null) - #expect(SurrealValue.none != SurrealValue.null) - } - - @Test("Range keeps its inclusive and exclusive bounds") - func ranges() throws { - let value = SurrealValue.range( - from: SurrealBound(value: .int(1), isInclusive: true), - to: SurrealBound(value: .int(10), isInclusive: false) - ) - #expect(try roundTrip(value) == value) - } - - @Test("Malformed input throws instead of trapping") - func malformed() { - #expect(throws: SurrealCBORError.self) { try SurrealCBOR.decode(Data()) } - #expect(throws: SurrealCBORError.self) { try SurrealCBOR.decode(Data([0x64, 0x61])) } - #expect(throws: SurrealCBORError.self) { try SurrealCBOR.decode(Data([0x1B, 0x00])) } - #expect(throws: SurrealCBORError.self) { try SurrealCBOR.decode(Data([0x9F, 0x01])) } - } - - @Test("A length header near Int max throws instead of overflowing") - func lengthOverflow() { - // byte string (major 2) claiming an 8-byte length of Int64.max - #expect(throws: SurrealCBORError.self) { - try SurrealCBOR.decode(Data([0x5B, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])) - } - // text string (major 3) claiming the same - #expect(throws: SurrealCBORError.self) { - try SurrealCBOR.decode(Data([0x7B, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])) - } - } - - @Test("Deeply nested input throws nestingTooDeep instead of overflowing the stack") - func nestingDepth() { - var deep = Data(repeating: 0x81, count: 100_000) - deep.append(0xF6) - #expect(throws: SurrealCBORError.nestingTooDeep) { try SurrealCBOR.decode(deep) } - - // A legitimately deep-but-bounded value still decodes. - var shallow = Data(repeating: 0x81, count: 10) - shallow.append(0x01) - #expect(throws: Never.self) { try SurrealCBOR.decode(shallow) } - } - - @Test("Unknown tags fall back to the wrapped value") - func unknownTag() throws { - let unknown = Data([0xD8, 0x63, 0x01]) - #expect(try SurrealCBOR.decode(unknown) == .int(1)) - } -} - -struct SurrealDBDisplayTests { - @Test("Record ids render as table:id") - func recordIds() { - #expect(SurrealValue.recordId(SurrealRecordID(table: "person", id: .string("alice"))).displayText == "person:alice") - #expect(SurrealValue.recordId(SurrealRecordID(table: "person", id: .int(10))).displayText == "person:10") - #expect(SurrealValue.recordId(SurrealRecordID(table: "person", id: .string("a b"))).displayText == "person:`a b`") - } - - @Test("Datetimes keep nanosecond precision and handle pre-epoch") - func datetimes() { - #expect(SurrealValue.datetime(seconds: 1_726_403_696, nanoseconds: 789_000_000).displayText - == "2024-09-15T12:34:56.789Z") - #expect(SurrealValue.datetime(seconds: 1_704_067_200, nanoseconds: 123_456_789).displayText - == "2024-01-01T00:00:00.123456789Z") - #expect(SurrealValue.datetime(seconds: -14_182_940, nanoseconds: 0).displayText - == "1969-07-20T20:17:40Z") - } - - @Test("Durations render as SurrealQL literals") - func durations() { - #expect(SurrealValue.duration(seconds: 3600, nanoseconds: 0).displayText == "1h") - #expect(SurrealValue.duration(seconds: 5400, nanoseconds: 0).displayText == "1h30m") - #expect(SurrealValue.duration(seconds: 0, nanoseconds: 500_000_000).displayText == "500ms") - #expect(SurrealValue.duration(seconds: 0, nanoseconds: 1).displayText == "1ns") - #expect(SurrealValue.duration(seconds: 0, nanoseconds: 0).displayText == "0ns") - } - - @Test("Nested values render as compact JSON, capped") - func nested() { - let object = SurrealValue.object([ - (key: "city", value: .string("Ho Chi Minh")), - (key: "n", value: .int(2)), - ]) - #expect(object.displayText == #"{"city":"Ho Chi Minh","n":2}"#) - #expect(SurrealValue.array([.string("red"), .string("blue")]).displayText == #"["red","blue"]"#) - - let huge = SurrealValue.array(Array(repeating: .string(String(repeating: "x", count: 100)), count: 500)) - #expect(huge.displayText.count <= SurrealValue.maxSerializedLength + 3) - } - - @Test("Geometry renders as GeoJSON") - func geometry() { - let point = SurrealValue.tagged(tag: 88, value: .array([.double(12.34), .double(56.78)])) - #expect(point.displayText == #"{"type":"Point","coordinates":[12.34,56.78]}"#) - } -} diff --git a/TableProTests/Plugins/SurrealDBDriverTests.swift b/TableProTests/Plugins/SurrealDBDriverTests.swift deleted file mode 100644 index 8529412a42..0000000000 --- a/TableProTests/Plugins/SurrealDBDriverTests.swift +++ /dev/null @@ -1,929 +0,0 @@ -// -// SurrealDBDriverTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct SurrealQLTests { - @Test("Identifiers are backtick-quoted only when they need it") - func identifiers() { - #expect(SurrealQL.quoteIdentifier("person") == "person") - #expect(SurrealQL.quoteIdentifier("odd-table") == "`odd-table`") - #expect(SurrealQL.quoteIdentifier("with space") == "`with space`") - #expect(SurrealQL.quoteIdentifier("select") == "`select`") - #expect(SurrealQL.quoteIdentifier("we`ird") == "`we\\`ird`") - } - - @Test("String literals escape quotes and backslashes") - func literals() { - #expect(SurrealQL.stringLiteral("O'Brien") == "'O\\'Brien'") - #expect(SurrealQL.stringLiteral("back\\slash") == "'back\\\\slash'") - #expect(SurrealQL.stringLiteral("plain") == "'plain'") - } - - @Test("Record ids parse from every form SurrealDB emits") - func recordIds() { - #expect(SurrealQL.parseRecordId("person:alice") == SurrealRecordID(table: "person", id: .string("alice"))) - #expect(SurrealQL.parseRecordId("person:10") == SurrealRecordID(table: "person", id: .int(10))) - #expect(SurrealQL.parseRecordId("person:`a:b`") == SurrealRecordID(table: "person", id: .string("a:b"))) - #expect(SurrealQL.parseRecordId("person:\u{27E8}10\u{27E9}") == SurrealRecordID(table: "person", id: .string("10"))) - #expect(SurrealQL.parseRecordId("alice", fallbackTable: "person") - == SurrealRecordID(table: "person", id: .string("alice"))) - #expect(SurrealQL.parseRecordId("") == nil) - } - - @Test("A backticked string id is not re-inferred as an int") - func quotedStringIdStaysString() { - #expect(SurrealQL.parseRecordId("person:`10`") == SurrealRecordID(table: "person", id: .string("10"))) - #expect(SurrealQL.parseRecordId("person:\u{27E8}10\u{27E9}") == SurrealRecordID(table: "person", id: .string("10"))) - #expect(SurrealQL.parseRecordId("person:10") == SurrealRecordID(table: "person", id: .int(10))) - } - - @Test("Record ids round-trip through their display literal") - func recordIdRoundTrip() { - let ids: [SurrealValue] = [ - .string("alice"), - .int(10), - .string("10"), - .string("a:b"), - .string("has space"), - ] - for id in ids { - let record = SurrealRecordID(table: "person", id: id) - let literal = SurrealValue.recordId(record).displayText - #expect(SurrealQL.parseRecordId(literal) == record, "\(literal) did not round-trip") - } - } - - @Test("A record id whose table needs quoting round-trips without changing table") - func oddTableRoundTrip() { - let record = SurrealRecordID(table: "a:b", id: .int(1)) - let literal = SurrealValue.recordId(record).displayText - #expect(literal == "`a:b`:1") - #expect(SurrealQL.parseRecordId(literal) == record) - } -} - -struct SurrealQueryBuilderTests { - private let scope = SurrealScope(namespace: "ns", database: "db") - - @Test("Browse emits real, self-scoping SurrealQL") - func browse() { - let query = SurrealQueryBuilder.browse( - table: "person", scope: scope, - sortColumns: [(column: "name", ascending: false)], limit: 100, offset: 200 - ) - #expect(query.contains("USE NS ns DB db;")) - #expect(query.contains("SELECT * FROM person")) - #expect(query.contains("ORDER BY name DESC, id ASC")) - #expect(query.contains("LIMIT 100 START 200")) - } - - @Test("A query with no scope omits USE") - func noScope() { - let query = SurrealQueryBuilder.browse( - table: "person", scope: SurrealScope(namespace: nil, database: nil), - sortColumns: [], limit: 10, offset: 0 - ) - #expect(!query.contains("USE")) - #expect(query.hasPrefix("SELECT * FROM person")) - } - - @Test("A hostile filter value never escapes its literal") - func injection() { - let hostile = "x'; REMOVE TABLE person; --" - let query = SurrealQueryBuilder.filtered( - table: "person", scope: scope, - filters: [PluginQueryFilter(column: "name", op: "=", value: hostile)], - logicMode: "and", sortColumns: [], limit: 10, offset: 0 - ) - #expect(query.contains("name = 'x\\'; REMOVE TABLE person; --'")) - #expect(!query.contains(hostile), "the quote must be escaped so the payload cannot close the literal") - } - - @Test("Filter operators map onto SurrealQL") - func operators() throws { - func clause(_ op: String, _ value: String) throws -> String { - try SurrealQueryBuilder.whereClause( - filters: [PluginQueryFilter(column: "c", op: op, value: value)], logicMode: "and" - ) ?? "" - } - #expect(try clause("=", "5") == "c = 5") - #expect(try clause(">", "5") == "c > 5") - #expect(try clause("=", "abc") == "c = 'abc'") - #expect(try clause("=", "true") == "c = true") - #expect(try clause("IS NULL", "") == "(c = NONE OR c = NULL)") - #expect(try clause("CONTAINS", "x") == "string::contains( c, 'x')") - #expect(try clause("STARTS WITH", "x") == "string::starts_with( c, 'x')") - #expect(try clause("IN", "1, 2") == "c INSIDE [1, 2]") - } - - @Test("Only a real table:id shape becomes a record literal; look-alikes stay strings") - func literalRecordDisambiguation() throws { - func clause(_ value: String) throws -> String { - try SurrealQueryBuilder.whereClause( - filters: [PluginQueryFilter(column: "c", op: "=", value: value)], logicMode: "and" - ) ?? "" - } - #expect(try clause("person:tobie") == "c = person:tobie") - #expect(try clause("12:30") == "c = '12:30'", "a time-shaped value is a string, not a record") - #expect(try clause("1e5") == "c = 1e5") - #expect(try clause("null") == "c = NULL") - #expect(try clause("none") == "c = NONE") - #expect(try clause("plain") == "c = 'plain'") - } - - @Test("A hostile filter value cannot escape its literal in any branch") - func literalBranchesContainPayloads() throws { - func clause(_ value: String) throws -> String { - try SurrealQueryBuilder.whereClause( - filters: [PluginQueryFilter(column: "c", op: "=", value: value)], logicMode: "and" - ) ?? "" - } - // String branch: the closing quote is escaped. - #expect(try clause("x'; REMOVE TABLE person; --") == "c = 'x\\'; REMOVE TABLE person; --'") - // Record branch: the id part is backtick-quoted, so ; stays inside the identifier. - let recordish = try clause("person:a;REMOVE") - #expect(recordish.hasPrefix("c = person:`") && recordish.hasSuffix("`")) - } - - @Test("Count uses GROUP ALL") - func count() { - let query = SurrealQueryBuilder.count(table: "person", scope: scope, filters: [], logicMode: "and") - #expect(query.contains("SELECT count() AS total FROM person GROUP ALL;")) - } - - private func clause( - _ filter: TableFilter, - kinds: [String: PluginColumnKind] = [:] - ) throws -> String? { - try SurrealQueryBuilder.whereClause( - filters: [filter.asPluginQueryFilter], logicMode: "and", columnKinds: kinds - ) - } - - @Test("IS EMPTY matches a missing, null or empty-string field") - func isEmpty() throws { - let filter = TableFilter(columnName: "name", filterOperator: .isEmpty) - #expect(try clause(filter) == "(name = NONE OR name = NULL OR name = '')") - } - - @Test("IS NOT EMPTY keeps the fields that hold a value, not the empty ones") - func isNotEmpty() throws { - let filter = TableFilter(columnName: "name", filterOperator: .isNotEmpty) - #expect(try clause(filter) == "(name != NONE AND name != NULL AND name != '')") - } - - @Test("REGEX matches the pattern and passes over a missing or null field") - func regex() throws { - let filter = TableFilter(columnName: "name", filterOperator: .regex, value: "^A") - #expect(try clause(filter) == "(name != NONE AND name != NULL AND string::matches( name, '^A'))") - } - - @Test("The raw filter row is spliced in as a SurrealQL condition") - func rawRow() throws { - let filter = TableFilter(columnName: TableFilter.rawSQLColumn, rawSQL: "age > 10") - #expect(try clause(filter) == "(age > 10)") - } - - @Test("A raw condition that only reads keeps its strings, identifiers and function calls") - func rawConditionThatReads() throws { - let conditions = [ - "name = 'DELETE me' AND `update` = 1", - "array::len(array::remove([1, 2], 0)) = 1 OR ->likes->person CONTAINS person:\u{27E8}update\u{27E9}", - "(age > 10 AND age < 20) OR $session.update = NONE", - "age IN 10..20 AND string::lowercase(name) = 'a'" - ] - for text in conditions { - let filter = TableFilter(columnName: TableFilter.rawSQLColumn, rawSQL: text) - #expect(try clause(filter) == "(" + text + ")") - } - } - - @Test("A raw condition that could write, end the statement or hide the rest of it is refused") - func rawConditionThatWrites() { - let conditions = [ - "age > 1 OR (CREATE probe) = []", - "true OR { DELETE person }", - "age > 1 OR (upsert probe:one) = []", - "fn::wipe() = true", - "fn ::wipe() = true", - "fn\n::wipe() = true", - "http::post('https://example.com') = NONE", - "http ::post('https://example.com') = NONE", - "file::delete(bucket:/a.txt) = NONE", - "NONE ?:UPSERT probe:one SET n += 1", - "{a:UPSERT probe:one}.a != NONE", - "age > 1 ?:DELETE person:z", - "age IN 1..UPSERT probe:one SET n += 1", - "age > 1; REMOVE TABLE person", - "age > 1 -- the rest", - "age > 1 /* note */", - "age > 1 # note", - "age > 1) OR (true", - "name = 'unterminated" - ] - for text in conditions { - let filter = TableFilter(columnName: TableFilter.rawSQLColumn, rawSQL: text) - #expect(throws: SurrealFilterRefusal.rawConditionNotReadOnly, "\(text)") { - try clause(filter) - } - } - } - - @Test("A record id named after a writing keyword is refused unless its id is bracketed") - func rawConditionWithKeywordRecordId() throws { - let bare = TableFilter(columnName: TableFilter.rawSQLColumn, rawSQL: "id = person:update") - #expect(throws: SurrealFilterRefusal.rawConditionNotReadOnly) { - try clause(bare) - } - let bracketed = TableFilter(columnName: TableFilter.rawSQLColumn, rawSQL: "id = person:\u{27E8}update\u{27E9}") - #expect(try clause(bracketed) == "(id = person:\u{27E8}update\u{27E9})") - } - - @Test("A refused raw condition never reaches the server as SurrealQL") - func refusedRawConditionThrowsOnTheServer() { - let raw = TableFilter(columnName: TableFilter.rawSQLColumn, rawSQL: "true OR (DELETE person) = []") - let query = SurrealQueryBuilder.filtered( - table: "person", scope: scope, filters: [raw.asPluginQueryFilter], - logicMode: "and", sortColumns: [], limit: 10, offset: 0 - ) - #expect(query.hasPrefix("USE NS ns DB db;\nTHROW '")) - #expect(!query.contains("DELETE")) - } - - @Test("BETWEEN from the filter bar's own encoding uses both bounds") - func betweenFromAppEncoding() throws { - let filter = TableFilter(columnName: "age", filterOperator: .between, value: "10", secondValue: "20") - #expect(filter.asPluginQueryFilter.value == "10,20") - #expect(try clause(filter, kinds: ["age": .integer]) == "(age >= 10 AND age <= 20)") - } - - @Test("BETWEEN keeps a comma that belongs to a bound") - func betweenWithCommaInBound() throws { - let filter = TableFilter(columnName: "name", filterOperator: .between, value: "Smith, John", secondValue: "Zed") - #expect(try clause(filter, kinds: ["name": .text]) == "(name >= 'Smith, John' AND name <= 'Zed')") - } - - @Test("BETWEEN takes the lower bound off by scalars, so a bound ending in a prepend mark keeps its text") - func betweenWithPrependScalarBeforeTheSeparator() throws { - let filter = TableFilter(columnName: "code", filterOperator: .between, value: "A\u{0600}", secondValue: "B") - #expect(try clause(filter, kinds: ["code": .text]) == "(code >= 'A\u{0600}' AND code <= 'B')") - } - - @Test("BETWEEN without a separate upper bound splits the joined value") - func betweenFromJoinedValue() throws { - let clause = try SurrealQueryBuilder.whereClause( - filters: [PluginQueryFilter(column: "age", op: "BETWEEN", value: "10,20")], logicMode: "and" - ) - #expect(clause == "(age >= 10 AND age <= 20)") - } - - @Test("BETWEEN without a separate upper bound splits the joined value by scalars") - func betweenFromJoinedValueWithPrependScalar() throws { - let clause = try SurrealQueryBuilder.whereClause( - filters: [PluginQueryFilter(column: "code", op: "BETWEEN", value: "A\u{0600},B")], - logicMode: "and", - columnKinds: ["code": .text] - ) - #expect(clause == "(code >= 'A\u{0600}' AND code <= 'B')") - } - - @Test("BETWEEN with one bound is refused") - func betweenWithOneBound() { - #expect(throws: SurrealFilterRefusal.incompleteRange) { - try SurrealQueryBuilder.whereClause( - filters: [PluginQueryFilter(column: "age", op: "BETWEEN", value: "10")], logicMode: "and" - ) - } - } - - @Test("Every operator the filter bar offers has a condition of its own, never an equality") - func everyFilterBarOperatorIsMapped() throws { - for filterOperator in FilterOperator.allCases where filterOperator != .equal { - let filter = TableFilter(columnName: "c", filterOperator: filterOperator, value: "1", secondValue: "2") - let condition = try clause(filter) - #expect(condition != "c = 1", "\(filterOperator.rawValue) fell back to an equality") - } - } - - @Test("An operator SurrealDB has no condition for is refused") - func unknownOperatorRefused() { - #expect(throws: SurrealFilterRefusal.unsupportedOperator("SOUNDS LIKE")) { - try SurrealQueryBuilder.whereClause( - filters: [PluginQueryFilter(column: "name", op: "SOUNDS LIKE", value: "x")], logicMode: "and" - ) - } - } - - @Test("A refused filter runs as a THROW, so the grid reports it instead of showing no rows") - func refusedFilterThrowsOnTheServer() { - let filters = [PluginQueryFilter(column: "name", op: "SOUNDS LIKE", value: "x")] - let refusal = "USE NS ns DB db;\nTHROW 'SurrealDB cannot filter rows with SOUNDS LIKE.';" - let browse = SurrealQueryBuilder.filtered( - table: "person", scope: scope, filters: filters, - logicMode: "and", sortColumns: [], limit: 10, offset: 0 - ) - let count = SurrealQueryBuilder.count(table: "person", scope: scope, filters: filters, logicMode: "and") - #expect(browse == refusal) - #expect(count == refusal) - } -} - -struct SurrealFieldKindTests { - @Test("Optional fields parse on both versions") - func optionals() { - let legacy = SurrealFieldKind.parse("option") - let modern = SurrealFieldKind.parse("none | int") - #expect(legacy.base == .int) - #expect(legacy.isOptional) - #expect(modern.base == .int) - #expect(modern.isOptional) - - let plain = SurrealFieldKind.parse("int") - #expect(plain.base == .int) - #expect(!plain.isOptional) - } - - @Test("Record links expose their target table") - func records() { - #expect(SurrealFieldKind.parse("record").isRecordLink) - #expect(SurrealFieldKind.parse("record").recordTable == "person") - #expect(SurrealFieldKind.parse("none | record").isRecordLink) - #expect(SurrealFieldKind.parse("option>").recordTable == "person") - } - - @Test("Structured and scalar kinds") - func kinds() { - #expect(SurrealFieldKind.parse("array").base == .array) - #expect(SurrealFieldKind.parse("object").base == .object) - #expect(SurrealFieldKind.parse("decimal").base == .decimal) - #expect(SurrealFieldKind.parse("geometry").base == .geometry) - #expect(SurrealFieldKind.parse("").base == .any) - } - - @Test("A kind can be inferred from a typed value read back from the server") - func inferFromValue() { - #expect(SurrealFieldKind.infer(from: .int(1))?.base == .int) - #expect(SurrealFieldKind.infer(from: .bool(true))?.base == .bool) - #expect(SurrealFieldKind.infer(from: .decimal("1.5"))?.base == .decimal) - #expect(SurrealFieldKind.infer(from: .datetime(seconds: 0, nanoseconds: 0))?.base == .datetime) - #expect(SurrealFieldKind.infer(from: .recordId(SurrealRecordID(table: "t", id: .int(1))))?.base == .record) - // NULL/NONE carry no type, so they must not overwrite a learned kind. - #expect(SurrealFieldKind.infer(from: .null) == nil) - #expect(SurrealFieldKind.infer(from: .none) == nil) - } -} - -struct SurrealInfoParserTests { - @Test("Table list reads schemafull on 3.x and full on 2.x") - func schemafullFlag() { - let modern = SurrealValue.object([(key: "tables", value: .array([ - .object([ - (key: "name", value: .string("pf")), - (key: "schemafull", value: .bool(true)), - (key: "kind", value: .object([(key: "kind", value: .string("NORMAL"))])), - ]), - ]))]) - let legacy = SurrealValue.object([(key: "tables", value: .array([ - .object([ - (key: "name", value: .string("pf")), - (key: "full", value: .bool(true)), - (key: "kind", value: .object([(key: "kind", value: .string("NORMAL"))])), - ]), - ]))]) - - #expect(SurrealInfoParser.tables(from: modern).first?.isSchemafull == true) - #expect(SurrealInfoParser.tables(from: legacy).first?.isSchemafull == true) - } - - @Test("Relation tables are detected") - func relations() { - let value = SurrealValue.object([(key: "tables", value: .array([ - .object([ - (key: "name", value: .string("follows")), - (key: "schemafull", value: .bool(false)), - (key: "kind", value: .object([(key: "kind", value: .string("RELATION"))])), - ]), - ]))]) - #expect(SurrealInfoParser.tables(from: value).first?.isRelation == true) - } - - @Test("Nested array fields are dropped on both versions") - func nestedFields() { - #expect(SurrealInfoParser.isNestedFieldName("tags.*")) - #expect(SurrealInfoParser.isNestedFieldName("tags[*]")) - #expect(!SurrealInfoParser.isNestedFieldName("tags")) - - let value = SurrealValue.object([(key: "fields", value: .array([ - .object([(key: "name", value: .string("tags")), (key: "kind", value: .string("array"))]), - .object([(key: "name", value: .string("tags.*")), (key: "kind", value: .string("string"))]), - .object([(key: "name", value: .string("tags[*]")), (key: "kind", value: .string("string"))]), - ]))]) - let columns = SurrealInfoParser.columns(from: value, isRelation: false) - #expect(columns.map(\.name) == ["id", "tags"]) - #expect(columns.first?.isPrimaryKey == true) - } - - @Test("Relation tables pin in and out after id") - func edgeColumns() { - let value = SurrealValue.object([(key: "fields", value: .array([ - .object([(key: "name", value: .string("since")), (key: "kind", value: .string("datetime"))]), - ]))]) - let columns = SurrealInfoParser.columns(from: value, isRelation: true) - #expect(columns.map(\.name) == ["id", "in", "out", "since"]) - } - - @Test("Index cols read as a 3.x array and a 2.x string") - func indexes() { - let modern = SurrealValue.object([(key: "indexes", value: .array([ - .object([ - (key: "name", value: .string("pf_nm")), - (key: "cols", value: .array([.string("nm")])), - (key: "index", value: .string("UNIQUE")), - ]), - ]))]) - let legacy = SurrealValue.object([(key: "indexes", value: .array([ - .object([ - (key: "name", value: .string("pf_nm")), - (key: "cols", value: .string("nm")), - (key: "index", value: .string("UNIQUE")), - ]), - ]))]) - - #expect(SurrealInfoParser.indexes(from: modern).first?.columns == ["nm"]) - #expect(SurrealInfoParser.indexes(from: legacy).first?.columns == ["nm"]) - #expect(SurrealInfoParser.indexes(from: modern).first?.isUnique == true) - } -} - -struct SurrealRowFlattenerTests { - @Test("Columns are the union of top-level keys, id first") - func union() { - let rows = SurrealValue.array([ - .object([ - (key: "n", value: .int(1)), - (key: "id", value: .recordId(SurrealRecordID(table: "t", id: .string("a")))), - ]), - .object([ - (key: "id", value: .recordId(SurrealRecordID(table: "t", id: .string("b")))), - (key: "other", value: .string("x")), - ]), - ]) - let flattened = SurrealRowFlattener.flatten(rows, length: .display) - #expect(flattened.columns == ["id", "n", "other"]) - #expect(flattened.rows.count == 2) - } - - @Test("Ragged rows leave sparse cells empty, not broken") - func ragged() { - let rows = SurrealValue.array([ - .object([(key: "a", value: .int(1))]), - .object([(key: "b", value: .int(2))]), - ]) - let flattened = SurrealRowFlattener.flatten(rows, length: .display) - #expect(flattened.columns == ["a", "b"]) - #expect(flattened.rows[0][1] == .null) - #expect(flattened.rows[1][0] == .null) - } - - @Test("Nested values become compact JSON text") - func nested() { - let rows = SurrealValue.array([ - .object([(key: "meta", value: .object([(key: "k", value: .int(1))]))]), - ]) - let flattened = SurrealRowFlattener.flatten(rows, length: .display) - #expect(flattened.rows[0][0] == .text(#"{"k":1}"#)) - } - - @Test("An export row keeps arrays and objects longer than the grid's cap whole") - func exportRowKeepsLongStructuresWhole() throws { - let embedding = (0..<1_536).map { Double($0) / 1_024 } - let vector = SurrealValue.array(embedding.map { .double($0) }) - let rows = SurrealValue.array([ - .object([ - (key: "embedding", value: vector), - (key: "meta", value: .object([(key: "vector", value: vector)])), - ]), - ]) - let row = try #require(SurrealRowFlattener.flatten(rows, length: .whole).rows.first) - - #expect(try parsedJSON(row[0]) as? [Double] == embedding) - #expect(try parsedJSON(row[1]) as? [String: [Double]] == ["vector": embedding]) - } - - @Test("A grid row still cuts an array longer than the cap") - func gridRowCutsLongStructures() throws { - let vector = SurrealValue.array((0..<1_536).map { .double(Double($0) / 1_024) }) - let rows = SurrealValue.array([.object([(key: "embedding", value: vector)])]) - let row = try #require(SurrealRowFlattener.flatten(rows, length: .display).rows.first) - let text = try #require(row[0].asText) - - #expect(text.hasSuffix("...")) - #expect((text as NSString).length == SurrealValue.maxSerializedLength + 3) - } - - private func parsedJSON(_ cell: PluginCellValue) throws -> Any { - let text = try #require(cell.asText) - return try JSONSerialization.jsonObject(with: Data(text.utf8)) - } -} - -struct SurrealStatementGeneratorTests { - private let scope = SurrealScope(namespace: "ns", database: "db") - private let columns = ["id", "name", "age"] - private let kinds: [String: SurrealFieldKind] = [ - "name": SurrealFieldKind.parse("string"), - "age": SurrealFieldKind.parse("option"), - ] - - private func decoded(_ cell: PluginCellValue) -> SurrealValue { - SurrealCellCoder.value(from: cell) - } - - private func writes( - table: String = "person", - columns: [String]? = nil, - changes: [PluginRowChange] = [], - insertedRowData: [Int: [PluginCellValue]] = [:], - deletedRowIndices: Set = [], - insertedRowIndices: Set = [] - ) throws -> [PluginRowWrite] { - try SurrealStatementGenerator.rowWrites( - table: table, scope: scope, columns: columns ?? self.columns, kinds: kinds, - changes: changes, insertedRowData: insertedRowData, - deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices - ) - } - - @Test("An update sets only the changed cells and never replaces the record") - func update() throws { - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 2, columnName: "age", oldValue: .text("30"), newValue: .text("31"))], - originalRow: [.text("person:alice"), .text("Alice"), .text("30")] - ) - let write = try #require(try writes(changes: [change]).first) - - #expect(write.statement.contains("UPDATE $p0 SET age = $p1;")) - #expect(!write.statement.contains("CONTENT")) - #expect(!write.statement.contains("MERGE")) - #expect(write.statement.contains("USE NS ns DB db;")) - #expect(write.rowIndices == [0]) - - #expect(decoded(write.parameters[0]) - == .recordId(SurrealRecordID(table: "person", id: .string("alice")))) - #expect(decoded(write.parameters[1]) == .int(31), "an int column must bind as an int, not a string") - } - - @Test("The record id is bound, never interpolated") - func boundRecordId() throws { - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "name", oldValue: .text("a"), newValue: .text("'; REMOVE TABLE person; --"))], - originalRow: [.text("person:alice"), .text("a"), .null] - ) - let write = try #require(try writes(changes: [change]).first) - #expect(!write.statement.contains("REMOVE TABLE")) - #expect(decoded(write.parameters[1]) == .string("'; REMOVE TABLE person; --")) - } - - @Test("An insert omits a blank id so the server mints one") - func insert() throws { - let write = try #require(try writes( - insertedRowData: [0: [.text(""), .text("Carol"), .text("22")]], insertedRowIndices: [0] - ).first) - #expect(write.statement.contains("CREATE person SET name = $p0, age = $p1;")) - #expect(decoded(write.parameters[1]) == .int(22)) - #expect(write.rowIndices == [0]) - } - - @Test("An insert with an explicit id binds it as a record id") - func insertWithId() throws { - let write = try #require(try writes( - insertedRowData: [0: [.text("person:carol"), .text("Carol"), .null]], insertedRowIndices: [0] - ).first) - #expect(write.statement.contains("CREATE $p0 SET name = $p1;")) - #expect(decoded(write.parameters[0]) - == .recordId(SurrealRecordID(table: "person", id: .string("carol")))) - } - - @Test("A delete addresses the record directly") - func delete() throws { - let change = PluginRowChange( - rowIndex: 0, type: .delete, cellChanges: [], - originalRow: [.text("person:alice"), .text("Alice"), .text("30")] - ) - let write = try #require(try writes(changes: [change], deletedRowIndices: [0]).first) - #expect(write.statement.contains("DELETE $p0;")) - #expect(write.parameters.count == 1) - #expect(write.rowIndices == [0]) - } - - @Test("Each write names the row it carries out") - func eachWriteNamesItsRow() throws { - let update = PluginRowChange( - rowIndex: 3, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "name", oldValue: .text("A"), newValue: .text("B"))], - originalRow: [.text("person:a"), .text("A"), .null] - ) - let delete = PluginRowChange( - rowIndex: 5, type: .delete, cellChanges: [], - originalRow: [.text("person:b"), .text("B"), .null] - ) - let all = try writes( - changes: [update, delete], - insertedRowData: [7: [.text(""), .text("C"), .null]], - deletedRowIndices: [5], - insertedRowIndices: [7] - ) - #expect(all.map(\.rowIndices) == [[3], [7], [5]]) - } - - @Test("The auto-id marker on insert lets the server mint the id") - func autoDefaultInsertId() throws { - let write = try #require(try writes( - insertedRowData: [0: [.text("__DEFAULT__"), .text("Carol"), .text("22")]], insertedRowIndices: [0] - ).first) - #expect(write.statement.contains("CREATE person SET")) - #expect(!write.statement.contains("__DEFAULT__")) - #expect(!write.statement.contains("person:__DEFAULT__")) - } - - @Test("The auto-id marker on an updated field is skipped, never written literally") - func autoDefaultUpdateField() throws { - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "name", oldValue: .text("Alice"), newValue: .text("__DEFAULT__"))], - originalRow: [.text("person:alice"), .text("Alice"), .text("30")] - ) - #expect(try writes(changes: [change]).isEmpty, "an all-default update produces no statement, not a literal write") - } - - @Test("An update with no cell changes has nothing to write and is not refused") - func emptyUpdate() throws { - let probe = PluginRowChange(rowIndex: 0, type: .update, cellChanges: [], originalRow: nil) - #expect(try writes(changes: [probe]).isEmpty) - } - - @Test("An edit to the id column is refused, never dropped") - func immutableId() { - let change = PluginRowChange( - rowIndex: 4, - type: .update, - cellChanges: [(columnIndex: 0, columnName: "id", oldValue: .text("person:a"), newValue: .text("person:b"))], - originalRow: [.text("person:a"), .text("Alice"), .null] - ) - #expect(throws: PluginRowWriteRefusal(rowIndex: 4, reason: "A record's id cannot be edited.")) { - try writes(changes: [change]) - } - } - - @Test("An edit to in or out beside another field is refused, never saved without it", arguments: ["in", "out"]) - func edgeEndpointEditIsRefused(column: String) throws { - let edgeColumns = ["id", "in", "out", "weight"] - let columnIndex = try #require(edgeColumns.firstIndex(of: column)) - let change = PluginRowChange( - rowIndex: 2, - type: .update, - cellChanges: [ - (columnIndex: columnIndex, columnName: column, oldValue: .text("person:a"), newValue: .text("person:c")), - (columnIndex: 3, columnName: "weight", oldValue: .text("1"), newValue: .text("2")) - ], - originalRow: [.text("likes:x"), .text("person:a"), .text("person:b"), .text("1")] - ) - let error = #expect(throws: PluginRowWriteRefusal.self) { - try writes(table: "likes", columns: edgeColumns, changes: [change]) - } - #expect(error?.rowIndex == 2) - #expect(error?.reason.contains("'\(column)'") == true) - } - - private let documentColumns = ["id", "tags", "meta"] - private let documentKinds: [String: SurrealFieldKind] = ["tags": SurrealFieldKind.parse("array")] - - private func tagArray(count: Int) -> String { - SurrealValue.array((0.. String { - tagArray(count: 1_500) - } - - private func shortenedRefusal(_ column: String) -> PluginRowWriteRefusal { - PluginRowWriteRefusal( - rowIndex: 0, - reason: "The value in \(column) is shortened for display, so saving it would store only the part shown. " - + "Change this field with a query." - ) - } - - private func documentEdit( - _ column: String, - from old: String, - to new: PluginCellValue, - beside original: [PluginCellValue] = [.text("post:one"), .null, .null] - ) -> PluginRowChange { - let columnIndex = documentColumns.firstIndex(of: column) ?? 0 - var originalRow = original - originalRow[columnIndex] = .text(old) - return PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: columnIndex, columnName: column, oldValue: .text(old), newValue: new)], - originalRow: originalRow - ) - } - - private func documentWrite(_ change: PluginRowChange) throws -> PluginRowWrite { - let writes = try SurrealStatementGenerator.rowWrites( - table: "post", scope: scope, columns: documentColumns, kinds: documentKinds, - changes: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - return try #require(writes.first) - } - - @Test("An edit to an array shortened for display is refused rather than saved as the fragment") - func updateRefusesAShortenedArray() throws { - let shortened = shortenedTags() - try #require(shortened.hasSuffix("...")) - let edited = shortened.replacingOccurrences(of: "\"tag-0\"", with: "\"tag-Z\"") - #expect(throws: shortenedRefusal("tags")) { - try documentWrite(documentEdit("tags", from: shortened, to: .text(edited))) - } - } - - @Test("Text appended to a schemaless array shortened for display is refused") - func updateRefusesTextAppendedToAShortenedArray() throws { - let shortened = shortenedTags() - try #require(shortened.hasSuffix("...")) - let appended = String(shortened.dropLast(3)) + #","new"]"# - #expect(throws: shortenedRefusal("meta")) { - try documentWrite(documentEdit("meta", from: shortened, to: .text(appended))) - } - } - - @Test("A new row carrying a schemaless value shortened right after an object is refused") - func insertRefusesAValueShortenedAfterAnObject() throws { - let shortened = SurrealValue.array( - Array(repeating: .object([(key: "k", value: .string("v"))]), count: 1_500) - ).displayText - try #require(shortened.hasSuffix("}...")) - #expect(throws: shortenedRefusal("meta")) { - try SurrealStatementGenerator.rowWrites( - table: "post", scope: scope, columns: documentColumns, kinds: documentKinds, - changes: [PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil)], - insertedRowData: [0: [.text(""), .null, .text(shortened)]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - @Test("A complete array written over one shortened for display is refused, since it may be the shown part closed") - func updateRefusesACompleteArrayWrittenOverAShortenedOne() throws { - let shownPart = tagArray(count: 800) - try #require(!shownPart.hasSuffix("...")) - #expect(throws: shortenedRefusal("tags")) { - try documentWrite(documentEdit("tags", from: shortenedTags(), to: .text(shownPart))) - } - } - - @Test("NULL written over a value shortened for display clears the field") - func updateWritesNullOverAShortenedValue() throws { - let write = try documentWrite(documentEdit("tags", from: shortenedTags(), to: .null)) - #expect(write.statement.contains("UPDATE $p0 SET tags = $p1;")) - #expect(decoded(write.parameters[1]) == .null) - } - - @Test("An edit to another field of a row holding a shortened value is written") - func updateWritesAnotherFieldBesideAShortenedValue() throws { - let change = documentEdit( - "meta", from: "0", to: .text("1"), beside: [.text("post:one"), .text(shortenedTags()), .null] - ) - let write = try documentWrite(change) - #expect(write.statement.contains("UPDATE $p0 SET meta = $p1;")) - #expect(decoded(write.parameters[1]) == .int(1)) - } -} - -struct SurrealCellCoderTests { - @Test("Text coerces to the column's declared type") - func typed() { - func value(_ text: String, _ kind: String) -> SurrealValue { - SurrealCellCoder.value(from: .text(text), kind: SurrealFieldKind.parse(kind)) - } - #expect(value("31", "int") == .int(31)) - #expect(value("1.5", "float") == .double(1.5)) - #expect(value("12.345", "decimal") == .decimal("12.345")) - #expect(value("true", "bool") == .bool(true)) - #expect(value("31", "string") == .string("31")) - #expect(value("person:alice", "record") - == .recordId(SurrealRecordID(table: "person", id: .string("alice")))) - #expect(value(#"{"a":1}"#, "object") == .object([(key: "a", value: .int(1))])) - #expect(value("2024-09-15T12:34:56.789Z", "datetime") - == .datetime(seconds: 1_726_403_696, nanoseconds: 789_000_000)) - #expect(value("1h30m", "duration") == .duration(seconds: 5_400, nanoseconds: 0)) - } - - @Test("With no known kind, numeric and bool text is typed, not left a string") - func fallbackInference() { - func value(_ text: String) -> SurrealValue { - SurrealCellCoder.value(from: .text(text), kind: nil) - } - #expect(value("31") == .int(31)) - #expect(value("true") == .bool(true)) - #expect(value("false") == .bool(false)) - #expect(value("hello") == .string("hello")) - #expect(value(#"{"a":1}"#) == .object([(key: "a", value: .int(1))])) - // A leading-zero string is not an int, so it stays a string. - #expect(value("007") == .string("007")) - } - - @Test("An empty cell in an optional column becomes NONE") - func empties() { - #expect(SurrealCellCoder.value(from: .null, kind: SurrealFieldKind.parse("option")) == .none) - #expect(SurrealCellCoder.value(from: .text(""), kind: SurrealFieldKind.parse("option")) == .none) - #expect(SurrealCellCoder.value(from: .text(""), kind: SurrealFieldKind.parse("string")) == .string("")) - } - - @Test("Parameters survive the PluginCellValue boundary") - func parameters() { - let values: [SurrealValue] = [ - .recordId(SurrealRecordID(table: "t", id: .int(1))), - .int(42), - .decimal("1.5"), - .none, - ] - for value in values { - #expect(SurrealCellCoder.value(from: SurrealCellCoder.parameter(value)) == value) - } - } - - @Test("Plain text and raw bytes are not mistaken for encoded parameters") - func rawCells() { - #expect(SurrealCellCoder.value(from: .text("hello")) == .string("hello")) - #expect(SurrealCellCoder.value(from: .bytes(Data([0x01, 0x02]))) == .bytes(Data([0x01, 0x02]))) - #expect(SurrealCellCoder.value(from: .null) == .null) - } -} - -struct SurrealDBConnectionConfigTests { - private func config(_ level: String, namespace: String = "ns", extra: [String: String] = [:]) -> SurrealDBConnectionConfig { - var fields = ["sdbAuthLevel": level] - fields.merge(extra) { _, new in new } - return SurrealDBConnectionConfig(config: DriverConnectionConfig( - host: "localhost", port: 8_000, username: "root", password: "secret", - database: namespace, ssl: SSLConfiguration(), additionalFields: fields - )) - } - - @Test("Each auth level demands the fields it needs") - func validation() throws { - try config("root").validate() - #expect(throws: SurrealDBError.self) { try config("namespace", namespace: "").validate() } - try config("namespace").validate() - #expect(throws: SurrealDBError.self) { try config("database").validate() } - try config("database", extra: ["sdbDatabase": "db"]).validate() - #expect(throws: SurrealDBError.self) { try config("record", extra: ["sdbDatabase": "db"]).validate() } - try config("record", extra: ["sdbDatabase": "db", "sdbAccess": "user"]).validate() - #expect(throws: SurrealDBError.self) { try config("token").validate() } - try config("token", extra: ["sdbToken": "jwt"]).validate() - } - - @Test("SurrealDB 1.x is rejected, 2.x and 3.x are supported") - func versionGate() { - #expect(SurrealServerVersion.parse("surrealdb/3.2.1")?.major == 3) - #expect(SurrealServerVersion.parse("surrealdb-2.6.5")?.major == 2) - #expect(SurrealServerVersion.isSupported("surrealdb-2.6.5")) - #expect(SurrealServerVersion.isSupported("surrealdb/3.2.1")) - #expect(!SurrealServerVersion.isSupported("surrealdb-1.5.6")) - } - - @Test("The endpoint follows the TLS setting") - func endpoint() { - #expect(config("root").baseURL?.absoluteString == "http://localhost:8000") - - let secure = SurrealDBConnectionConfig(config: DriverConnectionConfig( - host: "db.example.com", port: 443, username: "u", password: "p", - database: "ns", ssl: SSLConfiguration(mode: .required), additionalFields: [:] - )) - #expect(secure.baseURL?.absoluteString == "https://db.example.com:443") - #expect(secure.skipTLSVerify, "Required without CA verification must not verify the certificate") - - let verified = SurrealDBConnectionConfig(config: DriverConnectionConfig( - host: "db.example.com", port: 443, username: "u", password: "p", - database: "ns", ssl: SSLConfiguration(mode: .verifyIdentity), additionalFields: [:] - )) - #expect(!verified.skipTLSVerify) - } -} diff --git a/TableProTests/Plugins/SurrealDBMetadataTests.swift b/TableProTests/Plugins/SurrealDBMetadataTests.swift deleted file mode 100644 index 1355711050..0000000000 --- a/TableProTests/Plugins/SurrealDBMetadataTests.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// SurrealDBMetadataTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -@MainActor -struct SurrealDBMetadataTests { - private var snapshot: PluginMetadataSnapshot? { - PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: "SurrealDB") - } - - @Test("SurrealDB is offered before the plugin is installed") - func seeded() throws { - let snapshot = try #require(snapshot) - #expect(snapshot.displayName == "SurrealDB") - #expect(snapshot.defaultPort == 8_000) - #expect(snapshot.iconName == "surrealdb-icon") - #expect(snapshot.isDownloadable) - #expect(DatabaseType.allKnownTypes.contains(.surrealdb)) - } - - @Test("A SurrealDB namespace maps to the database level and a database to the schema level") - func hierarchy() throws { - let snapshot = try #require(snapshot) - #expect(snapshot.schema.containerEntityName == "Namespace") - #expect(snapshot.schema.schemaEntityName == "Database") - #expect(snapshot.schema.databaseGroupingStrategy == .bySchema) - #expect(snapshot.supportsDatabaseSwitching) - #expect(snapshot.capabilities.supportsSchemaSwitching) - } - - @Test("The record id is the primary key and cannot be edited") - func recordIdentity() throws { - let snapshot = try #require(snapshot) - #expect(snapshot.schema.defaultPrimaryKeyColumn == "id") - #expect(snapshot.schema.immutableColumns == ["id"]) - } - - @Test("The schema entity name defaults to Schema for every other driver") - func schemaEntityNameDefault() { - #expect(PluginManager.shared.schemaEntityName(for: .postgresql) == "Schema") - #expect(PluginManager.shared.schemaEntityName(for: .surrealdb) == "Database") - } - - @Test("The connection form and the plugin bundle declare the same fields") - func connectionFieldsMatch() throws { - let encoder = JSONEncoder() - encoder.outputFormatting = .sortedKeys - - let seeded = try encoder.encode(surrealDBConnectionFields()) - let declared = try encoder.encode(surrealDBPluginConnectionFields()) - - #expect( - seeded == declared, - "The app seed and the plugin bundle must declare identical connection fields" - ) - } - - @Test("The token field hides the built-in password, and nothing else does") - func passwordHiding() { - let fields = surrealDBPluginConnectionFields() - let token = fields.first { $0.id == "sdbToken" } - #expect(token?.hidesPassword == true) - #expect(token?.visibleWhen?.values == ["token"]) - #expect(fields.filter(\.hidesPassword).count == 1) - } - - @Test("The built-in database field is relabeled to Namespace, so there is no duplicate Database field") - func namespaceIsTheBuiltInField() throws { - // The top container is the namespace; the built-in database field carries it and is labeled from here. - #expect(try #require(snapshot).schema.containerEntityName == "Namespace") - - // The custom Database field must not sit in the Connection section, or it collides with the built-in field. - let database = try #require(surrealDBPluginConnectionFields().first { $0.id == "sdbDatabase" }) - #expect(database.section == .authentication) - #expect(database.visibleWhen?.values == ["database", "record"]) - } -} diff --git a/TableProTests/Plugins/TeradataRowEditSQLTests.swift b/TableProTests/Plugins/TeradataRowEditSQLTests.swift deleted file mode 100644 index 5286563e1e..0000000000 --- a/TableProTests/Plugins/TeradataRowEditSQLTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// TeradataRowEditSQLTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing - -struct TeradataRowEditSQLTests { - @Test - func insertLeavesDefaultMarkedColumnsOut() { - let sql = TeradataRowEditSQL.insert( - target: "\"db\".\"t\"", - columns: ["id", "status", "note"], - values: [.text("1"), .text("__DEFAULT__"), .text("it's")] - ) - #expect(sql == "INSERT INTO \"db\".\"t\" (\"id\", \"note\") VALUES ('1', 'it''s')") - } - - @Test - func insertOfOnlyDefaultsUsesDefaultValues() { - let sql = TeradataRowEditSQL.insert( - target: "\"db\".\"t\"", columns: ["id"], values: [.text("__DEFAULT__")] - ) - #expect(sql == "INSERT INTO \"db\".\"t\" DEFAULT VALUES") - } - - @Test - func literalsCoverNullTextAndBytes() { - #expect(TeradataRowEditSQL.literal(.null) == "NULL") - #expect(TeradataRowEditSQL.literal(.text("a'b")) == "'a''b'") - #expect(TeradataRowEditSQL.literal(.bytes(Data([0x0A, 0xFF]))) == "'0AFF'XB") - } -} diff --git a/TableProTests/Plugins/TeradataSSLMappingTests.swift b/TableProTests/Plugins/TeradataSSLMappingTests.swift deleted file mode 100644 index 3139730f7f..0000000000 --- a/TableProTests/Plugins/TeradataSSLMappingTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Foundation -import TableProPluginKit -import TableProTeradataCore -import Testing - -struct TeradataSSLMappingTests { - private func source(_ path: String) throws -> String { - let repository = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - return try String(contentsOf: repository.appendingPathComponent(path), encoding: .utf8) - } - - @Test("The remaining millisecond budget wins and keeps its precision") - func connectTimeoutMillisecondsWin() { - let timeout = TeradataConnectTimeout(additionalFields: [ - "connectTimeoutMilliseconds": "4321", - "connectTimeoutSeconds": "9" - ]) - - #expect(timeout.milliseconds == 4_321) - } - - @Test("Legacy seconds are accepted and invalid input keeps the driver default") - func connectTimeoutFallback() { - #expect(TeradataConnectTimeout(additionalFields: ["connectTimeoutSeconds": "12"]).milliseconds == 12_000) - #expect(TeradataConnectTimeout(additionalFields: ["connectTimeoutMilliseconds": "bad"]).milliseconds == 20_000) - #expect(TeradataConnectTimeout(additionalFields: ["connectTimeoutSeconds": "0"]).milliseconds == 1) - #expect( - TeradataConnectTimeout(additionalFields: ["connectTimeoutSeconds": String(Int64.min)]).milliseconds == 1 - ) - } - - @Test("Only a server refusal may make the optional VERSION probe non-fatal") - func versionProbeFailurePolicy() { - #expect(TeradataVersionProbe.mayIgnore(TeradataWireError.server(code: 3_523, message: "denied"))) - #expect(!TeradataVersionProbe.mayIgnore(TeradataWireError.connectionFailed("timed out"))) - #expect(!TeradataVersionProbe.mayIgnore(TeradataWireError.truncated("silent socket"))) - #expect(!TeradataVersionProbe.mayIgnore(TeradataWireError.cancelled)) - #expect(!TeradataVersionProbe.mayIgnore(CancellationError())) - } - - @Test("Cancellation closes Teradata I/O and VERSION completes before driver adoption") - func versionProbeUsesProvisionalConnection() throws { - let asyncConnection = try source("Plugins/TeradataDriverPlugin/TeradataAsyncConnection.swift") - let driver = try source("Plugins/TeradataDriverPlugin/TeradataPlugin.swift") - - #expect(asyncConnection.contains("withTaskCancellationHandler")) - #expect(asyncConnection.contains("connection.cancel()")) - let finish = try #require(driver.range(of: "try await connection.finishConnecting()")) - let adoption = try #require(driver.range(of: "self.connection = connection")) - #expect(finish.lowerBound < adoption.lowerBound) - } - - @Test("Verify CA with an empty or blank CA path is refused before any options are built") - func verifyCaWithoutCertificateIsRefused() { - for path in ["", " "] { - let error = #expect(throws: TeradataSSLConfigurationError.self) { - _ = try TeradataSSLMapping.tlsOptions(for: SSLConfiguration(mode: .verifyCa, caCertificatePath: path)) - } - #expect(error == .verifyCaNeedsCertificate) - } - } - - @Test("The refusal names the missing CA certificate") - func refusalMessageNamesTheCertificate() { - let message = TeradataSSLConfigurationError.verifyCaNeedsCertificate.pluginErrorMessage - #expect(message.contains("CA certificate")) - #expect(TeradataSSLConfigurationError.verifyCaNeedsCertificate.errorDescription == message) - } - - @Test("Verify CA with a CA path checks the chain but not the hostname") - func verifyCaWithCertificateMaps() throws { - let options = try TeradataSSLMapping.tlsOptions( - for: SSLConfiguration(mode: .verifyCa, caCertificatePath: "/certs/ca.pem") - ) - #expect(options.verifiesCertificate) - #expect(!options.verifiesHostname) - #expect(options.caCertificatePath == "/certs/ca.pem") - #expect(options.modeLabel == "VERIFY-CA") - } - - @Test("Verify Identity with no CA path is not refused") - func verifyIdentityWithoutCertificateMaps() throws { - let options = try TeradataSSLMapping.tlsOptions(for: SSLConfiguration(mode: .verifyIdentity)) - #expect(options.verifiesCertificate) - #expect(options.verifiesHostname) - #expect(options.caCertificatePath.isEmpty) - } - - @Test("Disabled and Required ignore the CA path") - func nonVerifyingModesAreNotRefused() throws { - let disabled = try TeradataSSLMapping.tlsOptions(for: SSLConfiguration(mode: .disabled)) - #expect(!disabled.enabled) - let required = try TeradataSSLMapping.tlsOptions(for: SSLConfiguration(mode: .required)) - #expect(required.enabled) - #expect(!required.verifiesCertificate) - } -} diff --git a/TableProTests/Plugins/TrinoRowEditValuesTests.swift b/TableProTests/Plugins/TrinoRowEditValuesTests.swift deleted file mode 100644 index 2febc5d4cf..0000000000 --- a/TableProTests/Plugins/TrinoRowEditValuesTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// TrinoRowEditValuesTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import TableProTrinoCore -import Testing - -struct TrinoRowEditValuesTests { - private let insert = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - - @Test - func insertLeavesDefaultMarkedColumnsOut() { - let values = TrinoRowEditValues.insertValues( - insert, - columns: ["id", "status"], - insertedRowData: [0: [.text("1"), .text("__DEFAULT__")]], - typeName: { _ in "varchar" } - ) - #expect(values.map(\.name) == ["id"]) - } - - @Test - func insertBuiltFromCellChangesLeavesDefaultMarkedColumnsOut() { - let change = PluginRowChange( - rowIndex: 0, - type: .insert, - cellChanges: [ - (columnIndex: 0, columnName: "id", oldValue: .null, newValue: .text("1")), - (columnIndex: 1, columnName: "status", oldValue: .null, newValue: .text("__DEFAULT__")) - ], - originalRow: nil - ) - let values = TrinoRowEditValues.insertValues( - change, columns: ["id", "status"], insertedRowData: [:], typeName: { _ in "varchar" } - ) - #expect(values.map(\.name) == ["id"]) - } - - @Test - func insertOfOnlyDefaultsWritesNoStatement() { - let values = TrinoRowEditValues.insertValues( - insert, - columns: ["status"], - insertedRowData: [0: [.text("__DEFAULT__")]], - typeName: { _ in "varchar" } - ) - #expect(TrinoRowEditSQL.insert(qualifiedTable: "t", columns: values) == nil) - } -} diff --git a/TableProTests/Plugins/TrinoSSLMappingTests.swift b/TableProTests/Plugins/TrinoSSLMappingTests.swift deleted file mode 100644 index 406eddbb73..0000000000 --- a/TableProTests/Plugins/TrinoSSLMappingTests.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// TrinoSSLMappingTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import TableProTrinoCore -import Testing - -struct TrinoSSLMappingTests { - private let verifyCANeedsCertificate = TrinoError.invalidConfiguration( - "Verify CA needs a CA certificate. On the connection's Network tab, choose the CA certificate " - + "that signed the server's certificate, or set SSL Mode to Verify Identity." - ) - - @Test("Verify CA with no CA certificate is refused before any connection is made") - func verifyCAWithoutCertificateIsRefused() { - for path in ["", " "] { - #expect(throws: verifyCANeedsCertificate) { - try TrinoSSLMapping.tlsOptions(for: SSLConfiguration(mode: .verifyCa, caCertificatePath: path)) - } - } - } - - @Test("Verify Identity with no CA certificate checks the system trust store") - func verifyIdentityWithoutCertificateUsesSystemTrust() throws { - let options = try TrinoSSLMapping.tlsOptions(for: SSLConfiguration(mode: .verifyIdentity)) - - #expect(options.mode == .full) - #expect(options.anchorCertificate == nil) - #expect(options.clientCredential == nil) - } - - @Test("A client certificate is never loaded with SSL off, and a key with no certificate means no certificate") - func clientCertificateIsIgnoredWhenUnused() throws { - let disabled = SSLConfiguration( - mode: .disabled, - clientCertificatePath: "/nonexistent/client.pem", - clientKeyPath: "/nonexistent/client.key" - ) - let keyOnly = SSLConfiguration(mode: .required, clientKeyPath: "/nonexistent/client.key") - - #expect(try TrinoSSLMapping.clientCredential(for: disabled) == nil) - #expect(try TrinoSSLMapping.clientCredential(for: keyOnly) == nil) - } - - @Test("A client certificate with no key is refused") - func certificateWithoutKeyIsRefused() { - let ssl = SSLConfiguration(mode: .required, clientCertificatePath: "/nonexistent/client.pem") - - #expect(throws: TrinoError.invalidConfiguration( - "A client certificate needs its client key. On the connection's Network tab, choose the " - + "client key, or clear the client certificate." - )) { - try TrinoSSLMapping.clientCredential(for: ssl) - } - } - - @Test("A client certificate file that cannot be read is named in the refusal") - func unreadableCertificateIsNamed() { - let ssl = SSLConfiguration( - mode: .verifyIdentity, - clientCertificatePath: " /nonexistent/client.pem ", - clientKeyPath: "/nonexistent/client.key" - ) - - #expect(throws: TrinoError.invalidConfiguration("The client certificate at /nonexistent/client.pem could not be read.")) { - try TrinoSSLMapping.clientCredential(for: ssl) - } - } - - @Test("A password or access token over plain HTTP is refused with what to change") - func plaintextRefusals() { - #expect(TrinoCredentialKind.password.plaintextRefusal == .invalidConfiguration( - "A password is sent only over TLS, and SSL Mode is Disabled. Set SSL Mode to Verify Identity, " - + "or clear the password if the cluster has no authentication." - )) - #expect(TrinoCredentialKind.accessToken.plaintextRefusal == .invalidConfiguration( - "An access token is sent only over TLS, and SSL Mode is Disabled. Set SSL Mode to Verify " - + "Identity, or clear the Access Token if the cluster has no authentication." - )) - #expect(TrinoError.credentialsRequireTLS(.password).connectionFailure as? TrinoError - == TrinoCredentialKind.password.plaintextRefusal) - } - - @Test("A redirect at connect reads as advice the connection form can show") - func redirectAdvice() { - let noAddress = TrinoError.redirected(statusCode: 300, location: nil, advice: .checkAddress) - let turnOnTLS = TrinoError.redirected( - statusCode: 308, - location: "https://trino.example.com/v1/statement", - advice: .turnOnTLS(port: 443) - ) - let checkAddress = TrinoError.redirected( - statusCode: 302, - location: "https://sso.example.com/auth", - advice: .checkAddress - ) - - #expect(noAddress.connectionFailure as? TrinoError == .invalidConfiguration( - "The server answered with HTTP 300 and no address. Trino never redirects, so a proxy in " - + "front of it sent this. Check the host, port and SSL Mode." - )) - #expect(turnOnTLS.connectionFailure as? TrinoError == .invalidConfiguration( - "The server redirected the request to https://trino.example.com/v1/statement, which needs HTTPS. " - + "Set Port to 443 and SSL Mode to Verify Identity." - )) - #expect(checkAddress.connectionFailure as? TrinoError == .invalidConfiguration( - "The server redirected the request to https://sso.example.com/auth. TablePro does not follow " - + "redirects, and Trino never sends one, so a proxy in front of it did. Check the host, port and SSL Mode." - )) - } - - @Test("A missing client certificate asks for one, and a rejected one says the server did not accept it") - func clientCertificateFailures() { - let required = TrinoError.tlsHandshakeFailed(kind: .clientCertificateRequired, serverMessage: "Unauthorized") - let rejected = TrinoError.tlsHandshakeFailed( - kind: .clientCertificateRejected, - serverMessage: "The network connection was lost." - ) - - guard case .clientCertRequired(let requiredMessage)? = required.connectionFailure as? SSLHandshakeError else { - Issue.record("Expected clientCertRequired, got \(required.connectionFailure)") - return - } - #expect(requiredMessage == "Unauthorized") - guard case .unknown(let rejectedMessage)? = rejected.connectionFailure as? SSLHandshakeError else { - Issue.record("Expected an unknown TLS failure, got \(rejected.connectionFailure)") - return - } - #expect(rejectedMessage == "The server did not accept the client certificate. The network connection was lost.") - } - - @Test("Other Trino errors reach the connection form unchanged") - func otherErrorsPassThrough() { - let error = TrinoError.authenticationFailed("Access Denied: Invalid credentials") - - #expect(error.connectionFailure as? TrinoError == error) - } -} diff --git a/TableProTests/Plugins/TypesenseConnectionFieldsTests.swift b/TableProTests/Plugins/TypesenseConnectionFieldsTests.swift deleted file mode 100644 index ac4985026e..0000000000 --- a/TableProTests/Plugins/TypesenseConnectionFieldsTests.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// TypesenseConnectionFieldsTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -/// The bundle manifest, read from the repository rather than from a loaded bundle. -/// -/// `PluginManifest` reads `TableProProvidesDatabaseTypeIds` from a plugin's Info.plist without -/// loading its code, and `isDriverInstalled` answers from that. Without the key the driver is -/// eagerly loaded (which the app logs as "declared no TableProProvides* capability keys ...; -/// eager loading will block startup") and its type never reaches `lazyDriverURLs`, so picking -/// Typesense in the connection form offers to download a plugin that is already installed. -struct TypesensePluginManifestTests { - private static let infoPlist: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - .appendingPathComponent("Plugins/TypesenseDriverPlugin/Info.plist") - }() - - private func manifest() throws -> [String: Any] { - let data = try Data(contentsOf: Self.infoPlist) - let plist = try PropertyListSerialization.propertyList(from: data, format: nil) - return (plist as? [String: Any]) ?? [:] - } - - @Test("The bundle declares the database type it provides, so the app can register it lazily") - func declaresProvidedDatabaseTypeIds() throws { - let plist = try manifest() - let ids = try #require(plist["TableProProvidesDatabaseTypeIds"] as? [String]) - #expect(ids == ["Typesense"]) - } - - /// The id in the manifest is what `isDriverInstalled` looks up, so it has to be the same - /// string the registry snapshot and `DatabaseType.typesense` use. - @Test("The declared id matches the registered database type") - @MainActor - func declaredIdMatchesTheRegisteredType() throws { - let plist = try manifest() - let ids = try #require(plist["TableProProvidesDatabaseTypeIds"] as? [String]) - #expect(ids.contains(DatabaseType.typesense.rawValue)) - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - #expect(defaults.contains { $0.typeId == DatabaseType.typesense.rawValue }) - } - - /// Read from `currentPluginKitVersion` rather than written out, so the next ABI bump moves - /// this with it. Hardcoding the number left this suite asserting 21 after the plists went to - /// 22 and failed the branch that bumped them. - @Test("The bundle pins the PluginKit ABI and the release it ships in") - func declaresVersionGates() throws { - let plist = try manifest() - #expect(plist["TableProPluginKitVersion"] as? Int == PluginManager.currentPluginKitVersion) - #expect(plist["TableProMinAppVersion"] as? String == "0.73.0") - } -} - -struct TypesenseConnectionFieldsTests { - private func typesenseFields() throws -> [ConnectionField] { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - let entry = try #require(defaults.first { $0.typeId == "Typesense" }) - return entry.snapshot.connection.additionalConnectionFields - } - - @Test("Registry declares the API key and the TLS toggle") - func registryDeclaresAllFields() throws { - let fields = try typesenseFields() - #expect(fields.map(\.id) == ["typesenseApiKey", "typesenseSkipTLSVerify"]) - } - - @Test("The API key is a required secure field in the authentication section") - func apiKeyIsRequiredAndSecure() throws { - let fields = try typesenseFields() - let apiKey = try #require(fields.first { $0.id == "typesenseApiKey" }) - #expect(apiKey.isSecure) - #expect(apiKey.isRequired) - #expect(apiKey.section == .authentication) - #expect(apiKey.visibleWhen == nil) - } - - /// Typesense has no user accounts, so leaving Username and Password on the form would ask for - /// two credentials the server never reads. - @Test("The API key replaces both built-in credential rows") - func apiKeyReplacesUsernameAndPassword() throws { - let fields = try typesenseFields() - #expect(fields.hidesPassword(forValues: [:])) - #expect(fields.hidesUsername(forValues: [:])) - } - - @Test("A secure connection field is stored in the Keychain, not the connection file") - @MainActor - func apiKeyIsTreatedAsSecureStorage() throws { - let secureIds = PluginManager.shared.secureConnectionFieldIds(for: DatabaseType.typesense) - #expect(secureIds.contains("typesenseApiKey")) - #expect(!secureIds.contains("typesenseSkipTLSVerify")) - } - - @Test("The TLS toggle sits in the advanced section and defaults to off") - func tlsToggleDefaultsOff() throws { - let fields = try typesenseFields() - let toggle = try #require(fields.first { $0.id == "typesenseSkipTLSVerify" }) - #expect(toggle.section == .advanced) - #expect(toggle.defaultValue == "false") - if case .toggle = toggle.fieldType {} else { - Issue.record("Expected a toggle field type") - } - } - - @Test("The snapshot describes a container-only engine on the Typesense default port") - func snapshotDescribesTheEngine() throws { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - let entry = try #require(defaults.first { $0.typeId == "Typesense" }) - let snapshot = entry.snapshot - #expect(snapshot.defaultPort == 8_108) - #expect(snapshot.connection.hidesBuiltInDatabase) - #expect(!snapshot.supportsDatabaseSwitching) - #expect(snapshot.schema.tableEntityName == "Collections") - #expect(snapshot.schema.defaultPrimaryKeyColumn == "id") - #expect(snapshot.schema.immutableColumns == ["id"]) - } -} diff --git a/TableProTests/Plugins/TypesenseDriverTests.swift b/TableProTests/Plugins/TypesenseDriverTests.swift deleted file mode 100644 index c881e28774..0000000000 --- a/TableProTests/Plugins/TypesenseDriverTests.swift +++ /dev/null @@ -1,1063 +0,0 @@ -// -// TypesenseDriverTests.swift -// TableProTests -// -// Tests for Typesense plugin pure logic (compiled from TypesenseDriverPlugin sources). -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -private func field( - _ name: String, - _ type: String, - sortable: Bool = false, - optional: Bool = false, - facet: Bool = false -) -> TypesenseField { - TypesenseField(name: name, type: type, isSortable: sortable, isOptional: optional, isFacet: facet) -} - -private let booksFields: [String: TypesenseField] = [ - "id": TypesenseSchema.idField, - "title": field("title", "string"), - "authors": field("authors", "string[]", facet: true), - "year": field("year", "int32", sortable: true), - "rating": field("rating", "float", sortable: true), - "inprint": field("inprint", "bool", sortable: true), - "tag": field("tag", "string", sortable: true, optional: true), -] - -struct TypesenseConsoleParserTests { - @Test("Parses method, path, and JSON body") - func parsesFullRequest() { - let input = "POST /multi_search\n{\n \"searches\": []\n}" - let request = TypesenseConsoleParser.parse(input) - #expect(request?.method == "POST") - #expect(request?.path == "/multi_search") - #expect(request?.body?.contains("searches") == true) - } - - @Test("Normalizes a path without a leading slash") - func normalizesPath() { - let request = TypesenseConsoleParser.parse("GET collections/books/documents/search?q=*") - #expect(request?.method == "GET") - #expect(request?.path == "/collections/books/documents/search?q=*") - #expect(request?.body == nil) - } - - @Test("Lowercase method is normalized and PATCH is supported") - func normalizesMethods() { - #expect(TypesenseConsoleParser.parse("get /collections")?.method == "GET") - #expect(TypesenseConsoleParser.parse("patch /collections/a/documents/1\n{}")?.method == "PATCH") - } - - @Test("Rejects an unsupported method and empty input") - func rejectsUnknownMethod() { - #expect(TypesenseConsoleParser.parse("FETCH /collections") == nil) - #expect(TypesenseConsoleParser.parse("") == nil) - } - - /// `DELETE FROM books` shares its first word with an HTTP verb. Measured before the guard: the - /// parser read `FROM books` as a path and the driver sent a real `DELETE` to `/FROM books`. - @Test("SQL that starts with an HTTP verb is not read as a request") - func rejectsSQLWearingAVerb() { - #expect(TypesenseConsoleParser.parse("DELETE FROM books") == nil) - #expect(TypesenseConsoleParser.parse("DELETE FROM books WHERE id = 1") == nil) - #expect(TypesenseConsoleParser.parse("GET rows FROM books") == nil) - } - - /// A query string may hold a space, so the guard only looks at the path. - @Test("A space inside the query string is still accepted") - func keepsSpacesInQueryStrings() { - let request = TypesenseConsoleParser.parse("GET /collections/books/documents/search?q=hello world") - #expect(request?.path == "/collections/books/documents/search?q=hello world") - } -} - -struct TypesenseSchemaTests { - private let response: [String: Any] = [ - "name": "books", - "num_documents": 42, - "default_sorting_field": "year", - "fields": [ - ["name": ".*", "type": "auto", "sort": false, "optional": true], - ["name": "title", "type": "string", "sort": false, "optional": false], - ["name": "year", "type": "int32", "sort": true, "optional": false], - ["name": "meta", "type": "object", "sort": false, "optional": true], - ["name": "meta.pages", "type": "int64", "sort": true, "optional": true], - ["name": "notes", "type": "object", "sort": false, "optional": true], - ], - ] - - @Test("Decodes name, document count and default sorting field") - func decodesCollection() throws { - let collection = try #require(TypesenseSchema.collection(from: response)) - #expect(collection.name == "books") - #expect(collection.numDocuments == 42) - #expect(collection.defaultSortingField == "year") - } - - @Test("The auto-schema wildcard entry is never a column") - func dropsWildcardField() throws { - let collection = try #require(TypesenseSchema.collection(from: response)) - #expect(!collection.presentedFields.contains { $0.name == TypesenseSchema.wildcardField }) - } - - @Test("An object parent gives way to its dotted leaves, and survives when it has none") - func dropsObjectParentsThatHaveLeaves() throws { - let collection = try #require(TypesenseSchema.collection(from: response)) - let names = collection.presentedFields.map(\.name) - #expect(!names.contains("meta")) - #expect(names.contains("meta.pages")) - #expect(names.contains("notes")) - } - - @Test("id leads the columns and is never a schema field") - func idLeadsTheColumns() throws { - let collection = try #require(TypesenseSchema.collection(from: response)) - #expect(collection.columns.first == "id") - #expect(!collection.presentedFields.contains { $0.name == "id" }) - #expect(collection.fieldsByName["id"]?.isSortable == false) - } - - @Test("An empty default_sorting_field decodes as absent") - func emptyDefaultSortDecodesAsNil() throws { - let collection = try #require( - TypesenseSchema.collection(from: ["name": "c", "default_sorting_field": "", "fields": []]) - ) - #expect(collection.defaultSortingField == nil) - } - - @Test("Nested documents flatten onto dotted columns") - func flattensNestedDocuments() { - let documents: [[String: Any]] = [ - ["id": "a1", "title": "Dune", "meta": ["pages": 412]], - ] - let rows = TypesenseSchema.rows(for: documents, columns: ["id", "title", "meta.pages"], length: .display) - #expect(rows == [[.text("a1"), .text("Dune"), .text("412")]]) - } - - @Test("A missing value is null, and arrays and objects render as JSON") - func rendersMissingAndCompositeValues() { - let documents: [[String: Any]] = [["id": "a1", "authors": ["Tolkien", "Lewis"]]] - let rows = TypesenseSchema.rows(for: documents, columns: ["id", "authors", "title"], length: .display) - #expect(rows[0][0] == .text("a1")) - #expect(rows[0][1] == .text("[\"Tolkien\",\"Lewis\"]")) - #expect(rows[0][2] == .null) - } - - /// Typesense reports an `object[]` field as its dotted leaves and returns the document with - /// the array intact, so a column named `variants.sku` has to read across the array's elements. - @Test("A dotted column over an object array collects a value from each element") - func readsAcrossObjectArrays() { - let documents: [[String: Any]] = [[ - "id": "1", - "variants": [["sku": "A1", "qty": 3], ["sku": "B2", "qty": 5]], - ]] - let rows = TypesenseSchema.rows( - for: documents, columns: ["id", "variants.sku", "variants.qty"], length: .display - ) - #expect(rows[0][0] == .text("1")) - #expect(rows[0][1] == .text("[\"A1\",\"B2\"]")) - #expect(rows[0][2] == .text("[3,5]")) - } - - @Test("An element missing the leaf keeps its place, and no element means null") - func objectArrayLeavesTolerateGaps() { - let documents: [[String: Any]] = [ - ["variants": [["sku": "A1"], ["qty": 9]]], - ["variants": [["qty": 9]]], - ["variants": []], - ] - let rows = TypesenseSchema.rows(for: documents, columns: ["variants.sku"], length: .display) - #expect(rows[0][0] == .text("[\"A1\",null]")) - #expect(rows[1][0] == .null) - #expect(rows[2][0] == .null) - } - - /// Two leaves of one array are read side by side, so a gap in either has to hold its position - /// or the second sku reads as the first variant's. - @Test("Sibling leaves of one object array stay the same length") - func objectArrayLeavesStayAligned() { - let documents: [[String: Any]] = [["variants": [["qty": 3], ["sku": "B2", "qty": 5]]]] - let rows = TypesenseSchema.rows(for: documents, columns: ["variants.sku", "variants.qty"], length: .display) - #expect(rows[0][0] == .text("[null,\"B2\"]")) - #expect(rows[0][1] == .text("[3,5]")) - } - - @Test("A deeper path still walks through an array on the way") - func readsThroughNestedObjectArrays() { - let documents: [[String: Any]] = [[ - "variants": [["price": ["eur": 10]], ["price": ["eur": 20]]], - ]] - let rows = TypesenseSchema.rows(for: documents, columns: ["variants.price.eur"], length: .display) - #expect(rows[0][0] == .text("[10,20]")) - } - - @Test("The schema drops an object array parent that has dotted leaves") - func dropsObjectArrayParents() { - let fields = [ - TypesenseField(name: "variants", type: "object[]", isSortable: false, isOptional: true, isFacet: false), - TypesenseField(name: "variants.sku", type: "string[]", isSortable: false, isOptional: true, isFacet: false), - ] - #expect(TypesenseSchema.presentedFields(fields).map(\.name) == ["variants.sku"]) - } - - @Test("An export row keeps arrays and objects longer than the grid's cap whole") - func exportRowKeepsLongStructuresWhole() throws { - let embedding = (0..<1_536).map { Double($0) / 1_024 } - let variants = (0..<4).map { _ in ["vector": embedding] } - let documents: [[String: Any]] = [["id": "1", "embedding": embedding, "variants": variants]] - let row = try #require(TypesenseSchema.rows( - for: documents, - columns: ["embedding", "variants", "variants.vector"], - length: .whole - ).first) - - #expect(try parsedJSON(row[0]) as? [Double] == embedding) - #expect(try parsedJSON(row[1]) as? [[String: [Double]]] == variants) - #expect(try parsedJSON(row[2]) as? [[Double]] == variants.map { _ in embedding }) - } - - @Test("A grid row still cuts an array longer than the cap") - func gridRowCutsLongStructures() throws { - let embedding = (0..<1_536).map { Double($0) / 1_024 } - let row = try #require(TypesenseSchema.rows( - for: [["embedding": embedding]], columns: ["embedding"], length: .display - ).first) - let text = try #require(row[0].asText) - - #expect(text.hasSuffix("...")) - #expect((text as NSString).length == 10_003) - } - - private func parsedJSON(_ cell: PluginCellValue) throws -> Any { - let text = try #require(cell.asText) - return try JSONSerialization.jsonObject(with: Data(text.utf8)) - } - - @Test("Booleans render as true and false, not 1 and 0") - func rendersBooleans() { - let rows = TypesenseSchema.rows(for: [["inprint": true]], columns: ["inprint"], length: .display) - #expect(rows == [[.text("true")]]) - } - - @Test("Union columns put id first and sort the rest") - func unionColumnsLeadWithId() { - let columns = TypesenseSchema.unionColumns(fromDocuments: [ - ["zeta": 1, "id": "x"], - ["alpha": 2], - ]) - #expect(columns == ["id", "alpha", "zeta"]) - } -} - -struct TypesenseFilterBuilderTests { - private func clause(_ column: String, _ op: String, _ value: String, second: String? = nil) throws -> String { - try TypesenseFilterBuilder.clause( - for: TypesenseFilterSpec(column: column, op: op, value: value, secondValue: second), - fields: booksFields - ) - } - - @Test("A string value is backticked and a numeric one is not") - func quotesByFieldType() throws { - #expect(try clause("title", "=", "The Hobbit") == "title:=`The Hobbit`") - #expect(try clause("year", "=", "1965") == "year:=1965") - #expect(try clause("inprint", "=", "TRUE") == "inprint:=true") - #expect(try clause("id", "=", "a1") == "id:=`a1`") - } - - @Test("Equality, negation, membership and prefix take their Typesense shapes") - func buildsOperatorShapes() throws { - #expect(try clause("tag", "!=", "cyber") == "tag:!=`cyber`") - #expect(try clause("tag", "IN", "cyber, scifi") == "tag:=[`cyber`,`scifi`]") - #expect(try clause("tag", "NOT IN", "cyber, scifi") == "tag:!=[`cyber`,`scifi`]") - #expect(try clause("title", "CONTAINS", "hobbit") == "title:`hobbit`") - #expect(try clause("title", "NOT CONTAINS", "hobbit") == "title:!`hobbit`") - #expect(try clause("title", "STARTS WITH", "Neuro") == "title:`Neuro`*") - } - - @Test("Comparisons are numeric and BETWEEN reads its upper bound off the filter") - func buildsNumericComparisons() throws { - #expect(try clause("year", ">", "1900") == "year:>1900") - #expect(try clause("year", ">=", "1900") == "year:>=1900") - #expect(try clause("rating", "<", "4.5") == "rating:<4.5") - #expect(try clause("year", "BETWEEN", "1937,1965", second: "1965") == "year:[1937..1965]") - } - - @Test("A filter-bar BETWEEN takes its lower bound off the value the app joins with the upper bound") - func buildsRangeFromAppEncoding() throws { - let filter = TableFilter( - columnName: "year", filterOperator: .between, value: "1937", secondValue: "1965" - ).asPluginQueryFilter - #expect(filter.value == "1937,1965") - - let clause = try TypesenseFilterBuilder.clause(for: TypesenseFilterSpec(filter), fields: booksFields) - #expect(clause == "year:[1937..1965]") - } - - @Test("A filter-bar BETWEEN survives the search tag round-trip") - func buildsRangeAfterSearchTagRoundTrip() throws { - let filter = TableFilter( - columnName: "rating", filterOperator: .between, value: "3.5", secondValue: "4.5" - ).asPluginQueryFilter - let tag = TypesenseQueryBuilder.encodeSearch( - collection: "books", offset: 0, limit: 10, sorts: [], - filters: TypesenseFilterBuilder.specs(from: [filter]), logicMode: "AND" - ) - let parsed = try #require(TypesenseQueryBuilder.parseSearch(tag)) - let expression = try TypesenseFilterBuilder.expression( - filters: parsed.filters, logicMode: parsed.logicMode, fields: booksFields - ) - #expect(expression == "rating:[3.5..4.5]") - } - - @Test("A comparison on a string field is refused rather than silently matching nothing") - func refusesStringComparison() { - #expect(throws: TypesenseFilterError.comparisonNeedsNumber(column: "title", op: ">")) { - try clause("title", ">", "D") - } - } - - @Test("A comparison against non-numeric text is refused") - func refusesNonNumericComparison() { - #expect(throws: TypesenseFilterError.comparisonNeedsNumber(column: "year", op: ">")) { - try clause("year", ">", "soon") - } - } - - @Test("BETWEEN without an upper bound is refused") - func refusesBetweenWithoutUpperBound() { - #expect(throws: TypesenseFilterError.missingUpperBound(column: "year")) { - try clause("year", "BETWEEN", "1937") - } - } - - @Test("Operators Typesense cannot express are refused by name") - func refusesUnsupportedOperators() { - for op in ["IS NULL", "IS NOT NULL", "IS EMPTY", "IS NOT EMPTY", "REGEX", "ENDS WITH"] { - #expect(throws: TypesenseFilterError.unsupportedOperator(op), "\(op)") { - try clause("title", op, "x") - } - } - } - - /// The reason the guard exists: Typesense has no escape for a backtick, so a value carrying one - /// closes its literal and the rest of the value is parsed as filter syntax. Measured on 29.0, - /// this exact payload matched every document in the collection. - @Test("A backtick in a value is refused instead of injecting filter syntax") - func refusesBacktickInjection() { - #expect(throws: TypesenseFilterError.backtickInValue(column: "tag")) { - try clause("tag", "=", "odd` || year:>0") - } - #expect(throws: TypesenseFilterError.backtickInValue(column: "tag")) { - try clause("tag", "IN", "safe, odd` || year:>0") - } - #expect(throws: TypesenseFilterError.backtickInValue(column: "title")) { - try clause("title", "CONTAINS", "a`b") - } - } - - @Test("A backtick in a numeric field's value never reaches a literal") - func numericFieldRejectsInjectionThroughComparison() { - #expect(throws: TypesenseFilterError.comparisonNeedsNumber(column: "year", op: ">")) { - try clause("year", ">", "1900` || year:>0") - } - } - - /// A numeric or boolean value is sent unquoted, so it carries no delimiter of its own: without - /// a parse check, `year:=1900 || year:>0` reaches the server as filter syntax. The field type - /// that picks the unquoted branch is declared by the server, so the check cannot be skipped. - @Test("An unquoted numeric value must parse as a number, on every operator that sends one") - func numericLiteralsAreValidated() { - let injection = "1900 || year:>0" - #expect(throws: TypesenseFilterError.notANumber(column: "year", value: injection)) { - try clause("year", "=", injection) - } - #expect(throws: TypesenseFilterError.notANumber(column: "year", value: injection)) { - try clause("year", "!=", injection) - } - #expect(throws: TypesenseFilterError.notANumber(column: "year", value: injection)) { - try clause("year", "IN", "1,\(injection)") - } - #expect(throws: TypesenseFilterError.notANumber(column: "year", value: injection)) { - try clause("year", "NOT IN", "1,\(injection)") - } - } - - @Test("An unquoted boolean value must be true or false") - func booleanLiteralsAreValidated() throws { - #expect(throws: TypesenseFilterError.notABoolean(column: "inprint", value: "true || year:>0")) { - try clause("inprint", "=", "true || year:>0") - } - #expect(try clause("inprint", "=", "FALSE") == "inprint:=false") - } - - @Test("A well-formed number or boolean still goes over unquoted") - func validUnquotedLiteralsSurvive() throws { - #expect(try clause("year", "=", "1965") == "year:=1965") - #expect(try clause("rating", "=", "4.5") == "rating:=4.5") - #expect(try clause("year", "=", "-3") == "year:=-3") - #expect(try clause("year", "IN", "1937, 1965") == "year:=[1937,1965]") - #expect(try clause("inprint", "!=", "true") == "inprint:!=true") - } - - @Test("An unknown field is quoted as text so it cannot carry an injection either") - func quotesUnknownFieldsAsText() throws { - let clause = try TypesenseFilterBuilder.clause( - for: TypesenseFilterSpec(column: "learned", op: "=", value: "value"), - fields: booksFields - ) - #expect(clause == "learned:=`value`") - } - - /// Typesense matches strings without regard to case and offers no option to change that, so - /// the driver must not build a different clause when the filter row asks for one. - @Test("The case setting never changes the clause, because Typesense cannot honour it") - func caseSettingDoesNotChangeTheClause() throws { - for op in ["=", "!=", "CONTAINS", "NOT CONTAINS", "STARTS WITH", "IN"] { - let sensitive = PluginQueryFilter(column: "tag", op: op, value: "Cyber", isCaseSensitive: true) - let insensitive = PluginQueryFilter(column: "tag", op: op, value: "Cyber", isCaseSensitive: false) - let first = try TypesenseFilterBuilder.clause( - for: TypesenseFilterSpec(sensitive), fields: booksFields - ) - let second = try TypesenseFilterBuilder.clause( - for: TypesenseFilterSpec(insensitive), fields: booksFields - ) - #expect(first == second, "\(op)") - } - } - - @Test("Several filters are parenthesised and joined by the logic mode") - func joinsClauses() throws { - let filters = [ - TypesenseFilterSpec(column: "year", op: ">", value: "1900"), - TypesenseFilterSpec(column: "tag", op: "=", value: "cyber"), - ] - let and = try TypesenseFilterBuilder.expression( - filters: filters, logicMode: "AND", fields: booksFields - ) - let or = try TypesenseFilterBuilder.expression( - filters: filters, logicMode: "OR", fields: booksFields - ) - #expect(and == "(year:>1900) && (tag:=`cyber`)") - #expect(or == "(year:>1900) || (tag:=`cyber`)") - } - - @Test("A single filter is not parenthesised, and no filters means no filter_by") - func singleAndEmptyExpressions() throws { - let single = try TypesenseFilterBuilder.expression( - filters: [TypesenseFilterSpec(column: "year", op: "=", value: "1965")], - logicMode: "AND", - fields: booksFields - ) - #expect(single == "year:=1965") - #expect(try TypesenseFilterBuilder.expression(filters: [], logicMode: "AND", fields: [:]) == nil) - } - - @Test("The raw filter row is passed through verbatim, and an empty one is dropped") - func rawColumnPassesThrough() throws { - let raw = try TypesenseFilterBuilder.expression( - filters: [TypesenseFilterSpec(column: TypesenseFilterBuilder.rawColumn, op: "=", value: "year:>1900")], - logicMode: "AND", - fields: booksFields - ) - #expect(raw == "year:>1900") - - let blank = try TypesenseFilterBuilder.expression( - filters: [TypesenseFilterSpec(column: TypesenseFilterBuilder.rawColumn, op: "=", value: " ")], - logicMode: "AND", - fields: booksFields - ) - #expect(blank == nil) - } -} - -struct TypesenseQueryBuilderTests { - @Test("A tagged search round-trips through its encoding") - func taggedSearchRoundTrips() throws { - let query = TypesenseQueryBuilder.encodeSearch( - collection: "books", - offset: 250, - limit: 1_000, - sorts: [TypesenseSortSpec(column: "year", ascending: false)], - filters: [TypesenseFilterSpec(column: "tag", op: "=", value: "cyber")], - logicMode: "OR" - ) - #expect(TypesenseQueryBuilder.isTaggedQuery(query)) - let parsed = try #require(TypesenseQueryBuilder.parseSearch(query)) - #expect(parsed.collection == "books") - #expect(parsed.offset == 250) - #expect(parsed.limit == 1_000) - #expect(parsed.sorts == [TypesenseSortSpec(column: "year", ascending: false)]) - #expect(parsed.filters == [TypesenseFilterSpec(column: "tag", op: "=", value: "cyber")]) - #expect(parsed.logicMode == "OR") - } - - @Test("A collection name with a colon survives the encoding") - func encodesAwkwardCollectionNames() throws { - let query = TypesenseQueryBuilder.encodeSearch( - collection: "a:b:c", offset: 0, limit: 10, sorts: [], filters: [], logicMode: "AND" - ) - let parsed = try #require(TypesenseQueryBuilder.parseSearch(query)) - #expect(parsed.collection == "a:b:c") - } - - @Test("An appended ORDER BY is split off and parsed") - func extractsAppendedOrderBy() { - let base = TypesenseQueryBuilder.encodeSearch( - collection: "books", offset: 0, limit: 10, sorts: [], filters: [], logicMode: "AND" - ) - let (stripped, sorts) = TypesenseQueryBuilder.extractOrderBy("\(base) ORDER BY \"year\" DESC, tag ASC") - #expect(stripped == base) - #expect(sorts == [ - TypesenseSortSpec(column: "year", ascending: false), - TypesenseSortSpec(column: "tag", ascending: true), - ]) - } - - /// Sorting a field whose schema says `sort: false` is a 400 that fails the whole search. - @Test("Only sortable fields reach sort_by, and id never does") - func dropsUnsortableColumns() { - let sorts = [ - TypesenseSortSpec(column: "title", ascending: true), - TypesenseSortSpec(column: "id", ascending: true), - TypesenseSortSpec(column: "year", ascending: false), - ] - #expect(TypesenseQueryBuilder.sortBy(sorts, fields: booksFields) == "year:desc") - } - - @Test("No sortable column means no sort_by at all") - func noSortableColumnsMeansNoClause() { - let sorts = [TypesenseSortSpec(column: "title", ascending: true)] - #expect(TypesenseQueryBuilder.sortBy(sorts, fields: booksFields) == nil) - } - - @Test("sort_by stops at three fields") - func capsSortFieldsAtThree() { - let sorts = [ - TypesenseSortSpec(column: "year", ascending: true), - TypesenseSortSpec(column: "rating", ascending: false), - TypesenseSortSpec(column: "tag", ascending: true), - TypesenseSortSpec(column: "inprint", ascending: false), - ] - #expect(TypesenseQueryBuilder.sortBy(sorts, fields: booksFields) == "year:asc,rating:desc,tag:asc") - } - - @Test("A page larger than 250 rows is split into requests Typesense accepts") - func splitsPagesAtTheHitCeiling() { - #expect(TypesenseQueryBuilder.chunks(offset: 0, limit: 1_000) == [ - TypesenseSearchChunk(offset: 0, limit: 250), - TypesenseSearchChunk(offset: 250, limit: 250), - TypesenseSearchChunk(offset: 500, limit: 250), - TypesenseSearchChunk(offset: 750, limit: 250), - ]) - } - - @Test("A page inside the ceiling is one request, and the offset is carried") - func smallPagesAreOneRequest() { - #expect(TypesenseQueryBuilder.chunks(offset: 40, limit: 100) == [ - TypesenseSearchChunk(offset: 40, limit: 100), - ]) - } - - @Test("A remainder chunk asks only for what is left") - func lastChunkAsksForTheRemainder() { - let chunks = TypesenseQueryBuilder.chunks(offset: 0, limit: 600) - #expect(chunks.count == 3) - #expect(chunks.last == TypesenseSearchChunk(offset: 500, limit: 100)) - } - - @Test("A non-positive limit asks for nothing") - func nonPositiveLimitsAskForNothing() { - #expect(TypesenseQueryBuilder.chunks(offset: 0, limit: 0).isEmpty) - #expect(TypesenseQueryBuilder.chunks(offset: 0, limit: -5).isEmpty) - } - - /// The 51st search in one multi_search answers 400 for the whole batch. - @Test("Searches are batched at the multi_search ceiling") - func batchesAtTheMultiSearchCeiling() { - let chunks = TypesenseQueryBuilder.chunks(offset: 0, limit: 100_000) - let batches = TypesenseQueryBuilder.batches(chunks) - #expect(chunks.count == 400) - #expect(batches.count == 8) - #expect(batches.allSatisfy { $0.count <= TypesenseQueryBuilder.maxSearchesPerRequest }) - #expect(batches.flatMap { $0 } == chunks) - } - - @Test("A search body names its collection and carries only the parameters it has") - func buildsSearchBodies() { - let body = TypesenseQueryBuilder.searchBody( - collection: "books", - chunk: TypesenseSearchChunk(offset: 10, limit: 25), - filterBy: "year:>1900", - sortBy: nil - ) - #expect(body["collection"] as? String == "books") - #expect(body["q"] as? String == "*") - #expect(body["offset"] as? Int == 10) - #expect(body["limit"] as? Int == 25) - #expect(body["filter_by"] as? String == "year:>1900") - #expect(body["sort_by"] == nil) - } - - @Test("A count body asks for no hits at all") - func buildsCountBodies() { - let body = TypesenseQueryBuilder.countBody(collection: "books", filterBy: nil) - #expect(body["per_page"] as? Int == 0) - #expect(body["filter_by"] == nil) - } -} - -struct TypesenseOperationsTests { - /// Without these the app composes its own SQL and sends it to `execute`. Measured before the - /// fix: exporting sent `SELECT * FROM c`, dropping sent `DROP TABLE c` and truncating sent - /// `DELETE FROM c`; the first two were refused by the console parser and the third reached the - /// server as a real HTTP `DELETE /FROM c`. - @Test("An export query round-trips the collection name") - func exportRoundTrips() throws { - let query = TypesenseOperations.encodeExport(collection: "books") - #expect(query.hasPrefix(TypesenseOperations.exportTag)) - #expect(TypesenseOperations.decodeExport(query) == "books") - #expect(TypesenseOperations.decodeExport("GET /collections") == nil) - #expect(TypesenseOperations.decodeExport(TypesenseOperations.exportTag) == nil) - } - - @Test("A collection name with a slash stays one segment in the export path") - func exportPathEncodesTheName() { - #expect(TypesenseOperations.exportPath(collection: "a/b") == "/collections/a%2Fb/documents/export") - } - - @Test("Dropping a collection is a DELETE on the collection itself") - func dropsACollection() throws { - let request = try #require(TypesenseOperations.dropCollection(named: "books", objectType: "TABLE")) - #expect(request.method == "DELETE") - #expect(request.path == "/collections/books") - #expect(request.body == nil) - } - - /// Typesense has no databases, views or schemas, so there is no request to send for one and - /// the app keeps whatever fallback it had. - @Test("Only a collection can be dropped") - func refusesToDropOtherObjects() { - #expect(TypesenseOperations.dropCollection(named: "x", objectType: "VIEW") == nil) - #expect(TypesenseOperations.dropCollection(named: "x", objectType: "DATABASE") == nil) - #expect(TypesenseOperations.dropCollection(named: "x", objectType: "COLLECTION") != nil) - } - - /// `truncate=true` keeps the schema. Deleting by a match-everything filter would take the - /// learned fields with it. - @Test("Truncating empties the documents and keeps the collection") - func truncatesWithTheTruncateFlag() { - let request = TypesenseOperations.truncateCollection(named: "books") - #expect(request.method == "DELETE") - #expect(request.path == "/collections/books/documents?truncate=true") - } - - @Test("Compact maps to its endpoint, and an operation from another engine is refused") - func mapsMaintenanceOperations() throws { - let request = try #require(TypesenseOperations.maintenance("Compact Database")) - #expect(request.method == "POST") - #expect(request.path == "/operations/db/compact") - #expect(TypesenseOperations.maintenance("VACUUM") == nil) - #expect(TypesenseOperations.maintenance("compact database") != nil) - } -} - -struct TypesenseApiKeysTests { - private let payload: [String: Any] = [ - "keys": [ - [ - "id": 0, - "description": "Search-only key", - "actions": ["documents:search"], - "collections": ["books"], - "value_prefix": "JrJX", - ], - ["id": 7, "description": "", "actions": ["*"], "collections": ["*"]], - ], - ] - - @Test("Keys decode, and a key with no description still gets a name") - func decodesKeys() throws { - let keys = TypesenseApiKeys.keys(from: payload) - #expect(keys.count == 2) - #expect(keys[0].displayName == "Search-only key (#0)") - #expect(keys[1].displayName == "Key #7") - #expect(keys[0].valuePrefix == "JrJX") - } - - /// `PluginPrincipalRef` carries only a name, so the key id has to survive inside it. - @Test("The key id survives the round trip through the principal name") - func idRoundTripsThroughTheName() { - let keys = TypesenseApiKeys.keys(from: payload) - for key in keys { - #expect(TypesenseApiKeys.id(fromDisplayName: key.displayName) == key.id) - } - #expect(TypesenseApiKeys.id(fromDisplayName: "nonsense") == nil) - } - - @Test("A key's actions and collections become one grant per pair") - func projectsGrants() { - let keys = TypesenseApiKeys.keys(from: payload) - let scoped = TypesenseApiKeys.grants(for: keys[0], database: "default") - #expect(scoped.count == 1) - #expect(scoped[0].privilege == "documents:search") - #expect(scoped[0].scope == .table(database: "default", schema: nil, table: "books")) - - let wildcard = TypesenseApiKeys.grants(for: keys[1], database: "default") - #expect(wildcard.map(\.scope) == [.server]) - } - - @Test("A key's comment keeps its prefix apart from a comma separated collection list") - func commentSeparatesPrefixFromCollections() throws { - let prefix = String(format: String(localized: "Starts with %@"), "JrJX") - let scoped = TypesenseApiKey( - id: 1, description: "", actions: [], collections: ["books", "authors"], valuePrefix: "JrJX", expiresAt: nil - ) - #expect(TypesenseApiKeys.comment(for: scoped) == "\(prefix); books, authors") - - let keys = TypesenseApiKeys.keys(from: payload) - #expect(TypesenseApiKeys.comment(for: keys[1]) == String(localized: "All collections")) - } - - @Test("A create request carries the description, actions and collections") - func buildsACreateRequest() throws { - let request = try #require(TypesenseApiKeys.createRequest( - description: "Reader", actions: ["documents:search"], collections: ["books"] - )) - #expect(request.method == "POST") - #expect(request.path == "/keys") - #expect(request.body == #"{"actions":["documents:search"],"collections":["books"],"description":"Reader"}"#) - } - - @Test("An empty collection list means every collection") - func defaultsToEveryCollection() throws { - let request = try #require(TypesenseApiKeys.createRequest( - description: "R", actions: [], collections: [] - )) - #expect(request.body?.contains(#""collections":["*"]"#) == true) - } - - @Test("Deleting a key addresses it by id") - func buildsADeleteRequest() { - let request = TypesenseApiKeys.deleteRequest(id: 7) - #expect(request.method == "DELETE") - #expect(request.path == "/keys/7") - } -} - -struct TypesensePathEncodingTests { - @Test("A slash never survives into the path, so a segment stays one segment") - func encodesSlashes() { - #expect(TypesensePathEncoding.segment("a/b") == "a%2Fb") - } - - @Test("A dot is encoded, so `.` and `..` cannot act as path segments") - func encodesDots() { - #expect(TypesensePathEncoding.segment(".") == "%2E") - #expect(TypesensePathEncoding.segment("..") == "%2E%2E") - #expect(TypesensePathEncoding.segment("v1.2") == "v1%2E2") - } - - @Test("A query or fragment marker is encoded rather than changing the request") - func encodesQueryAndFragmentMarkers() { - #expect(TypesensePathEncoding.segment("a?truncate=true") == "a%3Ftruncate=true") - #expect(TypesensePathEncoding.segment("a#b") == "a%23b") - } - - @Test("An ordinary id passes through unchanged") - func leavesOrdinaryIdsAlone() { - #expect(TypesensePathEncoding.segment("abc-123_x") == "abc-123_x") - } - - private let base = URL(string: "http://localhost:8108")! - - @Test("An ordinary path resolves against the connection's own base URL") - func resolvesOrdinaryPaths() throws { - let url = try #require(TypesensePathEncoding.resolve("/collections", against: base)) - #expect(url.absoluteString == "http://localhost:8108/collections") - } - - /// `//host` is a network-path reference, so `URL(string:relativeTo:)` reads what follows as a - /// host. The API key header rides on every request, so this shape would leak it. - @Test("A path that names another host is refused rather than resolved") - func refusesPathsThatLeaveTheOrigin() { - for path in [ - "//attacker.example.com/x", - "//127.0.0.1:9999/exfil", - "//user@evil/x", - "https://attacker.example.com/x", - "http://localhost:9999/x", - ] { - #expect(TypesensePathEncoding.resolve(path, against: base) == nil, "\(path)") - } - } - - @Test("A path holding a query string still resolves on the origin") - func resolvesPathsWithQueries() throws { - let url = try #require( - TypesensePathEncoding.resolve("/collections/books/documents/search?q=*", against: base) - ) - #expect(url.host == "localhost") - #expect(url.port == 8_108) - } -} - -struct TypesenseStatementGeneratorTests { - private let columns = ["id", "title", "year", "inprint", "authors"] - - private var generator: TypesenseStatementGenerator { - TypesenseStatementGenerator(collection: "books", columns: columns, fields: booksFields) - } - - private func request(_ writes: [PluginRowWrite]) throws -> TypesenseWriteRequest { - let first = try #require(writes.first) - return try #require(TypesenseStatementGenerator.decode(first.statement)) - } - - @Test("An insert posts the document, typed by the collection schema") - func insertPostsTheDocument() throws { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let writes = try generator.generateRowWrites( - from: [change], - insertedRowData: [0: [ - .text("a1"), .text("Dune"), .text("1965"), .text("true"), .text("[\"Herbert\"]"), - ]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - let request = try request(writes) - #expect(request.method == "POST") - #expect(request.path == "/collections/books/documents") - #expect(request.body == #"{"authors":["Herbert"],"id":"a1","inprint":true,"title":"Dune","year":1965}"#) - } - - @Test("A blank id is left out so Typesense assigns one") - func insertOmitsABlankId() throws { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - let writes = try generator.generateRowWrites( - from: [change], - insertedRowData: [0: [.text(""), .text("Dune"), .null, .null, .null]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - let request = try request(writes) - #expect(request.body == #"{"title":"Dune"}"#) - } - - @Test("An update patches only the changed fields and never rewrites id") - func updatePatchesChangedFields() throws { - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [ - (columnIndex: 1, columnName: "title", oldValue: .text("Dune"), newValue: .text("Dune II")), - (columnIndex: 0, columnName: "id", oldValue: .text("a1"), newValue: .text("zz")), - ], - originalRow: [.text("a1"), .text("Dune"), .text("1965"), .text("true"), .null] - ) - let writes = try generator.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - ) - let request = try request(writes) - #expect(request.method == "PATCH") - #expect(request.path == "/collections/books/documents/a1") - #expect(request.body == #"{"title":"Dune II"}"#) - } - - @Test("A delete addresses the document by id") - func deleteAddressesTheDocument() throws { - let change = PluginRowChange( - rowIndex: 0, type: .delete, cellChanges: [], - originalRow: [.text("a1"), .text("Dune"), .null, .null, .null] - ) - let writes = try generator.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] - ) - let request = try request(writes) - #expect(request.method == "DELETE") - #expect(request.path == "/collections/books/documents/a1") - #expect(request.body == nil) - } - - @Test("An update or delete with no id is skipped rather than guessed at") - func skipsRowsWithoutAnId() throws { - let update = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "title", oldValue: .null, newValue: .text("x"))], - originalRow: nil - ) - let delete = PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: [.text("")]) - let writes = try generator.generateRowWrites( - from: [update, delete], insertedRowData: [:], deletedRowIndices: [1], insertedRowIndices: [] - ) - #expect(writes.isEmpty) - } - - @Test("A document id needing escaping is percent-encoded into the path") - func encodesDocumentIdsInPaths() throws { - let change = PluginRowChange( - rowIndex: 0, type: .delete, cellChanges: [], originalRow: [.text("a/b c")] - ) - let writes = try generator.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] - ) - let request = try request(writes) - #expect(request.path == "/collections/books/documents/a%2Fb%20c") - } - - /// Typesense accepts a document whose id is `..`, and `URL.absoluteURL` resolves a `..` - /// segment away, so an unencoded dot turns a row delete into a request for the parent path. - @Test("A dot-only document id is encoded rather than left to resolve as a path segment") - func encodesRelativePathSegments() throws { - let change = PluginRowChange( - rowIndex: 0, type: .delete, cellChanges: [], originalRow: [.text("..")] - ) - let writes = try generator.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] - ) - let request = try request(writes) - #expect(request.path == "/collections/books/documents/%2E%2E") - #expect(!request.path.hasSuffix("/..")) - } - - /// A Typesense collection name is free text and may hold a slash, which addressed - /// `/collections/a/b` and answered 404 while the collection listed in the sidebar. - @Test("A collection name with a slash stays one path segment") - func encodesCollectionNamesInPaths() throws { - let slashed = TypesenseStatementGenerator( - collection: "a/b", columns: columns, fields: booksFields - ) - let change = PluginRowChange( - rowIndex: 0, type: .delete, cellChanges: [], originalRow: [.text("x")] - ) - let writes = try slashed.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [] - ) - let request = try request(writes) - #expect(request.path == "/collections/a%2Fb/documents/x") - } - - @Test("A write request round-trips through its tagged encoding") - func writeRequestRoundTrips() throws { - let original = TypesenseWriteRequest( - method: "PATCH", path: "/collections/books/documents/a:1", body: #"{"a":"b:c"}"# - ) - let encoded = TypesenseStatementGenerator.encode(original) - #expect(TypesenseStatementGenerator.isTaggedStatement(encoded)) - #expect(TypesenseStatementGenerator.decode(encoded) == original) - } - - @Test("Each request names the change it writes") - func writesNameTheirChange() throws { - let original: [PluginCellValue] = [.text("a1"), .text("Dune"), .null, .null, .null] - let writes = try generator.generateRowWrites( - from: [ - PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "title", oldValue: .text("Dune"), newValue: .text("II"))], - originalRow: original - ), - PluginRowChange(rowIndex: 1, type: .delete, cellChanges: [], originalRow: original), - ], - insertedRowData: [:], - deletedRowIndices: [1], - insertedRowIndices: [] - ) - #expect(writes.map(\.rowIndices) == [[0], [1]]) - } - - private func shortenedAuthors() -> String { - TypesenseSchema.cell((0..<1_500).map { "author-\($0)" }, length: .display).asText ?? "" - } - - private func shortenedRefusal(_ column: String) -> PluginRowWriteRefusal { - PluginRowWriteRefusal( - rowIndex: 0, - reason: "The value in \(column) is shortened for display, so saving it would store only the part shown. " - + "Change this field with a query." - ) - } - - private func authorsEdit(from old: String, to new: PluginCellValue) -> PluginRowChange { - PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 4, columnName: "authors", oldValue: .text(old), newValue: new)], - originalRow: [.text("a1"), .text("Dune"), .text("1965"), .text("true"), .text(old)] - ) - } - - private func updateRequest(_ change: PluginRowChange) throws -> TypesenseWriteRequest { - try request(generator.generateRowWrites( - from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] - )) - } - - @Test("An edit to an array shortened for display is refused rather than saved as the fragment") - func updateRefusesAShortenedArray() throws { - let shortened = shortenedAuthors() - try #require(shortened.hasSuffix("...")) - let edited = shortened.replacingOccurrences(of: "\"author-0\"", with: "\"author-Z\"") - #expect(throws: shortenedRefusal("authors")) { - try updateRequest(authorsEdit(from: shortened, to: .text(edited))) - } - } - - @Test("Text appended to an array shortened for display is refused") - func updateRefusesTextAppendedToAShortenedArray() throws { - let shortened = shortenedAuthors() - try #require(shortened.hasSuffix("...")) - let appended = String(shortened.dropLast(3)) + #","new"]"# - #expect(throws: shortenedRefusal("authors")) { - try updateRequest(authorsEdit(from: shortened, to: .text(appended))) - } - } - - @Test("A new row carrying a value shortened for display is refused") - func insertRefusesAShortenedValue() throws { - let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) - #expect(throws: shortenedRefusal("authors")) { - try generator.generateRowWrites( - from: [change], - insertedRowData: [0: [.text("a2"), .text("Dune"), .null, .null, .text(shortenedAuthors())]], - deletedRowIndices: [], - insertedRowIndices: [0] - ) - } - } - - @Test("A complete array written over one shortened for display is refused, since it may be the shown part closed") - func updateRefusesACompleteArrayWrittenOverAShortenedOne() throws { - let shownPart = TypesenseSchema.cell((0..<600).map { "author-\($0)" }, length: .display).asText ?? "" - try #require(!shownPart.hasSuffix("...")) - #expect(throws: shortenedRefusal("authors")) { - try updateRequest(authorsEdit(from: shortenedAuthors(), to: .text(shownPart))) - } - } - - @Test("NULL written over a value shortened for display clears the field") - func updateWritesNullOverAShortenedValue() throws { - let request = try updateRequest(authorsEdit(from: shortenedAuthors(), to: .null)) - #expect(request.body == #"{"authors":null}"#) - } - - @Test("An edit to another field of a row holding a shortened value is written") - func updateWritesAnotherFieldBesideAShortenedValue() throws { - let change = PluginRowChange( - rowIndex: 0, - type: .update, - cellChanges: [(columnIndex: 1, columnName: "title", oldValue: .text("Dune"), newValue: .text("Dune II"))], - originalRow: [.text("a1"), .text("Dune"), .text("1965"), .text("true"), .text(shortenedAuthors())] - ) - #expect(try updateRequest(change).body == #"{"title":"Dune II"}"#) - } -} diff --git a/TableProTests/Plugins/TypesenseOperationsConsoleTextTests.swift b/TableProTests/Plugins/TypesenseOperationsConsoleTextTests.swift deleted file mode 100644 index ef85f062dd..0000000000 --- a/TableProTests/Plugins/TypesenseOperationsConsoleTextTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// TypesenseOperationsConsoleTextTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct TypesenseOperationsConsoleTextTests { - @Test("Dropping a collection is the native request") - func dropIsNative() throws { - let request = try #require(TypesenseOperations.dropCollection(named: "books", objectType: "TABLE")) - #expect(TypesenseOperations.consoleText(request) == "DELETE /collections/books") - } - - @Test("Truncating a collection keeps the truncate flag") - func truncateKeepsFlag() { - let text = TypesenseOperations.consoleText(TypesenseOperations.truncateCollection(named: "books")) - #expect(text == "DELETE /collections/books/documents?truncate=true") - } - - /// The statement is shown in the confirmation and then run, so a statement the driver's own - /// parser rejects would be the #2884 defect in a second engine. - @Test("Both statements parse as the requests they name") - func statementsRoundTrip() throws { - let drop = try #require(TypesenseOperations.dropCollection(named: "books", objectType: "TABLE")) - let parsedDrop = try #require(TypesenseConsoleParser.parse(TypesenseOperations.consoleText(drop))) - #expect(parsedDrop.method == "DELETE") - #expect(parsedDrop.path == "/collections/books") - - let truncate = TypesenseOperations.truncateCollection(named: "books") - let parsedTruncate = try #require(TypesenseConsoleParser.parse(TypesenseOperations.consoleText(truncate))) - #expect(parsedTruncate.method == "DELETE") - #expect(parsedTruncate.path == "/collections/books/documents?truncate=true") - } - - /// A tagged blob has no leading verb, so `QueryClassifier` tiered a collection drop as an - /// ordinary write and it skipped the destructive gate. - @Test("A drop reads as destructive") - func dropClassifiesDestructive() throws { - let request = try #require(TypesenseOperations.dropCollection(named: "books", objectType: "TABLE")) - let classification = QueryClassifier.classify( - TypesenseOperations.consoleText(request), databaseType: .typesense - ) - #expect(classification.tier == .destructive) - } - - @Test("A truncate reads as destructive") - func truncateClassifiesDestructive() { - let text = TypesenseOperations.consoleText(TypesenseOperations.truncateCollection(named: "books")) - #expect(QueryClassifier.classify(text, databaseType: .typesense).tier == .destructive) - } -} diff --git a/TableProTests/Plugins/VendoredSQLiteImportTests.swift b/TableProTests/Plugins/VendoredSQLiteImportTests.swift deleted file mode 100644 index 7692ecc3e5..0000000000 --- a/TableProTests/Plugins/VendoredSQLiteImportTests.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// VendoredSQLiteImportTests.swift -// TableProTests -// -// A plugin that links its own SQLite compiled one file against the SDK's `SQLite3` module, whose -// `link "sqlite3"` puts macOS's library on that plugin's link line beside the vendored one. -// - -import Foundation -import Testing - -struct VendoredSQLiteImportTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - - /// The source directories of every target `project.yml` force-loads the vendored library into: - /// the plugin's own folder and each TableProCore product it links, read from the manifest so a - /// new target on that library is covered the moment it is added. - private static func vendoredSourceDirectories() throws -> [URL] { - let manifest = try String(contentsOf: repositoryRoot.appendingPathComponent("project.yml"), encoding: .utf8) - return targetBlocks(in: manifest) - .filter { $0.contains("libsqlite3_vendored.a") } - .flatMap { block in - values(of: "folder", in: block).map { repositoryRoot.appendingPathComponent("Plugins/\($0)") } - + values(of: "product", in: block).map { - repositoryRoot.appendingPathComponent("Packages/TableProCore/Sources/\($0)") - } - } - .filter { FileManager.default.fileExists(atPath: $0.path) } - } - - private static func targetBlocks(in manifest: String) -> [String] { - var blocks: [[Substring]] = [] - for line in manifest.split(separator: "\n", omittingEmptySubsequences: false) { - if line.range(of: #"^ [A-Za-z][A-Za-z0-9]*:\s*$"#, options: .regularExpression) != nil { - blocks.append([]) - } - if !blocks.isEmpty { - blocks[blocks.count - 1].append(line) - } - } - return blocks.map { $0.joined(separator: "\n") } - } - - private static func values(of key: String, in block: String) -> [String] { - block.split(separator: "\n").compactMap { line -> String? in - var trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("- ") { - trimmed = String(trimmed.dropFirst(2)) - } - guard trimmed.hasPrefix("\(key):") else { return nil } - return trimmed.dropFirst(key.count + 1).trimmingCharacters(in: .whitespaces) - } - } - - private static func swiftSources(in directories: [URL]) throws -> [(path: String, text: String)] { - try directories.flatMap { directory -> [(path: String, text: String)] in - guard let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else { - return [] - } - return try enumerator.compactMap { $0 as? URL } - .filter { $0.pathExtension == "swift" } - .map { ($0.path, try String(contentsOf: $0, encoding: .utf8)) } - } - } - - @Test("The scan finds both plugins that link their own SQLite, and the package they share") - func scanReachesTheVendoredTargets() throws { - let names = try Self.vendoredSourceDirectories().map(\.lastPathComponent) - - #expect(names.contains("SQLiteDriverPlugin")) - #expect(names.contains("LibSQLDriverPlugin")) - #expect(names.contains("TableProSQLiteCore")) - } - - @Test("No source compiled into a plugin on the vendored SQLite imports the SDK's SQLite3 module") - func noVendoredTargetImportsTheSystemModule() throws { - let sources = try Self.swiftSources(in: Self.vendoredSourceDirectories()) - let offenders = sources - .filter { $0.text.range(of: #"(?m)^\s*(@\w+\s+)*import\s+SQLite3\b"#, options: .regularExpression) != nil } - .map { URL(fileURLWithPath: $0.path).lastPathComponent } - - #expect(sources.count > 10, "The plugin sources were not found; the guard below would pass vacuously") - #expect(offenders.isEmpty, "These files import the SDK's SQLite3 instead of CSQLite: \(offenders)") - } -} diff --git a/TableProTests/Plugins/WeaviateConnectionFieldsTests.swift b/TableProTests/Plugins/WeaviateConnectionFieldsTests.swift deleted file mode 100644 index 015ab94303..0000000000 --- a/TableProTests/Plugins/WeaviateConnectionFieldsTests.swift +++ /dev/null @@ -1,137 +0,0 @@ -import Foundation -@testable import TablePro -import TableProPluginKit -import TableProWeaviateCore -import Testing - -struct WeaviateRegistrySnapshotTests { - private func snapshot() throws -> PluginMetadataSnapshot { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - return try #require(defaults.first { $0.typeId == "Weaviate" }).snapshot - } - - @Test("Weaviate is a collection engine on port 8080 with no SQL dialect") - func connectionShape() throws { - let snapshot = try snapshot() - #expect(snapshot.defaultPort == 8_080) - #expect(snapshot.editor.sqlDialect == nil) - #expect(snapshot.queryLanguageName == "GraphQL") - #expect(snapshot.schema.tableEntityName == "Collections") - #expect(snapshot.schema.defaultPrimaryKeyColumn == "uuid") - #expect(snapshot.schema.immutableColumns == ["uuid", "vector"]) - #expect(!snapshot.supportsForeignKeys) - #expect(snapshot.capabilities.supportsSSH) - #expect(snapshot.capabilities.supportsSSL) - #expect(snapshot.connection.category == .document) - #expect(snapshot.iconName == "weaviate-icon") - } - - @Test("Auth field ids are Weaviate-prefixed and do not collide with Elasticsearch") - func authFieldIdsArePrefixed() throws { - let ids = try snapshot().connection.additionalConnectionFields.map(\.id) - #expect(ids == [ - WeaviateFieldID.authMethod, - WeaviateFieldID.apiKey, - WeaviateFieldID.skipTLSVerify - ]) - #expect(!ids.contains("esAuthMethod")) - #expect(!ids.contains("esApiKey")) - let elasticsearch = try #require( - PluginMetadataRegistry.shared.registryPluginDefaults().first { $0.typeId == "Elasticsearch" } - ) - let esIds = elasticsearch.snapshot.connection.additionalConnectionFields.map(\.id) - #expect(Set(ids).isDisjoint(with: Set(esIds))) - } -} - -struct WeaviateConnectionFieldsTests { - private func fields() throws -> [ConnectionField] { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - let entry = try #require(defaults.first { $0.typeId == "Weaviate" }) - return entry.snapshot.connection.additionalConnectionFields - } - - @Test("Auth method defaults to none") - func authMethodDefaultsToNone() throws { - let fields = try fields() - let method = try #require(fields.first { $0.id == WeaviateFieldID.authMethod }) - #expect(method.defaultValue == "none") - guard case .dropdown(let options) = method.fieldType else { - Issue.record("Expected a dropdown field type") - return - } - #expect(options.map(\.value) == ["none", "apiKey"]) - } - - @Test("The API key replaces both built-in credential rows") - func apiKeyReplacesUsernameAndPassword() throws { - let fields = try fields() - let apiKey = try #require(fields.first { $0.id == WeaviateFieldID.apiKey }) - #expect(apiKey.isSecure) - #expect(!apiKey.isRequired) - #expect(apiKey.hidesPassword) - #expect(fields.hidesPassword(forValues: [:])) - #expect(fields.hidesUsername(forValues: [:])) - #expect(fields.hidesPassword(forValues: [WeaviateFieldID.authMethod: "none"])) - #expect(fields.hidesPassword(forValues: [WeaviateFieldID.authMethod: "apiKey"])) - #expect(fields.hidesUsername(forValues: [WeaviateFieldID.authMethod: "none"])) - #expect(fields.hidesUsername(forValues: [WeaviateFieldID.authMethod: "apiKey"])) - } - - @Test("The snapshot hides the built-in password for every auth method") - @MainActor - func snapshotHidesBuiltInPassword() throws { - let defaults = PluginMetadataRegistry.shared.registryPluginDefaults() - let snapshot = try #require(defaults.first { $0.typeId == "Weaviate" }).snapshot - #expect(snapshot.connection.hidesBuiltInPassword) - for method in ["none", "apiKey"] { - var connection = DatabaseConnection(name: "Weaviate", type: .weaviate) - connection.additionalFields = [WeaviateFieldID.authMethod: method] - #expect(PluginManager.shared.hidesPassword(for: connection), "\(method)") - } - } -} - -struct WeaviateFieldParityTests { - private static let repoRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - - private func source(_ path: String) throws -> String { - try String(contentsOf: Self.repoRoot.appendingPathComponent(path), encoding: .utf8) - } - - /// The plugin's own field list replaces the registry snapshot the moment the plugin is - /// installed, and no test can link both copies, so the two sources are compared as text. - @Test("The plugin's field list matches the registry copy") - func pluginCopyMatchesRegistryCopy() throws { - let plugin = try source("Plugins/WeaviateDriverPlugin/WeaviatePlugin.swift") - let registry = try source("TablePro/Core/Plugins/PluginMetadataRegistry+WeaviateDefaults.swift") - for field in [WeaviateFieldID.authMethod, WeaviateFieldID.apiKey, WeaviateFieldID.skipTLSVerify] { - #expect(registry.contains("id: \"\(field)\""), Comment(rawValue: field)) - } - #expect(plugin.contains("id: WeaviateFieldID.authMethod")) - #expect(plugin.contains("id: WeaviateFieldID.apiKey")) - #expect(plugin.contains("id: WeaviateFieldID.skipTLSVerify")) - for copy in [plugin, registry] { - #expect(copy.contains("hidesPassword: true")) - #expect(copy.contains("withHidesUsername(true)")) - #expect(copy.contains("apiKey")) - } - } -} - -struct WeaviatePluginManifestTests { - @Test("Info.plist declares the current PluginKit ABI and the Weaviate type id") - func plistDeclaresType() throws { - let url = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("Plugins/WeaviateDriverPlugin/Info.plist") - let plist = try #require(NSDictionary(contentsOf: url) as? [String: Any]) - #expect(plist["TableProPluginKitVersion"] as? Int == PluginManager.currentPluginKitVersion) - #expect(plist["TableProProvidesDatabaseTypeIds"] as? [String] == ["Weaviate"]) - } -} diff --git a/TableProTests/Services/ContentWindowBackdropTests.swift b/TableProTests/Services/ContentWindowBackdropTests.swift index f5c0233fd7..a1d7258b0f 100644 --- a/TableProTests/Services/ContentWindowBackdropTests.swift +++ b/TableProTests/Services/ContentWindowBackdropTests.swift @@ -63,43 +63,4 @@ struct ContentWindowBackdropTests { try expectFollowsTheTheme(window) } } - - private static let contentWindowControllers = [ - "TablePro/Core/Services/Infrastructure/TabWindowController.swift", - "TablePro/Views/DataFiles/DataFileWindowController.swift", - "TablePro/Views/Compare/CompareSyncWindowController.swift", - "TablePro/Views/Results/ValueViewerWindowController.swift", - ] - - private static let systemWindowControllers = [ - "TablePro/Views/Settings/SettingsWindowController.swift", - "TablePro/Views/Welcome/WelcomeWindowController.swift", - "TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift", - ] - - private func code(of path: String) throws -> String { - let url = try ThemeSourceScan.repositoryRoot().appendingPathComponent(path) - let source = try String(contentsOf: url, encoding: .utf8) - return ThemeSourceScan.codeLines(of: source, path: path).map(\.text).joined(separator: "\n") - } - - /// A backdrop held only by a local stops answering theme changes as soon as `init` returns. - @Test("Each content window controller stores the backdrop it attaches") - func contentWindowsStoreTheirBackdrop() throws { - let stored = try ThemeSourceScan.Rule("stored backdrop", #"(let|var)\s+\w+\s*:\s*ThemeWindowBackdrop\b"#) - let attached = try ThemeSourceScan.Rule("attached backdrop", #"\w+\s*=\s*ThemeWindowBackdrop\(window:"#) - for path in Self.contentWindowControllers { - let code = try code(of: path) - #expect(stored.matches(code), "\(path) does not store a ThemeWindowBackdrop") - #expect(attached.matches(code), "\(path) does not attach a ThemeWindowBackdrop") - } - } - - @Test("Settings, Welcome and the connection form keep the system window") - func systemWindowsHaveNoBackdrop() throws { - for path in Self.systemWindowControllers { - let code = try code(of: path) - #expect(!code.contains("ThemeWindowBackdrop"), "\(path) attaches a window backdrop") - } - } } diff --git a/TableProTests/Services/MenuQueryScopeGuardTests.swift b/TableProTests/Services/MenuQueryScopeGuardTests.swift deleted file mode 100644 index d4fea58cfa..0000000000 --- a/TableProTests/Services/MenuQueryScopeGuardTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// MenuQueryScopeGuardTests.swift -// TableProTests -// -// AppKit auto-manages the window list at the foot of the Window menu, and titles each entry -// "Title (Subtitle)" when the window carries a subtitle and exactly "Title" when it does not. -// TablePro's windows carry no subtitle, so every open window contributes a menu-bar item whose -// title is exactly its tab's title. -// -// Several tab types resolve to a title that is also the name of a Database-menu command, because -// the command and the tab are the same feature: Query Insights, Server Dashboard, Users & Roles, -// ER Diagram, Create Table. That is correct in the product and cannot be renamed away. What it -// means for a UI test is that `app.menuBars.menuItems["Query Insights"]` matches TWO elements once -// that tab is open, and XCUITest fails the click with "Multiple matching elements found". -// -// That shipped: QueryInsightsTabUITests resolved it unscoped and went red the moment the window -// subtitle was removed. The fix is to scope the query to its own menu, and this guard is what -// stops the next one being written unscoped. -// - -import Foundation -@testable import TablePro -import Testing - -struct MenuQueryScopeGuardTests { - /// Every title a tab can give a window, and therefore every title AppKit can add to the Window - /// menu. Taken from `WindowTitleResolver.resolveTitle`'s own switch rather than retyped, so a - /// new tab type joins this list by construction. - private static let windowTitles: [String] = [ - String(localized: "Server Dashboard"), - String(localized: "Users & Roles"), - String(localized: "Query Insights"), - String(localized: "ER Diagram"), - String(localized: "Create Table"), - String(localized: "Source"), - /// `WindowTitleResolver.fallbackTitle` spelled out, not referenced: the resolver is - /// `@MainActor`, and a main-actor member cannot initialize a nonisolated static. The - /// parity test below is what keeps the two in step. - String(localized: "SQL Query"), - ] - - @Test("No UI test resolves a menu item by a title a window can also carry") - func uiTestsScopeAmbiguousMenuQueries() throws { - var offenders: [String] = [] - - for url in try Self.uiTestSources() { - let text = try String(contentsOf: url, encoding: .utf8) - for (offset, line) in text.components(separatedBy: .newlines).enumerated() { - guard line.contains("menuBars.menuItems[") else { continue } - guard let title = Self.windowTitles.first(where: { line.contains("\"\($0)\"") }) else { continue } - offenders.append("\(url.lastPathComponent):\(offset + 1) resolves \"\(title)\"") - } - } - - #expect( - offenders.isEmpty, - """ - A window showing this tab puts an item of the same title in the Window menu, so an \ - unscoped query matches two elements and the click fails. Scope it to its own menu, \ - e.g. app.menuBars.menuBarItems["Database"].menuItems[…]: \(offenders.sorted()) - """ - ) - } - - /// The resolver is the source of the list above. If it learns a title this suite does not know - /// about, the guard silently stops covering it, so the two are compared here. - @Test("The guarded titles still match what the resolver can return") - func guardedTitlesMatchTheResolver() throws { - let resolver = try String( - contentsOf: Self.repoRoot() - .appendingPathComponent("TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift"), - encoding: .utf8 - ) - let literals = Self.stringLiterals(in: resolver) - .filter { !$0.isEmpty && $0 != "%@ Query" && !$0.contains("·") } - - let unguarded = literals.filter { !Self.windowTitles.contains($0) } - #expect( - unguarded.isEmpty, - "WindowTitleResolver can return \(unguarded.sorted()), which this guard does not cover" - ) - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("TablePro.xcodeproj").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } - - private static func stringLiterals(in source: String) -> [String] { - var found: [String] = [] - for line in source.components(separatedBy: .newlines) { - guard line.contains("String(localized:"), !line.trimmingCharacters(in: .whitespaces).hasPrefix("///") - else { continue } - let parts = line.components(separatedBy: "\"") - guard parts.count >= 2 else { continue } - found.append(parts[1]) - } - return found - } - - private static func uiTestSources() throws -> [URL] { - let root = try Self.repoRoot().appendingPathComponent("TableProUITests") - guard let enumerator = FileManager.default.enumerator( - at: root, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - return enumerator.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" } - } -} diff --git a/TableProTests/Services/ToolbarSourceAccessTests.swift b/TableProTests/Services/ToolbarSourceAccessTests.swift deleted file mode 100644 index cd1f14640d..0000000000 --- a/TableProTests/Services/ToolbarSourceAccessTests.swift +++ /dev/null @@ -1,240 +0,0 @@ -// -// ToolbarSourceAccessTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// Nothing in the app may act on what AppKit says is on screen in a toolbar. -/// -/// Measured on macOS 27: one visit to Customize Toolbar leaves `NSToolbar.visibleItems` and -/// `NSToolbarItem.isVisible` over-reporting for the life of the item instance, listing items that -/// are hidden or clipped. The error always runs toward "on screen", and acting on it is what hands -/// `NSPopover` an anchor with no window, an `NSInvalidArgumentException` Swift cannot catch. The -/// toolbar keeps its own record instead, `ToolbarVisibility`, and nothing enforces reading that -/// record over AppKit's but this scan, the same shape `SyncMapperFieldAccessTests` uses to keep raw -/// `record["` out of the sync mappers. -/// -/// Two scans, because the two properties are not equally ambiguous. `visibleItems` means one thing -/// anywhere, so the whole app is scanned for it. `isVisible` is also a window's, a panel's and a -/// dozen view models', so across the app it is flagged only on a receiver named for a toolbar item; -/// that heuristic misses `$0.isVisible`, `\.isVisible` and `items[i].isVisible`, which are exactly -/// how a toolbar's items get filtered. So the files that handle toolbar items are scanned for every -/// `isVisible` whatever its receiver, and the one read allowed there is named: the switcher's -/// `toolbar.isVisible`, which is whether the toolbar itself is shown and was measured to read -/// correctly after a palette visit. -struct ToolbarSourceAccessTests { - private static let rootDirectory: URL = { - var directory = URL(fileURLWithPath: #filePath) - for _ in 0..<3 { directory.deleteLastPathComponent() } - return directory - }() - - private static let appDirectory: URL? = { - let app = rootDirectory.appendingPathComponent("TablePro") - return FileManager.default.fileExists(atPath: app.path) ? app : nil - }() - - /// Every file that builds, reads or presents from a toolbar item. The directories are scanned - /// whole, so a file added to one of them is covered the day it is added. - private static let toolbarDirectories = [ - "TablePro/Core/Services/Infrastructure/Toolbar", - "TablePro/Views/Toolbar", - ] - - private static let toolbarFiles = [ - "TablePro/Views/Components/PopoverPresenter.swift", - "TablePro/Views/Compare/CompareEndpointToolbarController.swift", - ] - - /// Every `MainWindowToolbar` source, by prefix, beside the class itself. - private static let toolbarFilePrefix = "TablePro/Core/Services/Infrastructure/MainWindowToolbar" - - /// The one read the toolbar scan allows, and where. Named by file and by the exact expression, - /// so a second `toolbar.isVisible` anywhere else is still flagged. - private static let allowedRead = ( - path: "TablePro/Views/Toolbar/ToolbarSwitcherPresenter.swift", - expression: "toolbar.isVisible" - ) - - private static let isVisibleRead = try? NSRegularExpression( - pattern: #"([A-Za-z_][A-Za-z0-9_]*)\s*[?!]?\s*\.isVisible\b"# - ) - - private static let anyVisibilityRead = try? NSRegularExpression( - pattern: #"\.(isVisible|visibleItems)\b"# - ) - - private static func sources() throws -> [(path: String, text: String)] { - guard let appDirectory, - let enumerator = FileManager.default.enumerator(at: appDirectory, includingPropertiesForKeys: nil) - else { return [] } - return try enumerator - .compactMap { $0 as? URL } - .filter { $0.pathExtension == "swift" } - .map { url in - let path = url.path.replacingOccurrences(of: appDirectory.path, with: "TablePro") - return (path, try String(contentsOf: url, encoding: .utf8)) - } - } - - static func isToolbarSource(_ path: String) -> Bool { - path.hasPrefix(toolbarFilePrefix) - || toolbarFiles.contains(path) - || toolbarDirectories.contains { path.hasPrefix($0 + "/") } - } - - private static func toolbarSources() throws -> [(path: String, text: String)] { - try sources().filter { isToolbarSource($0.path) } - } - - /// The code on a line, with any comment dropped. The rule is written about in the comments that - /// explain it, so a doc comment naming `visibleItems` is not a read of it. - private static func code(of line: String) -> String { - guard let comment = line.range(of: "//") else { return line } - return String(line[.. Bool { - let code = code(of: line) - if code.contains(".visibleItems") { return true } - guard let isVisibleRead else { return false } - let range = NSRange(code.startIndex..., in: code) - return isVisibleRead.matches(in: code, range: range).contains { match in - guard let receiverRange = Range(match.range(at: 1), in: code) else { return false } - let receiver = code[receiverRange].lowercased() - return receiver.hasSuffix("item") || receiver.hasSuffix("items") - } - } - - /// The toolbar-file rule: every `isVisible` and `visibleItems` in code, whatever the receiver, - /// key paths included. - static func readsAnyVisibility(_ line: String) -> Bool { - let code = code(of: line) - guard let anyVisibilityRead else { return false } - return anyVisibilityRead.firstMatch(in: code, range: NSRange(code.startIndex..., in: code)) != nil - } - - /// Whether a line's only visibility read is the allowed one. A line that also reads something - /// else is not excused by carrying it. - static func isAllowedRead(_ line: String, path: String) -> Bool { - guard path == allowedRead.path else { return false } - let remainder = code(of: line).replacingOccurrences(of: allowedRead.expression, with: "") - return code(of: line).contains(allowedRead.expression) && !readsAnyVisibility(remainder) - } - - private static func hits( - in sources: [(path: String, text: String)], - where reads: (String) -> Bool - ) -> [(path: String, line: Int, code: String)] { - sources.flatMap { source in - source.text - .components(separatedBy: .newlines) - .enumerated() - .filter { reads($0.element) } - .map { (source.path, $0.offset + 1, $0.element.trimmingCharacters(in: .whitespaces)) } - } - } - - // MARK: - Reach - - @Test("The app scan reaches the app's sources") - func sourcesAreReachable() throws { - let sources = try Self.sources() - #expect(sources.count > 100, "Found \(sources.count) sources; the guard below would pass vacuously") - } - - /// A moved or renamed file would otherwise drop out of the toolbar scan and leave it green. - @Test("The toolbar scan reaches every file it names, and the allowed read is among them") - func toolbarScanReachesItsFiles() throws { - let paths = Set(try Self.toolbarSources().map(\.path)) - - #expect(paths.count >= 10, "Only \(paths.count) toolbar sources scanned: \(paths.sorted())") - for file in Self.toolbarFiles + [Self.allowedRead.path] { - #expect(paths.contains(file), "\(file) is not being scanned") - } - for directory in Self.toolbarDirectories { - #expect(paths.contains { $0.hasPrefix(directory + "/") }, "Nothing scanned under \(directory)") - } - #expect(paths.contains(Self.toolbarFilePrefix + ".swift")) - #expect(paths.filter { $0.hasPrefix(Self.toolbarFilePrefix) }.count >= 5) - } - - // MARK: - Matchers - - @Test("The app-wide matcher tells a toolbar item's visibility from the toolbar's own") - func matcherKeysOnTheReceiver() { - #expect(Self.readsToolbarVisibility("let shown = toolbar.visibleItems ?? []")) - #expect(Self.readsToolbarVisibility("guard item.isVisible else { return }")) - #expect(Self.readsToolbarVisibility("if toolbarItem?.isVisible == true {")) - #expect(Self.readsToolbarVisibility("let on = subitem.isVisible && group.isHidden")) - - #expect(!Self.readsToolbarVisibility("guard let toolbar = window?.toolbar, toolbar.isVisible else {")) - #expect(!Self.readsToolbarVisibility("toolbarVisible: window.toolbar?.isVisible ?? false")) - #expect(!Self.readsToolbarVisibility("mentionState.isVisible = true")) - #expect(!Self.readsToolbarVisibility("/// `NSToolbar.visibleItems` over-reports after a palette visit.")) - } - - /// The forms the receiver heuristic cannot see, which is why the toolbar files take this one. - @Test("The toolbar matcher flags every read, whatever its receiver") - func toolbarMatcherFlagsEveryRead() { - #expect(Self.readsAnyVisibility("let shown = toolbar.items.filter { $0.isVisible }")) - #expect(Self.readsAnyVisibility("let shown = toolbar.items.filter(\\.isVisible)")) - #expect(Self.readsAnyVisibility("if toolbar.items[i].isVisible {")) - #expect(Self.readsAnyVisibility("let on = toolbar.items.first { $0.itemIdentifier == id }?.isVisible")) - #expect(Self.readsAnyVisibility("guard anchor.isVisible else { return nil }")) - #expect(Self.readsAnyVisibility("let shown = toolbar.visibleItems ?? []")) - - #expect(!Self.readsAnyVisibility("/// Reading `item.isVisible` after a palette visit over-reports.")) - #expect(!Self.readsAnyVisibility("let hidden = visibility.hides(id) // not `.isVisible`")) - #expect(!Self.readsAnyVisibility("item.isHidden = visibility.hides(item.itemIdentifier)")) - #expect(!Self.readsAnyVisibility("let isVisibleNow = true")) - } - - @Test("Only the switcher's own toolbar read is allowed, and only on its own") - func allowanceIsExact() { - let path = Self.allowedRead.path - #expect(Self.isAllowedRead("guard let toolbar = window?.toolbar, toolbar.isVisible else { return nil }", path: path)) - #expect(!Self.isAllowedRead("guard toolbar.isVisible, item.isVisible else { return nil }", path: path)) - #expect(!Self.isAllowedRead("guard toolbar.isVisible else { return nil }", path: "TablePro/Views/Toolbar/Other.swift")) - #expect(!Self.isAllowedRead("guard anchor.isVisible else { return nil }", path: path)) - } - - // MARK: - Scans - - @Test("Nothing in the app reads NSToolbar.visibleItems or an item's isVisible") - func noVisibilityReads() throws { - let offenders = Self.hits(in: try Self.sources(), where: Self.readsToolbarVisibility) - .filter { !Self.isAllowedRead($0.code, path: $0.path) } - .map { "\($0.path):\($0.line): \($0.code)" } - - #expect(offenders.isEmpty, """ - These read AppKit's report of which toolbar items are on screen, which one Customize Toolbar \ - visit leaves over-reporting for good. Ask the toolbar's own record, \ - `MainWindowToolbar.visibility`, whether an item is hidden, and `NSToolbar.items` which \ - instance it is. - \(offenders.joined(separator: "\n")) - """) - } - - @Test("The toolbar files read no visibility at all, beyond whether the toolbar is shown") - func toolbarFilesReadNoVisibility() throws { - let hits = Self.hits(in: try Self.toolbarSources(), where: Self.readsAnyVisibility) - let allowed = hits.filter { Self.isAllowedRead($0.code, path: $0.path) } - let offenders = hits - .filter { !Self.isAllowedRead($0.code, path: $0.path) } - .map { "\($0.path):\($0.line): \($0.code)" } - - #expect(allowed.count == 1, """ - The switcher's `toolbar.isVisible` read is the one allowance, and it should be there exactly \ - once. Found \(allowed.count): \(allowed.map { "\($0.path):\($0.line)" }) - """) - #expect(offenders.isEmpty, """ - A toolbar file reads `isVisible` or `visibleItems`, which one Customize Toolbar visit leaves \ - over-reporting. Ask `MainWindowToolbar.visibility` whether an item is hidden. - \(offenders.joined(separator: "\n")) - """) - } -} diff --git a/TableProTests/Storage/CustomSlashCommandStorageSyncTests.swift b/TableProTests/Storage/CustomSlashCommandStorageSyncTests.swift index 81909fd411..f3f0ff907f 100644 --- a/TableProTests/Storage/CustomSlashCommandStorageSyncTests.swift +++ b/TableProTests/Storage/CustomSlashCommandStorageSyncTests.swift @@ -42,15 +42,4 @@ struct CustomSlashCommandStorageSyncTests { #expect(storage.commands.map(\.name) == ["remote"]) #expect(tracker.dirtyRecords(for: .settings).contains(CustomSlashCommandStorage.syncCategory) == false) } - - @Test("Custom slash commands round-trip through Codable") - func codableRoundTrip() throws { - let commands = [ - CustomSlashCommand(name: "explain", description: "Explain the query", promptTemplate: "Explain {{query}}"), - CustomSlashCommand(name: "optimize", promptTemplate: "Optimize {{query}}"), - ] - let data = try JSONEncoder().encode(commands) - let decoded = try JSONDecoder().decode([CustomSlashCommand].self, from: data) - #expect(decoded == commands) - } } diff --git a/TableProTests/Storage/SplitViewAutosaveSweepTests.swift b/TableProTests/Storage/SplitViewAutosaveSweepTests.swift index ea6eac4e28..b526bf8fa1 100644 --- a/TableProTests/Storage/SplitViewAutosaveSweepTests.swift +++ b/TableProTests/Storage/SplitViewAutosaveSweepTests.swift @@ -102,10 +102,7 @@ struct SplitViewAutosaveSweepTests { #expect(SplitViewAutosaveRecord.parse(key: key(drawer)) == .unrecognised) } - /// `SplitViewAutosaveName.resolved` appends a sandbox suffix under a UI test, and the suffix is - /// whatever the sandbox directory is called: measured on one machine as UUIDs and as `sandbox`, - /// `sb`, `pr2`, `shot` and `sb-repro1`. A suffixed record belongs to a test case and must not be - /// judged by a rule written for the real domain. + /// Older sandboxed runs suffixed records with their sandbox directory's name; those belong to a test, not to the user. @Test( "A sandbox-suffixed retired name is left alone", arguments: ["sandbox", "sb", "pr2", "shot", "sb-repro1", "0E5A1D48-1D0E-4F2E-9C21-2C19F0E3A77B"] diff --git a/TableProTests/Theme/ConnectionIdentityColorTests.swift b/TableProTests/Theme/ConnectionIdentityColorTests.swift index 998bcb3b19..7d41da9970 100644 --- a/TableProTests/Theme/ConnectionIdentityColorTests.swift +++ b/TableProTests/Theme/ConnectionIdentityColorTests.swift @@ -180,37 +180,3 @@ struct ConnectionIdentityColorTests { return (hue, brightness, alpha) } } - -/// `.toolbarBackground(_:for:)` styles a toolbar SwiftUI itself installs, and it reaches that -/// toolbar by flowing up to the root view of a `Window` scene. TablePro runs the AppKit lifecycle -/// with no SwiftUI scene at all and installs a real `NSToolbar` in `MainWindowToolbar`, so the -/// modifier has nothing to act on. It sat in the app unused for four months after the toolbar -/// migration removed its last call site, which made "raise the tint opacity" look like a one-line -/// fix for #2398 when it would have changed nothing on screen. -struct ToolbarBackgroundGuardTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - - @Test("No source file reaches for a SwiftUI toolbar background") - func appTargetHasNoToolbarBackground() throws { - let root = Self.repositoryRoot.appendingPathComponent("TablePro") - let enumerator = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) - - var offenders: [String] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - guard let source = try? String(contentsOf: url, encoding: .utf8) else { continue } - guard source.contains(".toolbarBackground(") else { continue } - offenders.append(url.lastPathComponent) - } - - #expect( - offenders.isEmpty, - "These reach a SwiftUI toolbar the app does not have: \(offenders.sorted())" - ) - } -} diff --git a/TableProTests/Theme/LiteralColorGuardTests.swift b/TableProTests/Theme/LiteralColorGuardTests.swift deleted file mode 100644 index 475675673e..0000000000 --- a/TableProTests/Theme/LiteralColorGuardTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// LiteralColorGuardTests.swift -// TableProTests -// -// A hand-set RGB, white or hex color never follows Light and Dark, Accent, Highlight or Increase -// Contrast. Colors are either a system semantic color or a value inside a theme definition. -// - -import Foundation -import Testing - -struct LiteralColorGuardTests { - private static let scannedTrees = ["TablePro", "Packages/TableProEditor/Sources"] - - // Theme definitions are where fixed colors belong. - private static let allowedDirectories = ["TablePro/Theme/"] - - // A plugin's brand color is metadata about the engine, drawn on its glyph. - private static let brandColorFilePrefix = "PluginMetadataRegistry" - private static let brandColorLineMarker = "brandColorHex" - - private static let allowedFiles = [ - // Maps a TablePlus status color from a connection URL onto the connection palette. - "ConnectionURLParser.swift", - // Swatches a CSS color found in the user's own JSON or PHP value. - "RGBAColor+NSColor.swift", - // Derives a gray from a color it is handed, inside the editor package. - "NSColor+Greyscale.swift", - ] - - static func isAllowed(path: String, line: String) -> Bool { - if allowedDirectories.contains(where: { path.hasPrefix($0) }) { - return true - } - let fileName = (path as NSString).lastPathComponent - if allowedFiles.contains(fileName) || fileName.contains("Fixture") { - return true - } - return fileName.hasPrefix(brandColorFilePrefix) && line.contains(brandColorLineMarker) - } - - static func rules() throws -> [ThemeSourceScan.Rule] { - [ - try ThemeSourceScan.Rule("RGB color", #"\b(?:NS)?Color\(red:"#), - try ThemeSourceScan.Rule( - "RGB color", - #"\bNSColor\((?:srgbRed|calibratedRed|deviceRed|displayP3Red):"# - ), - try ThemeSourceScan.Rule("color space color", #"\bColor\(\s*\.(?:sRGB|sRGBLinear|displayP3)\s*,"#), - try ThemeSourceScan.Rule("white color", #"\b(?:NS)?Color\(white:"#), - try ThemeSourceScan.Rule("white color", #"\bNSColor\((?:calibratedWhite|deviceWhite):"#), - try ThemeSourceScan.Rule("hex color", ##""#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?(?![0-9A-Fa-f])"##), - ] - } - - @Test("No literal color outside the theme definitions") - func noLiteralColors() throws { - let rules = try Self.rules() - let tree = try ThemeSourceScan.scan(Self.scannedTrees, rules: rules) - let lines = tree.lines.filter { !Self.isAllowed(path: $0.path, line: $0.text) } - let offenders = ThemeSourceScan.violations(in: lines, rules: rules) - - #expect(tree.fileCount > 1_000, "Scanned only \(tree.fileCount) files; the tree did not resolve") - #expect( - offenders.isEmpty, - """ - These hand-set a color. Use a system color (Color.primary, .secondary, NSColor.labelColor, \ - .systemRed, .separatorColor) or, for the editor and the grid, a theme slot: - \(offenders.joined(separator: "\n")) - """ - ) - } - - @Test("Every allowlisted file still exists") - func allowlistIsCurrent() throws { - var fileNames: Set = [] - _ = try ThemeSourceScan.scan(Self.scannedTrees) { path in - fileNames.insert((path as NSString).lastPathComponent) - return false - } - - for name in Self.allowedFiles { - #expect(fileNames.contains(name), "\(name) is allowlisted but no longer exists") - } - } - - @Test("The guard flags each literal form and passes system colors") - func matcherCatchesAViolation() throws { - let sample = """ - Color(red: 1.0, green: 0.4, blue: 0.47) - NSColor(srgbRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) - context.fill(Path(rect), with: .color(Color(white: 0.88))) - NSColor(calibratedWhite: 0.5, alpha: 1) - let accent = "#FF79C6" - let tint = Color(.sRGB, red: 0.2, green: 0.4, blue: 0.6, opacity: 1) - // Color(red: 0, green: 0, blue: 0) in a comment - let label = NSColor.labelColor - let issue = "#1234 was fixed" - let tooLong = "#FF79C6A" - """ - let lines = ThemeSourceScan.flaggedLines(in: sample, rules: try Self.rules()) - - #expect(lines == [1, 2, 3, 4, 5, 6]) - } - - @Test("Brand colors are allowed only on their own line in the registry") - func brandColorExemptionIsNarrow() { - let registry = "TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift" - #expect(Self.isAllowed(path: registry, line: ##"brandColorHex: "#336791","##)) - #expect(!Self.isAllowed(path: registry, line: ##"let tint = "#336791""##)) - #expect(!Self.isAllowed(path: "TablePro/Views/AIChat/ChatComposerView.swift", line: "Color(red: 1, green: 0, blue: 0)")) - } -} diff --git a/TableProTests/Theme/NativeDefaultsTests.swift b/TableProTests/Theme/NativeDefaultsTests.swift index 39a3102029..40b43836fc 100644 --- a/TableProTests/Theme/NativeDefaultsTests.swift +++ b/TableProTests/Theme/NativeDefaultsTests.swift @@ -34,7 +34,7 @@ struct NativeDefaultsTests { } private static func bundledTheme(_ id: String) throws -> ThemeDefinition { - let url = try ThemeSourceScan.repositoryRoot().appendingPathComponent("TablePro/Resources/Themes/\(id).json") + let url = try #require(Bundle.main.url(forResource: id, withExtension: "json")) return try ThemeDocument(data: try Data(contentsOf: url)).definition() } diff --git a/TableProTests/Theme/PaneBackgroundGuardTests.swift b/TableProTests/Theme/PaneBackgroundGuardTests.swift deleted file mode 100644 index 3d3fa29792..0000000000 --- a/TableProTests/Theme/PaneBackgroundGuardTests.swift +++ /dev/null @@ -1,216 +0,0 @@ -// -// PaneBackgroundGuardTests.swift -// TableProTests -// -// A List, Form or ScrollView draws its own background, and that background follows the system. -// Hiding it, painting list rows, or wrapping a pane in the old `themedContent` surface is how panes -// stopped matching an unthemed Mac app. A window's color comes only from ThemeWindowBackdrop. -// - -import Foundation -import Testing - -struct PaneBackgroundGuardTests { - private static let windowBackdropOwner = "TablePro/Theme/ThemeWindowBackdrop.swift" - - // A clear backdrop is how a borderless panel takes its own shape; it fills nothing. - private static let notClear = #"(?!\s*(?:NSColor)?\.clear\b)"# - - static func rules() throws -> [ThemeSourceScan.Rule] { - [ - try ThemeSourceScan.Rule("hidden scroll content background", #"\.scrollContentBackground\(\s*\.hidden\s*\)"#), - try ThemeSourceScan.Rule("themed content surface", #"\b[Tt]hemedContent"#), - try ThemeSourceScan.Rule("list row background", #"\.listRowBackground\("#), - ] - } - - struct WindowRules { - let write: ThemeSourceScan.Rule - let named: ThemeSourceScan.Rule - let own: ThemeSourceScan.Rule - let subclass: ThemeSourceScan.Rule - let local: ThemeSourceScan.Rule - } - - static func windowRules() throws -> WindowRules { - WindowRules( - write: try ThemeSourceScan.Rule("background color write", #"\bbackgroundColor\s*=(?!=)"#), - named: try ThemeSourceScan.Rule( - "window background color", - #"(?:\b\w*(?:[Ww]indow|[Pp]anel|[Ss]heet)\w*|\b(?:w|win|wnd))[?!]?\.backgroundColor\s*=(?!=)"# + notClear - ), - own: try ThemeSourceScan.Rule( - "window subclass background color", - #"(?:\bself\.|(? Set { - var names: Set = [] - for line in lines { - let text = line.text as NSString - for match in rule.expression.matches(in: line.text, range: NSRange(location: 0, length: text.length)) { - for group in 1 ..< match.numberOfRanges where match.range(at: group).location != NSNotFound { - names.insert(text.substring(with: match.range(at: group))) - } - } - } - return names - } - - /// A bare `backgroundColor =` is the window's own only in a file that subclasses a window or panel. - static func windowBackgroundWrites(in lines: [ThemeSourceScan.Line]) throws -> [String] { - let rules = try windowRules() - let candidates = lines.filter { $0.path != windowBackdropOwner } - let windowFiles = Set(candidates.filter { rules.subclass.matches($0.text) }.map(\.path)) - var offenders = ThemeSourceScan.violations(in: candidates, rules: [rules.named]) - + ThemeSourceScan.violations(in: candidates.filter { windowFiles.contains($0.path) }, rules: [rules.own]) - - for (_, file) in Dictionary(grouping: candidates, by: \.path) { - let locals = windowLocals(in: file, rule: rules.local) - guard !locals.isEmpty else { continue } - let write = try ThemeSourceScan.Rule( - "window local background color", - #"(? 1_000, "Scanned only \(tree.fileCount) files; the tree did not resolve") - #expect( - offenders.isEmpty, - """ - Let the List, Form or ScrollView draw its own background and rows: - \(offenders.joined(separator: "\n")) - """ - ) - } - - @Test("Only the window backdrop sets a window's background color") - func windowColorComesFromTheBackdrop() throws { - let rules = try Self.windowRules() - let tree = try ThemeSourceScan.scan(["TablePro"], rules: [rules.write]) - let offenders = try Self.windowBackgroundWrites(in: tree.lines) - - #expect(tree.fileCount > 1_000, "Scanned only \(tree.fileCount) files; the tree did not resolve") - #expect( - offenders.isEmpty, - """ - Hold a ThemeWindowBackdrop instead; it sets the window color only while a theme sets \ - window.background and restores the system one otherwise: - \(offenders.joined(separator: "\n")) - """ - ) - } - - @Test("The guard flags a hidden background, painted rows and the themed surface") - func matcherCatchesAViolation() throws { - let sample = """ - Form { content } - .scrollContentBackground(.hidden) - List { rows }.themedContent() - // .scrollContentBackground(.hidden) was removed - ScrollView { rows } - .scrollContentBackground(.visible) - struct ThemedContentSurface: ViewModifier {} - Text(name).listRowBackground(Color.clear) - // .listRowBackground(fill) was removed - """ - let lines = ThemeSourceScan.flaggedLines(in: sample, rules: try Self.rules()) - - #expect(lines == [2, 3, 7, 8]) - } - - @Test("The guard flags a window color set outside the backdrop") - func matcherCatchesAWindowColor() throws { - let window = """ - final class SheetPanel: NSPanel { - func configure() { - backgroundColor = palette[.windowBackdrop] - self.backgroundColor = .windowBackgroundColor - backgroundColor = .clear - layer?.backgroundColor = fill - // backgroundColor = .red - if backgroundColor == .clear {} - } - } - """ - let controller = """ - final class PaneController: NSViewController { - func configure() { - backgroundColor = fill - view.window?.backgroundColor = palette[.windowBackdrop] - editorWindow.backgroundColor = .textBackgroundColor - panel.backgroundColor = NSColor.clear - scrollView.backgroundColor = fill - textView.backgroundColor = fill - sheet.backgroundColor = fill - sheetController.window?.backgroundColor = fill - win.backgroundColor = .clear - } - } - """ - let locals = """ - func restyle(_ target: NSWindow, view: NSView) { - target.backgroundColor = fill - let w = view.window - w?.backgroundColor = fill - guard let host = view.window else { return } - host.backgroundColor = fill - let content = windowContent.scrollView - content.backgroundColor = fill - host.contentView?.backgroundColor = fill - } - """ - let subclass = """ - final class ToolWindow: EditorWindow { - func configure() { - backgroundColor = fill - } - } - """ - let windowController = """ - final class ToolController: NSWindowController { - func configure() { - backgroundColor = fill - } - } - """ - let lines = ThemeSourceScan.codeLines(of: window, path: "SheetPanel.swift") - + ThemeSourceScan.codeLines(of: controller, path: "PaneController.swift") - + ThemeSourceScan.codeLines(of: locals, path: "Restyle.swift") - + ThemeSourceScan.codeLines(of: subclass, path: "ToolWindow.swift") - + ThemeSourceScan.codeLines(of: windowController, path: "ToolController.swift") - + ThemeSourceScan.codeLines(of: window, path: "TablePro/Theme/ThemeWindowBackdrop.swift") - let flagged = try Self.windowBackgroundWrites(in: lines).map { $0.components(separatedBy: ": ")[0] } - - #expect(flagged.sorted() == [ - "PaneController.swift:10", - "PaneController.swift:4", - "PaneController.swift:5", - "PaneController.swift:9", - "Restyle.swift:2", - "Restyle.swift:4", - "Restyle.swift:6", - "SheetPanel.swift:3", - "SheetPanel.swift:4", - "ToolWindow.swift:3", - ]) - } -} diff --git a/TableProTests/Theme/ThemeCatalogTests.swift b/TableProTests/Theme/ThemeCatalogTests.swift index 35c323636e..5ea0681c0f 100644 --- a/TableProTests/Theme/ThemeCatalogTests.swift +++ b/TableProTests/Theme/ThemeCatalogTests.swift @@ -208,7 +208,7 @@ struct ThemeCatalogTests { func bundledThemesLoad() throws { let sandbox = try Sandbox() defer { sandbox.remove() } - let bundle = try ThemeSourceScan.repositoryRoot().appendingPathComponent("TablePro/Resources/Themes") + let bundle = try #require(Bundle.main.resourceURL) let catalog = ThemeCatalog(userDirectory: sandbox.themes, bundleDirectory: bundle) let ids = catalog.themes.map(\.id) diff --git a/TableProTests/Theme/ThemeColorReadGuardTests.swift b/TableProTests/Theme/ThemeColorReadGuardTests.swift deleted file mode 100644 index ef65236f46..0000000000 --- a/TableProTests/Theme/ThemeColorReadGuardTests.swift +++ /dev/null @@ -1,289 +0,0 @@ -// -// ThemeColorReadGuardTests.swift -// TableProTests -// -// A theme colors content and the window backdrop: the code views, the data grid and the closed list of -// content surfaces below. Chrome, controls, Settings and forms stay system, so a theme read or theming -// helper in any other file fails here. -// - -import Foundation -@testable import TablePro -import Testing - -struct ThemeColorReadGuardTests { - private static let allowedDirectories = [ - "TablePro/Theme/", - "TablePro/Views/Settings/Appearance/", - "TablePro/Views/Results/Cells/", - "TablePro/Views/Results/Selection/", - ] - - // The data grid's own files under TablePro/Views/Results/; the satellites around it are panes. - private static let gridFilePrefixes = ["DataGrid", "SortableHeader", "CellOverlay"] - - private static let codeViewFiles = [ - "SQLEditorView", - "TableProEditorTheme", - "HighlightedSQLTextView", - "SQLStatementPreview", - "SQLCodePreview", - "JSONCodeEditor", - "AIChatCodeBlockView", - "AIChatToolUseBlockView", - "AIChatToolResultBlockView", - "DDLTextView", - "TriggerEditorView", - "RewindReviewSheet", - "AgentProposedStatementsView", - "VimCursorManager", - "FoldPreviewView", - "CodeWell", - "ValueWell", - "TextValueEditor", - "ValueSourceEditor", - "TextViewerWindowController", - "SvgViewerContentView", - "CellImageWindowController", - "ServerOutputTextView", - "HexEditorContentView", - "PhpViewerView", - "FavoriteEditDialog", - "OptionsPaneView", - "SQLPreviewSheet", - "MaintenanceSheet", - "HistoryDetailPane", - ] - - private static let contentWindowFiles = [ - "TabWindowController", - "DataFileWindowController", - "CompareSyncWindowController", - "ValueViewerWindowController", - ] - - private static let contentSurfaceFiles = [ - "MainEditorContentView", - "GridStandInBackground", - "EditorTabStrip", - "EditorTabStripSurfaces", - "VersionHistoryTabView", - "ObjectSourceTabView", - "DataFileContentView", - "FilterPanelView", - "TableStructureView", - "TriggerDetailView", - "ClickHousePartsView", - "PrivilegeScopeOutlineView", - "TreeOutlineView", - "TreeOutlineCoordinator", - "TreeOutlineCellView", - "TreeValueStyle", - "TreeValueTone+Theme", - "JSONRowInspectorView", - "JSONNodeRowView", - "JSONRowColors", - "SingleLineEditorView", - "FieldEditorContent", - "MultiLineEditorView", - "JsonEditorView", - "ImageFieldView", - "PhpSerializedFieldView", - "CompareStatusStyle", - "TextDiffView", - "FileConflictDiffSheet", - "AIChatWalkthroughBlockView", - "ERDiagramPalette", - "ERDiagramNodeRenderer", - "ERDiagramScene", - "ERDiagramSceneView", - "MagnifiableCanvasView", - "QueryPlanDiagramView", - "QueryPlanDetailPane", - "QueryPlanOutlineCoordinator", - "ResultChartCanvas", - "ThemePreviewCard", - ] - - private static var allowedFiles: [String] { - codeViewFiles + contentWindowFiles + contentSurfaceFiles - } - - static func isAllowed(_ path: String) -> Bool { - if allowedDirectories.contains(where: { path.hasPrefix($0) }) { - return true - } - let fileName = (path as NSString).lastPathComponent - if path.hasPrefix("TablePro/Views/Results/"), gridFilePrefixes.contains(where: { fileName.hasPrefix($0) }) { - return true - } - return allowedFiles.contains { fileName == "\($0).swift" || fileName.hasPrefix("\($0)+") } - } - - static func rules() throws -> [ThemeSourceScan.Rule] { - [ - try ThemeSourceScan.Rule("palette subscript", #"\bpalette\["#), - try ThemeSourceScan.Rule("palette color", #"\bpalette\.color\("#), - try ThemeSourceScan.Rule("resolved subscript", #"\.resolved\[|\bresolved\[\s*\."#), - try ThemeSourceScan.Rule("resolved color", #"\bresolved\.(?:color|cgColor|isOverridden)\("#), - try ThemeSourceScan.Rule("ThemeSlot", #"\bThemeSlot\b"#), - try ThemeSourceScan.Rule("theme slot", ThemeSourceScan.slotTokenPattern()), - try ThemeSourceScan.Rule( - "theme surface", - #"\btheme(?:Background|Foreground)\(|\bvalueWell\(|\bCodeWell\b|\bstyle:\s*\.code\b"# - ), - try ThemeSourceScan.Rule( - "theme chrome", - #"\bTheme(?:ShapeStyle|TextViewStyle|TableChrome|TableRowView|TableRowDrawing|WindowBackdrop)\b"# - ), - try ThemeSourceScan.Rule("theme helper", Self.helperPattern), - ] - } - - private static let helpers = ["ValueSourceEditor", "ERDiagramPalette", "DiagramCanvasBackdrop", "HighlightedSQLTextView"] - - private static var helperPattern: String { - #"\b(?:gridStandInBackground|diffLineForeground|makeEditorTheme)\(|\bCompareStatusStyle\.(?:lineTint|rowTint)"# - + #"|\bTableProEditorTheme\.make\(|\bEditorTabStripPalette\.trackFill\b"# - + #"|\b(?:TreeValueTone\.\w+|tone(?:\(for: \w+\))?)\.color\b"# - + #"|\b(?:"# + helpers.joined(separator: "|") + #")\b"# - } - - /// The files whose code reads the theme, by name without the extension. Declaring a helper is - /// not reading the theme through it. - static func readers(in lines: [ThemeSourceScan.Line], rules: [ThemeSourceScan.Rule]) throws -> Set { - let names = (helpers + ["makeEditorTheme"]).joined(separator: "|") - let declaration = try ThemeSourceScan.Rule( - "helper declaration", - #"\b(?:struct|enum|class|protocol|extension|typealias|func|var|let)\s+(?:"# + names + #")\b"# - ) - var readers: Set = [] - for line in lines where rules.contains(where: { $0.matches(line.text) }) && !declaration.matches(line.text) { - readers.insert(((line.path as NSString).lastPathComponent as NSString).deletingPathExtension) - } - return readers - } - - @Test("Only content surfaces and the window backdrop read the theme") - func themeStaysOnContentSurfaces() throws { - let rules = try Self.rules() - let tree = try ThemeSourceScan.scan(["TablePro"], rules: rules) { !Self.isAllowed($0) } - let offenders = ThemeSourceScan.violations(in: tree.lines, rules: rules) - - #expect(tree.fileCount > 1_000, "Scanned only \(tree.fileCount) files; the tree did not resolve") - #expect( - offenders.isEmpty, - """ - These theme a surface outside the content files this suite lists. Chrome, controls, Settings \ - and forms use a system color (.primary, .secondary, Color(nsColor: .separatorColor), \ - NSColor.labelColor, .systemRed); a new content surface goes on the list: - \(offenders.joined(separator: "\n")) - """ - ) - } - - @Test("Every allowlisted file still exists") - func allowlistIsCurrent() throws { - var fileNames: Set = [] - _ = try ThemeSourceScan.scan(["TablePro"]) { path in - fileNames.insert((path as NSString).lastPathComponent) - return false - } - - for name in Self.allowedFiles { - #expect(fileNames.contains("\(name).swift"), "\(name).swift is allowlisted but no longer exists") - } - } - - @Test("A file that stops reading the theme leaves the list") - func entriesStillReadTheTheme() throws { - let rules = try Self.rules() - let names = Set(Self.allowedFiles) - let tree = try ThemeSourceScan.scan(["TablePro"], rules: rules) { path in - names.contains(((path as NSString).lastPathComponent as NSString).deletingPathExtension) - } - let readers = try Self.readers(in: tree.lines, rules: rules) - - #expect(names.subtracting(readers).isEmpty, "Read no theme API: \(names.subtracting(readers).sorted())") - } - - @Test("Declaring a helper does not count as reading the theme, and calling it does") - func helperDeclarationIsNotARead() throws { - let rules = try Self.rules() - let declares = ThemeSourceScan.codeLines( - of: "final class DiagramCanvasBackdrop {\nstruct HighlightedSQLTextView: NSViewRepresentable {", - path: "Helper.swift" - ) - let calls = ThemeSourceScan.codeLines(of: "backdrop = DiagramCanvasBackdrop(scrollView: scrollView)", path: "Caller.swift") - - #expect(ThemeSourceScan.violations(in: declares, rules: rules).count == 2) - #expect(try Self.readers(in: declares, rules: rules).isEmpty) - #expect(try Self.readers(in: calls, rules: rules) == ["Caller"]) - } - - @Test("The guard flags a theme read and passes plain code") - func matcherCatchesAViolation() throws { - let sample = """ - let fill = themeEngine.palette.color(.panelBackground) - // palette[.gridText] named in a comment - let text = ThemeEngine.shared.resolved[.gridText] - let local = resolved[fieldId] - let request = self.editorText - let tint = activeTheme.color(for: .syntaxKeyword) - content.themeBackground(slot, native: .background) - let chrome = ThemeTableChrome(tableView: outline, family: .records) - CodeWell(sql, font: font) - TextValueEditor(text: $text, style: .code) - field.valueWell(clipsContent: true) - let style: Kind = .codeBlock - placeholder.gridStandInBackground() - // CodeWell and .valueWell() named in a comment - content.background(.background) - keyField.textColor = TreeValueTone.key.color - Color(nsColor: tone(for: scalar).color) - isEmphasized ? .alternateSelectedControlTextColor : tone.color - .fill(EditorTabStripPalette.trackFill) - backdrop = DiagramCanvasBackdrop(scrollView: scrollView) - HighlightedSQLTextView(sql: sql, lineLayout: .wrapped) - theme: TableProEditorTheme.make(), - final class ThemedRow: NSTableRowView, ThemeTableRowDrawing {} - swatch.color = decoration.color - """ - let lines = ThemeSourceScan.flaggedLines(in: sample, rules: try Self.rules()) - - #expect(lines == [1, 3, 6, 7, 8, 9, 10, 11, 13, 16, 17, 18, 19, 20, 21, 22, 23]) - - let messages = ThemeSourceScan.violations( - in: ThemeSourceScan.codeLines(of: sample, path: "Pane.swift"), - rules: try Self.rules() - ) - #expect(messages.first?.hasPrefix("Pane.swift:1: ") == true, "\(messages)") - } - - @Test("The allowlist covers content surfaces and nothing beside them") - func allowlistShape() { - #expect(Self.isAllowed("TablePro/Views/Results/DataGridRowView.swift")) - #expect(Self.isAllowed("TablePro/Views/Results/Extensions/DataGridView+Selection.swift")) - #expect(Self.isAllowed("TablePro/Views/Results/Cells/DataGridCellPalette.swift")) - #expect(Self.isAllowed("TablePro/Views/Editor/SQLEditorView.swift")) - #expect(Self.isAllowed("TablePro/Views/Structure/DDLTextView.swift")) - #expect(Self.isAllowed("TablePro/Views/Structure/TriggerDetailView.swift")) - #expect(Self.isAllowed("TablePro/Views/Results/TreeOutline/TreeValueTone+Theme.swift")) - #expect(Self.isAllowed("TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift")) - #expect(Self.isAllowed("TablePro/Views/RowInspector/JSON/JSONRowColors.swift")) - #expect(Self.isAllowed("TablePro/Views/Editor/History/HistoryDetailPane.swift")) - #expect(Self.isAllowed("TablePro/Core/Services/Infrastructure/TabWindowController.swift")) - #expect(Self.isAllowed("TablePro/Theme/ThemeEngine+EditorTheme.swift")) - - #expect(!Self.isAllowed("TablePro/Views/Editor/QueryEditorBar.swift")) - #expect(!Self.isAllowed("TablePro/Views/Editor/History/HistoryListPane.swift")) - #expect(!Self.isAllowed("TablePro/Views/Results/TreeOutline/TreeOutlineMenu.swift")) - #expect(!Self.isAllowed("TablePro/Views/Results/InlineErrorBanner.swift")) - #expect(!Self.isAllowed("TablePro/Views/RowInspector/InspectorFieldListView.swift")) - #expect(!Self.isAllowed("TablePro/Views/UsersRoles/PrivilegeScopeRowView.swift")) - #expect(!Self.isAllowed("TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift")) - #expect(!Self.isAllowed("TablePro/Views/Settings/GeneralSettingsView.swift")) - #expect(!Self.isAllowed("TablePro/Views/Welcome/WelcomeOutlineView.swift")) - #expect(!Self.isAllowed("TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift")) - } -} diff --git a/TableProTests/Theme/ThemeContrastTests.swift b/TableProTests/Theme/ThemeContrastTests.swift index 4a9114b4da..d075dd98d8 100644 --- a/TableProTests/Theme/ThemeContrastTests.swift +++ b/TableProTests/Theme/ThemeContrastTests.swift @@ -15,9 +15,8 @@ import Testing @MainActor struct ThemeContrastTests { private static func bundledThemes() throws -> [ThemeDefinition] { - let directory = try ThemeSourceScan.repositoryRoot().appendingPathComponent("TablePro/Resources/Themes") - return try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "json" } + try (Bundle.main.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? []) + .filter { $0.lastPathComponent.hasPrefix(ThemeDefinition.builtInPrefix) } .map { try ThemeDocument(data: try Data(contentsOf: $0)).definition() } } diff --git a/TableProTests/Theme/ThemeSlotCoverageTests.swift b/TableProTests/Theme/ThemeSlotCoverageTests.swift index 97c4bca1cd..cc780c3d37 100644 --- a/TableProTests/Theme/ThemeSlotCoverageTests.swift +++ b/TableProTests/Theme/ThemeSlotCoverageTests.swift @@ -2,10 +2,6 @@ // ThemeSlotCoverageTests.swift // TableProTests // -// A slot exists only because a call site reads it. Whole colour groups once shipped declared, -// editable and read by nothing, and the substring check that replaced them passed for -// `.editorText` through `self.editorText` and for `.gridSelection` through `.gridSelectionText`. -// import AppKit import Foundation @@ -13,58 +9,6 @@ import Foundation import Testing struct ThemeSlotCoverageTests { - /// The bridge that builds the editor theme is the one file under `TablePro/Theme/` that reads - /// slots rather than declaring them; the editor slots have no other reader. - private static let readerInsideThemeDirectory = "TablePro/Theme/ThemeEngine+EditorTheme.swift" - - private static func isReader(_ path: String) -> Bool { - !path.hasPrefix("TablePro/Theme/") || path == readerInsideThemeDirectory - } - - private static func referencedSlots(in lines: [ThemeSourceScan.Line]) throws -> Set { - let expression = try NSRegularExpression(pattern: ThemeSourceScan.slotTokenPattern()) - var found: Set = [] - for line in lines { - let range = NSRange(line.text.startIndex..., in: line.text) - for match in expression.matches(in: line.text, range: range) { - guard let name = Range(match.range(at: 1), in: line.text) else { continue } - found.insert(String(line.text[name])) - } - } - return found - } - - @Test("Every slot is read as a whole token outside the theme definitions") - func everySlotHasAReader() throws { - let slotRule = try ThemeSourceScan.Rule("theme slot", ThemeSourceScan.slotTokenPattern()) - let tree = try ThemeSourceScan.scan( - ["TablePro", "Packages"], - skipping: ["Tests"], - rules: [slotRule], - including: Self.isReader - ) - let referenced = try Self.referencedSlots(in: tree.lines) - - #expect(tree.fileCount > 500, "Scanned only \(tree.fileCount) files; the tree did not resolve") - for slot in ThemeSlot.allCases { - #expect(referenced.contains(String(describing: slot)), "Nothing outside TablePro/Theme/ reads .\(slot)") - } - } - - @Test("The token match ignores properties, longer names and comments") - func tokenMatchIsExact() throws { - let sample = """ - let request = self.editorText - let fill = palette[.gridSelectionText] - // resolved[.gridDeleted] was removed - let text = resolved[.syntaxKeyword] - let typed = ThemeSlot.gridLine - """ - let referenced = try Self.referencedSlots(in: ThemeSourceScan.codeLines(of: sample, path: "Sample.swift")) - - #expect(referenced == ["gridSelectionText", "syntaxKeyword", "gridLine"]) - } - @Test("Slot paths are unique and sit under their group's key") func slotPathsAreWellFormed() { let paths = ThemeSlot.allCases.map(\.rawValue) @@ -104,17 +48,16 @@ struct ThemeSlotCoverageTests { // MARK: - Bundled themes - /// The files on disk are the list, so a third bundled theme is decoded here the day it lands. - private static func bundledThemeFiles() throws -> [URL] { - let directory = try ThemeSourceScan.repositoryRoot().appendingPathComponent("TablePro/Resources/Themes") - return try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "json" } + /// The bundle is the list, so a third bundled theme is decoded here the day it ships. + private static func bundledThemeFiles() -> [URL] { + (Bundle.main.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? []) + .filter { $0.lastPathComponent.hasPrefix(ThemeDefinition.builtInPrefix) } .sorted { $0.lastPathComponent < $1.lastPathComponent } } @Test("Every bundled theme decodes, and its id is its file name") func bundledThemesDecode() throws { - let files = try Self.bundledThemeFiles() + let files = Self.bundledThemeFiles() var ids: Set = [] for file in files { diff --git a/TableProTests/Theme/ThemeSourceScan.swift b/TableProTests/Theme/ThemeSourceScan.swift deleted file mode 100644 index 053f451b33..0000000000 --- a/TableProTests/Theme/ThemeSourceScan.swift +++ /dev/null @@ -1,234 +0,0 @@ -// -// ThemeSourceScan.swift -// TableProTests -// - -import Foundation -@testable import TablePro - -/// Reads the real source tree for the theme guard suites. Comments are dropped before matching, -/// because a comment may name exactly what was removed. -enum ThemeSourceScan { - struct Line { - let path: String - let number: Int - let text: String - - var location: String { "\(path):\(number)" } - } - - struct Tree { - let fileCount: Int - let lines: [Line] - } - - struct Rule { - let name: String - let expression: NSRegularExpression - - init(_ name: String, _ pattern: String) throws { - self.name = name - expression = try NSRegularExpression(pattern: pattern) - } - - func matches(_ text: String) -> Bool { - expression.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil - } - } - - enum ScanError: Error { - case repositoryRootNotFound - case treeNotFound(String) - } - - static func repositoryRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 8 { - let manifest = directory.appendingPathComponent("project.yml").path - let themeTree = directory.appendingPathComponent("TablePro/Theme").path - if FileManager.default.fileExists(atPath: manifest), FileManager.default.fileExists(atPath: themeTree) { - return directory - } - directory.deleteLastPathComponent() - } - throw ScanError.repositoryRootNotFound - } - - /// Hidden directories (`.build`, `.swiftpm`) are never source, and `skipping` names more by - /// their last path component, such as a package's `Tests`. A file whose raw text matches none - /// of `rules` is counted but not lexed, which keeps an unoptimized test build fast. - static func scan( - _ trees: [String], - skipping: Set = [], - rules: [Rule] = [], - including include: (String) -> Bool = { _ in true } - ) throws -> Tree { - let root = try repositoryRoot() - var fileCount = 0 - var lines: [Line] = [] - - for tree in trees { - let base = root.appendingPathComponent(tree, isDirectory: true) - guard let enumerator = FileManager.default.enumerator( - at: base, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - ) else { - throw ScanError.treeNotFound(tree) - } - - for case let url as URL in enumerator { - if skipping.contains(url.lastPathComponent), - (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true { - enumerator.skipDescendants() - continue - } - guard url.pathExtension == "swift" else { continue } - - let path = relativePath(of: url, in: base, tree: tree) - guard include(path) else { continue } - - fileCount += 1 - let source = try String(contentsOf: url, encoding: .utf8) - guard rules.isEmpty || rules.contains(where: { $0.matches(source) }) else { continue } - lines += codeLines(of: source, path: path) - } - } - - return Tree(fileCount: fileCount, lines: lines) - } - - static func violations(in lines: [Line], rules: [Rule]) -> [String] { - lines.flatMap { line in - rules - .filter { $0.matches(line.text) } - .map { "\(line.location): \($0.name): \(line.text.trimmingCharacters(in: .whitespaces))" } - } - } - - /// The line numbers of an inline sample that the rules flag, for a guard's self-test. - static func flaggedLines(in sample: String, rules: [Rule]) -> Set { - let lines = codeLines(of: sample, path: "Sample.swift").filter { line in - rules.contains { $0.matches(line.text) } - } - return Set(lines.map(\.number)) - } - - /// `.editorText` or `ThemeSlot.editorText`. A dot after an identifier, `)` or `]` is some - /// other property (`self.editorText`), and the closing word boundary keeps `.gridSelection` - /// from matching inside `.gridSelectionText`. - static func slotTokenPattern(_ slots: [ThemeSlot] = ThemeSlot.allCases) -> String { - let names = slots.map { String(describing: $0) }.joined(separator: "|") - return #"(?:(? [Line] { - var state = LexState() - var lines: [Line] = [] - for (index, raw) in source.components(separatedBy: "\n").enumerated() { - let code = strip(raw, state: &state) - guard !code.trimmingCharacters(in: .whitespaces).isEmpty else { continue } - lines.append(Line(path: path, number: index + 1, text: code)) - } - return lines - } - - private struct LexState { - var inBlockComment = false - var inMultilineString = false - } - - private static let slash = UInt8(ascii: "/") - private static let star = UInt8(ascii: "*") - private static let quote = UInt8(ascii: "\"") - private static let backslash = UInt8(ascii: "\\") - - /// Enough of a lexer to drop `//` and `/* */` comments without cutting a `//` inside a string - /// literal, such as a URL. It works on UTF-8 bytes: every token it looks for is ASCII, and no - /// byte of a multi-byte character is. - private static func strip(_ line: String, state: inout LexState) -> String { - let bytes = Array(line.utf8) - var kept: [UInt8] = [] - kept.reserveCapacity(bytes.count) - var index = 0 - var inString = false - - func pair(_ first: UInt8, _ second: UInt8, at position: Int) -> Bool { - position + 1 < bytes.count && bytes[position] == first && bytes[position + 1] == second - } - - func tripleQuote(at position: Int) -> Bool { - position + 2 < bytes.count - && bytes[position] == quote && bytes[position + 1] == quote && bytes[position + 2] == quote - } - - while index < bytes.count { - let byte = bytes[index] - - if state.inBlockComment { - if pair(star, slash, at: index) { - state.inBlockComment = false - index += 2 - } else { - index += 1 - } - continue - } - - if state.inMultilineString { - if tripleQuote(at: index) { - state.inMultilineString = false - kept += [quote, quote, quote] - index += 3 - } else { - kept.append(byte) - index += 1 - } - continue - } - - if inString { - kept.append(byte) - if byte == backslash, index + 1 < bytes.count { - kept.append(bytes[index + 1]) - index += 2 - continue - } - if byte == quote { - inString = false - } - index += 1 - continue - } - - if pair(slash, slash, at: index) { - break - } - if pair(slash, star, at: index) { - state.inBlockComment = true - index += 2 - continue - } - if tripleQuote(at: index) { - state.inMultilineString = true - kept += [quote, quote, quote] - index += 3 - continue - } - if byte == quote { - inString = true - } - kept.append(byte) - index += 1 - } - - return String(bytes: kept, encoding: .utf8) ?? line - } - - private static func relativePath(of url: URL, in base: URL, tree: String) -> String { - let basePath = base.standardizedFileURL.path - let filePath = url.standardizedFileURL.path - guard filePath.hasPrefix(basePath + "/") else { return url.lastPathComponent } - return tree + filePath.dropFirst(basePath.count) - } -} diff --git a/TableProTests/Theme/ValueFontTests.swift b/TableProTests/Theme/ValueFontTests.swift index f8f1dd6944..fbea0e9896 100644 --- a/TableProTests/Theme/ValueFontTests.swift +++ b/TableProTests/Theme/ValueFontTests.swift @@ -4,8 +4,8 @@ // // The row inspector, the cell popovers and the Compare row diff all render a stored value, so they // follow the data grid font rather than the editor font, as the grid's own inline cell editor -// already did. These tests pin that choice and the places that carry it, because both settings -// ship at the same family and size, so a regression is invisible until a user splits them. +// already did. Both settings ship at the same family and size, so a regression is invisible until +// a user splits them. // import AppKit @@ -13,21 +13,13 @@ import Foundation @testable import TablePro import Testing -/// The first two tests change the fonts on the shared `ThemeEngine`. What keeps that from reaching a +/// Both tests change the fonts on the shared `ThemeEngine`. What keeps that from reaching a /// suite running in parallel is that both bodies are synchronous and `@MainActor`, so nothing else on /// the main actor can interleave between applying the test fonts and restoring the original ones, the /// same way `DataGridRowTintThemeTests` holds. Adding an `await` inside `withTypography` breaks it. @Suite("Stored value font", .serialized) @MainActor struct ValueFontTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - private static func typography(editorSize: Int, gridSize: Int) -> TypographySettings { TypographySettings( editorFontFamily: "Menlo", @@ -67,147 +59,4 @@ struct ValueFontTests { #expect(afterSmall == afterLarge) } - - // MARK: - The places that carry the rule - - /// The inspector sets the font once for the whole editor subtree, so a field editor added later - /// inherits it without knowing the rule exists. Losing this puts every inspector field back on the - /// system face at once, and the structured viewers have to keep opting out, because they carry - /// their own toolbar and placeholders and present the same way in a pop-out window. - @Test("The inspector sets the value font on its editor subtree") - func inspectorSetsTheValueFontOnce() throws { - let source = try source(of: "TablePro/Views/RowInspector/InspectorFieldRow.swift") - #expect(source.contains(".font(Self.valueFont(for: kind))")) - #expect(source.contains("ThemeEngine.shared.valueFontSwiftUI")) - /// The opt-out list, spelled out so a kind cannot join it silently. Every member carries its - /// own toolbar and presents in a pop-out window too, so each names the value font on its own - /// value text; `standaloneValueViewsResolveTheValueFont` is what holds them to that. - #expect(source.contains("case .json, .phpSerialized, .image:")) - /// A geometry field opts out on a line of its own, so the list above still reads as it did - /// and this kind cannot drift into the arm that inherits. - #expect(source.contains("case .geometry:\n return nil")) - } - - /// Everything outside the inspector has no shared root to inherit from: a popover and a pop-out - /// window are each their own presentation, and an `NSViewRepresentable` never sees a SwiftUI - /// `.font` at all. Each of these therefore resolves it by name. - /// - /// The Compare rows pane is not among them any more. It draws its values through `DataGridView`, - /// which takes its cell font from `ThemeEngine.dataGridFonts` in `DataGridCellPalette`, and that - /// is the same setting `valueFont` returns. A pane that no longer renders a value itself has - /// nothing to name. - @Test("Every value view outside the inspector resolves the value font") - func standaloneValueViewsResolveTheValueFont() throws { - let paths = [ - "TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift", - "TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift", - "TablePro/Views/RowInspector/FieldEditors/SetPickerView.swift", - "TablePro/Views/Results/CellOverlayEditor.swift", - "TablePro/Views/Results/CellOverlayViewer.swift", - /// The text viewer, SVG source and cell image markup draw through this editor. - "TablePro/Views/Results/ValueSourceEditor.swift", - "TablePro/Views/Results/HexEditorContentView.swift", - "TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift", - /// Reached from `.blobHex`, which inherits the value font from the row, and from - /// `.image`, which does not, so it has to name the font itself or the same dump renders - /// monospaced down one route and proportional down the other. - "TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift", - /// Opts out of the row's font and hosts a text editor, a JSON editor or a hex dump - /// under its Text segment, so the pane names the font for all three. - "TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift", - "TablePro/Views/Results/ForeignKeyPreviewView.swift", - "TablePro/Views/Results/ArrayValueEditorView.swift", - "TablePro/Views/Results/SetPopoverContentView.swift", - /// The JSON and PHP trees both draw their rows through this outline. - "TablePro/Views/Results/TreeOutline/TreeOutlineRepresentable.swift", - "TablePro/Views/Results/PhpViewerView.swift", - ] - - /// Either spelling names the value font. A view reads it through the `themeEngine` it - /// observes, so a change in Settings redraws it; a static helper, which has no instance to - /// observe through, reads the shared engine directly. - var offenders: [String] = [] - for path in paths { - let text = try source(of: path) - if !text.contains("ThemeEngine.shared.valueFont"), !text.contains("themeEngine.valueFont") { - offenders.append(path) - } - } - - #expect(offenders.isEmpty, "These render a stored value and must resolve its font: \(offenders)") - } - - @Test("The value source viewers draw their text through the value source editor") - func sourceViewersUseTheValueSourceEditor() throws { - let paths = [ - "TablePro/Views/Results/TextViewerWindowController.swift", - "TablePro/Views/Results/SvgViewerContentView.swift", - "TablePro/Views/Results/CellImageWindowController.swift", - ] - var offenders: [String] = [] - for path in paths { - let text = try source(of: path) - if !text.contains("ValueSourceEditor("), !text.contains("ThemeEngine.shared.valueFont"), - !text.contains("themeEngine.valueFont") { - offenders.append(path) - } - } - #expect(offenders.isEmpty, "These render a stored value and must resolve its font: \(offenders)") - } - - /// Naming a font inside a field editor overrides what the subtree set, which is how the defect - /// looked in the first place. `.subheadline` was on the single-line, multi-line and schema editors, - /// and `preferredFont(forTextStyle:)` was what the multi-line editor handed AppKit. The remaining - /// `.caption` and `.caption2` in this directory are all button and badge chrome, so they are not - /// listed. - @Test("No field editor overrides the subtree with a system text style") - func fieldEditorsNameNoSystemTextStyle() throws { - let banned = [".font(.subheadline)", "preferredFont(forTextStyle:", ".font(.system("] - let directory = Self.repositoryRoot - .appendingPathComponent("TablePro/Views/RowInspector/FieldEditors") - let contents = try FileManager.default.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: nil - ) - - var offenders: [String] = [] - for url in contents where url.pathExtension == "swift" { - let source = try String(contentsOf: url, encoding: .utf8) - if banned.contains(where: source.contains) { - offenders.append(url.lastPathComponent) - } - } - - #expect(offenders.isEmpty, "These should inherit the value font instead: \(offenders)") - } - - /// The sidebar's JSON editor caches its whole configuration in `@State`, so it repaints only when - /// something tells it to. It used to watch the colour scheme alone, which left it on the old font - /// after a size, theme or accessibility change until the row was reselected. `themeChanged` also - /// fires on a light/dark flip, so it covers what the colour scheme did. - @Test("The JSON editor rebuilds its configuration on every appearance change") - func jsonEditorWatchesEveryAppearanceChange() throws { - let source = try source(of: "TablePro/Views/Results/JSONCodeEditor.swift") - - /// Observed, not read off the shared instance. The value `onChange(of:)` compares is read - /// while the body is built, and a body that does not observe the settings is never rebuilt - /// when they change, so `onChange(of: AppSettingsManager.shared.editor)` never fired. - #expect(source.contains("@ObservedObject private var settingsManager = AppSettingsManager.shared")) - #expect(source.contains("onChange(of: settingsManager.editor)")) - #expect(source.contains("onReceive(AppEvents.shared.themeChanged)")) - #expect(source.contains("onReceive(AppEvents.shared.accessibilityTextSizeChanged)")) - } - - /// A dump line is a fixed count of characters, so the popover that holds one has to size itself - /// from the value font rather than from a width that was right for one hardcoded size. - @Test("The hex popover sizes itself from a dump line") - func hexPopoverSizesItselfFromTheDump() throws { - let source = try source(of: "TablePro/Views/Results/HexEditorContentView.swift") - #expect(source.contains("HexDumpLayout.lineWidthInCharacters")) - #expect(!source.contains(".frame(width: 520")) - } - - private func source(of path: String) throws -> String { - try String(contentsOf: Self.repositoryRoot.appendingPathComponent(path), encoding: .utf8) - } } diff --git a/TableProTests/Utilities/RowSortComparatorTests.swift b/TableProTests/Utilities/RowSortComparatorTests.swift index f48b7124e0..3283bce86d 100644 --- a/TableProTests/Utilities/RowSortComparatorTests.swift +++ b/TableProTests/Utilities/RowSortComparatorTests.swift @@ -62,10 +62,4 @@ struct RowSortComparatorTests { let result = RowSortComparator.compare("file2", "file10", columnType: .text(rawType: "VARCHAR")) #expect(result == .orderedAscending) } - - @Test("nil column type uses numeric string comparison") - func nilColumnType() { - let result = RowSortComparator.compare("10", "2", columnType: nil) - #expect(result == .orderedDescending) - } } diff --git a/TableProTests/Views/Compare/CompareCountedStringTests.swift b/TableProTests/Views/Compare/CompareCountedStringTests.swift index 47549b7bdf..aaceaf1d81 100644 --- a/TableProTests/Views/Compare/CompareCountedStringTests.swift +++ b/TableProTests/Views/Compare/CompareCountedStringTests.swift @@ -4,7 +4,6 @@ // import Foundation -@testable import TablePro import Testing /// A Compare & Sync window that reads "1 differences" is a defect the compiler cannot see. The @@ -49,36 +48,6 @@ struct CompareCountedStringTests { #expect(text.hasPrefix("1 row matches.")) } - @Test("Every Compare plural lives in the catalog rather than in a Swift branch") - func catalogDeclaresTheComparePlurals() throws { - for key in Self.pluralKeys { - let entry = try Self.catalogEntry(key) - let localizations = try #require(entry["localizations"] as? [String: Any]) - let english = try #require(localizations["en"] as? [String: Any]) - let variations = try #require(english["variations"] as? [String: Any]) - let plural = try #require(variations["plural"] as? [String: Any]) - - #expect(plural["one"] != nil, "\(key) declares no singular") - #expect(plural["other"] != nil, "\(key) declares no plural") - } - } - - /// A plural variation rewrites only the source language. Rewriting the English `stringUnit` into - /// a `%#@substitution@` form instead changes the source every translation is checked against, - /// which is what leaves `StringCatalogIntegrityTests` reporting five offenders on the one string - /// that already does it. - @Test("A Compare plural leaves the English source string alone") - func pluralsDoNotRewriteTheEnglishSourceString() throws { - for key in Self.pluralKeys { - let entry = try Self.catalogEntry(key) - let localizations = try #require(entry["localizations"] as? [String: Any]) - let english = try #require(localizations["en"] as? [String: Any]) - - #expect(english["stringUnit"] == nil, "\(key) rewrote its source string") - #expect(english["substitutions"] == nil, "\(key) rewrote its source string") - } - } - /// The counted noun follows the second argument in each of these, which a plural variation on /// the format string cannot reach, so the view picks the singular sentence instead. They are /// keys of their own and have to stay that way. @@ -100,34 +69,4 @@ struct CompareCountedStringTests { ) == "1 statement stays out of this run and staging keeps what it has for it." ) } - - private static let pluralKeys = [ - "%d changes", - "%d bytes", - "%d rows hold NULL in a key column and were left out. Choose a key with no NULLs to compare them.", - "%d rows match. Matching rows are counted, not listed, so a difference is never crowded out of this list." - ] - - private static func catalogEntry(_ key: String) throws -> [String: Any] { - let url = try repoRoot().appendingPathComponent("TablePro/Resources/Localizable.xcstrings") - let data = try Data(contentsOf: url) - let catalog = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - let strings = try #require(catalog["strings"] as? [String: Any]) - return try #require(strings[key] as? [String: Any]) - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CatalogError.repoRootNotFound - } - - private enum CatalogError: Error { - case repoRootNotFound - } } diff --git a/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift b/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift index 4ecd58fcaf..0852fc2e41 100644 --- a/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift +++ b/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift @@ -95,11 +95,6 @@ struct SQLCompletionAdapterFuzzyTests { #expect(fuzzyMatch(pattern: "SELECT", target: "select") == false) } - @Test("Same case matches") - func sameCaseMatches() { - #expect(fuzzyMatch(pattern: "select", target: "select") == true) - } - // MARK: - Unicode @Test("ASCII pattern against accented target") diff --git a/TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift b/TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift index 9e252288e9..b969ce53e8 100644 --- a/TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift +++ b/TableProTests/Views/Editor/SQLEditorCoordinatorCleanupTests.swift @@ -16,13 +16,6 @@ import Testing struct SQLEditorCoordinatorCleanupTests { // MARK: - destroy() Safety - @Test("destroy() on fresh coordinator without prepareCoordinator does not crash") - func destroyWithoutPrepare() { - let coordinator = SQLEditorCoordinator() - coordinator.destroy() - #expect(coordinator.isDestroyed == true) - } - @Test("destroy() called twice is idempotent and does not crash") func destroyTwiceIdempotent() { let coordinator = SQLEditorCoordinator() @@ -55,13 +48,6 @@ struct SQLEditorCoordinatorCleanupTests { // MARK: - Post-Destroy State - @Test("After destroy(), vimMode is .normal") - func postDestroyVimMode() { - let coordinator = SQLEditorCoordinator() - coordinator.destroy() - #expect(coordinator.vimMode == .normal) - } - @Test("After destroy(), isEditorFirstResponder returns false") func postDestroyFirstResponder() { let coordinator = SQLEditorCoordinator() @@ -78,12 +64,6 @@ struct SQLEditorCoordinatorCleanupTests { // MARK: - Initial State - @Test("Fresh coordinator isDestroyed is false") - func freshCoordinatorNotDestroyed() { - let coordinator = SQLEditorCoordinator() - #expect(coordinator.isDestroyed == false) - } - @Test("Fresh coordinator vimMode is .normal") func freshCoordinatorVimModeNormal() { let coordinator = SQLEditorCoordinator() diff --git a/TableProTests/Views/EmptyTitleControlLabelTests.swift b/TableProTests/Views/EmptyTitleControlLabelTests.swift deleted file mode 100644 index 9ab62a9d88..0000000000 --- a/TableProTests/Views/EmptyTitleControlLabelTests.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// EmptyTitleControlLabelTests.swift -// TableProTests -// -// A control built with an empty title, its visible label a separate `Text` beside it, has no name -// for VoiceOver: nothing links the two. Measured through the Accessibility API on macOS 27, the AI -// pane's provider pop-up, the plugin category filter and the CSV import's NULL text field all -// exposed an empty title and description with no title element. A titled control in a grouped -// `Form`, a `LabeledContent` row, or a title kept for accessibility behind `.labelsHidden()` names -// it, with one exception: outside a `Form`, a `TextField` does not expose its title at all, so it -// needs an `.accessibilityLabel()`. -// - -import Foundation -import Testing - -struct EmptyTitleControlLabelTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - - private static let emptyTitle = try? NSRegularExpression( - pattern: #"\b(Picker|TextField|SecureField|Toggle|Stepper|DatePicker)\(""\s*,"# - ) - - /// A `LabeledContent` row hands its label to the control as its title element, which VoiceOver - /// reads. It sits on one of the two lines above the control. - private static let linesSearchedForLabeledContent = 2 - - /// The modifier chain that follows the control, where an `accessibilityLabel` would name it. - private static let linesSearchedForLabel = 10 - - @Test("Every view and plugin control with an empty title is named another way") - func emptyTitleControlsAreNamed() throws { - let roots = ["TablePro/Views", "Plugins"] - var inspected = 0 - var offenders: [String] = [] - for root in roots { - let directory = Self.repositoryRoot.appendingPathComponent(root) - let enumerator = try #require(FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil)) - for case let url as URL in enumerator where url.pathExtension == "swift" && !url.path.contains("/Tests/") { - let result = try Self.scan(url) - inspected += result.inspected - offenders += result.offenders - } - } - - /// Guards the scanner itself: the MCP server fields and the plugin enable switch are - /// empty-title controls named another way, so a scan that finds none is broken. - #expect(inspected >= 5, "Expected to find empty-title controls to check, found \(inspected)") - #expect( - offenders.isEmpty, - """ - These controls have an empty title and no other name, so VoiceOver reads only their role. \ - Give them a title (with .labelsHidden() when the label is drawn elsewhere), a LabeledContent \ - row, or an .accessibilityLabel(), which a text field outside a Form needs: \(offenders.sorted()) - """ - ) - } - - /// Text fields outside a `Form` whose title or prompt was their only name. Measured on macOS 27: - /// there, neither reaches the Accessibility API, so each one names itself. - private static let selfNamedTextFields: [(path: String, label: String)] = [ - ("TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift", #".accessibilityLabel(String(localized: "New database"))"#), - ("TablePro/Views/Structure/CreateTableView.swift", #".accessibilityLabel(String(localized: "Table Name"))"#), - ("TablePro/Views/Import/RowImportSheet.swift", #".accessibilityLabel(String(localized: "New table"))"#), - ("TablePro/Views/Import/RowImportSheet.swift", #".accessibilityLabel(String(localized: "Column name"))"#), - ("TablePro/Views/Editor/QueryParameterPanelView.swift", #".accessibilityLabel(Text(verbatim: ":\(parameter.name)"))"#), - ("TablePro/Views/Results/DateTimePickerContentView.swift", ".accessibilityLabel(name)") - ] - - @Test("Text fields outside a Form carry their own accessibility label") - func textFieldsOutsideAFormAreNamed() throws { - for field in Self.selfNamedTextFields { - let url = Self.repositoryRoot.appendingPathComponent(field.path) - let source = try String(contentsOf: url, encoding: .utf8) - #expect(source.contains(field.label), "\(field.path) lost \(field.label)") - } - } - - private static func scan(_ url: URL) throws -> (inspected: Int, offenders: [String]) { - let pattern = try #require(emptyTitle) - let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: "\n") - var inspected = 0 - var offenders: [String] = [] - for (index, line) in lines.enumerated() { - let range = NSRange(line.startIndex..., in: line) - guard pattern.firstMatch(in: line, range: range) != nil else { continue } - inspected += 1 - - let before = lines[max(0, index - linesSearchedForLabeledContent) ..< index].joined(separator: "\n") - let after = lines[index ..< min(lines.count, index + linesSearchedForLabel)].joined(separator: "\n") - if before.contains("LabeledContent(") || after.contains("accessibilityLabel") { - continue - } - offenders.append("\(url.lastPathComponent):\(index + 1)") - } - return (inspected, offenders) - } -} diff --git a/TableProTests/Views/Hosting/HostingViewSizingOptionsTests.swift b/TableProTests/Views/Hosting/HostingViewSizingOptionsTests.swift index 6271a3cc56..6ce5918c50 100644 --- a/TableProTests/Views/Hosting/HostingViewSizingOptionsTests.swift +++ b/TableProTests/Views/Hosting/HostingViewSizingOptionsTests.swift @@ -9,75 +9,17 @@ import SwiftUI @testable import TablePro import Testing -/// `NSHostingView.sizingOptions` defaults to `.standardBounds`, which measures a minimum, an -/// intrinsic and a maximum size on every layout pass and publishes all three to the enclosing -/// AppKit view. CLAUDE.md's rule about that cost, and about a nested minimum reaching the window's -/// split dividers, is written entirely in terms of `NSHostingController`; `NSHostingView` inherits -/// none of it. A host pinned to a container it does not size states `[]`. +/// `NSHostingView.sizingOptions` defaults to `.standardBounds`, which measures three sizes on every +/// layout pass and publishes them to the enclosing AppKit view. A host its container sizes states `[]`. struct HostingViewSizingOptionsTests { - /// Each host is pinned by constraints or by the enclosing table's own row geometry, so none of - /// them may publish a size of its own. A window's `contentView` is deliberately not on this - /// list: there `.standardBounds` is what sets the window's min and max content size. - private static let pinnedHosts = [ - "TablePro/Views/Sidebar/SourceList/SidebarHostingCellView.swift", - "TablePro/Views/Shared/FieldDrivenList.swift", - "TablePro/Views/DataFiles/DataFileSplitViewController.swift", - "TablePro/Views/Components/CheckboxOutlineView.swift", - "TablePro/Views/QueryPlan/QueryPlanDiagramCanvasView.swift", - ] - - @Test("Every hosting view pinned to its container states its sizing options") - func pinnedHostsDeclareSizingOptions() throws { - let root = try repositoryRoot() - - for source in Self.pinnedHosts { - let text = try String(contentsOf: root.appendingPathComponent(source), encoding: .utf8) - #expect( - text.contains("sizingOptions = []"), - """ - \(source) builds an `NSHostingView` that its container already sizes, so it must \ - set `sizingOptions = []`. Left at `.standardBounds` it measures three sizes per \ - layout pass and can pin the enclosing split view's dividers. - """ - ) - } - } - - private static let chromeHostedOutlines = [ - "TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift", - "TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift", - ] - - @Test("The records outlines host every cell through the theme chrome's cell, which states its sizing options") + @Test("The theme chrome's hosting cell states its sizing options") @MainActor - func chromeHostedCellsDeclareSizingOptions() throws { + func chromeHostedCellsDeclareSizingOptions() { let cell = ThemeTableChrome.HostingCell.make( NSUserInterfaceItemIdentifier("cell"), in: NSTableView(), showing: Text(verbatim: "value") ) #expect(cell.sizingOptions.isEmpty) - - let root = try repositoryRoot() - for source in Self.chromeHostedOutlines { - let text = try String(contentsOf: root.appendingPathComponent(source), encoding: .utf8) - #expect(text.contains("ThemeTableChrome.HostingCell.make("), "\(source) no longer hosts its cells through ThemeTableChrome.HostingCell") - #expect(!text.contains("NSHostingView("), "\(source) builds an NSHostingView of its own; set `sizingOptions = []` on it") - } - } - - private func repositoryRoot(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw SizingTestError.repositoryRootNotFound - } - - private enum SizingTestError: Error { - case repositoryRootNotFound } } diff --git a/TableProTests/Views/IconOnlyControlLabelTests.swift b/TableProTests/Views/IconOnlyControlLabelTests.swift deleted file mode 100644 index 11700185f8..0000000000 --- a/TableProTests/Views/IconOnlyControlLabelTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// IconOnlyControlLabelTests.swift -// TableProTests -// -// A control whose whole label is an SF Symbol has no name for VoiceOver to read and no tooltip for -// a sighted user to discover it by, so the symbol name is all anyone gets. Four shipped that way: -// the row inspector's value menu, the delete button on a custom slash command, and both month -// chevrons in the date picker, whose own day cells were labelled correctly. -// - -import Foundation -import Testing - -struct IconOnlyControlLabelTests { - private static let labelMarker = "} label: {" - - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - - /// Any one of these gives the control a name. `accessibilityHidden` and `accessibilityElement` - /// count because they hand the naming to an ancestor that speaks for the whole subtree, which is - /// what an editor tab does. - private static let namingModifiers = [ - ".help(", - "accessibilityLabel", - "accessibilityHidden", - "accessibilityElement" - ] - - @Test("Every SwiftUI control labelled only by an SF Symbol carries a name") - func iconOnlyControlsAreNamed() throws { - let viewsRoot = Self.repositoryRoot.appendingPathComponent("TablePro/Views") - let enumerator = try #require( - FileManager.default.enumerator(at: viewsRoot, includingPropertiesForKeys: nil) - ) - - var inspected = 0 - var offenders: [String] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - let result = Self.scan(url, root: Self.repositoryRoot) - inspected += result.inspected - offenders += result.offenders - } - - /// Guards the scanner itself. Brace matching that closes early finds no label block at all, - /// which reads as a clean run rather than as the broken scan it is. - #expect(inspected > 40, "Expected to find icon-only controls to check, found \(inspected)") - #expect( - offenders.isEmpty, - "These controls show only an SF Symbol and need .help() or .accessibilityLabel(): \(offenders.sorted())" - ) - } - - private static func scan(_ url: URL, root: URL) -> (inspected: Int, offenders: [String]) { - guard let source = try? String(contentsOf: url, encoding: .utf8) else { return (0, []) } - let lines = source.components(separatedBy: .newlines) - let relativePath = url.path.replacingOccurrences(of: root.path + "/", with: "") - - var inspected = 0 - var offenders: [String] = [] - for (index, line) in lines.enumerated() where line.contains(labelMarker) { - let (block, end) = labelBlock(lines, from: index) - guard block.contains("Image(systemName:") else { continue } - /// A symbol beside its own text is decoration, and the text is already the name. - guard !block.contains("Text("), !block.contains("Label(") else { continue } - - inspected += 1 - let chain = lines[end ..< min(end + 18, lines.count)].joined(separator: "\n") - guard !namingModifiers.contains(where: chain.contains) else { continue } - offenders.append("\(relativePath):\(index + 1)") - } - return (inspected, offenders) - } - - /// The label closure, brace matched. The opening line has to be measured from its `{` alone: the - /// marker carries the previous closure's `}` too, and counting that balances the line to zero, so - /// every block reads as one line long and no control is ever inspected. - private static func labelBlock(_ lines: [String], from start: Int) -> (block: String, end: Int) { - var depth = 0 - var collected: [String] = [] - for index in start ..< lines.count { - let line = lines[index] - var measured = line - if index == start, let range = line.range(of: labelMarker) { - measured = String(line[range.lowerBound...].dropFirst("} label: ".count)) - } - collected.append(line) - depth += measured.filter { $0 == "{" }.count - depth -= measured.filter { $0 == "}" }.count - if depth <= 0 { return (collected.joined(separator: "\n"), index) } - } - return (collected.joined(separator: "\n"), lines.count - 1) - } -} diff --git a/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift b/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift deleted file mode 100644 index c652b4cdd0..0000000000 --- a/TableProTests/Views/Main/CoordinatorSidebarActionsTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -// TODO: Re-enable when ActiveSheet conforms to Equatable or tests updated -#if false -// -// CoordinatorSidebarActionsTests.swift -// TableProTests -// -// Tests for sidebar action guard conditions on MainContentCoordinator. -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -struct CoordinatorSidebarActionsTests { - // MARK: - Helpers - - @MainActor - private func makeCoordinator( - type: DatabaseType = .mysql, - safeModeLevel: SafeModeLevel = .silent - ) -> (MainContentCoordinator, QueryTabManager) { - var connection = TestFixtures.makeConnection(type: type) - connection.safeModeLevel = safeModeLevel - let tabManager = QueryTabManager() - let changeManager = DataChangeManager() - let toolbarState = ConnectionToolbarState() - - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: changeManager, - toolbarState: toolbarState - ) - return (coordinator, tabManager) - } - - // MARK: - createView - - @Test("createView with readOnly safe mode returns early without crashing") - @MainActor - func createViewBlockedByReadOnlySafeMode() { - let (coordinator, _) = makeCoordinator(safeModeLevel: .readOnly) - defer { coordinator.teardown() } - - // Should return early due to safeModeLevel.blocksAllWrites - coordinator.createView() - } - - @Test("createView does not crash for each database type", arguments: [ - DatabaseType.mysql, .mariadb, .postgresql, .sqlite, .redshift, - .clickhouse, .mssql, .oracle, .mongodb, .redis, .duckdb, - ]) - @MainActor - func createViewDoesNotCrash(type: DatabaseType) { - let (coordinator, _) = makeCoordinator(type: type) - defer { coordinator.teardown() } - - // Exercises the switch branch for each type; WindowOpener call - // is a side effect we can't assert on, but we verify no crash. - coordinator.createView() - } - - // MARK: - openImportDialog - - @Test("openImportDialog with readOnly safe mode returns early without crashing") - @MainActor - func openImportDialogBlockedByReadOnlySafeMode() { - let (coordinator, _) = makeCoordinator(safeModeLevel: .readOnly) - defer { coordinator.teardown() } - - coordinator.openImportDialog(formatId: "sql") - } - - @Test("openImportDialog with MongoDB returns early at type guard") - @MainActor - func openImportDialogBlockedForMongoDB() { - let (coordinator, _) = makeCoordinator(type: .mongodb) - defer { coordinator.teardown() } - - // Hits the MongoDB/Redis guard; shows an alert as side effect - // but should not crash. - coordinator.openImportDialog(formatId: "sql") - } - - @Test("openImportDialog with Redis returns early at type guard") - @MainActor - func openImportDialogBlockedForRedis() { - let (coordinator, _) = makeCoordinator(type: .redis) - defer { coordinator.teardown() } - - coordinator.openImportDialog(formatId: "sql") - } - - // MARK: - openExportDialog - - @Test("openExportDialog sets activeSheet to exportDialog") - @MainActor - func openExportDialogSetsActiveSheet() { - let (coordinator, _) = makeCoordinator() - defer { coordinator.teardown() } - - coordinator.openExportDialog() - - #expect(coordinator.activeSheet == .exportDialog) - } -} -#endif diff --git a/TableProTests/Views/Main/EditorTabStripGestureConventionTests.swift b/TableProTests/Views/Main/EditorTabStripGestureConventionTests.swift deleted file mode 100644 index ec633bfa69..0000000000 --- a/TableProTests/Views/Main/EditorTabStripGestureConventionTests.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// EditorTabStripGestureConventionTests.swift -// TableProTests -// -// The tab strip promotes a preview tab on a double-click by reading the click count off the event -// AppKit is dispatching, never by composing a SwiftUI tap gesture. That is not a style preference, -// it is the only shape that measured acceptable. Driven with CGEvents posted to .cghidEventTap so -// the window server assigned the click count itself, against a Button carrying the strip's own -// shape, with NSEvent.doubleClickInterval at its 0.5s default: -// -// Button alone (the shipping strip) single: select +33ms double: select, select -// + .onTapGesture(count: 2) single: select +371ms double: PROMOTE only -// + .simultaneousGesture(TapGesture(count: 2)) single: select +26ms double: select, PROMOTE, select -// + reading NSApp.currentEvent.clickCount single: select +22ms double: select, select, PROMOTE -// -// So a count:2 tap gesture holds every selection back 371ms and drops it entirely on the double, -// and a simultaneous one selects twice. Neither is visible to any other test: the tab still -// selects and still promotes, just late, or twice. Hence this guard. -// - -import Foundation -import Testing - -struct EditorTabStripGestureConventionTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 4 { - url.deleteLastPathComponent() - } - return url - }() - - /// Agent mode's session rail is the other list a row opens from, and it carried the banned - /// spelling for as long as it shipped, because this scan only ever read one file. The scan is - /// widened by tree rather than by file so the next view added under it is covered as it lands. - /// - /// `QuickSwitcherPanelView` has the same shape and is left out on purpose: it is not part of this - /// work, and a scan that fails on a file nobody touched is a scan that gets disabled. - private static let scannedTrees = ["TablePro/Views/Agent"] - - /// One spelling covers both, because `onTapGesture(count:` contains `TapGesture(count:`. - /// Listing them separately made a single offence report twice. - private static let bannedGestures = ["TapGesture(count:"] - - /// Comments are dropped before the scan, because the file documents the measurement above by - /// naming the very spellings this test bans. - private func code(of source: String) -> String { - source - .split(separator: "\n", omittingEmptySubsequences: false) - .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } - .joined(separator: "\n") - } - - @Test("The tab strip composes no multi-click SwiftUI tap gesture") - func stripUsesNoMultiClickTapGesture() throws { - let url = Self.repositoryRoot.appendingPathComponent("TablePro/Views/Main/EditorTabStrip.swift") - let source = code(of: try String(contentsOf: url, encoding: .utf8)) - - let offenders = Self.bannedGestures.filter { source.contains($0) } - - #expect( - offenders.isEmpty, - """ - EditorTabStrip.swift uses \(offenders.joined(separator: ", ")). A multi-click SwiftUI \ - tap gesture delays every tab selection by ~371ms and suppresses it on the double-click. \ - Resolve the click through EditorTabActivationResolver instead. - """ - ) - } - - /// The rail opens a session on the list's own primary action, which is `NSTableView`'s - /// `doubleAction` underneath and costs a single click nothing. A `count: 2` tap gesture on the - /// row is the same 371ms on every click there as it is in the tab strip. - @Test("Agent mode's views compose no multi-click tap gesture") - func agentViewsUseNoMultiClickTapGesture() throws { - var scanned = 0 - var offenders: [String] = [] - for tree in Self.scannedTrees { - let root = Self.repositoryRoot.appendingPathComponent(tree) - let enumerator = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) - for case let url as URL in enumerator where url.pathExtension == "swift" { - scanned += 1 - let source = code(of: try String(contentsOf: url, encoding: .utf8)) - for spelling in Self.bannedGestures where source.contains(spelling) { - offenders.append("\(tree)/\(url.lastPathComponent)") - } - } - } - - /// Guards the scan itself: a path that stopped resolving reads as a clean run. - #expect(scanned >= 6, "Expected to scan Agent mode's views, scanned \(scanned) files") - #expect( - offenders.isEmpty, - """ - \(offenders.sorted()) compose a multi-click SwiftUI tap gesture, which delays every \ - single click by ~371ms. Open from the list's primary action and the row's accessibility \ - action instead. - """ - ) - } - - /// A scan that stops matching anything is a test that passes forever. This pins both halves: - /// a real call is still caught, and the comment that documents it is still ignored. - @Test("The scan catches a real gesture and ignores one named in a comment") - func scanCatchesCodeButNotComments() { - let withCall = code(of: " .onTapGesture(count: 2) { keep() }") - #expect(Self.bannedGestures.contains { withCall.contains($0) }) - - let withComment = code(of: " /// Never .onTapGesture(count: 2), it costs 371ms.") - #expect(Self.bannedGestures.contains { withComment.contains($0) } == false) - } -} diff --git a/TableProTests/Views/Main/EditorTabStripGlassGuardTests.swift b/TableProTests/Views/Main/EditorTabStripGlassGuardTests.swift deleted file mode 100644 index 12c01a89f1..0000000000 --- a/TableProTests/Views/Main/EditorTabStripGlassGuardTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// EditorTabStripGlassGuardTests.swift -// TableProTests -// -// A source scan, because the thing it guards cannot be rasterised: a `glassEffect` renders in the -// window server, and `cacheDisplay` returns an empty bitmap for the whole hosting view rather -// than for the glass alone. The property is structural anyway, so a scan states it directly. -// - -import Foundation -import Testing - -struct EditorTabStripGlassGuardTests { - /// The strip may carry exactly one Liquid Glass surface, and it is the new-tab button. - /// - /// The track and the selected tab were both glass, tinted apart, and the step between them - /// turned out to be a function of the wallpaper behind the window: measured across twenty - /// arrangements on macOS 27, every glass surface nested in, beside, or unioned with another - /// rendered darker than its track in light appearance, and the shipped pair inverted again - /// whenever the window lost key (#2439). Apple asks for the same shape in WWDC25 session 219, - /// "avoid applying the material to both layers. Instead, use fills, transparency, and vibrancy - /// for the top elements", and macOS 27's own `NSSegmentedControlRole.tabs` draws this control - /// with opaque fills. - @Test("Only the new-tab button is drawn in glass") - func onlyTheNewTabButtonUsesGlass() throws { - let source = try Self.stripSource() - let lines = source.components(separatedBy: .newlines) - let glassLines = lines.indices.filter { lines[$0].contains("glassEffect(") } - - #expect( - glassLines.count == 1, - """ - The editor tab strip may draw exactly one glass surface, the new-tab button. A track \ - or a selected tab drawn in glass takes its colour from whatever is behind the window, \ - so the selection stops meaning the same thing over every wallpaper: \ - \(glassLines.map { "line \($0 + 1)" }) - """ - ) - - let owner = glassLines.first.map { index -> String in - lines[...index].reversed().first { $0.contains("func ") } ?? "" - } - #expect( - owner?.contains("newTabSurface") == true, - "The strip's one glass surface must belong to newTabSurface, not \(owner ?? "nothing")" - ) - } - - /// Fully rounded, because the system's own tab bar is. Its runtime view tree reports - /// `cornerRadius = 12` on each 24pt tab, exactly half the height, and a corner fit of the 28pt - /// track lands at 12 to 14pt. An earlier pass took the shallower `(height - 4) / 4` radius from - /// an in-content `NSSegmentedControl`, which is a different control in a different place. - @Test("The track and its tabs stay fully rounded") - func surfacesAreFullyRounded() throws { - let source = try Self.stripSource() - let lines = source.components(separatedBy: .newlines) - let shaped = lines.indices.filter { - lines[$0].contains("RoundedRectangle(") || lines[$0].contains("cornerRadius:") - } - - #expect( - shaped.isEmpty, - """ - The strip's surfaces take EditorTabStripLayout's capsule shapes, matching the system \ - tab bar's own half-height corner radius: \(shaped.map { "line \($0 + 1)" }) - """ - ) - } - - private static func stripSource(file: StaticString = #filePath) throws -> String { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - let candidate = directory.appendingPathComponent("CLAUDE.md") - if FileManager.default.fileExists(atPath: candidate.path) { - let strip = directory - .appendingPathComponent("TablePro/Views/Main/EditorTabStrip.swift") - return try String(contentsOf: strip, encoding: .utf8) - } - directory = directory.deletingLastPathComponent() - } - throw GlassGuardError.repoRootNotFound - } - - private enum GlassGuardError: Error { - case repoRootNotFound - } -} diff --git a/TableProTests/Views/Main/ResultBufferHandoffGuardTests.swift b/TableProTests/Views/Main/ResultBufferHandoffGuardTests.swift deleted file mode 100644 index ee17872094..0000000000 --- a/TableProTests/Views/Main/ResultBufferHandoffGuardTests.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// ResultBufferHandoffGuardTests.swift -// TableProTests -// -// The shared row buffer holds the active result's rows and is the only copy that Fetch All, Load -// More and cell edits write to. Anything that replaces a tab's results has to hand it back to the -// result that owns it first, or a pinned result silently loses rows it fetched. The flush was -// added to two of the five replacement sites and missed the other three, which is the kind of -// omission no behavioural test catches (#2243). -// -// The rule is per file, so it catches a replacement added where nothing flushes at all. A second -// unflushed call inside a file that already flushes elsewhere still needs a reader. -// - -import Foundation -import Testing - -@testable import TablePro - -struct ResultBufferHandoffGuardTests { - @Test("Every file that replaces a tab's results also hands the row buffer back") - func replacementSitesFlushTheBuffer() throws { - let offenders = try Self.filesCalling("replaceUnpinnedResults(") - .union(Self.filesCalling("replaceResult(")) - .subtracting(Self.filesCalling("flushBufferToActiveResult(")) - - #expect( - offenders.isEmpty, - """ - Replacing a tab's results drops whatever the buffer held for the outgoing result. Call \ - flushBufferToActiveResult(tabId:pinnedOnly:) before the replacement, and \ - seedBufferFromActiveResult(tabId:) after it: \(offenders.sorted()) - """ - ) - } - - private static func filesCalling(_ call: String) throws -> Set { - let root = try repoRoot().appendingPathComponent("TablePro") - guard let walker = FileManager.default.enumerator( - at: root, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { return [] } - - var files: Set = [] - for case let url as URL in walker where url.pathExtension == "swift" { - let text = try String(contentsOf: url, encoding: .utf8) - let callSites = text.components(separatedBy: .newlines).filter { - $0.contains(call) && !$0.contains("func ") && !$0.trimmingCharacters(in: .whitespaces).hasPrefix("///") - } - if !callSites.isEmpty { files.insert(url.lastPathComponent) } - } - return files - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } -} diff --git a/TableProTests/Views/Main/SaveCompletionTests.swift b/TableProTests/Views/Main/SaveCompletionTests.swift index 30e580abba..ebd92c2270 100644 --- a/TableProTests/Views/Main/SaveCompletionTests.swift +++ b/TableProTests/Views/Main/SaveCompletionTests.swift @@ -175,24 +175,6 @@ struct SaveCompletionTests { // MARK: - Pending Table Operations - @Test("saveChanges with no tab selected and read-only does not crash") - func noTabSelected_readOnly_doesNotCrash() { - let (coordinator, _, changeManager) = makeCoordinator(safeModeLevel: .readOnly) - changeManager.hasChanges = true - - var truncates: Set = [] - var deletes: Set = [] - var options: [DatabaseTreeTableRef: TableOperationOptions] = [:] - - coordinator.saveChanges( - pendingTruncates: &truncates, - pendingDeletes: &deletes, - tableOperationOptions: &options - ) - - #expect(changeManager.hasChanges == true) - } - @Test("saveChanges with no changes and no pending ops does nothing") func noChangesNoPendingOps_noop() { let (coordinator, tabManager, _) = makeCoordinator() diff --git a/TableProTests/Views/Main/StructureActionHandlerTests.swift b/TableProTests/Views/Main/StructureActionHandlerTests.swift index e969f94a3f..be1632f54d 100644 --- a/TableProTests/Views/Main/StructureActionHandlerTests.swift +++ b/TableProTests/Views/Main/StructureActionHandlerTests.swift @@ -145,23 +145,6 @@ struct StructureActionHandlerTests { #expect(counts["removeRow"] == 1) } - // MARK: - Nil Closures Are Safe - - @Test("nil closures do not crash via optional chaining") - func nilClosures_areSafe() { - let handler = StructureViewActionHandler() - - // All closures are nil by default; optional chaining should be a no-op - handler.refresh?() - handler.previewSQL?() - handler.copyRows?() - handler.pasteRows?() - handler.undo?() - handler.redo?() - - // Reaching here without a crash is the assertion - } - // MARK: - Coordinator Integration @Test("coordinator.structureActions dispatches refresh closure") diff --git a/TableProTests/Views/Menu/MenuItemImageVisibilityTests.swift b/TableProTests/Views/Menu/MenuItemImageVisibilityTests.swift index 63e206dc9c..8962059534 100644 --- a/TableProTests/Views/Menu/MenuItemImageVisibilityTests.swift +++ b/TableProTests/Views/Menu/MenuItemImageVisibilityTests.swift @@ -36,43 +36,4 @@ struct MenuItemImageVisibilityTests { #expect(item.image == nil) } - - @Test("Every menu whose image carries the meaning goes through the helper") - func informativeMenuBuildersUseTheHelper() throws { - let root = try repositoryRoot() - let sources = [ - "TablePro/Views/Highlight/HighlightMenuBuilder.swift", - "TablePro/Views/Sidebar/Menu/SidebarMenuBuilder.swift", - "TablePro/Views/Connection/GroupPopUpButton.swift", - "TablePro/Core/Menu/SafeModeMenuDelegate.swift", - "TablePro/Views/Results/EnumMenuPicker.swift", - ] - - for source in sources { - let text = try String(contentsOf: root.appendingPathComponent(source), encoding: .utf8) - #expect( - text.contains("setInformativeImage("), - """ - \(source) builds a menu whose image is the row's only distinguishing content, so it \ - must use `setInformativeImage(_:)`. On macOS 27 a plain `image =` is hidden and the \ - rows become identical. - """ - ) - } - } - - private func repositoryRoot(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw MenuImageTestError.repositoryRootNotFound - } - - private enum MenuImageTestError: Error { - case repositoryRootNotFound - } } diff --git a/TableProTests/Views/MenuDisclosureIndicatorTests.swift b/TableProTests/Views/MenuDisclosureIndicatorTests.swift deleted file mode 100644 index 6b4491c5d6..0000000000 --- a/TableProTests/Views/MenuDisclosureIndicatorTests.swift +++ /dev/null @@ -1,260 +0,0 @@ -// -// MenuDisclosureIndicatorTests.swift -// TableProTests -// -// A SwiftUI `Menu` on macOS is an `NSPopUpButton` carrying its own `NSPopUpIndicatorView`, and a -// `ControlGroup`'s menu segment carries the same one. A chevron passed as the label becomes the -// control's icon and lands beside that indicator rather than replacing it, so the control shows -// two. Five shipped that way: the Run split button, the connection form's Tags row, the query -// editor's scope picker and both AI chat pickers. -// -// The two dead ones are the reason a reviewer cannot catch this by reading. SwiftUI reduces a -// macOS menu label to one image plus one text and silently drops the rest, so a chevron written -// after an icon and a title renders nothing at all, and the source looks identical either way. -// -// The second suite guards the name rather than the glyph, and it is the same kind of trap: the -// modifier that looks like it supplies a name is the one that removes it. -// - -import Foundation -import Testing - -struct MenuDisclosureIndicatorTests { - private static let labelMarker = "} label: {" - - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - - /// A `Menu` hosted by a toolbar is the reverse of the rule the first test below holds. Measured - /// through the accessibility API on a build linked against the macOS 26 SDK: the label alone - /// publishes `AXTitle` "chevron.pulldown", in every label form that test allows, and - /// `.accessibilityLabel` on the `Menu` is the one construction that names it. Both menus shipped - /// unnamed. Keyed by file, each of which holds a single `Menu`, because a line number moves with - /// every edit above it. - private static let toolbarHostedMenus: Set = [ - "TablePro/Views/Integrations/IntegrationsActivityLogPane.swift", - "TablePro/Views/Welcome/WelcomeLibraryPane.swift", - ] - - /// `.accessibilityLabel` on a `Menu` is not additive. Measured with System Events against a - /// standalone SwiftUI app: a menu labelled `Text("Add tags")` publishes `AXTitle` "Add tags", - /// and the same menu with `.accessibilityLabel(Text("Add tags"))` on it publishes an empty name. - /// The modifier replaces what the label was providing, with nothing. Four controls in this app - /// carried it and were silent to VoiceOver because of it, the result-set chooser among them, - /// which is why two suites asserting on its name could never have passed. - /// - /// `.accessibilityElement(children: .contain)` ahead of the label is a different construction: - /// the label then names the container that modifier creates rather than the menu, and that is - /// the one form that names a pull-down whose label draws only an icon. The trailing pane's - /// ellipsis menu relies on it, and `TrailingPaneSurfaceUITests` reads its name on CI. - @Test("No menu names itself with accessibilityLabel") - func menusCarryTheirNameInTheirLabel() throws { - let viewsRoot = Self.repositoryRoot.appendingPathComponent("TablePro/Views") - let enumerator = try #require( - FileManager.default.enumerator(at: viewsRoot, includingPropertiesForKeys: nil) - ) - - var inspected = 0 - var offenders: [String] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - guard let source = try? String(contentsOf: url, encoding: .utf8) else { continue } - let relativePath = url.path.replacingOccurrences(of: Self.repositoryRoot.path + "/", with: "") - let result = Self.scanForMisplacedNames(source, relativePath: relativePath) - inspected += result.inspected - guard !Self.toolbarHostedMenus.contains(relativePath) else { continue } - offenders += result.offenders - } - - #expect(inspected > 20, "Expected to find SwiftUI menus to check, found \(inspected)") - #expect( - offenders.isEmpty, - "These menus name themselves with .accessibilityLabel, which leaves them nameless. Put the name in the label, as `Label { Text(name) } icon: { EmptyView() }` with .labelStyle(.iconOnly), or as the label's own Text: \(offenders.sorted())" - ) - } - - @Test("A toolbar-hosted menu names itself with accessibilityLabel") - func toolbarHostedMenusCarryTheirNameOnTheMenu() throws { - for relativePath in Self.toolbarHostedMenus.sorted() { - let url = Self.repositoryRoot.appendingPathComponent(relativePath) - let source = try String(contentsOf: url, encoding: .utf8) - let result = Self.scanForMisplacedNames(source, relativePath: relativePath) - - #expect(result.inspected == 1, "Expected one menu in \(relativePath), found \(result.inspected)") - #expect( - result.offenders.count == 1, - "The toolbar menu in \(relativePath) needs .accessibilityLabel on the Menu itself, or VoiceOver reads it as \"chevron.pulldown\"" - ) - } - } - - @Test("A label on the menu itself is flagged, a label on a container wrapping it is not") - func scannerTellsTheMenuFromItsContainer() { - let namedMenu = """ - Menu { - Button("Fields") {} - } label: { - Image(systemName: "ellipsis") - } - .menuStyle(.button) - .accessibilityLabel("Options") - """ - let namedContainer = """ - Menu { - Button("Fields") {} - } label: { - Image(systemName: "ellipsis") - } - .menuStyle(.button) - .accessibilityElement(children: .contain) - .accessibilityLabel("Options") - """ - - #expect(Self.scanForMisplacedNames(namedMenu, relativePath: "Menu.swift").offenders == ["Menu.swift:1"]) - #expect(Self.scanForMisplacedNames(namedContainer, relativePath: "Menu.swift").offenders.isEmpty) - } - - private static func scanForMisplacedNames( - _ source: String, - relativePath: String - ) -> (inspected: Int, offenders: [String]) { - let lines = source.components(separatedBy: .newlines) - - var inspected = 0 - var offenders: [String] = [] - for (index, line) in lines.enumerated() where line.contains(labelMarker) { - guard let opening = menuOpening(lines, closingLabelAt: index) else { continue } - inspected += 1 - let end = labelBlockEnd(lines, from: index) - let chain = lines[end ..< min(end + 14, lines.count)].joined(separator: "\n") - guard let modifiers = chain.range(of: ".accessibilityLabel") else { continue } - let precedingModifiers = chain[.. 20, "Expected to find SwiftUI menus to check, found \(inspected)") - #expect( - offenders.isEmpty, - "These menus draw a chevron the control already draws. Let it draw its own, and keep the name with `Label { Text(name) } icon: { EmptyView() }` plus .labelStyle(.iconOnly): \(offenders.sorted())" - ) - } - - /// Only menus. A plain `Button` presenting a `.popover` draws no indicator, so its hand-drawn - /// chevron is the only affordance it has and is correct: `TypePickerFieldView` and - /// `CopyObjectsConfigureView` both rely on that. - private static func scan(_ url: URL, root: URL) -> (inspected: Int, offenders: [String]) { - guard let source = try? String(contentsOf: url, encoding: .utf8) else { return (0, []) } - let lines = source.components(separatedBy: .newlines) - let relativePath = url.path.replacingOccurrences(of: root.path + "/", with: "") - - var inspected = 0 - var offenders: [String] = [] - - for (index, line) in lines.enumerated() where isMenuInitializer(line) { - inspected += 1 - guard line.contains("systemImage: \"chevron") else { continue } - offenders.append("\(relativePath):\(index + 1)") - } - - for (index, line) in lines.enumerated() where line.contains(labelMarker) { - guard let opening = menuOpening(lines, closingLabelAt: index) else { continue } - inspected += 1 - guard labelBlock(lines, from: index).contains("\"chevron") else { continue } - offenders.append("\(relativePath):\(opening + 1)") - } - - return (inspected, offenders) - } - - /// `Menu("Title", systemImage: "…") { … }`, the form that carries its label on the initializer - /// and so never opens a `label:` closure for the block scan below to find. The prefix check - /// keeps `NSMenu(` and any other suffix match out. - private static func isMenuInitializer(_ line: String) -> Bool { - guard let range = line.range(of: "Menu(") else { return false } - let preceding = line[line.startIndex ..< range.lowerBound].last - return preceding.map { !$0.isLetter && !$0.isNumber && $0 != "." && $0 != "_" } ?? true - } - - /// Walks back from a `} label: {` to the construction it closes and reports where that started, - /// but only when it is a `Menu`. The content closure is arbitrary SwiftUI, so this counts braces - /// rather than matching the previous line: a `Menu` holding a nested `Picker` or a `ForEach` - /// sits many lines above its own label. - private static func menuOpening(_ lines: [String], closingLabelAt index: Int) -> Int? { - var depth = 1 - var cursor = index - - while cursor > 0 { - cursor -= 1 - let line = lines[cursor] - depth += line.filter { $0 == "}" }.count - depth -= line.filter { $0 == "{" }.count - guard depth <= 0 else { continue } - return opensAMenu(line) ? cursor : nil - } - return nil - } - - /// The trailing-closure form, wherever it sits on the line: `Menu {`, `return Menu {` and - /// `let x = Menu {` are all the same construction. Anchoring this to the start of the trimmed - /// line missed the query editor's scope picker and the AI chat mode picker, both of which - /// return theirs. - private static func opensAMenu(_ line: String) -> Bool { - for marker in ["Menu {", "Menu{"] { - guard let range = line.range(of: marker) else { continue } - let preceding = line[line.startIndex ..< range.lowerBound].last - let isWordBoundary = preceding.map { !$0.isLetter && !$0.isNumber && $0 != "." && $0 != "_" } ?? true - if isWordBoundary { return true } - } - return false - } - - /// The label closure, brace matched. The opening line has to be measured from its `{` alone: - /// the marker carries the content closure's `}` too, and counting that balances the line to - /// zero, so every block reads as one line long and no label is ever inspected. - private static func labelBlock(_ lines: [String], from start: Int) -> String { - let end = labelBlockEnd(lines, from: start) - return lines[start ... end].joined(separator: "\n") - } - - private static func labelBlockEnd(_ lines: [String], from start: Int) -> Int { - var depth = 0 - for index in start ..< lines.count { - let line = lines[index] - var measured = line - if index == start, let range = line.range(of: labelMarker) { - measured = String(line[range.lowerBound...].dropFirst("} label: ".count)) - } - depth += measured.filter { $0 == "{" }.count - depth -= measured.filter { $0 == "}" }.count - if depth <= 0 { return index } - } - return lines.count - 1 - } -} diff --git a/TableProTests/Views/ReduceMotionGateTests.swift b/TableProTests/Views/ReduceMotionGateTests.swift deleted file mode 100644 index ddb1a42b32..0000000000 --- a/TableProTests/Views/ReduceMotionGateTests.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// ReduceMotionGateTests.swift -// TableProTests -// -// Reduce Motion is a system accessibility setting, and `withAnimation` captures its animation at -// the call, so a call site that does not read the setting animates regardless of it. `withMotion` -// is the gate. The data grid already gated its row insert and remove through `rowAnimation`, which -// is what makes the six that did not a slip rather than a decision: two jump host removals, and -// both copy confirmations in the plan view and the structure editor. -// - -import Foundation -import Testing - -struct ReduceMotionGateTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - - @Test("Every animated call site reads the Reduce Motion setting") - func animationsAreGated() throws { - let appRoot = Self.repositoryRoot.appendingPathComponent("TablePro") - let enumerator = try #require( - FileManager.default.enumerator(at: appRoot, includingPropertiesForKeys: nil) - ) - - var offenders: [String] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - /// The gate itself is the one place allowed to call the ungated function. - guard url.lastPathComponent != "MotionAccessibility.swift" else { continue } - offenders += Self.offenders(in: url, root: Self.repositoryRoot) - } - - #expect( - offenders.isEmpty, - "These call sites animate against Reduce Motion, use withMotion: \(offenders.sorted())" - ) - } - - private static func offenders(in url: URL, root: URL) -> [String] { - guard let source = try? String(contentsOf: url, encoding: .utf8) else { return [] } - let relativePath = url.path.replacingOccurrences(of: root.path + "/", with: "") - - var offenders: [String] = [] - for (index, line) in source.components(separatedBy: .newlines).enumerated() { - guard !line.trimmingCharacters(in: .whitespaces).hasPrefix("//") else { continue } - offenders += withAnimationOffenders(in: line, path: relativePath, lineNumber: index + 1) - offenders += modifierOffenders(in: line, path: relativePath, lineNumber: index + 1) - } - return offenders - } - - private static func withAnimationOffenders(in line: String, path: String, lineNumber: Int) -> [String] { - var offenders: [String] = [] - var searchStart = line.startIndex - while let found = line.range(of: "withAnimation", range: searchStart ..< line.endIndex) { - searchStart = found.upperBound - let rest = line[found.upperBound...] - /// `NSTableView.insertRows(at:withAnimation:)` spells its parameter the same way and - /// gates separately, and `withAnimation(nil)` is already the reduced behaviour. - guard !rest.hasPrefix(":"), !rest.hasPrefix("(nil)") else { continue } - offenders.append("\(path):\(lineNumber)") - } - return offenders - } - - /// The modifier form escaped this scan entirely, which is how the AI composer's focus crossfade - /// ran at full duration under Reduce Motion: it is `.animation(_:value:)`, never `withAnimation`. - private static func modifierOffenders(in line: String, path: String, lineNumber: Int) -> [String] { - var offenders: [String] = [] - var searchStart = line.startIndex - while let found = line.range(of: ".animation(", range: searchStart ..< line.endIndex) { - searchStart = found.upperBound - let rest = line[found.upperBound...] - /// `.animation(nil, …)` is already the reduced behaviour, `.motionAnimation(` is the - /// gate itself, and `CALayer.animation(forKey:)` is a lookup rather than a change. - guard !rest.hasPrefix("nil"), !rest.hasPrefix("forKey") else { continue } - let before = line[line.startIndex ..< found.lowerBound] - guard !before.hasSuffix("motion"), !before.hasSuffix("layer") else { continue } - offenders.append("\(path):\(lineNumber)") - } - return offenders - } -} diff --git a/TableProTests/Views/Results/CellPositionTests.swift b/TableProTests/Views/Results/CellPositionTests.swift index 83d834495c..a117c50bce 100644 --- a/TableProTests/Views/Results/CellPositionTests.swift +++ b/TableProTests/Views/Results/CellPositionTests.swift @@ -2,58 +2,12 @@ // CellPositionTests.swift // TableProTests // -// Tests for CellPosition and RowVisualState value types. -// import Foundation import TableProPluginKit @testable import TablePro import Testing -struct CellPositionTests { - @Test("Equal positions are equal") - func equalPositionsAreEqual() { - let a = CellPosition(row: 5, column: 3) - let b = CellPosition(row: 5, column: 3) - #expect(a == b) - } - - @Test("Different row produces unequal positions") - func differentRowUnequal() { - let a = CellPosition(row: 0, column: 3) - let b = CellPosition(row: 1, column: 3) - #expect(a != b) - } - - @Test("Different column produces unequal positions") - func differentColumnUnequal() { - let a = CellPosition(row: 5, column: 0) - let b = CellPosition(row: 5, column: 1) - #expect(a != b) - } - - @Test("Both fields different produces unequal positions") - func bothFieldsDifferent() { - let a = CellPosition(row: 0, column: 0) - let b = CellPosition(row: 1, column: 1) - #expect(a != b) - } - - @Test("Zero position stores correctly") - func zeroPosition() { - let pos = CellPosition(row: 0, column: 0) - #expect(pos.row == 0) - #expect(pos.column == 0) - } - - @Test("Large indices stored correctly") - func largeIndices() { - let pos = CellPosition(row: 1_000_000, column: 500) - #expect(pos.row == 1_000_000) - #expect(pos.column == 500) - } -} - struct RowVisualStateTests { @Test("Empty state has all flags false and empty modifiedColumns") func emptyState() { diff --git a/TableProTests/Views/Results/DataGridBodyChromeTests.swift b/TableProTests/Views/Results/DataGridBodyChromeTests.swift index 6a167ef0bd..8b6ada0705 100644 --- a/TableProTests/Views/Results/DataGridBodyChromeTests.swift +++ b/TableProTests/Views/Results/DataGridBodyChromeTests.swift @@ -87,26 +87,6 @@ struct DataGridBodyChromeTests { return Grid(window: window, tableView: tableView, coordinator: coordinator) } - /// Clearing the mask is the fix, so a test that only checked the drawing would pass with the - /// separator views back and the cost with them. The fixture sets the mask itself, so asserting - /// on the fixture proves nothing; this reads the one line in the app that decides it. - @Test("The grid never asks AppKit for vertical grid lines") - func gridStyleMaskIsClearedInTheAppItself() throws { - let source = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("TablePro/Views/Results/DataGridView.swift") - let text = try String(contentsOf: source, encoding: .utf8) - - #expect(text.contains("gridStyleMask = []")) - #expect( - !text.contains("solidVerticalGridLineMask"), - "one separator view per column costs 518ms of layout per pass on a 500-column result" - ) - } - /// A partial repaint invalidates one cell's rect, and `rect(ofColumn:)` includes the intercell /// spacing, so the next column's separator lives inside the rect being repainted. It has to be /// redrawn there or every visited cell loses its right-hand rule until a full-row repaint. @@ -314,25 +294,6 @@ struct DataGridBodyChromeTests { #expect(abs(sampled.blueComponent - expected.blueComponent) < 0.02) } - /// A cached drawing of the table cannot show this (see above), so the one line in the app that - /// decides it is read instead, as `gridStyleMaskIsClearedInTheAppItself` reads its own. - @Test("The table never hands its background back to NSTableView") - func tableBackgroundIsTheGridsOwn() throws { - let source = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("TablePro/Views/Results/KeyHandlingTableView.swift") - let text = try String(contentsOf: source, encoding: .utf8) - - #expect(text.contains("DataGridBodyChrome.drawTableBackground(in: clipRect, of: self)")) - #expect( - !text.contains("super.drawBackground(inClipRect:"), - "NSTableView blends the stripe in twice past the last row, which nothing painting it once can match" - ) - } - /// The colour of one alternate stripe blended once over `background`, drawn into a single pixel. private func singleBlend(over background: NSColor, of row: Int, appearance: NSAppearance) -> NSColor? { guard let rep = NSBitmapImageRep( diff --git a/TableProTests/Views/Results/HexEditorTests.swift b/TableProTests/Views/Results/HexEditorTests.swift index 6913bc80b7..998bcfe672 100644 --- a/TableProTests/Views/Results/HexEditorTests.swift +++ b/TableProTests/Views/Results/HexEditorTests.swift @@ -309,64 +309,6 @@ struct HexEditorTests { #expect(parsed != original) #expect(original.hasPrefix(parsed)) } - - /// The inline inspector editor and the pop-out editor read the same formatter, so both have to - /// refuse a marked string. The pop-out has always disabled Save on it; the inline one treated - /// the marker as a syntax error, which reported "Invalid hex" over an untouched value and then - /// reverted every edit on blur instead of saying the value was too large to edit. - /// Both editors refuse to commit a value they only hold a prefix of, and both anchor that - /// refusal to the stored value rather than to the draft. Anchoring it to the draft is what makes - /// the guard defeatable: deleting the marker the formatter appended leaves a string that parses - /// cleanly, and the editor then writes 10,240 bytes over the whole blob. - @Test("The inline editor blocks a truncated commit and cannot be talked out of it") - func theInlineEditorGuardsTruncation() throws { - let inline = try source(of: "TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift") - #expect(inline.contains("guard !isTruncated else { return }")) - #expect(inline.contains("Truncated, read only")) - - let loader = try #require( - inline.range(of: "private func loadDraft()"), - "BlobHexEditorView must load the draft and the flag from one read" - ) - let body = inline[loader.lowerBound...].prefix(320) - #expect(body.contains("context.value.wrappedValue")) - #expect(body.contains("isTruncated = formatted.hasSuffix")) - - /// The flag is never recomputed from what the user typed. It was, and pasting an ellipsis - /// into an ordinary blob then locked the field for good, because it went read-only and the - /// commit guard returned without restoring the text, so the marker could not be deleted. - let assignments = inline.components(separatedBy: "isTruncated =").dropFirst() - for assignment in assignments { - #expect( - !assignment.prefix(60).contains("hexEditText"), - "The truncation flag must not be assigned from the draft: \(assignment.prefix(60))" - ) - } - } - - /// The pop-out had the same defeatable guard: `validateHex` recomputed `isTruncated` from the - /// text view on every keystroke, so deleting the marker cleared it, re-enabled Save, and - /// committed the prefix over the full blob. - @Test("The pop-out editor anchors truncation to the value it opened on") - func thePopOutEditorGuardsTruncation() throws { - let popOut = try source(of: "TablePro/Views/Results/HexEditorContentView.swift") - - #expect(popOut.contains("private let sourceIsTruncated: Bool")) - #expect(popOut.contains("guard isValid, !sourceIsTruncated else { return }")) - #expect(popOut.contains("|| sourceIsTruncated")) - } - - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 4 { - url.deleteLastPathComponent() - } - return url - }() - - private func source(of path: String) throws -> String { - try String(contentsOf: Self.repositoryRoot.appendingPathComponent(path), encoding: .utf8) - } } // swiftlint:enable force_unwrapping diff --git a/TableProTests/Views/Results/ResultChartLocalizationTests.swift b/TableProTests/Views/Results/ResultChartLocalizationTests.swift index 8d8c6a70d9..45112fe495 100644 --- a/TableProTests/Views/Results/ResultChartLocalizationTests.swift +++ b/TableProTests/Views/Results/ResultChartLocalizationTests.swift @@ -4,29 +4,11 @@ // import Foundation -@testable import TablePro import Testing -/// A count that reads "with 1 points" is a defect the compiler cannot see, and the string catalog -/// is the only place the plural can live: `String(format:)` resolves a plural variation, but only -/// when the catalog declares one. +/// A count that reads "with 1 points" is a defect the compiler cannot see: `String(format:)` +/// resolves a plural variation only when the catalog declares one. struct ResultChartLocalizationTests { - @Test("The chart accessibility summary declares a plural for its point count") - func accessibilitySummaryHasPluralVariations() throws { - let entry = try catalogEntry("%1$@ chart of %2$@ by %3$@ with %4$d points") - let english = try #require(entry["localizations"] as? [String: Any])["en"] as? [String: Any] - let substitutions = try #require(english?["substitutions"] as? [String: Any]) - let points = try #require(substitutions["points"] as? [String: Any]) - - #expect(points["argNum"] as? Int == 4) - #expect(points["formatSpecifier"] as? String == "d") - - let variations = try #require(points["variations"] as? [String: Any]) - let plural = try #require(variations["plural"] as? [String: Any]) - #expect(plural["one"] != nil) - #expect(plural["other"] != nil) - } - @Test("A single point reads as one point") func singlePointReadsAsSingular() { let summary = String( @@ -52,28 +34,4 @@ struct ResultChartLocalizationTests { #expect(summary.hasSuffix("with 4 points")) } - - private func catalogEntry(_ key: String) throws -> [String: Any] { - let url = try Self.repoRoot() - .appendingPathComponent("TablePro/Resources/Localizable.xcstrings") - let data = try Data(contentsOf: url) - let catalog = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - let strings = try #require(catalog["strings"] as? [String: Any]) - return try #require(strings[key] as? [String: Any]) - } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CatalogError.repoRootNotFound - } - - private enum CatalogError: Error { - case repoRootNotFound - } } diff --git a/TableProTests/Views/Results/StatusBarChromeTests.swift b/TableProTests/Views/Results/StatusBarChromeTests.swift index b1f79996e5..fb4bd63926 100644 --- a/TableProTests/Views/Results/StatusBarChromeTests.swift +++ b/TableProTests/Views/Results/StatusBarChromeTests.swift @@ -74,27 +74,4 @@ struct StatusBarChromeTests { #expect(!DetailPaneBackdrop.isThemed(contrast: .increased)) } } - - private static let statusBarSources = [ - "TablePro/Views/Results/StatusBarChrome.swift", - "TablePro/Views/Results/ResultStatusBar.swift", - "TablePro/Views/Results/SelectionSummaryReadout.swift", - "TablePro/Views/Compare/CompareStatusBar.swift", - "TablePro/Views/DataFiles/DataFileStatusBar.swift", - ] - - @Test("No status bar separates its items with a middle dot") - func noMiddleDotSeparators() throws { - let root = try ThemeSourceScan.repositoryRoot() - var offenders: [String] = [] - for path in Self.statusBarSources { - let text = try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) - for (index, line) in text.components(separatedBy: .newlines).enumerated() { - let code = line.trimmingCharacters(in: .whitespaces) - guard !code.hasPrefix("//"), code.contains("\u{00B7}") else { continue } - offenders.append("\(path):\(index + 1)") - } - } - #expect(offenders.isEmpty, "Middle dots in status bar text: \(offenders)") - } } diff --git a/TableProTests/Views/Results/ValueViewerWindowMinimumSizeTests.swift b/TableProTests/Views/Results/ValueViewerWindowMinimumSizeTests.swift index df23f17751..ab800d970a 100644 --- a/TableProTests/Views/Results/ValueViewerWindowMinimumSizeTests.swift +++ b/TableProTests/Views/Results/ValueViewerWindowMinimumSizeTests.swift @@ -73,35 +73,4 @@ struct ValueViewerWindowMinimumSizeTests { #expect(window.maxSize.height > 10_000) #expect(window.styleMask.contains(.resizable)) } - - /// One shared path builds every viewer's window, so the floor reaches all of them. - @Test("Every viewer builds its window through the shared controller") - func everyViewerUsesTheSharedPath() throws { - let viewers = [ - "CellImageWindowController", - "GeometryMapWindowController", - "JSONViewerWindowController", - "PhpViewerWindowController", - "TextViewerWindowController" - ] - let root = try repositoryRoot() - for viewer in viewers { - let path = "TablePro/Views/Results/\(viewer).swift" - let source = try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) - #expect(source.contains(": ValueViewerWindowController"), "\(viewer) left the shared controller") - #expect(source.contains("controller.present("), "\(viewer) no longer presents through it") - #expect(!source.contains("NSWindow("), "\(viewer) builds a window of its own") - } - } - - private func repositoryRoot(file: StaticString = #filePath) throws -> URL { - var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() - while directory.path != "/" { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } } diff --git a/TableProTests/Views/RowInspector/GeometryFieldChromeTests.swift b/TableProTests/Views/RowInspector/GeometryFieldChromeTests.swift deleted file mode 100644 index f7bdb80aa4..0000000000 --- a/TableProTests/Views/RowInspector/GeometryFieldChromeTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// GeometryFieldChromeTests.swift -// TableProTests -// -// What a geometry field's toolbar and panes are held to and no value-level test can see: one Open -// in Window button for both segments, names on the icon-only buttons, a preference only the -// picker writes, and no map view kept alive behind the inspector's JSON rendering. -// - -import Foundation -import Testing - -struct GeometryFieldChromeTests { - private static let path = "TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift" - - private func source() throws -> String { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - while directory.path != "/" { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { - return try String(contentsOf: directory.appendingPathComponent(Self.path), encoding: .utf8) - } - directory = directory.deletingLastPathComponent() - } - throw CocoaError(.fileNoSuchFile) - } - - /// The text and JSON editors draw their own button whenever they are handed a pop-out action, - /// which would put two buttons with one label and two targets on the Text segment. - @Test("The editors under Text carry no Open in Window button of their own") - func embeddedEditorsHaveNoPopOutButton() throws { - let source = try source() - #expect(source.contains("MultiLineEditorView(context: context, onPopOut: nil,")) - #expect(source.contains("JsonEditorView(context: context, onPopOut: nil,")) - #expect(source.components(separatedBy: #"Button(String(localized: "Open in Window")"#).count == 2) - } - - /// A tooltip is a hint, not a name: VoiceOver reads the label first. - @Test("Both icon-only buttons carry an accessibility label") - func iconOnlyButtonsAreNamed() throws { - let source = try source() - #expect(source.contains(#".accessibilityLabel(String(localized: "Fit to Geometry"))"#)) - #expect(source.contains(#".accessibilityLabel(String(localized: "Open in Window"))"#)) - } - - /// A write anywhere else would let a value that became drawable while it was typed change what - /// every other geometry field opens on. - @Test("Only the picker writes the mode preference") - func onlyThePickerWritesThePreference() throws { - let source = try source() - #expect(source.components(separatedBy: "storedMode = ").count == 3) - #expect(source.contains("private var storedMode = Mode.map.rawValue")) - #expect(source.contains("latch.choose(chosen)\n storedMode = chosen.rawValue")) - } - - /// The field list stays mounted, disabled, behind the JSON rendering. A map view left there - /// holds its memory for as long as the tab lives. - @Test("The map canvas is built only while the list is enabled") - func canvasFollowsTheEnabledState() throws { - let source = try source() - #expect(source.contains("@Environment(\\.isEnabled) private var isEnabled")) - #expect(source.contains("if isEnabled {\n GeometryFieldMapCanvas(")) - #expect(source.components(separatedBy: "GeometryFieldMapCanvas(").count == 2) - } -} diff --git a/TableProTests/Views/Settings/LicenseKeyRowAccessibilityTests.swift b/TableProTests/Views/Settings/LicenseKeyRowAccessibilityTests.swift deleted file mode 100644 index 15776b1c68..0000000000 --- a/TableProTests/Views/Settings/LicenseKeyRowAccessibilityTests.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// LicenseKeyRowAccessibilityTests.swift -// TableProTests -// -// Settings > License crashed any accessibility client that was already reading the pane when a -// license appeared, VoiceOver or an XCTest snapshot alike: the key text's label looped through -// SwiftUI until the stack overflowed. The loop needs exactly that order, a read of the pane, then -// the key row inserted, then a read of the row, so the row is read here the same way, through the -// Accessibility API, from an offscreen window. That read needs Accessibility access, so a source -// check refuses the same shape everywhere else. -// - -import AppKit -import ApplicationServices -import Combine -import SwiftUI -import Testing - -@testable import TablePro - -@MainActor -struct LicenseKeyRowAccessibilityTests { - private final class Reveal: ObservableObject { - @Published var isShown = false - } - - private struct Pane: View { - @ObservedObject var reveal: Reveal - - var body: some View { - Form { - Section { - if reveal.isShown { - LicenseKeyRow(key: "ABCDE-FGHIJ-KLMNO-PQRST-UVWXY") - } else { - Text(verbatim: "No license") - } - } - } - .formStyle(.grouped) - } - } - - private static let windowTitle = "LicenseKeyRowAccessibilityTests" - - @Test( - "The license key row reads as hidden when it appears while the pane is being read", - .enabled(if: AXIsProcessTrusted(), "Reading this process's accessibility tree needs Accessibility access") - ) - func keyRowIsReadAfterAppearing() async throws { - let reveal = Reveal() - let host = NSHostingController(rootView: Pane(reveal: reveal)) - let window = NSWindow(contentViewController: host) - window.title = Self.windowTitle - window.setFrame(NSRect(x: -10_000, y: -10_000, width: 640, height: 240), display: false) - window.orderBack(nil) - defer { - window.orderOut(nil) - window.contentViewController = nil - } - - try await Task.sleep(for: .milliseconds(300)) - let before = try #require(Self.window(titled: Self.windowTitle)) - #expect(Self.texts(in: before).contains("No license")) - - reveal.isShown = true - try await Task.sleep(for: .milliseconds(300)) - - let after = try #require(Self.window(titled: Self.windowTitle)) - let texts = Self.texts(in: after) - #expect(texts.contains(String(localized: "License key"))) - #expect(texts.contains(String(localized: "License key, hidden"))) - #expect(!texts.contains { $0.hasPrefix("ABCDE") }) - } - - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 4 { - url.deleteLastPathComponent() - } - return url - }() - - /// The test above needs Accessibility access, which a test host does not have on CI, so the - /// shape that looped is also refused in source: an `.accessibilityLabel` later in the chain of - /// a `.lineLimit` inside a `LabeledContent`. Measured on macOS 27, the loop needed all three. - @Test("No line-limited view in a LabeledContent names itself with accessibilityLabel") - func lineLimitedLabelledContentIsNotRelabelled() throws { - var blocks = 0 - var offenders: [String] = [] - for root in ["TablePro/Views", "Plugins"] { - let directory = Self.repositoryRoot.appendingPathComponent(root) - let enumerator = try #require(FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil)) - for case let url as URL in enumerator where url.pathExtension == "swift" { - let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: "\n") - let result = Self.scan(lines) - blocks += result.blocks - offenders += result.offenders.map { "\(url.lastPathComponent):\($0)" } - } - } - - /// Guards the scanner itself: the app has dozens of `LabeledContent` rows. - #expect(blocks > 20, "Expected to find LabeledContent rows to check, found \(blocks)") - #expect( - offenders.isEmpty, - """ - A line-limited view in a LabeledContent is relabelled with .accessibilityLabel, which loops \ - in SwiftUI's accessibility until the stack overflows. Use .accessibilityRepresentation: \ - \(offenders.sorted()) - """ - ) - } - - /// Line numbers of an `.accessibilityLabel` in the same modifier chain as a `.lineLimit`, inside - /// a `LabeledContent` block found by brace depth. - private static func scan(_ lines: [String]) -> (blocks: Int, offenders: [Int]) { - var blocks = 0 - var offenders: [Int] = [] - for (start, line) in lines.enumerated() where line.contains("LabeledContent(") { - blocks += 1 - var depth = 0 - var opened = false - var end = start - for index in start ..< lines.count { - let opening = lines[index].filter { $0 == "{" }.count - depth += opening - lines[index].filter { $0 == "}" }.count - opened = opened || opening > 0 - end = index - if !opened || depth <= 0 { - break - } - } - for index in start ... end where lines[index].contains(".lineLimit(") { - var next = index + 1 - while next <= end { - let modifier = lines[next].trimmingCharacters(in: .whitespaces) - guard modifier.hasPrefix(".") || modifier.hasPrefix("//") else { break } - if modifier.hasPrefix(".accessibilityLabel(") { - offenders.append(next + 1) - } - next += 1 - } - } - } - return (blocks, Array(Set(offenders)).sorted()) - } - - private static func window(titled title: String) -> AXUIElement? { - let application = AXUIElementCreateApplication(getpid()) - let windows = value(of: application, kAXWindowsAttribute) as? [AXUIElement] ?? [] - return windows.first { value(of: $0, kAXTitleAttribute) as? String == title } - } - - /// Every name and value in the subtree, read attribute by attribute as VoiceOver does. - private static func texts(in element: AXUIElement, depth: Int = 0) -> [String] { - guard depth < 40 else { return [] } - var texts: [String] = [] - for attribute in [kAXTitleAttribute, kAXDescriptionAttribute, kAXValueAttribute, kAXHelpAttribute] { - if let text = value(of: element, attribute) as? String, !text.isEmpty { - texts.append(text) - } - } - for child in value(of: element, kAXChildrenAttribute) as? [AXUIElement] ?? [] { - texts += Self.texts(in: child, depth: depth + 1) - } - return texts - } - - private static func value(of element: AXUIElement, _ attribute: String) -> AnyObject? { - var value: AnyObject? - guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { - return nil - } - return value - } -} diff --git a/TableProTests/Views/Sidebar/DatabaseTreeSelectionTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeSelectionTests.swift index 3224749c43..00e9330eba 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeSelectionTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeSelectionTests.swift @@ -34,15 +34,6 @@ struct DatabaseTreeSelectionTests { #expect(inDb1 != inDb2) #expect(Set([inDb1, inDb2]).count == 2) } - - @Test("Identical database, schema, and table are equal") - func identicalRefsAreEqual() { - let lhs = DatabaseTreeTableRef(database: "db1", schema: "public", table: makeTable("users", schema: "public")) - let rhs = DatabaseTreeTableRef(database: "db1", schema: "public", table: makeTable("users", schema: "public")) - - #expect(lhs == rhs) - #expect(lhs.hashValue == rhs.hashValue) - } } struct SelectionDeltaTests { diff --git a/TableProTests/Views/SidebarNavigationResultTests.swift b/TableProTests/Views/SidebarNavigationResultTests.swift index d28ccfea9c..9f07678541 100644 --- a/TableProTests/Views/SidebarNavigationResultTests.swift +++ b/TableProTests/Views/SidebarNavigationResultTests.swift @@ -166,20 +166,6 @@ struct SidebarNavigationResultTests { #expect(result == .reuseActiveTab) } - @Test("Resolves to skip when clicking the active table in QueryTabManager") - @MainActor - func resolveSkipWithActiveTableInTabManager() throws { - let manager = QueryTabManager() - try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") - let result = SidebarNavigationResult.resolve( - clickedTableName: "users", - currentTabTableName: manager.selectedTab?.tableContext.tableName, - hasExistingTabs: !manager.tabs.isEmpty, - isActiveTabReusable: false - ) - #expect(result == .skip) - } - @Test("Resolves to openNewTab when clicking a different table while the active tab is protected") @MainActor func resolveNewTabWhenActiveTabProtected() throws { diff --git a/TableProTests/Views/Structure/ClickHousePartsGridTests.swift b/TableProTests/Views/Structure/ClickHousePartsGridTests.swift index 5b88d3fb19..09f9ca21ea 100644 --- a/TableProTests/Views/Structure/ClickHousePartsGridTests.swift +++ b/TableProTests/Views/Structure/ClickHousePartsGridTests.swift @@ -52,12 +52,6 @@ struct ClickHousePartsGridTests { #expect(ClickHousePartsView.gridRow(for: inactive).spokenValues == [active: String(localized: "Inactive")]) } - @Test("Each column opens at the width the parts list has always given it") - func defaultWidths() { - #expect(ClickHousePartsView.columnWidths == [120, 200, 100, 100, 160, 60]) - #expect(ClickHousePartsView.columnWidths.count == ClickHousePartsView.columnTitles.count) - } - @Test("Each column keeps the minimum width the parts list has always given it") func minimumWidths() { #expect(ClickHousePartsView.columnMinimumWidths == [80, 100, 60, 60, 100, 50]) diff --git a/TableProTests/Views/TrailingPaneHouseRuleTests.swift b/TableProTests/Views/TrailingPaneHouseRuleTests.swift deleted file mode 100644 index b893becd03..0000000000 --- a/TableProTests/Views/TrailingPaneHouseRuleTests.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// TrailingPaneHouseRuleTests.swift -// TableProTests -// -// The connection window's panes carry no decorative dot and no middle-dot separator. Both read as -// generated rather than designed, and there were five: an unsaved-edit marker drawn as a coloured -// circle, the chat's typing indicator drawn as three of them, a middle dot between the counts in -// the data file window's status bar, and in the query history drawer a connection dot and two more -// middle dots. Status is an SF Symbol or words; separation is space. -// - -import Foundation -import Testing - -struct TrailingPaneHouseRuleTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - - private static let scannedTrees = [ - "TablePro/Views/RowInspector", - "TablePro/Views/DataFiles", - "TablePro/Views/AIChat", - /// Agent mode's result column is the third trailing surface, and its rail and conversation - /// answer to the same rule: the session on screen is marked with a glyph, not with a dot. - "TablePro/Views/Agent", - /// The query history drawer is the window's other pane of rows, and it had both defects at - /// once: the connection named by a filled dot, and two middle dots holding its three facts - /// apart. - "TablePro/Views/Editor/History", - ] - - /// `Circle()` is the dot itself, and a bare `"circle.fill"` is the same dot drawn as a symbol, - /// which is what the history drawer named its connection with. Matching the literal with its - /// opening quote is what keeps the compound symbols the panes do use out of it: every one of - /// them, `checkmark.circle.fill` and the rest, carries a word between the quote and `circle`. - /// - /// The middle dot is banned both as the character and as its escape, since either spelling - /// draws the same separator. - private static let bannedSpellings = ["Circle()", "\"circle.fill\"", "\u{00B7}", "\\u{00B7}", "\\u{00b7}"] - - /// Comments are dropped before the scan, because a comment may name what was removed. - private static func code(of source: String) -> String { - source - .split(separator: "\n", omittingEmptySubsequences: false) - .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } - .joined(separator: "\n") - } - - @Test("No decorative dot and no middle-dot separator in the connection window's panes") - func surfacesCarryNoDots() throws { - var scanned = 0 - var offenders: [String] = [] - for tree in Self.scannedTrees { - let root = Self.repositoryRoot.appendingPathComponent(tree) - let enumerator = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) - for case let url as URL in enumerator where url.pathExtension == "swift" { - scanned += 1 - let source = Self.code(of: try String(contentsOf: url, encoding: .utf8)) - for spelling in Self.bannedSpellings where source.contains(spelling) { - offenders.append("\(tree)/\(url.lastPathComponent): \(spelling)") - } - } - } - - /// Guards the scan itself: a path that stopped resolving reads as a clean run. - #expect(scanned > 30, "Expected to scan the pane's source trees, scanned \(scanned) files") - #expect(offenders.isEmpty, "Use an SF Symbol or spacing instead: \(offenders.sorted())") - } - - /// A scan that stops matching anything passes forever. This pins both halves. - @Test("The scan catches a real dot and ignores one named in a comment") - func scanCatchesCodeButNotComments() { - let drawn = Self.code(of: " Circle().fill(Color.accentColor)") - #expect(Self.bannedSpellings.contains { drawn.contains($0) }) - - let separator = Self.code(of: " Text(verbatim: \"\u{00B7}\")") - #expect(Self.bannedSpellings.contains { separator.contains($0) }) - - let symbol = Self.code(of: " Image(systemName: \"circle.fill\").font(.system(size: 6))") - #expect(Self.bannedSpellings.contains { symbol.contains($0) }) - - /// The other half of that spelling: a status glyph that happens to end in `circle.fill` is - /// the affordance the rule asks for, so banning it would ban the fix. - let compound = Self.code(of: " Image(systemName: \"checkmark.circle.fill\")") - #expect(Self.bannedSpellings.contains { compound.contains($0) } == false) - - let documented = Self.code(of: " /// Drawn as an SF Symbol, not a Circle() dot.") - #expect(Self.bannedSpellings.contains { documented.contains($0) } == false) - } -} diff --git a/TableProTests/Views/TransferAlertWindowOwnershipTests.swift b/TableProTests/Views/TransferAlertWindowOwnershipTests.swift deleted file mode 100644 index 19d202727e..0000000000 --- a/TableProTests/Views/TransferAlertWindowOwnershipTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// TransferAlertWindowOwnershipTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// A transfer dialog presents its alerts on the window it is hosted in, never on whichever window -/// happens to be key. -/// -/// A SwiftUI `.sheet` is a real AppKit sheet. When an import or export finishes, the dialog -/// dismisses its nested progress sheet and presents the result in the same transaction, and at -/// that instant `NSApp.keyWindow` is the progress sheet AppKit is tearing down. An alert begun on -/// it is a child of a dying parent, so AppKit ends it a few milliseconds later with -/// `ModalResponse.stop` and it never renders. Measured: passing `nil` fails the same way, because -/// `AlertHelper.resolveWindow` falls back to the same key window and a sheet window clears -/// `isContentWindow` (it is not an `NSPanel` and it is `.titled`). Nothing at runtime can tell a -/// dying sheet from a healthy one, so the rule has to hold at the call site (#2314). -struct TransferAlertWindowOwnershipTests { - private static let directories = [ - "TablePro/Views/Import", - "TablePro/Views/Export", - ] - - private static let alertPresenters = [ - "TransferResultAlert.", - "AlertHelper.", - ] - - private func repositoryRoot() throws -> URL { - var url = URL(fileURLWithPath: #filePath) - while url.pathComponents.count > 1 { - url.deleteLastPathComponent() - if FileManager.default.fileExists(atPath: url.appendingPathComponent("project.yml").path) { - return url - } - } - throw TransferAlertWindowOwnershipError.repositoryRootNotFound - } - - private func swiftFiles(in directory: URL) -> [URL] { - guard let walker = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else { - return [] - } - return walker.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" } - } - - private func sources(in directory: String, under root: URL) -> [(name: String, text: String)] { - swiftFiles(in: root.appendingPathComponent(directory)).compactMap { file in - guard let text = try? String(contentsOf: file, encoding: .utf8) else { return nil } - return (file.lastPathComponent, text) - } - } - - /// A presenter call can wrap over several lines, so the scan reads to the closing parenthesis - /// of the call rather than a fixed number of characters, which would truncate a long call and - /// silently pass it. - private func presenterCalls(in source: String) -> [String] { - var calls: [String] = [] - for presenter in Self.alertPresenters { - var searchRange = source.startIndex ..< source.endIndex - while let found = source.range(of: presenter, range: searchRange) { - calls.append(String(source[found.upperBound...].prefix(while: { $0 != ")" }))) - searchRange = found.upperBound ..< source.endIndex - } - } - return calls - } - - @Test("No transfer dialog hands an alert the key window or no window at all") - func transferDialogsOwnTheirAlertWindow() throws { - let root = try repositoryRoot() - var offenders: [String] = [] - - for directory in Self.directories { - for source in sources(in: directory, under: root) { - for call in presenterCalls(in: source.text) where call.contains("window:") { - if call.contains("window: NSApp.") || call.contains("window: nil") { - offenders.append("\(source.name): \(call.prefix(90))") - } - } - } - } - - #expect(offenders.isEmpty, "Alerts must be presented on the dialog's own window: \(offenders)") - } - - /// Spelling `window: hostWindow` proves nothing on its own. Drop the capture and `hostWindow` - /// stays nil forever, every call site still reads the same, and `resolveWindow(nil)` reproduces - /// the bug in full. The capture is the half that has to be there. - @Test("Every transfer dialog captures the window it presents on") - func everyDialogCapturesItsWindow() throws { - let root = try repositoryRoot() - var missing: [String] = [] - - for directory in Self.directories { - for source in sources(in: directory, under: root) where source.text.contains("window: hostWindow") { - let captures = source.text.contains("WindowAccessor") && source.text.contains("hostWindow = ") - if !captures { - missing.append(source.name) - } - } - } - - #expect(missing.isEmpty, "A dialog presenting on hostWindow must capture it: \(missing)") - } - - /// Both checks above pass trivially if they scan nothing, so pin what they are meant to reach. - @Test("The scan reaches all three transfer dialogs") - func theScanReachesAllThreeDialogs() throws { - let root = try repositoryRoot() - var presenting: Set = [] - - for directory in Self.directories { - for source in sources(in: directory, under: root) where source.text.contains("window: hostWindow") { - presenting.insert(source.name) - } - } - - #expect(presenting.contains("ImportDialog.swift")) - #expect(presenting.contains("RowImportSheet.swift")) - #expect(presenting.contains("ExportDialog.swift")) - } -} - -private enum TransferAlertWindowOwnershipError: Error { - case repositoryRootNotFound -} diff --git a/TableProTests/Views/UserFacingEmDashGuardTests.swift b/TableProTests/Views/UserFacingEmDashGuardTests.swift deleted file mode 100644 index 7b03203836..0000000000 --- a/TableProTests/Views/UserFacingEmDashGuardTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// UserFacingEmDashGuardTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// CLAUDE.md's writing style bans the em dash from anything a user reads, and a review only catches -/// it when someone happens to look at the right line. Five had accumulated in shipped UI strings, -/// including two window titles, so this reads the source instead. -/// -/// Two uses are deliberate and stay. A quoted em dash on its own is the macOS convention for "no -/// value", the one Finder and Activity Monitor use in list columns, and it is a glyph rather than a -/// sentence to rewrite. Log messages are not user-facing. The JetBrains keychain service names need -/// no exemption: they spell the character `\u{2014}`, so the literal never appears in the source. -struct UserFacingEmDashGuardTests { - private static let emDash: Character = "\u{2014}" - private static let placeholderGlyph = "\"\u{2014}\"" - - @Test("No em dash reaches a string literal in the app target") - func appTargetStringLiteralsAreClean() throws { - let (scanned, offenders) = try UserFacingSourceScan.scanAppTarget(offends: Self.carriesProseEmDash) - - #expect(scanned > 500, "Expected to scan the app target, scanned \(scanned) files") - #expect( - offenders.isEmpty, - """ - An em dash reached a user-facing string. Use a comma, period, colon, or rewrite it. \ - The house style is the comma; see "Truncated, read only" and "License expired, sync paused". - \(offenders.joined(separator: "\n")) - """ - ) - } - - static func carriesProseEmDash(line: String, previous: String) -> Bool { - guard let code = UserFacingSourceScan.userFacingCode(line: line, previous: previous) else { return false } - return quotedSpansContainEmDash(code.replacingOccurrences(of: placeholderGlyph, with: "")) - } - - private static func quotedSpansContainEmDash(_ line: String) -> Bool { - var insideQuotes = false - var escaped = false - for character in line { - if escaped { - escaped = false - continue - } - if character == "\\" { - escaped = true - continue - } - if character == "\"" { - insideQuotes.toggle() - continue - } - if insideQuotes, character == emDash { - return true - } - } - return false - } -} - -struct UserFacingEmDashClassifierTests { - private func offends(_ line: String, previous: String = "") -> Bool { - UserFacingEmDashGuardTests.carriesProseEmDash(line: line, previous: previous) - } - - @Test("Prose inside a string literal offends") - func proseOffends() { - #expect(offends("Text(\"NULL \u{2014} no referenced row\")")) - #expect(offends("window.title = String(format: String(localized: \"PHP \u{2014} %@\"), name)")) - } - - @Test("The no-value placeholder glyph is allowed") - func placeholderGlyphIsAllowed() { - #expect(!offends("Text(verbatim: \"\u{2014}\").foregroundStyle(.tertiary)")) - #expect(!offends("guard let bytes else { return \"\u{2014}\" }")) - } - - @Test("Comments and log messages are out of scope") - func commentsAndLogsAreAllowed() { - #expect(!offends("// counts newlines \u{2014} uses a fast NSString search")) - #expect(!offends("/// Returns nil when absent \u{2014} the caller retries")) - #expect(!offends("logger.warning(\"failed \u{2014} \\(error)\")")) - #expect(!offends("\"Startup failed: \\(statement) \u{2014} \\(error)\"", previous: "Self.startupLogger.warning(")) - #expect(!offends("lifecycleLogger.error(\"failed \u{2014} \\(error)\")")) - #expect(!offends(".error(\"a \u{2014} b\")", previous: "Logger(subsystem: \"com.TablePro\", category: \"Driver\")")) - #expect(!offends("let detail = parts.joined(separator: \", \") // was \"a \u{2014} b\"")) - } - - @Test("A finished log call does not cover the line after it") - func lineAfterLogCallOffends() { - #expect(offends("return \"A \u{2014} B\"", previous: "Self.logger.info(\"Loaded\")")) - #expect(offends("Text(\"A \u{2014} B\")", previous: "let info = ConnectionFailureClassifier.info(for: error)")) - } - - @Test("A call named like a log level is user-facing") - func logLevelLookalikesOffend() { - #expect(offends("let info = ConnectionFailureClassifier.info(for: error, hint: \"A \u{2014} B\")")) - #expect(offends("banners.append(.notice(String(localized: \"A \u{2014} B\")))")) - #expect(offends("return .warning(String(localized: \"A \u{2014} B\"))")) - } - - @Test("An em dash outside any quoted span is not a string literal") - func unquotedEmDashIsIgnored() { - #expect(!offends("let separator = someValue \u{2014} other")) - } - - @Test("An escaped unicode spelling is not the literal character") - func escapedSpellingIsIgnored() { - #expect(!offends("\"IntelliJ Platform DB \\u{2014} \\(uuid)\"")) - } -} diff --git a/TableProTests/Views/UserFacingMiddleDotGuardTests.swift b/TableProTests/Views/UserFacingMiddleDotGuardTests.swift deleted file mode 100644 index 74e69a4acc..0000000000 --- a/TableProTests/Views/UserFacingMiddleDotGuardTests.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// UserFacingMiddleDotGuardTests.swift -// TableProTests -// - -import Foundation -import Testing - -/// The escaped spelling draws the same separator as the character, so both are banned. -struct UserFacingMiddleDotGuardTests { - static let middleDot: Character = "\u{00B7}" - - @Test("No middle dot reaches a string literal in the app target") - func appTargetStringLiteralsAreClean() throws { - let (scanned, offenders) = try UserFacingSourceScan.scanAppTarget(offends: Self.carriesMiddleDot) - - #expect(scanned > 500, "Expected to scan the app target, scanned \(scanned) files") - #expect( - offenders.isEmpty, - """ - A middle dot reached a user-facing string. Join facts with ", ", put a qualifier in \ - parentheses, or lay the parts out as separate labels. - \(offenders.joined(separator: "\n")) - """ - ) - } - - /// Any code on the line counts, not only a quoted span, so a multi-line string literal is covered. - static func carriesMiddleDot(line: String, previous: String) -> Bool { - guard let code = UserFacingSourceScan.userFacingCode(line: line, previous: previous) else { return false } - if code.contains(middleDot) { return true } - return code.range(of: #"\\u\{0*b7\}"#, options: [.regularExpression, .caseInsensitive]) != nil - } -} - -struct UserFacingMiddleDotClassifierTests { - private func offends(_ line: String, previous: String = "") -> Bool { - UserFacingMiddleDotGuardTests.carriesMiddleDot(line: line, previous: previous) - } - - @Test("A middle dot inside a string literal offends") - func literalOffends() { - #expect(offends("Text(\"v\\(version) \u{00B7} \\(source)\")")) - #expect(offends(".joined(separator: \" \u{00B7} \")")) - #expect(offends("Text(\"\u{00B7}\")")) - } - - @Test("The escaped spelling offends in any case and padding") - func escapedSpellingOffends() { - #expect(offends(".joined(separator: \" \\u{00B7} \")")) - #expect(offends(".joined(separator: \" \\u{b7} \")")) - #expect(offends(".joined(separator: \" \\u{00b7} \")")) - } - - @Test("The body of a multi-line string literal is covered") - func multilineBodyOffends() { - #expect(offends(" Ran 5 times \u{00B7} avg 12 ms")) - } - - @Test("Comments and log messages are out of scope") - func commentsAndLogsAreAllowed() { - #expect(!offends("// Build detail string: \"PK \u{00B7} NOT NULL\"")) - #expect(!offends("/// Shipped as \"Team \u{00B7} Lifetime\"")) - #expect(!offends("let detail = parts.joined(separator: \", \") // was \u{00B7}")) - #expect(!offends("logger.debug(\"loaded \\(count) \u{00B7} \\(name)\")")) - #expect(!offends("\"\\(table) \u{00B7} \\(error)\"", previous: "Self.logger.warning(")) - #expect(!offends("lifecycleLogger.error(\"failed \u{00B7} \\(error)\")")) - #expect(!offends(".debug(\"a \u{00B7} b\")", previous: "Logger(subsystem: \"com.TablePro\", category: \"Driver\")")) - } - - @Test("A finished log call does not cover the line after it") - func lineAfterLogCallOffends() { - #expect(offends("return parts.joined(separator: \" \u{00B7} \")", previous: "Self.logger.info(\"Loaded\")")) - #expect(offends("\"A \u{00B7} B\"", previous: "let info = ConnectionFailureClassifier.info(for: error)")) - } - - @Test("A banner case named like a log level is user-facing") - func logLevelLookalikesOffend() { - #expect(offends("banners.append(.notice(String(localized: \"A \u{00B7} B\")))")) - #expect(offends("return .warning(String(localized: \"A \u{00B7} B\"))")) - } - - @Test("A comma, a URL and other escapes are not middle dots") - func lookalikesAreIgnored() { - #expect(!offends(".joined(separator: \", \")")) - #expect(!offends("let url = \"https://tablepro.app/docs\"")) - #expect(!offends("let bullet = \"\\u{2022}\"")) - #expect(!offends("let point = \"\\u{B70}\"")) - } -} diff --git a/TableProUITests/.swiftlint.yml b/TableProUITests/.swiftlint.yml new file mode 100644 index 0000000000..d2c0693e5b --- /dev/null +++ b/TableProUITests/.swiftlint.yml @@ -0,0 +1,16 @@ +only_rules: + - ui_test_base_class + - ui_menu_query_scope + +custom_rules: + ui_test_base_class: + name: "UI Test Base Class" + excluded: '.*/Support/UITestCase\.swift$' + regex: '\bXCUIApplication\(\)|:\s*XCTestCase\b' + message: "Subclass UITestCase and launch through launchApp(), so every run gets a sandbox." + severity: error + ui_menu_query_scope: + name: "Menu Query Scope" + regex: '(?-s)\bmenuBars\.menuItems\[[^\n]*"(?:Server Dashboard|Users & Roles|Query Insights|ER Diagram|Create Table|Source|SQL Query)"' + message: "A window can carry this title too. Resolve the item under its menu: app.menuBars.menuBarItems[\"Database\"].menuItems[...]." + severity: error diff --git a/TableProUITests/AIProviderPresetUITests.swift b/TableProUITests/AIProviderPresetUITests.swift deleted file mode 100644 index 522605f544..0000000000 --- a/TableProUITests/AIProviderPresetUITests.swift +++ /dev/null @@ -1,51 +0,0 @@ -import XCTest - -/// A preset earns its place in the menu by saving a provider that works: the vendor's Base URL -/// filled in, and Save held back until the key the vendor requires is there. Nothing is typed into -/// the key field, so the sheet never reaches the network. -final class AIProviderPresetUITests: UITestCase { - func testRequestyPresetFillsItsBaseURLAndWaitsForAKey() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let settingsMenuItem = app.menuBars.menuItems["Settings…"] - XCTAssertTrue(settingsMenuItem.waitToExist(timeout: 10)) - settingsMenuItem.click() - - let settingsWindow = app.windows["settings"] - XCTAssertTrue(settingsWindow.waitToExist(timeout: 10)) - - let aiPaneButton = app.toolbars.buttons["AI"] - XCTAssertTrue(aiPaneButton.waitToExist(timeout: 10)) - aiPaneButton.click() - - /// A borderless SwiftUI menu is not reliably a menu button in the accessibility tree, so it - /// is found by its title whatever element type carries it. - let addProvider = settingsWindow.descendants(matching: .any) - .matching(NSPredicate(format: "title == %@ OR label == %@", "Add Provider…", "Add Provider…")) - .firstMatch - XCTAssertTrue(addProvider.waitToExist(timeout: 10)) - addProvider.click() - - let requesty = app.menuItems["Requesty"] - XCTAssertTrue(requesty.waitToExist(timeout: 10)) - requesty.click() - - let sheet = settingsWindow.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10)) - - /// A SwiftUI form field carries no label of its own in the accessibility tree, so the - /// fields are found by identifier rather than by their visible titles. - let baseURL = sheet.textFields["ai-provider-base-url"] - XCTAssertTrue(baseURL.waitToExist(timeout: 10)) - XCTAssertEqual(baseURL.value as? String, "https://router.requesty.ai") - - let name = sheet.textFields["ai-provider-name"] - XCTAssertTrue(name.waitToExist(timeout: 10)) - XCTAssertEqual(name.value as? String, "Requesty") - - let save = settingsWindow.buttons["ai-provider-save"] - XCTAssertTrue(save.waitToExist(timeout: 10)) - XCTAssertFalse(save.isEnabled, "Requesty requires a key, so Save has to wait for one") - } -} diff --git a/TableProUITests/AIQueryActionsUITests.swift b/TableProUITests/AIQueryActionsUITests.swift deleted file mode 100644 index 70cae24b1c..0000000000 --- a/TableProUITests/AIQueryActionsUITests.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// AIQueryActionsUITests.swift -// TableProUITests -// - -import XCTest - -final class AIQueryActionsUITests: UITestCase { - func testReviewIsOfferedButDimmedUntilAProviderIsAdded() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - typeQuery("SELECT * FROM albums;", in: app) - - let window = app.windows.firstMatch - let review = window.descendants(matching: .any) - .matching(identifier: "query-ai-review") - .firstMatch - XCTAssertTrue(review.waitToExist(timeout: 10), "The query bar must carry the Review with AI control") - XCTAssertFalse(review.isEnabled, "With no AI provider the control must be dimmed, not act on nothing") - - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["Query"].click() - let item = menuBar.menuItems["Review with AI"] - XCTAssertTrue(item.waitToExist(timeout: 5), "Query > Review with AI must exist") - XCTAssertFalse(item.isEnabled, "Query > Review with AI must be dimmed with no provider") - XCTAssertTrue(menuBar.menuItems["Explain with AI"].exists) - XCTAssertTrue(menuBar.menuItems["Optimize with AI"].exists) - app.typeKey(.escape, modifierFlags: []) - } - - func testAIActionsMenuIsNamed() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - let menu = app.windows.firstMatch.descendants(matching: .any) - .matching(identifier: "query-ai-menu") - .firstMatch - XCTAssertTrue(menu.waitToExist(timeout: 10), "The query bar must carry the AI Actions menu") - XCTAssertTrue( - menu.label == "AI Actions" || menu.title == "AI Actions", - "The AI menu must be named AI Actions: got label '\(menu.label)', title '\(menu.title)'" - ) - } - - func testContextMenuHidesAIItemsItCannotRun() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - typeQuery("SELECT * FROM albums;", in: app) - - let editor = editorTextView(in: app) - editor.rightClick() - let menu = app.windows.firstMatch.menus.firstMatch - XCTAssertTrue(menu.menuItems["Format SQL"].waitToExist(timeout: 5), "The editor's context menu must open") - XCTAssertFalse(menu.menuItems["Review with AI"].exists, "An AI item that cannot run is hidden in a context menu") - app.typeKey(.escape, modifierFlags: []) - } -} diff --git a/TableProUITests/AgentModeMenuUITests.swift b/TableProUITests/AgentModeMenuUITests.swift deleted file mode 100644 index d93b04c35a..0000000000 --- a/TableProUITests/AgentModeMenuUITests.swift +++ /dev/null @@ -1,124 +0,0 @@ -import XCTest - -/// The mode is asserted through the menu bar, which reaches it at any window width. -/// -/// The toolbar has no mode control. Browse and Agent are chosen from View > Mode, from ⌥⇧⌘A, and -/// from the Mode submenu of the toolbar's Actions pull-down, which is filled from the same enum as -/// View > Mode so the two cannot offer different modes. The Actions pull-down can sit in the -/// toolbar's overflow menu on a narrow window, the runner's included, and the menu bar cannot. -final class AgentModeMenuUITests: UITestCase { - private func openViewModeMenu(in app: XCUIApplication) -> XCUIElement { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - menuBar.menuItems["Mode"].click() - return menuBar.menuItems["Mode"] - } - - func testModeIsReachableFromTheViewMenu() throws { - let app = try launchApp() - - _ = openViewModeMenu(in: app) - let menuBar = app.menuBars.firstMatch - - XCTAssertTrue( - menuBar.menuItems["Browse"].waitToExist(timeout: 10), - "View > Mode must offer Browse" - ) - XCTAssertTrue( - menuBar.menuItems["Agent"].exists, - "View > Mode must offer Agent" - ) - } - - /// With no mode control in the toolbar, the chord is the one-step way to switch, so the menu has - /// to carry it where a user looking for it will find it. - func testToggleAgentModeCarriesItsShortcut() throws { - let app = try launchApp() - - _ = openViewModeMenu(in: app) - let toggle = app.menuBars.firstMatch.menuItems["Toggle Agent Mode"] - - XCTAssertTrue( - toggle.waitToExist(timeout: 10), - "One chord toggles the mode; naming one arm of the pair would leave the other unreachable" - ) - } - - /// Browse and Agent are a radio pair, so they are never both ticked. - /// - /// Not "exactly one": with no connection window there is no `MainSplitViewController` in the - /// responder chain, so nothing validates the pair and neither carries a tick. That is the state - /// a fresh launch is in, and asserting on one being ticked would be asserting on the launch - /// having opened a connection. - func testTheModePairIsNeverBothTicked() throws { - let app = try launchApp() - - _ = openViewModeMenu(in: app) - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.menuItems["Browse"].waitToExist(timeout: 10)) - - let ticked = [menuBar.menuItems["Browse"], menuBar.menuItems["Agent"]] - .filter { $0.isSelected } - .count - XCTAssertLessThanOrEqual(ticked, 1, "Two arms of a radio pair cannot both carry the tick") - } - - /// The session lifecycle's only routes used to be the rail's two buttons and its context menu, so - /// a user with the rail collapsed had none at all, and none of the four could be found by search - /// or rebound. They are asserted on a fresh launch, where they are present and dim: the menu bar - /// is built once at launch and carries every command the app has, whatever window is in front. - func testFileSessionCarriesTheSessionLifecycle() throws { - let app = try launchApp() - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - - menuBar.menuBarItems["File"].click() - menuBar.menuItems["Session"].click() - - for title in ["New Session", "Open Session", "Close Session", "Delete Session…"] { - XCTAssertTrue( - menuBar.menuItems[title].waitToExist(timeout: 10), - "File > Session must offer \(title)" - ) - } - } - - /// The assistant's three conversation commands lived in the trailing pane's header menu, which - /// Agent mode replaces with the result column, so entering the mode took them away outright. - func testFileSessionCarriesTheConversationCommands() throws { - let app = try launchApp() - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - - menuBar.menuBarItems["File"].click() - menuBar.menuItems["Session"].click() - - for title in ["New Conversation", "Conversation History", "Clear Recents…"] { - XCTAssertTrue( - menuBar.menuItems[title].waitToExist(timeout: 10), - "File > Session must offer \(title)" - ) - } - } - - /// With no connection window in front there is no rail and no session, so every one of them is - /// present and dim. A command lit over a window that cannot run it is the defect these items - /// would otherwise introduce, since the menu bar is built once at launch for the whole app. - func testTheSessionCommandsAreDimWithNoConnectionWindow() throws { - let app = try launchApp() - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - - menuBar.menuBarItems["File"].click() - menuBar.menuItems["Session"].click() - XCTAssertTrue(menuBar.menuItems["New Session"].waitToExist(timeout: 10)) - - for title in ["New Session", "Open Session", "Close Session", "Delete Session…"] { - XCTAssertFalse( - menuBar.menuItems[title].isEnabled, - "\(title) acts on a rail that no window is drawing" - ) - } - } -} diff --git a/TableProUITests/AgentModeRoundTripUITests.swift b/TableProUITests/AgentModeRoundTripUITests.swift index 3beebfa501..b900249a1d 100644 --- a/TableProUITests/AgentModeRoundTripUITests.swift +++ b/TableProUITests/AgentModeRoundTripUITests.swift @@ -1,68 +1,36 @@ -// -// AgentModeRoundTripUITests.swift -// TableProUITests -// -// Agent mode used to draw its conversation on the other arm of the conditional that drew the -// browse content, so every trip into the mode and back rebuilt the browse tree and dropped what -// only its views held. The query editor's undo stack is one of those: the text view owns it and the -// tab does not, so a rebuilt editor shows the same text with nothing behind it to undo. -// - import XCTest +// Agent mode used to rebuild the browse content on every trip, and the query editor's undo stack lives +// in its text view, so a rebuilt editor showed the same text with nothing behind it to undo. final class AgentModeRoundTripUITests: UITestCase { - /// The menu titles are matched by their English text, so the app runs in a known language. - private let englishArguments = ["-AppleLanguages", "(en)"] private let query = "SELECT 1" func testATripThroughAgentModeKeepsTheEditorsUndo() throws { - let app = try launchWithSampleDatabase(arguments: englishArguments) - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10), "A new query tab must hold an editor") - typeQuery(query, in: app) - - chooseMode("Agent", in: app) - assertAgentModeIsShowing(in: app) - - chooseMode("Browse", in: app) - XCTAssertTrue(waitUntilHittable(editor, timeout: 15), "Browsing must put the editor back") - editor.click() - XCTAssertEqual(editor.value as? String, query, "The tab must still hold what was typed") + let launch = try launchWithFixture() + let app = launch.app + let window = launch.window - app.typeKey("z", modifierFlags: .command) + app.typeShortcut(.newTab) + let editor = editorTextView(in: window) + XCTAssertTrue(waitForEditorText(editor) { $0.isEmpty }, "Cmd+T must open an empty query tab") + typeQuery(query, in: window) + // A trip that never reached Agent mode would keep the editor trivially. + app.typeShortcut(.toggleAgentMode) XCTAssertTrue( - waitForPredicate(timeout: 5) { (editor.value as? String) != self.query }, - "Undo must still reach the typing done before the trip. A rebuilt editor has nothing to undo" + window.buttons["agent-session-add"].firstMatch.waitToExist(timeout: 15), + "Cmd+Opt+Shift+A must put the window in Agent mode, whose sidebar lists sessions" ) - } - - // MARK: - Helpers - private func chooseMode(_ title: String, in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - menuBar.menuItems["Mode"].click() - let item = menuBar.menuItems[title] - XCTAssertTrue(item.waitToExist(timeout: 10), "View > Mode must offer \(title)") - item.click() - } + app.typeShortcut(.toggleAgentMode) + XCTAssertTrue(waitUntilHittable(editor, timeout: 15), "Browsing must put the editor back") + editor.click() + XCTAssertEqual(editorText(editor), query, "The tab must still hold what was typed") - /// A trip that never reached Agent mode would keep the editor trivially, so the case first - /// proves the mode is on. Its pane toggle names the result column in either state, where the - /// browse one names the inspector or the assistant; which state depends on what earlier launches - /// left the pane in. - private func assertAgentModeIsShowing(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["View"].click() - let hideResult = menuBar.menuItems["Hide Result"] - let showResult = menuBar.menuItems["Show Result"] + app.typeShortcut(.undo) XCTAssertTrue( - waitForPredicate(timeout: 15) { hideResult.exists || showResult.exists }, - "Choosing Agent must put the window in Agent mode" + waitForEditorText(editor, timeout: 5) { $0 != self.query }, + "Undo must still reach the typing done before the trip; a rebuilt editor has nothing to undo" ) - app.typeKey(.escape, modifierFlags: []) } } diff --git a/TableProUITests/AgentSessionRailUITests.swift b/TableProUITests/AgentSessionRailUITests.swift deleted file mode 100644 index 865555653d..0000000000 --- a/TableProUITests/AgentSessionRailUITests.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// AgentSessionRailUITests.swift -// TableProUITests -// -// The rail could only ever add sessions: there was no command anywhere in the app that deleted one, -// so it grew without bound and its empty state was unreachable. Its bottom bar is a source list's -// own add and remove pair now, and removing asks first. -// - -import XCTest - -final class AgentSessionRailUITests: UITestCase { - /// The menu titles are matched by their English text, so the app runs in a known language. - private let englishArguments = ["-AppleLanguages", "(en)"] - - func testTheRailAddsAndDeletesSessions() throws { - let app = try launchWithSampleDatabase(arguments: englishArguments) - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 20)) - enterAgentMode(in: app) - - let rail = sessionRail(in: window) - XCTAssertTrue(rail.waitToExist(timeout: 20), "Agent mode opens a session, so the rail has one to list") - let opened = rail.outlineRows.count - - let add = window.buttons["agent-session-add"].firstMatch - XCTAssertTrue(waitUntilHittable(add, timeout: 15), "The rail's bottom bar adds to the list it sits under") - add.click() - - XCTAssertTrue( - waitForPredicate(timeout: 15) { rail.outlineRows.count == opened + 1 }, - "New Session must add a row and select it" - ) - - let remove = window.buttons["agent-session-remove"].firstMatch - XCTAssertTrue(waitUntilHittable(remove, timeout: 15), "A session is highlighted, so remove is live") - remove.click() - - let confirm = app.sheets.buttons["Delete"].firstMatch - XCTAssertTrue(confirm.waitToExist(timeout: 15), "Deleting a session throws its conversation away, so it asks") - confirm.click() - - XCTAssertTrue( - waitForPredicate(timeout: 15) { rail.outlineRows.count == opened }, - "Answering the question takes the session out of the rail" - ) - } - - /// Cancelling leaves the session where it was, which is the half of a confirmation that is worth - /// as much as the other. - func testCancellingTheQuestionKeepsTheSession() throws { - let app = try launchWithSampleDatabase(arguments: englishArguments) - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 20)) - enterAgentMode(in: app) - - let rail = sessionRail(in: window) - XCTAssertTrue(rail.waitToExist(timeout: 20)) - let opened = rail.outlineRows.count - - let remove = window.buttons["agent-session-remove"].firstMatch - XCTAssertTrue(waitUntilHittable(remove, timeout: 15)) - remove.click() - - let cancel = app.sheets.buttons["Cancel"].firstMatch - XCTAssertTrue(cancel.waitToExist(timeout: 15)) - cancel.click() - - XCTAssertTrue( - waitForPredicate(timeout: 10) { rail.outlineRows.count == opened }, - "A refused question changes nothing" - ) - } - - // MARK: - Helpers - - /// The mode is chosen from the menu bar, which reaches it at any window width; the toolbar has no - /// mode control. - private func enterAgentMode(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - menuBar.menuItems["Mode"].click() - let agent = menuBar.menuItems["Agent"] - XCTAssertTrue(agent.waitToExist(timeout: 10), "View > Mode must offer Agent") - agent.click() - } - - /// The rail is shown into the same column the object browser uses, so it is found the same way: - /// stepping through the window's direct children rather than searching the whole window, which - /// would walk the data grid's thousands of elements on the way. - private func sessionRail(in window: XCUIElement) -> XCUIElement { - window.children(matching: .splitGroup).firstMatch - .children(matching: .group).firstMatch - .descendants(matching: .outline).firstMatch - } -} diff --git a/TableProUITests/AppearanceSettingsUITests.swift b/TableProUITests/AppearanceSettingsUITests.swift index 379df13e7e..baf06806d0 100644 --- a/TableProUITests/AppearanceSettingsUITests.swift +++ b/TableProUITests/AppearanceSettingsUITests.swift @@ -1,43 +1,8 @@ import XCTest -/// Settings > Appearance, driven the way a person uses it. The app writes theme files into this -/// test's sandbox, so the checks on disk read the folder the app wrote. final class AppearanceSettingsUITests: UITestCase { - // Theme and menu titles are localized. - private let englishArguments = ["-AppleLanguages", "(en)"] - - func testThemePopupsOfferOnlyTheirOwnAppearance() throws { - let (app, window) = try openAppearancePane() - - let mode = window.popUpButtons["appearance-mode-picker"] - XCTAssertTrue(mode.waitToExist(timeout: 10)) - XCTAssertEqual(mode.value as? String, "System", "A fresh sandbox follows the system appearance") - - let dark = window.popUpButtons["appearance-dark-theme"] - XCTAssertTrue(dark.waitToExist(timeout: 10)) - XCTAssertEqual(dark.value as? String, "Default Dark") - choose("Nord", in: dark) - XCTAssertTrue( - waitForPredicate(timeout: 5) { (dark.value as? String) == "Nord" }, - "Picking Nord should make it the dark theme" - ) - - let light = window.popUpButtons["appearance-light-theme"] - XCTAssertEqual(light.value as? String, "Default Light") - XCTAssertTrue(waitUntilHittable(light, timeout: 10)) - light.click() - XCTAssertTrue(light.menuItems["Default Light"].waitToExist(timeout: 5)) - XCTAssertFalse(light.menuItems["Dracula"].exists, "A dark theme has no place in the Light popup") - XCTAssertFalse(light.menuItems["Nord"].exists, "A dark theme has no place in the Light popup") - app.typeKey(.escape, modifierFlags: []) - - // The UI test defaults domain outlives this test; leave the dark slot as later tests expect it. - choose("Default Dark", in: dark) - XCTAssertTrue(waitForPredicate(timeout: 5) { (dark.value as? String) == "Default Dark" }) - } - func testCustomThemeFromCreationToDeletion() throws { - let (_, window) = try openAppearancePane() + let window = try openAppearancePane() createTheme(basedOn: "Dracula", in: window) let copy = row("Dracula Copy", in: window) @@ -145,8 +110,7 @@ final class AppearanceSettingsUITests: UITestCase { "The cleared override should be saved" ) - // Setting a color goes through the shared color panel, which XCUITest cannot drive reliably; - // ThemeEditorDraftTests covers what a well hands back. + // The shared color panel cannot be driven reliably; ThemeEditorDraftTests covers what a well hands back. let background = element("theme-color-editor.background", in: sheet) XCTAssertTrue(waitUntilHittable(background, timeout: 5), "The cleared slot keeps its well") @@ -159,15 +123,15 @@ final class AppearanceSettingsUITests: UITestCase { // MARK: - Helpers - private func openAppearancePane() throws -> (XCUIApplication, XCUIElement) { - let app = try launchOpeningURL("tablepro://settings/appearance", arguments: englishArguments) - let window = app.windows["settings"] - XCTAssertTrue(window.waitToExist(timeout: 10)) + private func openAppearancePane() throws -> XCUIElement { + let app = try launchOpeningURL("tablepro://settings/appearance") + let settings = window("settings", in: app) + XCTAssertTrue(settings.waitToExist(timeout: 10)) XCTAssertTrue( - waitForPredicate(timeout: 5) { window.title == "Appearance" }, - "The link should open the Appearance pane, the window is titled \(window.title)" + waitForPredicate(timeout: 5) { settings.title == "Appearance" }, + "The link should open the Appearance pane, the window is titled \(settings.title)" ) - return (app, window) + return settings } private func choose(_ title: String, in popup: XCUIElement) { @@ -182,7 +146,7 @@ final class AppearanceSettingsUITests: UITestCase { container.descendants(matching: .any).matching(identifier: identifier).firstMatch } - /// SwiftUI text carries its string in `value` with no label, so a subscript by title finds nothing. + // SwiftUI text carries its string in `value` with no label, so a subscript by title finds nothing. private func row(_ name: String, in window: XCUIElement) -> XCUIElement { window.staticTexts.matching(NSPredicate(format: "value == %@", name)).firstMatch } diff --git a/TableProUITests/AuxiliaryWindowCloseUITests.swift b/TableProUITests/AuxiliaryWindowCloseUITests.swift index 0b60853d97..91ae1776ee 100644 --- a/TableProUITests/AuxiliaryWindowCloseUITests.swift +++ b/TableProUITests/AuxiliaryWindowCloseUITests.swift @@ -1,58 +1,46 @@ import XCTest -/// Command W has to close the window in front. It was bound to a command only a connection -/// window answered, so it did nothing in Settings, in the MCP Activity window, or in -/// any viewer the app opens, and the welcome window had to answer that command by hand to get -/// its own shortcut back. -/// -/// Windows are found by their accessibility identifier, not their title: the settings window -/// titles itself after the pane on show, so it reads "Data" as often as "Settings". +// Windows are found by identifier: the settings window titles itself after its pane. final class AuxiliaryWindowCloseUITests: UITestCase { - private func launchShowingWelcome() throws -> XCUIApplication { + func testCommandWClosesEachAuxiliaryWindow() throws { let app = try launchApp() - XCTAssertTrue(app.windows["welcome"].waitToExist(timeout: 10)) - return app - } + let welcome = welcomeWindow(in: app) + XCTAssertTrue(welcome.waitToExist(timeout: 10)) - private func openWindow(_ identifier: String, from menuItem: String, in app: XCUIApplication) -> XCUIElement { - let item = app.menuBars.menuItems[menuItem] - XCTAssertTrue(item.waitToExist(timeout: 10), "\(menuItem) must be in the menu bar") - item.click() - let window = app.windows[identifier] - XCTAssertTrue(window.waitToExist(timeout: 10), "\(menuItem) must open the \(identifier) window") - return window - } + app.typeShortcut(.settings) + let settings = window("settings", in: app) + XCTAssertTrue(settings.waitToExist(timeout: 10), "Command-comma must open the settings window") + assertCommandWCloses(settings, leaving: welcome, in: app) + + menuItem("MCP Activity…", under: "TablePro", in: app).click() + let activity = window("integrations-activity", in: app) + XCTAssertTrue(activity.waitToExist(timeout: 10), "MCP Activity must open its window") + assertCommandWCloses(activity, leaving: welcome, in: app) - /// The welcome window is left open behind each of these, so a passing assertion means Command W - /// closed the window in front rather than any window it could reach. - private func assertCommandWCloses(_ window: XCUIElement, in app: XCUIApplication) { - app.typeKey("w", modifierFlags: .command) + menuItem("Support TablePro", under: "Help", in: app).click() + let support = window("support", in: app) + XCTAssertTrue(support.waitToExist(timeout: 10), "Help > Support TablePro must open the support window") + // Asserted, never clicked: either hands the default browser a URL. XCTAssertTrue( - waitForPredicate(timeout: 5) { !window.exists }, - "Command W must close the front window" + support.descendants(matching: .any)["support-buy-license"].waitToExist(timeout: 5), + "An unlicensed user must be offered a license" ) - XCTAssertTrue(app.windows["welcome"].exists, "Command W must not reach past the front window") - } - - func testCommandWClosesTheSettingsWindow() throws { - let app = try launchShowingWelcome() - assertCommandWCloses(openWindow("settings", from: "Settings…", in: app), in: app) - } - - func testCommandWClosesTheMCPActivityWindow() throws { - let app = try launchShowingWelcome() - assertCommandWCloses(openWindow("integrations-activity", from: "MCP Activity…", in: app), in: app) - } + XCTAssertTrue( + support.descendants(matching: .any)["support-sponsor"].exists, + "The sponsor action must always be offered" + ) + assertCommandWCloses(support, leaving: welcome, in: app) - /// The welcome window used to answer the editor's own close command to get Command W back. - /// It closes through the standard one now, with nothing of its own in the way. - func testCommandWClosesTheWelcomeWindow() throws { - let app = try launchShowingWelcome() - let welcome = app.windows["welcome"] - app.typeKey("w", modifierFlags: .command) + app.typeShortcut(.closeTab) XCTAssertTrue( waitForPredicate(timeout: 5) { !welcome.exists }, - "Command W must close the welcome window" + "Command W must close the welcome window through the standard close command" ) } + + private func assertCommandWCloses(_ window: XCUIElement, leaving welcome: XCUIElement, in app: XCUIApplication) { + app.typeShortcut(.closeTab) + XCTAssertTrue(waitForPredicate(timeout: 5) { !window.exists }, "Command W must close the front window") + XCTAssertTrue(welcome.exists, "Command W must not reach past the front window") + } } diff --git a/TableProUITests/BackgroundLaunchDockPresenceUITests.swift b/TableProUITests/BackgroundLaunchDockPresenceUITests.swift index 9307fd8f3d..64d7ebb0af 100644 --- a/TableProUITests/BackgroundLaunchDockPresenceUITests.swift +++ b/TableProUITests/BackgroundLaunchDockPresenceUITests.swift @@ -1,67 +1,22 @@ import AppKit import XCTest -/// A TablePro the MCP bridge started answers a client and shows nobody anything, so it has no -/// business in the Dock or the app switcher. LaunchServices reports what the rest of the system -/// sees through `NSRunningApplication.activationPolicy`, which is what these assert on: a window -/// query cannot tell "no Dock icon" from "not in front". -/// -/// The app under test is found by its bundle path rather than its identifier, because the developer -/// running this may well have their own TablePro open, and a per-developer -/// `TABLEPRO_APP_BUNDLE_IDENTIFIER` means the identifier is not fixed either. +// A launch the MCP bridge asked for shows nobody anything, so it stays out of the Dock and the app switcher. final class BackgroundLaunchDockPresenceUITests: UITestCase { - private var builtProductsDirectory: URL { - Bundle(for: UITestCase.self).bundleURL - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - } - - private func appUnderTest() -> NSRunningApplication? { - NSWorkspace.shared.runningApplications.first { running in - guard let bundleURL = running.bundleURL else { return false } - return bundleURL.lastPathComponent == "TablePro.app" - && bundleURL.deletingLastPathComponent() == builtProductsDirectory - } - } - - private func waitForActivationPolicy( - _ expected: NSApplication.ActivationPolicy, - timeout: TimeInterval = 15 - ) -> Bool { - waitForPredicate(timeout: timeout) { appUnderTest()?.activationPolicy == expected } - } - - func testABridgeLaunchStaysOutOfTheDock() throws { - _ = try launchApp(environment: ["TABLEPRO_BACKGROUND_LAUNCH": "1"]) + func testABridgeLaunchStaysHiddenUntilTheAppIsOpened() throws { + let app = try launchApp(environment: ["TABLEPRO_BACKGROUND_LAUNCH": "1"]) XCTAssertTrue( waitForActivationPolicy(.accessory), "A launch the MCP bridge asked for must not put TablePro in the Dock or the app switcher" ) - } - - func testABridgeLaunchOpensNoWindow() throws { - let app = try launchApp(environment: ["TABLEPRO_BACKGROUND_LAUNCH": "1"]) - - XCTAssertTrue(waitForActivationPolicy(.accessory)) XCTAssertFalse( - app.windows["welcome"].waitToExist(timeout: 5), + welcomeWindow(in: app).waitToExist(timeout: 5), "Nobody asked for a window, so a background launch must not present one" ) - } - - /// Reaching for the app is the gesture that makes the session the person's own, and from then on - /// it is an ordinary app: a Dock icon, an app switcher entry and a menu bar to quit from. - /// - /// Opened the way Finder and Spotlight open it, rather than through `XCUIApplication.activate()`: - /// the reopen event this turns on is LaunchServices', and an app with no window and no Dock icon - /// has nothing for the accessibility layer to activate. - func testOpeningTheAppMakesItAnOrdinaryApp() throws { - let app = try launchApp(environment: ["TABLEPRO_BACKGROUND_LAUNCH": "1"]) - XCTAssertTrue(waitForActivationPolicy(.accessory)) + // Opened the way Finder does: the reopen event is LaunchServices', and an app with no window + // and no Dock icon gives XCUIApplication.activate() nothing to act on. let opened = expectation(description: "TablePro opened from the Finder") let configuration = NSWorkspace.OpenConfiguration() configuration.activates = true @@ -71,24 +26,10 @@ final class BackgroundLaunchDockPresenceUITests: UITestCase { ) { _, _ in opened.fulfill() } wait(for: [opened], timeout: 30) + XCTAssertTrue(waitForActivationPolicy(.regular), "Opening TablePro must give it back its Dock icon") XCTAssertTrue( - waitForActivationPolicy(.regular), - "Opening TablePro must give it back its Dock icon" - ) - XCTAssertTrue( - app.windows["welcome"].waitToExist(timeout: 10), + welcomeWindow(in: app).waitToExist(timeout: 10), "Opening TablePro with nothing on screen must show the welcome window" ) } - - func testAnOrdinaryLaunchIsInTheDockFromTheStart() throws { - let app = try launchApp() - - XCTAssertTrue(app.windows["welcome"].waitToExist(timeout: 10)) - XCTAssertEqual( - appUnderTest()?.activationPolicy, - .regular, - "A launch the person asked for is an ordinary app" - ) - } } diff --git a/TableProUITests/BackupScopeSheetUITests.swift b/TableProUITests/BackupScopeSheetUITests.swift deleted file mode 100644 index 0dcace0e5c..0000000000 --- a/TableProUITests/BackupScopeSheetUITests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import XCTest - -/// Issue #2485. `SQLitePlugin.fetchDatabases()` returns an empty list, and the backup flow used to -/// take that literally: the database picker rendered its "No databases" empty state and its confirm -/// button was dimmed for good, with Cancel the only way out. File > Backup Dump… was enabled the -/// whole time, and the docs said SQLite backup worked. -final class BackupScopeSheetUITests: UITestCase { - func testBackupDumpOnSQLiteReachesAnEnabledBackUpButton() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - - let backupDump = app.menuBars.menuItems["Backup Dump…"] - XCTAssertTrue(backupDump.waitToExist(timeout: 15), "File > Backup Dump… must be available") - XCTAssertTrue(backupDump.isEnabled, "SQLite has a dump tool, so the item must be enabled") - backupDump.click() - - let backUp = app.buttons["Back Up"].firstMatch - XCTAssertTrue(backUp.waitToExist(timeout: 20), "The backup sheet must open") - XCTAssertTrue( - waitForPredicate(timeout: 20) { backUp.isEnabled }, - "SQLite reports no databases of its own, and the sheet must still offer the one it opened" - ) - - /// A file-backed engine keeps its whole path in the field a server engine keeps a database - /// name in, so the tree once listed the row as - /// `/Users/me/Library/.../Chinook.sqlite` and named the dump file after it. - /// Scoped to the tree, because the destination row below it is a path on purpose. - let tree = app.outlines.matching( - NSPredicate(format: "identifier == %@ OR label == %@", "", "Databases and objects to back up") - ).firstMatch - let pathRow = tree.staticTexts - .matching(NSPredicate(format: "value BEGINSWITH %@", "/")) - .firstMatch - XCTAssertFalse( - pathRow.waitToExist(timeout: 3), - "The scope tree must list the database by name, not by its file path" - ) - - app.buttons["Cancel"].firstMatch.click() - XCTAssertTrue( - waitForPredicate(timeout: 15) { !backUp.exists }, - "Cancel must close the sheet" - ) - } -} diff --git a/TableProUITests/CellFilterMenuUITests.swift b/TableProUITests/CellFilterMenuUITests.swift deleted file mode 100644 index c967449ad6..0000000000 --- a/TableProUITests/CellFilterMenuUITests.swift +++ /dev/null @@ -1,66 +0,0 @@ -import XCTest - -/// #3066: a cell's context menu offers **Filter**, whose conditions come from the clicked value and -/// run against the whole table. -/// -/// **Clear** proves the filter ran: it exists only inside the filter bar, and it is enabled only -/// while a filter is applied. -final class CellFilterMenuUITests: UITestCase { - func testTheCellMenuFiltersTheTableByTheClickedValue() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let grid = try albumGrid(in: window) - - let cell = gridPoint(in: grid, of: window, dy: 70) - cell.click() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - cell.rightClick() - - let filter = window.menus.menuItems["Filter"].firstMatch - XCTAssertTrue(filter.waitToExist(timeout: 15), "A cell's context menu must offer Filter") - filter.hover() - - let equalsItems = app.menuItems.matching(NSPredicate(format: "title CONTAINS %@", " = \u{201C}")) - var equals: XCUIElement? - XCTAssertTrue( - waitForPredicate(timeout: 10) { - equals = equalsItems.allElementsBoundByIndex.first { $0.isHittable } - return equals != nil - }, - "The Filter submenu must offer an equals condition built from the clicked value" - ) - try XCTUnwrap(equals).click() - - XCTAssertTrue( - waitForPredicate(timeout: 15) { - window.buttons["Clear"].exists && window.buttons["Clear"].isEnabled - }, - "Choosing the condition must open the filter bar and run it" - ) - } - - // MARK: - Helpers - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - return window - } - - private func albumGrid(in window: XCUIElement) throws -> XCUIElement { - let row = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: Album")) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(row) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "Album produced no data grid") - XCTAssertTrue(waitForClickableRows(in: grid), "Album must load rows before a cell can be filtered") - return grid - } -} diff --git a/TableProUITests/CellImageViewerUITests.swift b/TableProUITests/CellImageViewerUITests.swift deleted file mode 100644 index 177fe3fb69..0000000000 --- a/TableProUITests/CellImageViewerUITests.swift +++ /dev/null @@ -1,112 +0,0 @@ -import XCTest - -/// #2535. A cell holding SVG markup used to show its source and nothing else. It now opens on the -/// drawing, with the markup one segment away. -/// -/// The value arrives from a query rather than a table, because Chinook carries no image column and -/// a result of a literal is read-only, which is the path the reporter described. -final class CellImageViewerUITests: UITestCase { - private let markup = "" - private let query = "SELECT '' AS logo;" - - func testAnSvgCellOpensOnTheImageSegmentWithItsMarkupBeside() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let editor = openQueryTab(in: app) - - typeSvgQuery(in: app) - XCTAssertTrue( - waitForValue(query, in: editor, timeout: 5), - "The editor must hold the query; got '\(editor.value as? String ?? "nil")'" - ) - - let grid = runQuery(in: app) - openCell(in: grid, of: window, app: app) - - XCTAssertTrue( - waitForPredicate(timeout: 15) { self.segment("Image", in: window).exists }, - "An SVG cell must offer an Image segment" - ) - let sourceSegment = segment("Source", in: window) - XCTAssertTrue(sourceSegment.exists, "The stored markup must stay one segment away") - - clickAtCenter(sourceSegment) - XCTAssertTrue( - waitForPredicate(timeout: 10) { self.markupIsVisible(in: window) }, - "The Source segment must show the stored markup" - ) - } - - func testAPlainTextCellStillOpensTheInlineViewer() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let editor = openQueryTab(in: app) - - app.typeText("SELECT 1 AS n;") - XCTAssertTrue(waitForValue("SELECT 1 AS n;", in: editor, timeout: 5)) - - let grid = runQuery(in: app) - openCell(in: grid, of: window, app: app) - - XCTAssertFalse( - segment("Image", in: window).waitToExist(timeout: 3), - "A value that is not an image must not gain an Image segment" - ) - } - - // MARK: - Helpers - - /// The editor auto-closes `'`, so the closing quote is never typed: it is already there, and - /// the caret jumps past it before the rest of the statement goes in. - private func typeSvgQuery(in app: XCUIApplication) { - app.typeText("SELECT '") - app.typeText(markup) - app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: .command) - app.typeText(" AS logo;") - } - - private func runQuery(in app: XCUIApplication) -> XCUIElement { - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: .command) - let grid = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 20), "The query must produce a result grid") - XCTAssertTrue(grid.tableRows.element(boundBy: 0).waitToExist(timeout: 20)) - return grid - } - - /// A coordinate, never the row or the cell: the grid publishes its columns as siblings of its - /// rows and later in the tree, so XCUITest reads both as obscured and refuses to click them. - /// `Return` rather than a second click, because the click path is gated on inline editing. - private func openCell(in grid: XCUIElement, of window: XCUIElement, app: XCUIApplication) { - gridPoint(in: grid, of: window, dy: 52).click() - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - } - - /// A segmented control publishes its segments as radio buttons, and the CI runner has been seen - /// publishing the same controls as plain buttons, so both are asked. - /// - /// `firstMatch` on both, because a SwiftUI segmented `Picker` publishes more than one element - /// carrying the segment's title. `exists` tolerates that; resolving a coordinate to click does - /// not, and fails the whole case with "Multiple matching elements found". - private func segment(_ title: String, in window: XCUIElement) -> XCUIElement { - let radio = window.radioButtons.matching(identifier: title).firstMatch - return radio.exists ? radio : window.buttons.matching(identifier: title).firstMatch - } - - private func markupIsVisible(in window: XCUIElement) -> Bool { - window.textViews.allElementsBoundByIndex.contains { - ($0.value as? String)?.contains("") == true - } - } - - private func openQueryTab(in app: XCUIApplication) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "A new tab starts with an empty editor") - return editor - } - - private func waitForValue(_ expected: String, in element: XCUIElement, timeout: TimeInterval) -> Bool { - waitForPredicate(timeout: timeout) { (element.value as? String) == expected } - } -} diff --git a/TableProUITests/CloseTabBeforeFirstClickUITests.swift b/TableProUITests/CloseTabBeforeFirstClickUITests.swift deleted file mode 100644 index a207480ce3..0000000000 --- a/TableProUITests/CloseTabBeforeFirstClickUITests.swift +++ /dev/null @@ -1,64 +0,0 @@ -import XCTest - -/// Command W closed the whole connection instead of the current tab in a window nobody had clicked -/// into yet: after a relaunch that restored the session, and on a connection opened fresh. -/// -/// AppKit gave the window its first responder as it appeared, and the connections strip's list was -/// the only candidate, on screen or collapsed to zero width. The strip answers Close itself, so the -/// keystroke closed its entry. Clicking the editor or the grid moved the keyboard out of the strip, -/// which is why the bug only showed before the first click. -/// -/// Nothing is clicked in either test, on purpose: the first click is what hid the bug. -final class CloseTabBeforeFirstClickUITests: UITestCase { - func testCommandWBeforeAnyClickClosesTheTabAndKeepsTheConnection() throws { - let app = try launchWithSampleDatabase() - let window = app.windows["main"] - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The sample database opens on a table tab") - - app.typeKey("w", modifierFlags: .command) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { !grid.exists }, - "Command W must close the table tab" - ) - XCTAssertTrue( - window.outlines.firstMatch.waitToExist(timeout: 5), - "The connection must stay open on its empty state, not close with the tab" - ) - } - - /// Two restored connections, so the strip is on screen by the time Command W is pressed, which - /// is where anyone who reopens more than one connection lands. - func testCommandWInARestoredSessionClosesATabAndKeepsBothConnections() throws { - try seedSQLiteSession( - connectionNames: ["Restored A", "Restored B"], - databaseSQL: "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO items (name) VALUES ('one');", - tabsEach: 3 - ) - let app = try launchApp() - let window = app.windows["main"] - let strip = window.tables.matching(identifier: "workspace-rail").firstMatch - - XCTAssertTrue( - waitForPredicate(timeout: 60) { strip.exists && strip.tableRows.count == 2 }, - "Both restored connections must be listed in the strip" - ) - XCTAssertTrue( - waitForPredicate(timeout: 30) { self.tabCount(in: window) == 3 }, - "The selected connection must restore its three tabs" - ) - - app.typeKey("w", modifierFlags: .command) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.tabCount(in: window) == 2 }, - "Command W must close one tab. Tabs now: \(tabCount(in: window))" - ) - XCTAssertEqual(strip.tableRows.count, 2, "Neither connection may close with the tab") - } - - private func tabCount(in window: XCUIElement) -> Int { - window.descendants(matching: .any).matching(identifier: "editor-tab").count - } -} diff --git a/TableProUITests/ColumnDefaultPickerUITests.swift b/TableProUITests/ColumnDefaultPickerUITests.swift deleted file mode 100644 index ab67449f92..0000000000 --- a/TableProUITests/ColumnDefaultPickerUITests.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// ColumnDefaultPickerUITests.swift -// TableProUITests -// -// The Default field offers the engine's own values and still takes typed SQL. Driven through the -// inspector rather than the grid: a data grid cell is drawn with CoreText and takes no synthetic -// click at all, while the inspector mounts real controls with identifiers on them. -// - -import XCTest - -final class ColumnDefaultPickerUITests: UITestCase { - func testTheDefaultFieldOffersTheEnginesOwnValuesAndAWayOut() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - try selectFirstColumnRow(in: app, window: window) - - let chooser = defaultValueMenu(in: window) - XCTAssertTrue( - chooser.waitToExist(timeout: 30), - "The Default field carries its own menu. Without it the only way to set a default is to " - + "know the engine's spelling of every expression by heart." - ) - chooser.click() - - for title in ["No default", "NULL", "Empty string", "Custom…"] { - XCTAssertTrue( - app.menuItems[title].waitToExist(timeout: 10), - "The Default menu must offer \(title) on every engine that has column defaults" - ) - } - - /// The sample database is SQLite, whose grammar needs the parentheses PostgreSQL rejects. - /// A menu carrying another engine's spelling produces DDL the server refuses. - XCTAssertTrue( - app.menuItems["CURRENT_TIMESTAMP"].exists, - "SQLite spells the current timestamp CURRENT_TIMESTAMP" - ) - XCTAssertFalse( - app.menuItems["gen_random_uuid()"].exists, - "gen_random_uuid() is PostgreSQL's, and SQLite has no such function" - ) - XCTAssertFalse( - app.menuItems["AUTO_INCREMENT"].exists, - "AUTO_INCREMENT is a column property on every engine, never a default" - ) - - app.typeKey(.escape, modifierFlags: []) - } - - /// Choosing writes the SQL rather than the label, which is the whole point of the two being - /// separate: `Empty string` is a name and `''` is the statement. - func testChoosingEmptyStringWritesTheSQLRatherThanTheLabel() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - try selectFirstColumnRow(in: app, window: window) - - let chooser = defaultValueMenu(in: window) - XCTAssertTrue(chooser.waitToExist(timeout: 30), "The Default field must carry its menu") - chooser.click() - - let emptyString = app.menuItems["Empty string"] - XCTAssertTrue(emptyString.waitToExist(timeout: 10), "The menu must offer Empty string") - emptyString.click() - - XCTAssertTrue( - waitForPredicate(timeout: 15) { - window.textFields.allElementsBoundByIndex.contains { ($0.value as? String) == "''" } - }, - "Choosing Empty string must put '' in the field, not the words Empty string" - ) - } - - /// Matched by identifier and without a type filter. The control is a SwiftUI `Menu`, which - /// macOS backs with an `NSPopUpButton`: it publishes `menuButton` or `popUpButton`, so - /// `window.buttons` can never match it, and which of the two it picks is not something a test - /// should depend on. The same shape reaches the inspector's own value menu. - private func defaultValueMenu(in window: XCUIElement) -> XCUIElement { - window.descendants(matching: .any)["column-default-menu"].firstMatch - } - - private func selectFirstColumnRow(in app: XCUIApplication, window: XCUIElement) throws { - let browserRow = objectBrowserRow("Album", in: window) - XCTAssertTrue(browserRow.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(browserRow) - - showStructure(in: app, window: window) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The structure editor must draw its column grid") - XCTAssertTrue( - waitForClickableRows(in: grid), - "Album must report its columns before one of them can be clicked" - ) - - /// Clear of the 42pt header, which a smaller offset lands on instead of a row. - gridPoint(in: grid, of: window, dy: 70).click() - showInspector(in: app) - } - - private func mainWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - return window - } - - private func showInspector(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - - let show = menuBar.menuItems["Show Inspector"] - if show.waitToExist(timeout: 5) { - show.click() - return - } - app.typeKey(.escape, modifierFlags: []) - } -} diff --git a/TableProUITests/ColumnJumpUITests.swift b/TableProUITests/ColumnJumpUITests.swift deleted file mode 100644 index 686355b052..0000000000 --- a/TableProUITests/ColumnJumpUITests.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// ColumnJumpUITests.swift -// TableProUITests -// - -import XCTest - -final class ColumnJumpUITests: UITestCase { - private static let columnCount = 60 - - /// One launch for every route into the panel and for the jump itself. The routes are - /// independent, so `continueAfterFailure` stays on: a broken menu item must not hide whether the - /// popover still reaches the panel. - func testJumpToColumnReachesAColumnPastTheViewport() throws { - continueAfterFailure = true - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let grid = runWideQuery(in: app) - let lastColumnHeader = grid.buttons["Column: col_60"] - XCTAssertTrue(lastColumnHeader.waitToExist(timeout: 10), "The result must expose its last column's header") - XCTAssertFalse( - isInView(lastColumnHeader, of: grid), - "Sixty columns must push the last one past the viewport" - ) - - app.typeKey("j", modifierFlags: [.command, .shift]) - let panel = switcherPanel(in: app) - let searchField = panel.textFields["column-jump-search-field"] - XCTAssertTrue(searchField.waitToExist(timeout: 15), "Command Shift J must open Jump to Column") - - searchField.typeText("col60") - let row = panel.buttons["col_60"] - XCTAssertTrue(row.waitToExist(timeout: 10), "A fuzzy match must list the column") - XCTAssertTrue( - ((row.value as? String) ?? "").contains("60 of 60"), - "The row must carry the column's position, got \(String(describing: row.value))" - ) - - app.typeKey(.return, modifierFlags: []) - XCTAssertTrue(searchField.waitForNonExistence(timeout: 5), "Return must close the panel") - XCTAssertTrue( - waitForPredicate(timeout: 10) { isInView(lastColumnHeader, of: grid) }, - "The jump must scroll the column into view" - ) - - /// The menu route, then an Escape on an empty field, which closes. - let menuItem = app.menuBars.menuItems["Jump to Column…"] - XCTAssertTrue(menuItem.waitToExist(timeout: 5)) - menuItem.click() - XCTAssertTrue(searchField.waitToExist(timeout: 15), "The Edit menu item must open the panel") - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(searchField.waitForNonExistence(timeout: 5), "Escape on an empty field must close the panel") - - /// The popover route carries the popover's search text into the panel. - let columnsButton = window.buttons["result-status-columns"] - XCTAssertTrue(waitUntilHittable(columnsButton, timeout: 10)) - columnsButton.click() - let popoverSearch = app.searchFields["column-visibility-search"].firstMatch - XCTAssertTrue(popoverSearch.waitToExist(timeout: 10), "The columns popover must offer its search field") - popoverSearch.click() - app.typeText("col_1") - /// `.any`, because a link-styled SwiftUI button is published as a link, not a button. - let jumpButton = app.descendants(matching: .any).matching(identifier: "column-visibility-jump").firstMatch - XCTAssertTrue(waitUntilHittable(jumpButton, timeout: 10), "The popover must offer Jump to Column") - jumpButton.click() - XCTAssertTrue(searchField.waitToExist(timeout: 15), "The popover button must open the panel") - XCTAssertTrue( - waitForPredicate(timeout: 5) { (searchField.value as? String ?? "") == "col_1" }, - "The popover's search text must seed the panel" - ) - app.typeKey(.escape, modifierFlags: []) - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) - } - - // MARK: - Helpers - - /// Whether the header's centre lies inside the grid's frame, which is what scrolling a column - /// into view changes. - /// - /// Not `isHittable`: a data grid header never reports it, even when published, enabled, correctly - /// placed and unobstructed, so a wait on it times out after a jump that did scroll. Measured after - /// the jump, `col_60` sat at x 1795 to 1880 inside a grid spanning 959 to 1881 and still read as - /// not hittable. - private func isInView(_ header: XCUIElement, of grid: XCUIElement) -> Bool { - guard header.exists, grid.exists else { return false } - let frame = header.frame - return grid.frame.contains(CGPoint(x: frame.midX, y: frame.midY)) - } - - /// Through `typeQuery`, because sixty columns is a long run of keystrokes to send straight after - /// a click: the first ones can land before the editor takes them, and a query that lost its - /// `SELECT` produces no header to find and no result for Command Shift J to act on. - private func runWideQuery(in app: XCUIApplication) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let columns = (1...Self.columnCount) - .map { String(format: "%d AS col_%02d", $0, $0) } - .joined(separator: ", ") - typeQuery("SELECT \(columns) FROM Track LIMIT 3;", in: app) - app.typeKey(.return, modifierFlags: .command) - - let grid = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 20), "The query must produce a result grid") - return grid - } - - /// `.any` because AppKit gives a floating panel the `AXDialog` subrole, which XCUITest reports as - /// Dialog rather than Window. See `OpenQuicklyCommandUITests`. - private func switcherPanel(in app: XCUIApplication) -> XCUIElement { - app.children(matching: .any).matching(identifier: "quick-switcher-panel").firstMatch - } -} diff --git a/TableProUITests/CompareSyncUITests.swift b/TableProUITests/CompareSyncUITests.swift deleted file mode 100644 index 46d9ea581c..0000000000 --- a/TableProUITests/CompareSyncUITests.swift +++ /dev/null @@ -1,77 +0,0 @@ -import XCTest - -/// The test sandbox carries no license, so this run sees exactly what an unlicensed user sees: -/// `Compare & Sync Databases…` stays enabled, because a paid feature the user can buy has to be -/// discoverable, and choosing it explains the gate instead of opening the window. -/// -/// The suite this replaces asserted on the window itself, on a `popUpButtons` value of -/// "Choose a connection" and on a Compare button, none of which exist: the window never opens -/// without a license, and the rebuilt toolbar spells its placeholder "Choose Source". It could not -/// pass on any machine. The window's own contract now lives in `CompareSyncSessionTests` and -/// `DatabaseEndpointTests`, which reach it without a license. -final class CompareSyncUITests: UITestCase { - private let licenseAlertMessage = "Compare & Sync requires the Starter plan" - - private func openCompareSyncMenuItem(in app: XCUIApplication) -> XCUIElement { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["Database"].click() - menuBar.menuItems["Compare"].click() - return menuBar.menuItems["Compare & Sync Databases…"] - } - - func testCompareSyncIsReachableFromTheDatabaseMenu() throws { - let app = try launchApp() - - let item = openCompareSyncMenuItem(in: app) - XCTAssertTrue( - item.waitToExist(timeout: 10), - "Compare & Sync must be reachable from Database > Compare" - ) - XCTAssertTrue( - item.isEnabled, - "A feature the user can buy stays enabled, so choosing it can explain what it needs" - ) - } - - /// The gate is enforced when the window is asked for rather than when the menu is validated, so - /// this is the only place the refusal can be observed. - func testChoosingItWithoutALicenseExplainsTheGateInsteadOfOpeningTheWindow() throws { - let app = try launchApp() - - let item = openCompareSyncMenuItem(in: app) - XCTAssertTrue(item.waitToExist(timeout: 10)) - item.click() - - let message = app.staticTexts[licenseAlertMessage] - XCTAssertTrue( - message.waitToExist(timeout: 10), - "An unlicensed run must say what Compare & Sync needs" - ) - XCTAssertFalse( - app.windows["Compare & Sync"].exists, - "Nothing may open the comparison window without a license" - ) - - let dialog = app.dialogs.firstMatch - guard dialog.exists, dialog.buttons["Cancel"].exists else { return } - dialog.buttons["Cancel"].click() - } - - /// The HIG's rule that every toolbar item is also a menu-bar command, checked where it can be - /// checked without a license: the item has to be in the menu even though it validates to - /// disabled with no window behind it. - func testSavingAComparisonIsAMenuBarCommand() throws { - let app = try launchApp() - - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["Database"].click() - menuBar.menuItems["Compare"].click() - - XCTAssertTrue( - menuBar.menuItems["Save Comparison…"].waitToExist(timeout: 10), - "Save Comparison must be reachable from Database > Compare" - ) - } -} diff --git a/TableProUITests/ConnectionActivityFooterUITests.swift b/TableProUITests/ConnectionActivityFooterUITests.swift deleted file mode 100644 index 73298626ac..0000000000 --- a/TableProUITests/ConnectionActivityFooterUITests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import XCTest - -/// The connection switcher's footer names the transport the window is on. The sample database is a -/// local file with no transport in front of it, so the deterministic assertion is the one this test -/// makes: the footer is there, and it reads Direct. -/// -/// A tunnelled connection is not covered here, because a byte count needs a live SSH server and a -/// query moving data through it. `SSHChannelRelayTests`, `TransportRateSamplerTests` and -/// `ConnectionTransportActivityTests` cover the counting, the rate and the per-transport choice. -final class ConnectionActivityFooterUITests: UITestCase { - func testTheSwitcherNamesTheTransportTheWindowIsOn() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("c", modifierFlags: [.command, .control]) - - let field = app.searchFields.matching( - NSPredicate(format: "placeholderValue BEGINSWITH[c] %@", "Search connections") - ).firstMatch - XCTAssertTrue(field.waitToExist(timeout: 15), "The connection switcher never opened") - - let transport = app.staticTexts["connection-activity-transport"] - XCTAssertTrue(transport.waitToExist(timeout: 10), "The switcher must carry a transport footer") - XCTAssertEqual( - transport.value as? String, - "Direct", - "A connection with no tunnel must read as Direct" - ) - - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(field.waitForNonExistence(timeout: 10), "Escape must dismiss the switcher") - } -} diff --git a/TableProUITests/ConnectionCloseUITests.swift b/TableProUITests/ConnectionCloseUITests.swift index 57ce5b4c95..e54e3b4f6e 100644 --- a/TableProUITests/ConnectionCloseUITests.swift +++ b/TableProUITests/ConnectionCloseUITests.swift @@ -1,56 +1,22 @@ import XCTest -/// Closing a connection used to be a command that could not be seen to do anything: it closed a -/// subset of one connection's tabs and left the row that invoked it exactly where it was, and on a -/// connection with no session it resolved no coordinator and returned in silence. -/// -/// These drive the command through the menu bar rather than the connections strip, because the -/// strip only appears once a second connection is open and the sample database is a single-file -/// SQLite connection. The command is the same one the strip's row invokes. final class ConnectionCloseUITests: UITestCase { - /// The regression test for the reported bug: choosing Close must have an observable effect. - /// - /// The observable is the command's own enablement, which is `hasSelectedWorkspace`: true while - /// a window is showing a connection and false once none is. Asserting on the editor view - /// instead made the test depend on which kind of tab the sample database happens to open with, - /// which is not what this is testing. The sample connection has no unsaved work, so it takes - /// the close-without-asking path. func testClosingTheOnlyConnectionLeavesNoConnectionShowing() throws { - let app = try launchWithSampleDatabase() + let launch = try launchWithFixture() + + menuItem("Close Connection", under: "File", in: launch.app).click() XCTAssertTrue( - waitForCloseConnection(in: app, toBeEnabled: true, timeout: 30), - "Opening the sample database must leave a connection showing" + waitForPredicate(timeout: 20) { !launch.window.exists }, + "Closing the window's only connection must close the window, not leave it as it was" ) - closeConnectionItem(in: app).click() - + let welcome = welcomeWindow(in: launch.app) + XCTAssertTrue(welcome.waitToExist(timeout: 10), "With no connection showing, the welcome window comes back") + let list = welcomeConnectionList(in: welcome) + XCTAssertTrue(list.waitToExist(timeout: 10)) XCTAssertTrue( - waitForCloseConnection(in: app, toBeEnabled: false, timeout: 20), - "Closing the connection must leave none showing, not leave the window as it was" + welcomeConnectionRow(named: UITestFixture.connectionName, in: list).waitToExist(timeout: 10), + "Closing ends the session and keeps the saved connection" ) } - - /// Opens the File menu and reports the item's enablement, closing the menu again so the next - /// poll starts from the same state. A menu item only answers `isEnabled` while its menu is up. - private func waitForCloseConnection( - in app: XCUIApplication, - toBeEnabled expected: Bool, - timeout: TimeInterval - ) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let item = closeConnectionItem(in: app) - guard item.waitToExist(timeout: 5) else { continue } - let isEnabled = item.isEnabled - app.typeKey(.escape, modifierFlags: []) - if isEnabled == expected { return true } - } - return false - } - - private func closeConnectionItem(in app: XCUIApplication) -> XCUIElement { - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["File"].click() - return menuBar.menuItems["Close Connection"] - } } diff --git a/TableProUITests/ConnectionExportOptionsUITests.swift b/TableProUITests/ConnectionExportOptionsUITests.swift deleted file mode 100644 index 58f6be7c47..0000000000 --- a/TableProUITests/ConnectionExportOptionsUITests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import SQLite3 -import XCTest - -final class ConnectionExportOptionsUITests: UITestCase { - func testGlobalSavedQueriesFollowTheParentCheckbox() throws { - try seedConnectionWithSavedQueries() - let app = try launchApp() - let welcome = app.windows["welcome"] - XCTAssertTrue(welcome.waitToExist(timeout: 15)) - - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["File"].click() - menuBar.menuItems["Export"].click() - menuBar.menuItems["Export Connections…"].click() - - let sheet = welcome.sheets.firstMatch - let parent = sheet.checkBoxes["export-include-saved-queries"] - XCTAssertTrue(parent.waitToExist(timeout: 10), "Seeded saved queries must show the saved query options") - XCTAssertTrue(isOn(parent), "Saved queries are included by default") - - let global = sheet.checkBoxes["export-include-global-saved-queries"] - XCTAssertTrue(global.waitToExist(timeout: 5), "A global saved query must offer the global option") - XCTAssertFalse(isOn(global), "Global saved queries are left out by default") - XCTAssertTrue(global.isEnabled) - - parent.click() - XCTAssertTrue( - waitForPredicate(timeout: 5) { !global.isEnabled }, - "The global option must turn off with its parent" - ) - - sheet.buttons["Cancel"].click() - } - - private func seedConnectionWithSavedQueries() throws { - let root = try XCTUnwrap(sandboxRoot, "setUpWithError did not prepare a sandbox") - let supportDirectory = root.appendingPathComponent("TablePro", isDirectory: true) - try FileManager.default.createDirectory(at: supportDirectory, withIntermediateDirectories: true) - - let connectionId = UUID().uuidString - let connection: [String: Any] = [ - "id": connectionId, - "name": "export-orders", - "host": "127.0.0.1", - "port": 3_306, - "database": "orders", - "username": "app", - "type": "MySQL", - "sshEnabled": false, - "sshHost": "", - "sshUsername": "", - "sshAuthMethod": "password", - "sshPrivateKeyPath": "", - "sortOrder": 0, - ] - try JSONSerialization.data(withJSONObject: [connection], options: [.sortedKeys]) - .write(to: supportDirectory.appendingPathComponent("connections.json"), options: .atomic) - - seedFavorites( - at: supportDirectory.appendingPathComponent("sql_favorites.db"), - rows: [("Daily orders", "SELECT 1", connectionId), ("Locks", "SELECT 2", nil)] - ) - } - - private func seedFavorites(at url: URL, rows: [(name: String, sql: String, connectionId: String?)]) { - var handle: OpaquePointer? - defer { sqlite3_close(handle) } - XCTAssertEqual(sqlite3_open(url.path, &handle), SQLITE_OK, "Could not create \(url.path)") - let now = Date().timeIntervalSince1970 - var statements = [ - """ - CREATE TABLE favorites (id TEXT PRIMARY KEY, name TEXT NOT NULL, query TEXT NOT NULL, keyword TEXT, - folder_id TEXT, connection_id TEXT, sort_order INTEGER NOT NULL DEFAULT 0, - created_at REAL NOT NULL, updated_at REAL NOT NULL) - """, - """ - CREATE TABLE folders (id TEXT PRIMARY KEY, name TEXT NOT NULL, parent_id TEXT, connection_id TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, updated_at REAL NOT NULL) - """, - "PRAGMA user_version = 2", - ] - for row in rows { - let scope = row.connectionId.map { "'\($0)'" } ?? "NULL" - statements.append(""" - INSERT INTO favorites (id, name, query, connection_id, created_at, updated_at) - VALUES ('\(UUID().uuidString)', '\(row.name)', '\(row.sql)', \(scope), \(now), \(now)) - """) - } - for sql in statements { - XCTAssertEqual(sqlite3_exec(handle, sql, nil, nil, nil), SQLITE_OK, "Could not run \(sql)") - } - } -} diff --git a/TableProUITests/ConnectionFormDatabaseFileUITests.swift b/TableProUITests/ConnectionFormDatabaseFileUITests.swift deleted file mode 100644 index ff98fe3d3e..0000000000 --- a/TableProUITests/ConnectionFormDatabaseFileUITests.swift +++ /dev/null @@ -1,132 +0,0 @@ -import XCTest - -/// A database file that does not exist yet is created on connect, and the form used to offer only -/// an open panel, which has no name field. New… names the file in a save panel and records the path; -/// nothing is written until the connection opens. SQLite drives it because it ships in the app. -final class ConnectionFormDatabaseFileUITests: UITestCase { - private let filePath = "connection-form-file-path" - private let browseButton = "connection-form-file-browse" - private let newButton = "connection-form-file-new" - /// AppKit's own identifier for the save panel's name field, as the runner's element tree shows it. - private let saveAsNameField = "saveAsNameTextField" - - func testNewNamesADatabaseFileThatDoesNotExistYet() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "SQLite", in: app) - let field = form.textFields[filePath] - XCTAssertTrue(field.waitToExist(timeout: 10), "SQLite should offer a Database File field") - XCTAssertTrue(form.buttons[browseButton].exists, "Browse… picks a file that exists") - - let name = "TablePro-UITest-\(UUID().uuidString).sqlite" - replaceText(in: field, with: "/tmp/\(name)") - - let new = form.buttons[newButton] - XCTAssertTrue(new.waitToExist(timeout: 5), "SQLite creates a missing file, so New… should be offered") - XCTAssertTrue(waitUntilHittable(new, timeout: 10)) - new.click() - - let panel = readySavePanel(on: form) - let create = panel.buttons["Create"] - XCTAssertTrue( - waitForPredicate(timeout: 10) { create.exists && create.isEnabled }, - "A name that does not exist yet should leave Create enabled" - ) - create.click() - - XCTAssertTrue( - waitForPredicate(timeout: 10) { !form.sheets.firstMatch.exists }, - "Create should close the panel" - ) - XCTAssertTrue( - waitForPredicate(timeout: 5) { (field.value as? String)?.hasSuffix("/\(name)") == true }, - "The panel should keep the name typed in the field and put its path back there" - ) - } - - func testCancelingNewLeavesThePathAlone() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "SQLite", in: app) - let field = form.textFields[filePath] - XCTAssertTrue(field.waitToExist(timeout: 10)) - let typed = "/tmp/TablePro-UITest-\(UUID().uuidString).db" - replaceText(in: field, with: typed) - - let new = form.buttons[newButton] - XCTAssertTrue(waitUntilHittable(new, timeout: 10)) - new.click() - - let panel = readySavePanel(on: form) - panel.typeKey(.escape, modifierFlags: []) - - XCTAssertTrue(waitForPredicate(timeout: 10) { !form.sheets.firstMatch.exists }) - XCTAssertEqual(field.value as? String, typed) - } - - // MARK: - Helpers - - /// The save panel New… opens, once it takes input. - /// - /// The sheet is in the accessibility tree before the panel can take a key or a click. The runner's - /// screen recordings in run 37204198338 show it arrive in three steps: on screen with Create dimmed, - /// then Create enabled, then, about a quarter of a second after it appeared, key, with the name - /// selected in its field and Create turned blue. A Return sent as soon as the sheet existed fell - /// into that gap and nothing took it: both attempts left the panel open, Create enabled and the - /// name typed, until the wait for it to close ran out. The runs that passed did so only because - /// XCTest's wait for the app to go idle after the New… click happened to outlast the panel's setup. - /// - /// The name field takes the keyboard in the same moment the panel becomes key, so waiting for that - /// waits for the panel to be ready. Nothing in the app can close the gap: making the sheet key once - /// it is presented is AppKit's own sequence. - private func readySavePanel(on form: XCUIElement) -> XCUIElement { - let panel = form.sheets.firstMatch - XCTAssertTrue(panel.waitToExist(timeout: 10), "New… should open a save panel on the form") - let nameField = panel.textFields[saveAsNameField] - XCTAssertTrue( - waitForPredicate(timeout: 10) { holdsKeyboardFocus(nameField) }, - "The save panel should become key with the keyboard in its name field" - ) - return panel - } - - private func replaceText(in field: XCUIElement, with text: String) { - XCTAssertTrue(waitUntilHittable(field, timeout: 10)) - field.click() - field.typeKey("a", modifierFlags: .command) - field.typeText(text) - } - - private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 10)) - newConnection.click() - - /// Scoped to the sheet: the welcome window behind it owns a search field of its own. - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") - - let search = sheet.searchFields.firstMatch - XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") - XCTAssertTrue(waitUntilHittable(search, timeout: 10)) - search.click() - search.typeText(type) - XCTAssertTrue( - waitForPredicate(timeout: 10) { (search.value as? String) == type }, - "Typing should reach the chooser's search field" - ) - - let row = sheet.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", type)) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 10), "The chooser should list \(type)") - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let form = app.windows["connection-form"] - XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing \(type) should open the connection form") - return form - } -} diff --git a/TableProUITests/ConnectionFormLocalSocketUITests.swift b/TableProUITests/ConnectionFormLocalSocketUITests.swift deleted file mode 100644 index dae624d5e9..0000000000 --- a/TableProUITests/ConnectionFormLocalSocketUITests.swift +++ /dev/null @@ -1,104 +0,0 @@ -import XCTest - -final class ConnectionFormLocalSocketUITests: UITestCase { - private let endpointPicker = "connection-form-endpoint" - private let socketField = "connection-form-socket-path" - private let hostField = "connection-form-host" - private let portField = "connection-form-port" - private let transportPicker = "connection-form-transport" - - func testSocketReplacesHostAndPortAndLeavesOnlyDirect() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "MySQL", in: app) - let name = form.textFields["connection-form-name"] - XCTAssertTrue(name.waitToExist(timeout: 10)) - XCTAssertTrue(waitUntilHittable(name, timeout: 10)) - name.click() - app.typeText("Local") - - let picker = form.popUpButtons[endpointPicker] - XCTAssertTrue(picker.waitToExist(timeout: 10), "MySQL should offer Connect Using") - XCTAssertTrue(form.textFields[hostField].exists, "Host and Port is the default") - XCTAssertFalse(form.textFields[socketField].exists) - - select(option: "Socket", in: picker) - XCTAssertTrue(form.textFields[socketField].waitToExist(timeout: 5), "Socket should show its path field") - XCTAssertTrue( - waitForPredicate(timeout: 5) { !form.textFields[self.hostField].exists }, - "Socket replaces Host" - ) - XCTAssertFalse(form.textFields[portField].exists, "Socket replaces Port") - - let validation = form.descendants(matching: .any).matching(identifier: "connection-form-validation") - XCTAssertTrue( - waitForPredicate(timeout: 5) { validation.firstMatch.exists }, - "An empty socket path should dim Save" - ) - let reason = validation.firstMatch.label + " " + String(describing: validation.firstMatch.value ?? "") - XCTAssertTrue(reason.contains("Socket"), "Save should wait for the socket path, not: \(reason)") - - selectConnectionFormTab("network", in: form) - let transport = form.popUpButtons[transportPicker] - XCTAssertTrue(transport.waitToExist(timeout: 10)) - XCTAssertTrue(waitUntilHittable(transport, timeout: 10)) - transport.click() - XCTAssertTrue(transport.menuItems["Direct"].waitToExist(timeout: 5)) - XCTAssertEqual(transport.menuItems.count, 1, "A socket connection offers only Direct") - app.typeKey(.escape, modifierFlags: []) - - selectConnectionFormTab("general", in: form) - let socket = form.textFields[socketField] - XCTAssertTrue(socket.waitToExist(timeout: 10)) - XCTAssertTrue(waitUntilHittable(socket, timeout: 10)) - socket.click() - app.typeText("/tmp/mysql.sock") - XCTAssertTrue( - waitForPredicate(timeout: 5) { !validation.firstMatch.exists }, - "A valid socket path should leave nothing blocking Save" - ) - - select(option: "Host and Port", in: form.popUpButtons[endpointPicker]) - XCTAssertTrue(form.textFields[hostField].waitToExist(timeout: 5), "Host and Port should come back") - XCTAssertFalse(form.textFields[socketField].exists) - } - - private func select(option: String, in picker: XCUIElement) { - XCTAssertTrue(waitUntilHittable(picker, timeout: 10)) - picker.click() - let item = picker.menuItems[option] - XCTAssertTrue(item.waitToExist(timeout: 5), "The picker should offer \(option)") - item.click() - } - - private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 10)) - newConnection.click() - - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") - - let search = sheet.searchFields.firstMatch - XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") - XCTAssertTrue(waitUntilHittable(search, timeout: 10)) - search.click() - search.typeText(type) - XCTAssertTrue( - waitForPredicate(timeout: 10) { (search.value as? String) == type }, - "Typing should reach the chooser's search field" - ) - - let row = sheet.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", type)) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 10), "The chooser should list \(type)") - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let form = app.windows["connection-form"] - XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing \(type) should open the connection form") - return form - } -} diff --git a/TableProUITests/ConnectionFormSidebarUITests.swift b/TableProUITests/ConnectionFormSidebarUITests.swift deleted file mode 100644 index 33b3e7cd25..0000000000 --- a/TableProUITests/ConnectionFormSidebarUITests.swift +++ /dev/null @@ -1,55 +0,0 @@ -import XCTest - -final class ConnectionFormSidebarUITests: UITestCase { - /// The sections are the form's only navigation and the titlebar has no sidebar button, so a - /// sidebar dragged shut would leave the form stuck on one section. - func testSidebarSurvivesADragToTheWindowEdge() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "PostgreSQL", in: app) - let splitter = form.splitters.firstMatch - XCTAssertTrue(splitter.waitToExist(timeout: 10), "The form should expose the sidebar divider") - - splitter.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).press( - forDuration: 0.3, - thenDragTo: form.coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.5)) - ) - - selectConnectionFormTab("options", in: form) - XCTAssertTrue( - form.textFields["connection-form-connect-timeout"].waitToExist(timeout: 10), - "A section should still be reachable from the sidebar after the drag" - ) - } - - private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 10)) - newConnection.click() - - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") - - let search = sheet.searchFields.firstMatch - XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") - XCTAssertTrue(waitUntilHittable(search, timeout: 10)) - search.click() - search.typeText(type) - XCTAssertTrue( - waitForPredicate(timeout: 10) { (search.value as? String) == type }, - "Typing should reach the chooser's search field" - ) - - let row = sheet.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", type)) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 10), "The chooser should list \(type)") - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let form = app.windows["connection-form"] - XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing \(type) should open the connection form") - return form - } -} diff --git a/TableProUITests/ConnectionFormTLSPortUITests.swift b/TableProUITests/ConnectionFormTLSPortUITests.swift deleted file mode 100644 index 9678ed73a2..0000000000 --- a/TableProUITests/ConnectionFormTLSPortUITests.swift +++ /dev/null @@ -1,126 +0,0 @@ -import XCTest - -/// The port lives on the General tab and the SSL mode on the Network tab, so typing a port that -/// the engine's own clients treat as TLS has to change a control on a tab the user is not looking -/// at, and say so beside the field they are typing in. ClickHouse drives it because it ships in -/// the app; Trino takes the same path but is registry-only. -final class ConnectionFormTLSPortUITests: UITestCase { - private let portField = "connection-form-port" - private let portNote = "connection-form-port-tls-note" - private let sslModePicker = "connection-form-ssl-mode" - - func testTypingATLSPortTurnsOnVerifyIdentityAndTypingAnotherTurnsItOff() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "ClickHouse", in: app) - let port = form.textFields[portField] - XCTAssertTrue(port.waitToExist(timeout: 10), "The General tab should offer a Port field") - XCTAssertFalse(hasElement(portNote, in: form), "The default port is not a TLS port") - - replaceText(in: port, with: "8443") - XCTAssertTrue( - waitForPredicate(timeout: 5) { self.hasElement(self.portNote, in: form) }, - "Port 8443 should say beside the field that it turned SSL on" - ) - - selectConnectionFormTab("network", in: form) - let picker = form.popUpButtons[sslModePicker] - XCTAssertTrue(picker.waitToExist(timeout: 10), "The Network tab should offer the SSL Mode picker") - XCTAssertEqual(picker.value as? String, "Verify Identity") - - selectConnectionFormTab("general", in: form) - replaceText(in: form.textFields[portField], with: "8123") - XCTAssertTrue( - waitForPredicate(timeout: 5) { !self.hasElement(self.portNote, in: form) }, - "Leaving the TLS port should take the note away" - ) - - selectConnectionFormTab("network", in: form) - XCTAssertTrue( - waitForPredicate(timeout: 5) { (form.popUpButtons[self.sslModePicker].value as? String) == "Disabled" }, - "The mode the port turned on should go back to the default with it" - ) - } - - func testDisabledOnATLSPortOffersVerifyIdentityBesideThePort() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "ClickHouse", in: app) - let port = form.textFields[portField] - XCTAssertTrue(port.waitToExist(timeout: 10)) - replaceText(in: port, with: "8443") - - selectConnectionFormTab("network", in: form) - let picker = form.popUpButtons[sslModePicker] - XCTAssertTrue(picker.waitToExist(timeout: 10)) - XCTAssertTrue(waitUntilHittable(picker, timeout: 10)) - picker.click() - let disabled = picker.menuItems["Disabled"] - XCTAssertTrue(disabled.waitToExist(timeout: 5), "The SSL Mode picker should offer Disabled") - disabled.click() - - selectConnectionFormTab("general", in: form) - let useVerifyIdentity = form.buttons["connection-form-port-tls-use"] - XCTAssertTrue( - useVerifyIdentity.waitToExist(timeout: 5), - "Disabled on a TLS port should offer Verify Identity beside the Port field" - ) - XCTAssertFalse(hasElement(portNote, in: form)) - XCTAssertTrue(waitUntilHittable(useVerifyIdentity, timeout: 10)) - useVerifyIdentity.click() - - XCTAssertTrue( - waitForPredicate(timeout: 5) { self.hasElement(self.portNote, in: form) }, - "Taking the offer should turn Verify Identity on" - ) - selectConnectionFormTab("network", in: form) - XCTAssertTrue( - waitForPredicate(timeout: 5) { - (form.popUpButtons[self.sslModePicker].value as? String) == "Verify Identity" - } - ) - } - - private func hasElement(_ identifier: String, in form: XCUIElement) -> Bool { - form.descendants(matching: .any).matching(identifier: identifier).count > 0 - } - - private func replaceText(in field: XCUIElement, with text: String) { - XCTAssertTrue(waitUntilHittable(field, timeout: 10)) - field.click() - field.typeKey("a", modifierFlags: .command) - field.typeText(text) - } - - private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 10)) - newConnection.click() - - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") - - let search = sheet.searchFields.firstMatch - XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") - XCTAssertTrue(waitUntilHittable(search, timeout: 10)) - search.click() - search.typeText(type) - XCTAssertTrue( - waitForPredicate(timeout: 10) { (search.value as? String) == type }, - "Typing should reach the chooser's search field" - ) - - let row = sheet.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", type)) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 10), "The chooser should list \(type)") - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let form = app.windows["connection-form"] - XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing \(type) should open the connection form") - return form - } -} diff --git a/TableProUITests/ConnectionFormTimeoutUITests.swift b/TableProUITests/ConnectionFormTimeoutUITests.swift deleted file mode 100644 index 8531920b17..0000000000 --- a/TableProUITests/ConnectionFormTimeoutUITests.swift +++ /dev/null @@ -1,137 +0,0 @@ -import XCTest - -final class ConnectionFormTimeoutUITests: UITestCase { - func testTimeoutFieldsValidateConnectionOverrides() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "PostgreSQL", in: app) - let name = form.textFields["connection-form-name"] - XCTAssertTrue(name.waitToExist(timeout: 10)) - name.click() - name.typeText("Timeout probe") - selectConnectionFormTab("options", in: form) - - let connectTimeout = form.textFields["connection-form-connect-timeout"] - let queryTimeout = form.textFields["connection-form-query-timeout"] - XCTAssertTrue(connectTimeout.waitToExist(timeout: 10)) - XCTAssertTrue(queryTimeout.waitToExist(timeout: 10)) - XCTAssertEqual(connectTimeout.placeholderValue, "Default (30)") - XCTAssertEqual(queryTimeout.placeholderValue, "Global (60)") - - replaceText(in: connectTimeout, with: "0") - XCTAssertTrue( - waitForValidation("Connect timeout must be between 1 and 600 seconds.", in: form), - "A zero connect timeout should block saving" - ) - - replaceText(in: connectTimeout, with: "45") - XCTAssertTrue( - waitForPredicate(timeout: 5) { !self.hasValidation(in: form) }, - "A connect timeout inside the supported range should clear validation" - ) - - replaceText(in: queryTimeout, with: "-1") - XCTAssertTrue( - waitForValidation("Query timeout must be between 0 and 2,147,483 seconds.", in: form), - "A negative query timeout should block saving" - ) - - replaceText(in: queryTimeout, with: "2147484") - XCTAssertTrue( - waitForValidation("Query timeout must be between 0 and 2,147,483 seconds.", in: form), - "A query timeout that overflows millisecond APIs should block saving" - ) - - replaceText(in: queryTimeout, with: "2147483") - XCTAssertTrue( - waitForPredicate(timeout: 5) { !self.hasValidation(in: form) }, - "The largest safe query timeout should clear validation" - ) - - replaceText(in: queryTimeout, with: "0") - XCTAssertTrue( - waitForPredicate(timeout: 5) { !self.hasValidation(in: form) }, - "Zero should be accepted as no query limit" - ) - - let save = form.buttons["Save"] - XCTAssertTrue(save.waitToExist(timeout: 10)) - XCTAssertTrue(waitUntilHittable(save, timeout: 10)) - save.click() - XCTAssertTrue(waitForPredicate(timeout: 15) { !form.exists }, "Save should close the form") - - let list = app.windows["welcome"].outlines["welcome-connection-list"] - let saved = list.staticTexts - .matching(NSPredicate(format: "value BEGINSWITH %@", "Timeout probe")) - .firstMatch - XCTAssertTrue(saved.waitToExist(timeout: 15), "The saved connection should appear in the welcome list") - XCTAssertTrue(waitUntilHittable(saved, timeout: 10)) - saved.rightClick() - let edit = contextMenuItem("Edit…", in: app) - XCTAssertTrue(waitUntilHittable(edit, timeout: 5)) - edit.click() - - let reopenedForm = app.windows["connection-form"] - XCTAssertTrue(reopenedForm.waitToExist(timeout: 10), "Edit should reopen the saved connection") - selectConnectionFormTab("options", in: reopenedForm) - let reopenedConnectTimeout = reopenedForm.textFields["connection-form-connect-timeout"] - let reopenedQueryTimeout = reopenedForm.textFields["connection-form-query-timeout"] - XCTAssertTrue(reopenedConnectTimeout.waitToExist(timeout: 10)) - XCTAssertTrue(reopenedQueryTimeout.waitToExist(timeout: 10)) - XCTAssertEqual(reopenedConnectTimeout.value as? String, "45") - XCTAssertEqual(reopenedQueryTimeout.value as? String, "0") - } - - private func waitForValidation(_ message: String, in form: XCUIElement) -> Bool { - let validation = form.descendants(matching: .any) - .matching(NSPredicate( - format: "identifier == %@ AND (label CONTAINS %@ OR value CONTAINS %@)", - "connection-form-validation", - message, - message - )) - .firstMatch - return waitForPredicate(timeout: 5) { validation.exists } - } - - private func hasValidation(in form: XCUIElement) -> Bool { - form.descendants(matching: .any) - .matching(identifier: "connection-form-validation") - .firstMatch.exists - } - - private func replaceText(in field: XCUIElement, with text: String) { - XCTAssertTrue(waitUntilHittable(field, timeout: 10)) - field.click() - field.typeKey("a", modifierFlags: .command) - field.typeText(text) - field.typeKey(.tab, modifierFlags: []) - } - - private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 10)) - newConnection.click() - - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") - - let search = sheet.searchFields.firstMatch - XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") - XCTAssertTrue(waitUntilHittable(search, timeout: 10)) - search.click() - search.typeText(type) - - let row = sheet.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", type)) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 10), "The chooser should list \(type)") - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let form = app.windows["connection-form"] - XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing \(type) should open the connection form") - return form - } -} diff --git a/TableProUITests/ConnectionFormTransportUITests.swift b/TableProUITests/ConnectionFormTransportUITests.swift deleted file mode 100644 index 5fc0e5fe2b..0000000000 --- a/TableProUITests/ConnectionFormTransportUITests.swift +++ /dev/null @@ -1,115 +0,0 @@ -import XCTest - -/// The transport picker is the whole point of the Network tab: choosing one has to put that -/// transport's fields on screen and take the previous one's away. The view models prove the -/// booleans, and nothing below them proves the swap reaches the form. -final class ConnectionFormTransportUITests: UITestCase { - private let transportPicker = "connection-form-transport" - private let sshHost = "connection-form-ssh-host" - private let socksHost = "connection-form-socks-host" - - func testChoosingATransportReplacesThePreviousOnesFields() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "PostgreSQL", in: app) - selectConnectionFormTab("network", in: form) - - let picker = form.popUpButtons[transportPicker] - XCTAssertTrue(picker.waitToExist(timeout: 10), "The Network tab should offer a Connect via picker") - - XCTAssertFalse(form.textFields[sshHost].exists, "Direct shows no transport fields") - XCTAssertFalse(form.textFields[socksHost].exists) - - select(option: "SSH Tunnel", in: picker) - XCTAssertTrue( - form.textFields[sshHost].waitToExist(timeout: 5), - "Choosing SSH Tunnel should show the SSH server fields" - ) - XCTAssertFalse(form.textFields[socksHost].exists) - - select(option: "SOCKS Proxy", in: picker) - XCTAssertTrue( - form.textFields[socksHost].waitToExist(timeout: 5), - "Choosing SOCKS Proxy should show the proxy fields" - ) - XCTAssertFalse( - form.textFields[sshHost].exists, - "A connection uses one transport, so the SSH fields go when SOCKS is chosen" - ) - - select(option: "Direct", in: picker) - XCTAssertTrue( - waitForPredicate(timeout: 5) { !form.textFields[socksHost].exists }, - "Direct should leave no transport fields on screen" - ) - XCTAssertFalse(form.textFields[sshHost].exists) - } - - func testTheActionBarNamesWhatIsBlockingSave() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "PostgreSQL", in: app) - - let validation = form.descendants(matching: .any).matching(identifier: "connection-form-validation") - XCTAssertTrue( - waitForPredicate(timeout: 10) { validation.count > 0 }, - "An unnamed connection should say so beside the dimmed Save button" - ) - - let name = form.textFields["connection-form-name"] - XCTAssertTrue(name.waitToExist(timeout: 5)) - name.click() - app.typeText("Probe") - - XCTAssertTrue( - waitForPredicate(timeout: 5) { validation.count == 0 }, - "Naming the connection should clear the message" - ) - } - - // MARK: - Helpers - - private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 10)) - newConnection.click() - - /// Scoped to the sheet, not the app: the welcome window behind it owns a `sidebar-filter` - /// search field that `app.searchFields.firstMatch` reaches first, so the driver name went - /// into the connection filter, the chooser list stayed unfiltered, and the wanted row was - /// never realised. - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") - - let search = sheet.searchFields.firstMatch - XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") - XCTAssertTrue(waitUntilHittable(search, timeout: 10)) - search.click() - search.typeText(type) - XCTAssertTrue( - waitForPredicate(timeout: 10) { (search.value as? String) == type }, - "Typing should reach the chooser's search field" - ) - - let row = sheet.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", type)) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 10), "The chooser should list \(type)") - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let form = app.windows["connection-form"] - XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing \(type) should open the connection form") - return form - } - - private func select(option: String, in picker: XCUIElement) { - XCTAssertTrue(waitUntilHittable(picker, timeout: 10)) - picker.click() - let item = picker.menuItems[option] - XCTAssertTrue(item.waitToExist(timeout: 5), "The transport picker should offer \(option)") - item.click() - } -} diff --git a/TableProUITests/ConnectionHealthCheckSettingUITests.swift b/TableProUITests/ConnectionHealthCheckSettingUITests.swift deleted file mode 100644 index 8b2fa4a9f8..0000000000 --- a/TableProUITests/ConnectionHealthCheckSettingUITests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import XCTest - -/// The setting behind #2700 is only useful if someone can find it and change it, and a picker that -/// forgets is worse than no picker: the user believes they stopped the traffic and it carries on. -final class ConnectionHealthCheckSettingUITests: UITestCase { - func testConnectionCheckIntervalIsOfferedAndRemembered() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let settingsMenuItem = app.menuBars.menuItems["Settings…"] - XCTAssertTrue(settingsMenuItem.waitToExist(timeout: 10)) - settingsMenuItem.click() - - let generalPaneButton = app.toolbars.buttons["General"] - XCTAssertTrue(generalPaneButton.waitToExist(timeout: 10)) - generalPaneButton.click() - - let picker = app.popUpButtons["connection-health-check-picker"].firstMatch - XCTAssertTrue(picker.waitToExist(timeout: 10)) - XCTAssertEqual(picker.value as? String, "Every 30 seconds") - - picker.click() - let onDemand = app.menuItems["Only when I use the connection"] - XCTAssertTrue(onDemand.waitToExist(timeout: 10)) - onDemand.click() - - XCTAssertEqual(picker.value as? String, "Only when I use the connection") - - generalPaneButton.click() - XCTAssertEqual(picker.value as? String, "Only when I use the connection") - } -} diff --git a/TableProUITests/ConnectionIconPickerUITests.swift b/TableProUITests/ConnectionIconPickerUITests.swift deleted file mode 100644 index e285e77f0f..0000000000 --- a/TableProUITests/ConnectionIconPickerUITests.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// ConnectionIconPickerUITests.swift -// TableProUITests -// -// The connection list draws the tile as decoration, hidden from accessibility, so the icon is read -// back through the form the list's Edit… opens, which loads the stored connection. -// - -import XCTest - -final class ConnectionIconPickerUITests: UITestCase { - private let well = "connection-form-icon" - private let search = "symbol-picker-search" - - func testAnIconPickedInTheAppearancePaneReachesTheConnectionList() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "PostgreSQL", in: app) - let name = form.textFields["connection-form-name"] - XCTAssertTrue(name.waitToExist(timeout: 10)) - name.click() - name.typeText("Icon probe") - - selectConnectionFormTab("appearance", in: form) - let iconWell = form.buttons[well] - XCTAssertTrue(iconWell.waitToExist(timeout: 10), "The Appearance tab should offer an Icon control") - XCTAssertEqual(iconWell.value as? String, "Default", "A new connection starts on the engine's icon") - XCTAssertTrue(waitUntilHittable(iconWell, timeout: 10)) - iconWell.click() - - let searchField = app.searchFields[search] - XCTAssertTrue(searchField.waitToExist(timeout: 5), "The icon control should open the picker") - XCTAssertTrue(waitUntilHittable(searchField, timeout: 5)) - searchField.click() - searchField.typeText("Server") - XCTAssertTrue( - waitForPredicate(timeout: 5) { (searchField.value as? String) == "Server" }, - "Typing should reach the picker's search field" - ) - app.typeKey(.return, modifierFlags: []) - - XCTAssertTrue(searchField.waitForNonExistence(timeout: 5), "Return should pick the match and close the picker") - XCTAssertTrue( - waitForPredicate(timeout: 5) { (iconWell.value as? String) == "Server" }, - "The picked icon should show in the form" - ) - - let save = form.buttons["Save"] - XCTAssertTrue(waitUntilHittable(save, timeout: 10)) - save.click() - XCTAssertTrue(waitForPredicate(timeout: 15) { !form.exists }, "Save should close the form") - - let list = app.windows["welcome"].outlines["welcome-connection-list"] - let saved = list.staticTexts - .matching(NSPredicate(format: "value BEGINSWITH %@", "Icon probe")) - .firstMatch - XCTAssertTrue(saved.waitToExist(timeout: 15), "The saved connection should appear in the welcome list") - XCTAssertTrue(waitUntilHittable(saved, timeout: 10)) - saved.rightClick() - let edit = contextMenuItem("Edit…", in: app) - XCTAssertTrue(waitUntilHittable(edit, timeout: 5)) - edit.click() - - let reopened = app.windows["connection-form"] - XCTAssertTrue(reopened.waitToExist(timeout: 10), "Edit should reopen the saved connection") - selectConnectionFormTab("appearance", in: reopened) - let reopenedWell = reopened.buttons[well] - XCTAssertTrue(reopenedWell.waitToExist(timeout: 10)) - XCTAssertEqual(reopenedWell.value as? String, "Server", "The list's connection should carry the picked icon") - } - - private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 10)) - newConnection.click() - - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") - - let chooserSearch = sheet.searchFields.firstMatch - XCTAssertTrue(chooserSearch.waitToExist(timeout: 10), "The chooser should offer its search field") - XCTAssertTrue(waitUntilHittable(chooserSearch, timeout: 10)) - chooserSearch.click() - chooserSearch.typeText(type) - - let row = sheet.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", type)) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 10), "The chooser should list \(type)") - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let form = app.windows["connection-form"] - XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing \(type) should open the connection form") - return form - } -} diff --git a/TableProUITests/ConnectionImportReviewUITests.swift b/TableProUITests/ConnectionImportReviewUITests.swift index 411a07c3c4..492901d35a 100644 --- a/TableProUITests/ConnectionImportReviewUITests.swift +++ b/TableProUITests/ConnectionImportReviewUITests.swift @@ -2,7 +2,7 @@ import AppKit import XCTest final class ConnectionImportReviewUITests: UITestCase { - private static let fixture = """ + private static let share = """ { "formatVersion": 2, "exportedAt": "2026-10-10T09:00:00Z", @@ -25,13 +25,18 @@ final class ConnectionImportReviewUITests: UITestCase { } """ - func testSavedQueriesFollowTheirConnection() throws { - let (_, sheet) = try openReview() + func testImportReportsConnectionsAndSavedQueries() throws { + let app = try launchWithConnectionShare(named: "Review Orders.tablepro", contents: Data(Self.share.utf8)) + let welcome = welcomeWindow(in: app) + XCTAssertTrue(welcome.waitToExist(timeout: 15), "Opening a connection file must show the welcome window") + let sheet = welcome.sheets.firstMatch + XCTAssertTrue(sheet.waitToExist(timeout: 15), "Opening a connection file must show the import review") let header = sheet.checkBoxes["import-review-queries-toggle"] XCTAssertTrue(header.waitToExist(timeout: 10), "The review must list the file's saved queries") XCTAssertTrue(isOn(header), "Both saved queries start checked") + let first = sheet.checkBoxes["import-query-q1"] let second = sheet.checkBoxes["import-query-q2"] XCTAssertTrue(waitUntilHittable(second, timeout: 5)) second.click() @@ -43,7 +48,6 @@ final class ConnectionImportReviewUITests: UITestCase { let connection = sheet.checkBoxes["import-connection-c1"] XCTAssertTrue(waitUntilHittable(connection, timeout: 5)) connection.click() - let first = sheet.checkBoxes["import-query-q1"] XCTAssertTrue( waitForPredicate(timeout: 5) { !first.isEnabled && !second.isEnabled }, "Unchecking the connection must disable the saved queries that belong to it" @@ -52,31 +56,33 @@ final class ConnectionImportReviewUITests: UITestCase { sheet.staticTexts.matching(Self.text("Its connection is not imported.")).firstMatch.exists, "A disabled query must say why" ) - } - func testImportReportsConnectionsAndSavedQueries() throws { - let (app, sheet) = try openReview() + connection.click() + XCTAssertTrue( + waitForPredicate(timeout: 5) { first.isEnabled && second.isEnabled }, + "Checking the connection again must give its saved queries back" + ) + XCTAssertTrue(isOn(first), "The query left checked stays checked") + XCTAssertFalse(isOn(second), "The query unchecked by hand stays unchecked") let importButton = sheet.buttons["import-review-import"] XCTAssertTrue(waitUntilHittable(importButton, timeout: 10)) importButton.click() let message = app.staticTexts - .matching(Self.text("1 connection was imported. 2 saved queries were added.")) + .matching(Self.text("1 connection was imported. 1 saved query was added.")) .firstMatch - XCTAssertTrue(message.waitToExist(timeout: 15), "The result alert must count the connection and both queries") - } + XCTAssertTrue( + message.waitToExist(timeout: 15), + "The result must count the connection and only the query still checked" + ) + app.buttons["OK"].firstMatch.click() - private func openReview() throws -> (XCUIApplication, XCUIElement) { - let app = try launchWithConnectionShare( - named: "Review Orders.tablepro", - contents: Data(Self.fixture.utf8) + let list = welcomeConnectionList(in: welcome) + XCTAssertTrue( + welcomeConnectionRow(named: "Review Orders", in: list).waitToExist(timeout: 10), + "The imported connection must be listed" ) - let welcome = app.windows["welcome"] - XCTAssertTrue(welcome.waitToExist(timeout: 15), "Opening a connection file must show the welcome window") - let sheet = welcome.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 15), "Opening a connection file must show the import review") - return (app, sheet) } // XCUITest reports a mixed checkbox as NSControl.StateValue.mixed (-1), not the AXValue of 2. diff --git a/TableProUITests/ConnectionTagEditorUITests.swift b/TableProUITests/ConnectionTagEditorUITests.swift deleted file mode 100644 index 6d028d19eb..0000000000 --- a/TableProUITests/ConnectionTagEditorUITests.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// ConnectionTagEditorUITests.swift -// TableProUITests -// -// "Add tags" used to be a label sitting beside the control rather than being the control, so the -// words did nothing and only the chevron opened the menu. It is now the pull-down's own title, -// which is what every macOS pull-down does with its placeholder. Clicking the words has to open -// the menu, and that is what this covers. -// - -import XCTest - -final class ConnectionTagEditorUITests: UITestCase { - private let tagMenu = "connection-form-tags" - - func testTheAddTagsPlaceholderOpensTheTagMenu() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "SQLite", in: app) - selectConnectionFormTab("appearance", in: form) - - let menu = form.descendants(matching: .any).matching(identifier: tagMenu).firstMatch - XCTAssertTrue(menu.waitToExist(timeout: 10), "The Appearance tab should offer a Tags control") - XCTAssertTrue(waitUntilHittable(menu, timeout: 10)) - - /// The placeholder's width is what says it is inside the control rather than beside it, and - /// width is the only thing that says it: a SwiftUI `Menu` is an `NSPopUpButton`, whose label - /// no suite has ever resolved, and the identifier reaches a wrapper that carries no name. - /// Measured, the control is 79pt wide titled "Add tags" and 24pt wide with an empty label, - /// so the threshold sits well clear of both. - XCTAssertGreaterThan( - menu.frame.width, - 40, - "With no tag chosen the placeholder is the control's own title, so the words are part of it" - ) - - menu.click() - XCTAssertTrue( - form.menus.firstMatch.waitToExist(timeout: 5), - "Clicking the Add tags control should open its menu" - ) - app.typeKey(.escape, modifierFlags: []) - } - - func testTheTagMenuOffersCreatingATag() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "SQLite", in: app) - selectConnectionFormTab("appearance", in: form) - - let menu = form.descendants(matching: .any).matching(identifier: tagMenu).firstMatch - XCTAssertTrue(menu.waitToExist(timeout: 10)) - XCTAssertTrue(waitUntilHittable(menu, timeout: 10)) - menu.click() - - XCTAssertTrue( - form.menus.firstMatch.menuItems["Create New Tag…"].waitToExist(timeout: 5), - "A connection with no tags yet still needs a route to making one" - ) - app.typeKey(.escape, modifierFlags: []) - } - - /// Scoped to the sheet, not the app: the welcome window behind it owns a `sidebar-filter` - /// search field that `app.searchFields.firstMatch` reaches first. - private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 10)) - newConnection.click() - - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") - - let search = sheet.searchFields.firstMatch - XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") - XCTAssertTrue(waitUntilHittable(search, timeout: 10)) - search.click() - search.typeText(type) - XCTAssertTrue( - waitForPredicate(timeout: 10) { (search.value as? String) == type }, - "Typing should reach the chooser's search field" - ) - - let row = sheet.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", type)) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 10), "The chooser should list \(type)") - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let form = app.windows["connection-form"] - XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing \(type) should open the connection form") - return form - } -} diff --git a/TableProUITests/ConnectionWindowChromeUITests.swift b/TableProUITests/ConnectionWindowChromeUITests.swift deleted file mode 100644 index 2f1cd61ebc..0000000000 --- a/TableProUITests/ConnectionWindowChromeUITests.swift +++ /dev/null @@ -1,201 +0,0 @@ -import XCTest - -/// The window a user opens is the window they end up with. -/// -/// A connection that is not up used to take the object browser off screen with it, along with the -/// filter field above it, so the window changed shape whenever the connection did. Disconnecting is -/// the deterministic way to reach that state: a connect fast enough to be reliable in a test is -/// also fast enough that no assertion can catch it mid-flight. -final class ConnectionWindowChromeUITests: UITestCase { - func testDisconnectingLeavesTheSidebarWhereItWas() throws { - let app = try launchWithSampleDatabase() - - let filterField = app.searchFields["sidebar-filter"] - XCTAssertTrue( - filterField.waitToExist(timeout: 30), - "A connected window has a filter field above its object list" - ) - - disconnect(in: app) - - XCTAssertTrue( - waitForDisconnect(in: app, timeout: 20), - "Disconnect must end the session, or this asserts nothing" - ) - XCTAssertTrue( - filterField.exists, - "Losing the session took the sidebar's chrome with it" - ) - } - - /// Switch Connection is the window's own command and reaches no coordinator, so it is the one - /// toolbar item that has to answer over a connection that is not up. - func testTheToolbarSurvivesTheConnectionGoingAway() throws { - let app = try launchWithSampleDatabase() - - let toolbar = app.windows.firstMatch.toolbars.firstMatch - XCTAssertTrue(toolbar.waitToExist(timeout: 30), "A connection window has a toolbar") - let itemsWhileConnected = toolbar.buttons.count - - disconnect(in: app) - XCTAssertTrue(waitForDisconnect(in: app, timeout: 20)) - - XCTAssertTrue(toolbar.exists, "Losing the session took the whole toolbar with it") - XCTAssertEqual( - toolbar.buttons.count, - itemsWhileConnected, - "Toolbar items are dimmed when a connection goes away, never removed" - ) - } - - /// The regression test for the blank pane this change first shipped. A window whose chrome is - /// stable but whose detail pane draws nothing is worse than the layout thrash it replaced, and - /// nothing else here would catch it: every other assertion is about the chrome. - /// - /// `192.0.2.1` is TEST-NET-1, reserved for documentation, so it either swallows the SYN or is - /// refused outright. Both are asserted, because the runner decides which: a card with Cancel - /// while the connect is in flight, or the failure pane's own action once it is not. What is - /// never acceptable is neither. - func testTheDetailPaneNeverDrawsNothingWhileConnecting() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 20)) - - try openProbeConnection(in: app) - - /// Each of these belongs to exactly one of the two panes that may legitimately be on - /// screen. Asserting on buttons instead let the test pass over the blank pane it exists to - /// catch, because a Cancel button elsewhere in the app satisfied it. - let connectingCard = app.descendants(matching: .any)["connection-connecting"] - XCTAssertTrue( - waitForPredicate(timeout: 20) { - connectingCard.exists - || app.staticTexts["Could not connect to Probe"].exists - || app.staticTexts["Not connected to Probe"].exists - }, - "The detail pane drew nothing at all while the connection was being opened" - ) - XCTAssertTrue( - app.searchFields["sidebar-filter"].exists, - "The sidebar's chrome has to stand through the whole of it" - ) - } - - /// The two sidebar lists are chosen from a control at the top of the sidebar, over the list it - /// switches. The sample has no favorites, so the Favorites list settles on its empty state, and - /// that state going away is what shows Tables took the sidebar back. - func testTheSidebarScopeControlSwitchesTablesAndFavorites() throws { - let app = try launchWithSampleDatabase(arguments: englishArguments) - let window = try connectionWindow(of: app) - - let scope = window.radioGroups["sidebar-scope"] - XCTAssertTrue(scope.waitToExist(timeout: 30), "The sidebar carries its Tables and Favorites control") - let tables = scope.radioButtons["Tables"] - let favorites = scope.radioButtons["Favorites"] - XCTAssertTrue(tables.waitToExist(timeout: 10)) - XCTAssertTrue(favorites.exists) - - XCTAssertTrue(waitUntilHittable(favorites, timeout: 10)) - favorites.click() - let noFavorites = window.staticTexts["No Favorites"] - XCTAssertTrue(noFavorites.waitToExist(timeout: 15), "The Favorites segment must show the Favorites list") - - XCTAssertTrue(waitUntilHittable(tables, timeout: 10)) - tables.click() - XCTAssertTrue( - waitForPredicate(timeout: 15) { !noFavorites.exists }, - "The Tables segment must take the sidebar back from the Favorites list" - ) - XCTAssertTrue( - objectBrowser(in: window).descendants(matching: .staticText).firstMatch.waitToExist(timeout: 15), - "The object browser must list the sample's tables again" - ) - } - - // MARK: - Helpers - - /// The labels are localized, so the app runs in English. `AppleLanguages` only takes effect as a - /// launch argument. - private let englishArguments = ["-AppleLanguages", "(en)"] - - private func connectionWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - return window - } - - /// Creates a connection that cannot answer and opens it. The form is driven the way a person - /// drives it, because a hand-written `connections.json` would pin the storage format rather - /// than the behaviour under test. - private func openProbeConnection(in app: XCUIApplication) throws { - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 10)) - newConnection.click() - - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10)) - let search = sheet.searchFields.firstMatch - XCTAssertTrue(search.waitToExist(timeout: 10)) - XCTAssertTrue(waitUntilHittable(search, timeout: 10)) - search.click() - search.typeText("PostgreSQL") - - let driverRow = sheet.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "PostgreSQL")) - .firstMatch - XCTAssertTrue(driverRow.waitToExist(timeout: 10)) - XCTAssertTrue(waitUntilHittable(driverRow, timeout: 10)) - driverRow.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let form = app.windows["connection-form"] - XCTAssertTrue(form.waitToExist(timeout: 10)) - - let name = form.textFields["connection-form-name"] - XCTAssertTrue(name.waitToExist(timeout: 10)) - name.click() - app.typeText("Probe") - - let host = form.textFields["connection-form-host"] - XCTAssertTrue(host.waitToExist(timeout: 10)) - host.click() - host.typeKey("a", modifierFlags: .command) - app.typeText("192.0.2.1") - - let save = form.buttons["Save"] - XCTAssertTrue(save.waitToExist(timeout: 10)) - XCTAssertTrue(waitUntilHittable(save, timeout: 10)) - save.click() - XCTAssertTrue(waitForPredicate(timeout: 15) { !form.exists }, "Save should close the form") - - /// The row combines its children into one static text whose value carries the name and the - /// host, `Probe, 192.0.2.1`, and no label; the runner's element tree shows it that way. - let row = app.windows["welcome"].staticTexts - .matching(NSPredicate(format: "value BEGINSWITH %@", "Probe")) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 15), "The saved connection must be listed on the welcome window") - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - } - - private func disconnect(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["Database"].click() - menuBar.menuItems["Disconnect"].click() - } - - /// Reconnect replaces Disconnect once the session is gone, so its enablement is the cheapest - /// proof the disconnect landed. The menu is closed again on every pass, because an item only - /// answers `isEnabled` while its menu is up. - private func waitForDisconnect(in app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["Database"].click() - let disconnectItem = menuBar.menuItems["Disconnect"] - guard disconnectItem.waitToExist(timeout: 5) else { continue } - let isStillConnected = disconnectItem.isEnabled - app.typeKey(.escape, modifierFlags: []) - if !isStillConnected { return true } - } - return false - } -} diff --git a/TableProUITests/CopyObjectsUITests.swift b/TableProUITests/CopyObjectsUITests.swift index e907f67f44..ce8603aed1 100644 --- a/TableProUITests/CopyObjectsUITests.swift +++ b/TableProUITests/CopyObjectsUITests.swift @@ -1,206 +1,46 @@ import XCTest -/// Issue #2487. Copy To has to be reachable from the object browser's own contextual menu, and the -/// sheet it opens has to name the source and refuse to continue before a target is chosen. -/// -/// The sample database is SQLite, which has one database and no `CREATE DATABASE`, so Duplicate -/// Database is deliberately not exercised here: it needs a server this runner does not have. Its -/// contract is asserted without one in `DatabaseTreeMenuSpecTests` and `ObjectCopySessionTests`. +// #2487: Copy To has to be on the object browser's own menu. The object search used to be a plain text +// field, so Escape went past it to the sheet's Cancel and threw away every choice made in the sheet. final class CopyObjectsUITests: UITestCase { - private let copyToTitle = "Copy To…" - - func testCopyToIsOnATableRowsContextualMenu() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - openContextMenu(onRow: "Album", in: window, of: app) - - let item = contextMenuItem(copyToTitle, in: app) - XCTAssertTrue( - item.waitToExist(timeout: 10), - "Copy To must be reachable from a table row's contextual menu" - ) - dismissMenu(in: app) - } - - func testChoosingCopyToOpensTheSheetNamingTheSource() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - openContextMenu(onRow: "Album", in: window, of: app) - let item = contextMenuItem(copyToTitle, in: app) - XCTAssertTrue(item.waitToExist(timeout: 10)) - item.click() - - let sheet = copyToSheet(of: window) + func testCopyToOpensFromTheRowMenuAndEscapeClearsTheSearchFirst() throws { + let launch = try launchWithFixture() + let window = launch.window + let app = launch.app + + rightClickInObjectBrowser("album", in: window) + let copyTo = contextMenuItem("Copy To…", in: app) + XCTAssertTrue(copyTo.waitToExist(timeout: 10), "Copy To must be on a table row's contextual menu") + copyTo.click() + + // A direct child, so no lookup walks the window behind the sheet. + let sheet = window.children(matching: .sheet).firstMatch let objects = sheet.outlines.matching(identifier: "copy-objects-list").firstMatch - let target = sheet.buttons.matching(identifier: "copy-objects-target").firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 20) { objects.exists || target.exists }, - "Copy To must open a sheet offering the objects and a target" - ) - - /// Nothing has been written and nothing can be: the sheet is still on its first step, so - /// Cancel is the whole interaction under test. - let cancel = sheet.buttons["Cancel"].firstMatch - if cancel.waitToExist(timeout: 10) { - cancel.click() - } - } + XCTAssertTrue(objects.waitToExist(timeout: 20), "Copy To must open a sheet listing the objects to copy") - /// The object search used to be a plain text field wearing a magnifying glass, so Escape went - /// straight past it to the sheet's Cancel: narrowing the list and changing your mind about the - /// search threw away the target, the content choice and every tick with it. An `NSSearchField` - /// takes the key itself while it holds text. - func testEscapeInTheObjectSearchClearsItRatherThanClosingTheSheet() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - openContextMenu(onRow: "Album", in: window, of: app) - let item = contextMenuItem(copyToTitle, in: app) - XCTAssertTrue(item.waitToExist(timeout: 10)) - item.click() - - let search = copyToSheet(of: window).searchFields - .matching(identifier: "copy-objects-search").firstMatch - XCTAssertTrue(search.waitToExist(timeout: 20), "The sheet must offer a search field") - XCTAssertTrue(waitUntilHittable(search, timeout: 20)) + let search = sheet.searchFields.matching(identifier: "copy-objects-search").firstMatch + XCTAssertTrue(waitUntilHittable(search, timeout: 20), "The sheet must offer an object search") search.click() - app.typeText("Album") - - app.typeKey(.escape, modifierFlags: []) - - XCTAssertTrue( - search.exists, - "Escape belongs to the search field while it holds text; the sheet must still be open" - ) - - /// Empty now, so this Escape is the one that leaves. - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue( - waitForPredicate(timeout: 10) { !search.exists }, - "Escape on an empty search field closes the sheet" - ) - } - - /// A filter narrows one table's rows without leaving the object list, and the row has to say so - /// afterwards: a filter that is set and invisible is a copy that quietly carries less than the - /// user thinks it does. - func testAPerTableFilterIsSetFromTheObjectListAndShownOnTheRow() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let sheet = try openCopyToSheet(onRow: "Album", in: window, of: app) - - let funnel = sheet.buttons.matching(identifier: "copy-objects-row-filter-Album").firstMatch - XCTAssertTrue(funnel.waitToExist(timeout: 20), "A table row must offer a row filter") - XCTAssertTrue(waitUntilHittable(funnel, timeout: 20)) - funnel.click() - - let field = app.descendants(matching: .any).matching(identifier: "row-scope-filter").firstMatch - XCTAssertTrue(field.waitToExist(timeout: 20), "The filter popover must offer a WHERE field") - XCTAssertTrue(waitUntilHittable(field, timeout: 20)) - field.click() - app.typeText("AlbumId > 10") - - let done = app.buttons["Done"].firstMatch - XCTAssertTrue(done.waitToExist(timeout: 10)) - done.click() - - let summary = sheet.staticTexts.matching(identifier: "copy-objects-row-scope-Album").firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 20) { summary.exists }, - "The row must show the filter it now carries" - ) - let spoken = [summary.label, (summary.value as? String) ?? ""].joined(separator: " ") + app.typeText("album") XCTAssertTrue( - spoken.contains("AlbumId"), - "The summary must name the filter. label=\(summary.label) value=\(String(describing: summary.value))" + waitForPredicate(timeout: 10) { (search.value as? String) == "album" }, + "Typing must reach the object search" ) app.typeKey(.escape, modifierFlags: []) - } - - /// A filter is one expression. Text carrying a second statement is refused rather than spliced - /// into the `SELECT` the copy runs, and Continue stays out of reach until it is gone. - func testAFilterCarryingASecondStatementBlocksContinue() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let sheet = try openCopyToSheet(onRow: "Album", in: window, of: app) - - let funnel = sheet.buttons.matching(identifier: "copy-objects-row-filter-Album").firstMatch - XCTAssertTrue(funnel.waitToExist(timeout: 20)) - XCTAssertTrue(waitUntilHittable(funnel, timeout: 20)) - funnel.click() - - let field = app.descendants(matching: .any).matching(identifier: "row-scope-filter").firstMatch - XCTAssertTrue(field.waitToExist(timeout: 20)) - XCTAssertTrue(waitUntilHittable(field, timeout: 20)) - field.click() - app.typeText("1=1; DROP TABLE Album") - - let done = app.buttons["Done"].firstMatch - XCTAssertTrue(done.waitToExist(timeout: 10)) - done.click() - - let cont = sheet.buttons["Continue"].firstMatch XCTAssertTrue( - waitForPredicate(timeout: 20) { cont.exists && !cont.isEnabled }, - "Continue must stay unavailable while a filter holds a second statement" + waitForPredicate(timeout: 10) { + guard search.exists else { return false } + let text = (search.value as? String) ?? "" + return text.isEmpty || text == search.placeholderValue + }, + "Escape belongs to the search field while it holds text: it must clear it and leave the sheet open" ) app.typeKey(.escape, modifierFlags: []) - } - - // MARK: - Helpers - - @discardableResult - private func openCopyToSheet( - onRow name: String, - in window: XCUIElement, - of app: XCUIApplication - ) throws -> XCUIElement { - openContextMenu(onRow: name, in: window, of: app) - let item = contextMenuItem(copyToTitle, in: app) - XCTAssertTrue(item.waitToExist(timeout: 10)) - item.click() - let sheet = copyToSheet(of: window) - let list = sheet.outlines.matching(identifier: "copy-objects-list").firstMatch - XCTAssertTrue(list.waitToExist(timeout: 20), "Copy To must open its object list") - return sheet - } - - /// Every lookup inside the sheet starts here, and none of them walks the window. - /// - /// The window behind the sheet holds a data grid, and a grid answers its first accessibility - /// question by mounting a cell per row of the page: the sample opens on `Track`, so the window - /// publishes about 12,800 elements. A `descendants(matching: .any)` taken off the window walks - /// all of them, which measured 8.5 to 14.5 seconds per evaluation on the runner, so three polls - /// used up a twenty second wait and the suite reported a sheet that was open on screen as never - /// having opened. The sheet is a direct child of the window, so `children` resolves it without - /// touching the grid, and everything under it is a sixty element subtree. - private func copyToSheet(of window: XCUIElement) -> XCUIElement { - window.children(matching: .sheet).firstMatch - } - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" + waitForPredicate(timeout: 10) { !sheet.exists }, + "Escape on an empty search field must close the sheet" ) - return window - } - - /// The tree's rows are hosted cells that AppKit reports as disabled, so the menu is raised - /// through a coordinate rather than through the element. - private func openContextMenu(onRow name: String, in window: XCUIElement, of app: XCUIApplication) { - let row = objectBrowserRow(name, in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(name)") - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).rightClick() - } - - private func dismissMenu(in app: XCUIApplication) { - app.typeKey(.escape, modifierFlags: []) } } diff --git a/TableProUITests/CreateTableValidationUITests.swift b/TableProUITests/CreateTableValidationUITests.swift deleted file mode 100644 index 2467cbb28f..0000000000 --- a/TableProUITests/CreateTableValidationUITests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// CreateTableValidationUITests.swift -// TableProUITests -// -// The Create Table tab says why it cannot run and dims the button, instead of dropping the rows it -// could not use. A foreign key with no constraint name used to be deleted from the generated -// statement with no message at all, and the SQL Preview agreed with the deletion. -// -// The grids themselves are out of reach here: a data grid cell is drawn with CoreText and takes no -// synthetic click, so the round trip from a filled foreign key row to a FOREIGN KEY clause is -// covered by CreateTableDraftBuilderTests and SQLiteCreateTableDDLTests instead. -// -// Every element is addressed by identifier. `window.buttons["Create Table"].firstMatch` matches the -// editor tab of that name before it reaches the button, and it reports itself enabled. -// -// The reason's text per draft state is asserted in CreateTableDraftBuilderTests rather than here: -// driving it needs typed text to reach a SwiftUI TextField's binding, which does not land through -// XCUITest reliably enough to gate a branch on. -// - -import XCTest - -final class CreateTableValidationUITests: UITestCase { - func testAnEmptyDraftNamesWhatIsMissingAndDimsCreateTable() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - try openNewTableTab(in: app, window: window) - - let reason = validationLabel(in: window) - XCTAssertTrue( - reason.waitToExist(timeout: 30), - "A Create Table tab that cannot run yet must say so. Silence is what let a filled-in " - + "foreign key vanish between the grid and the SQL Preview." - ) - - let commit = window.buttons["create-table-commit"].firstMatch - XCTAssertTrue(commit.waitToExist(timeout: 10), "The tab must carry a Create Table button") - XCTAssertFalse( - commit.isEnabled, - "An empty draft has no table name and no column, so Create Table stays dimmed" - ) - } - - private func validationLabel(in window: XCUIElement) -> XCUIElement { - window.staticTexts.matching(identifier: "create-table-validation").firstMatch - } - - private func openNewTableTab(in app: XCUIApplication, window: XCUIElement) throws { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 20)) - menuBar.menuBarItems["Database"].click() - - let newTable = menuBar.menuItems["New Table…"] - XCTAssertTrue(newTable.waitToExist(timeout: 10), "Database > New Table… must be reachable") - newTable.click() - } - - private func mainWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - return window - } -} diff --git a/TableProUITests/DataEditUITests.swift b/TableProUITests/DataEditUITests.swift new file mode 100644 index 0000000000..d56ccae54c --- /dev/null +++ b/TableProUITests/DataEditUITests.swift @@ -0,0 +1,47 @@ +import XCTest + +final class DataEditUITests: UITestCase { + func testAddingEditingAndDeletingRowsSavesToTheFile() throws { + let (launch, grid) = try launchWithFixtureTable("note") + let window = launch.window + let app = launch.app + XCTAssertTrue(grid.waitForValue("Plain", row: 3, column: 3), "note must open with its three rows") + + app.typeShortcut(.addRow) + XCTAssertTrue(grid.cell(row: 4, column: 3).waitToExist(timeout: 10), "Add Row must append a fourth row") + // Add Row opens an editor on the first editable column; each value below goes to a named cell. + app.typeKey(.escape, modifierFlags: []) + grid.editCell(row: 4, column: 2, to: "new") + grid.editCell(row: 4, column: 3, to: "Fresh row") + XCTAssertTrue(grid.waitForValue("Fresh row", row: 4, column: 3, timeout: 10), "Typing must fill the new row") + XCTAssertEqual(grid.value(row: 4, column: 2), "new", "Typing must fill the new row") + + grid.editCell(row: 1, column: 3, to: "Buy bread") + XCTAssertTrue(grid.waitForValue("Buy bread", row: 1, column: 3, timeout: 10), "The edit must land in row 1") + app.typeShortcut(.undo) + XCTAssertTrue(grid.waitForValue("Buy milk", row: 1, column: 3, timeout: 10), "Command Z must undo the edit") + XCTAssertEqual(grid.value(row: 4, column: 3), "Fresh row", "Undo must leave the new row alone") + + grid.clickCell(row: 2, column: 3) + app.typeShortcut(.deleteRow) + let deleted = grid.cell(row: 2, column: 3) + XCTAssertTrue( + waitForPredicate(timeout: 10) { deleted.label.hasSuffix("marked for deletion") }, + "Command Delete must mark row 2 for deletion, it reads '\(deleted.label)'" + ) + + app.typeShortcut(.save) + let confirmDelete = window.sheets.buttons["Delete"].firstMatch + XCTAssertTrue(waitUntilHittable(confirmDelete, timeout: 20), "Saving a deleted row must ask first") + confirmDelete.click() + + XCTAssertTrue( + waitForPredicate(timeout: 30) { + FixtureFile.rows("SELECT tag, body FROM note ORDER BY id", in: launch.database) + == [["todo", "Buy milk"], ["NULL", "Plain"], ["new", "Fresh row"]] + }, + "Save must write the new row and the deletion, and nothing that was undone; the file holds " + + "\(FixtureFile.rows("SELECT tag, body FROM note ORDER BY id", in: launch.database) ?? [])" + ) + } +} diff --git a/TableProUITests/DataFileWindowUITests.swift b/TableProUITests/DataFileWindowUITests.swift index 266ea6df50..913e309f90 100644 --- a/TableProUITests/DataFileWindowUITests.swift +++ b/TableProUITests/DataFileWindowUITests.swift @@ -1,154 +1,35 @@ import XCTest -/// The data file window over a small CSV: typed filtering from a cell, Replace All and its undo, -/// Row Details, and a statistics value that filters the file. -/// -/// The status bar's row count is what each test reads back, because it is the one piece of text -/// that answers "which rows are showing" without walking the grid's cells. final class DataFileWindowUITests: UITestCase { - private let numbers = Data("amount,name\n9,nine\n50,fifty\n100,hundred\n".utf8) - - func testACellFilterComparesNumbersAsNumbers() throws { - let app = try launchWithDataFile(named: "numbers.csv", contents: numbers) - let (window, grid) = try readyWindow(of: app) - - let cell = cellPoint(in: grid, row: 1) - cell.click() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - cell.rightClick() - - let filter = window.menus.menuItems["Filter"].firstMatch - XCTAssertTrue(filter.waitToExist(timeout: 15), "A cell's context menu must offer Filter") - filter.hover() - - let greater = app.menuItems.matching(NSPredicate(format: "title CONTAINS %@", " > \u{201C}")) - var item: XCUIElement? - XCTAssertTrue( - waitForPredicate(timeout: 10) { - item = greater.allElementsBoundByIndex.first { $0.isHittable } - return item != nil - }, - "A numeric column must offer a greater-than condition" - ) - try XCTUnwrap(item).click() - - XCTAssertTrue( - waitForRowCount("1 of 3 rows", in: window), - "amount > 50 keeps 100 alone; a text comparison would have kept 9" - ) - } - - func testReplaceAllIsOneUndoStep() throws { - let words = Data("word\nfoo\nfoo bar\nbaz\n".utf8) - let app = try launchWithDataFile(named: "words.csv", contents: words) - let (window, _) = try readyWindow(of: app) - - app.typeKey("f", modifierFlags: [.command, .option]) - let findField = window.searchFields["data-file-find-field"].firstMatch - XCTAssertTrue(findField.waitToExist(timeout: 10), "Find and Replace must open the find bar") - findField.click() - app.typeText("foo") - XCTAssertTrue(waitForText("1 of 2", in: window), "The find bar must count both matches") - - let replaceField = window.textFields["data-file-replace-field"].firstMatch - XCTAssertTrue(replaceField.waitToExist(timeout: 5), "Find and Replace must show the replace field") - replaceField.click() - app.typeText("qux") - window.buttons["data-file-replace-all"].firstMatch.click() - XCTAssertTrue(waitForText("No matches", in: window), "Replace All must leave nothing to find") - - let edit = app.menuBars.menuBarItems["Edit"] - edit.click() - let undo = edit.menus.menuItems["Undo Replace All"].firstMatch - XCTAssertTrue(undo.waitToExist(timeout: 5), "Edit must offer Undo Replace All, not Undo Typing") - undo.click() - XCTAssertTrue(waitForText("1 of 2", in: window), "One Undo must bring every replaced value back") - } - - func testRowDetailsShowsTheSelectedRow() throws { - let app = try launchWithDataFile(named: "numbers.csv", contents: numbers) - let (window, grid) = try readyWindow(of: app) - - cellPoint(in: grid, row: 2).click() - app.typeKey("i", modifierFlags: [.command, .option]) - + func testADataFileOpensWithItsSheetsAndRowDetails() throws { + let workbook = try XCTUnwrap(Data(base64Encoded: Self.twoSheetWorkbook)) + let app = try launchWithDataFile(named: "book.xlsx", contents: workbook) + let window = dataFileWindow(in: app) + XCTAssertTrue(window.waitToExist(timeout: 30), "The workbook produced no data file window") + let grid = dataGrid(in: window) + XCTAssertTrue(waitForClickableRows(in: grid), "The workbook must load rows") + XCTAssertTrue(waitForRowCount("3 rows", in: window), "The workbook must open on its first sheet, People") + + rowPoint(in: grid, row: 1).click() + app.typeShortcut(.toggleInspector) let header = window.descendants(matching: .any)["data-file-row-details-header"].firstMatch XCTAssertTrue(header.waitToExist(timeout: 10), "Row Details must open beside the grid") - let hundred = window.descendants(matching: .any) - .matching(NSPredicate(format: "value == %@ OR label == %@", "hundred", "hundred")) + let bob = window.descendants(matching: .any) + .matching(NSPredicate(format: "value == %@ OR label == %@", "Bob", "Bob")) XCTAssertTrue( waitForPredicate(timeout: 10) { - hundred.allElementsBoundByIndex.contains { $0.frame.minX >= header.frame.minX - 1 } + bob.allElementsBoundByIndex.contains { $0.frame.minX >= header.frame.minX - 1 } }, - "Row Details must show the selected row's values" + "Row Details must show the selected row, Bob" ) - } - - func testAStatisticsValueFiltersTheFile() throws { - let cities = Data("city\nParis\nLondon\nParis\nRome\n".utf8) - let app = try launchWithDataFile(named: "cities.csv", contents: cities) - let (window, grid) = try readyWindow(of: app) - - cellPoint(in: grid, row: 0).click() - let edit = app.menuBars.menuBarItems["Edit"] - edit.click() - edit.menus.menuItems["Data"].firstMatch.hover() - let statistics = edit.menus.menuItems["Column Statistics…"].firstMatch - XCTAssertTrue(statistics.waitToExist(timeout: 5), "Edit > Data must offer Column Statistics") - statistics.click() - - let paris = app.buttons.matching(identifier: "data-file-statistics-value") - .matching(NSPredicate(format: "label BEGINSWITH %@", "Paris")) - .firstMatch - XCTAssertTrue(paris.waitToExist(timeout: 15), "The statistics popover must list Paris as a top value") - paris.click() - - XCTAssertTrue(waitForRowCount("2 of 4 rows", in: window), "Picking a top value must filter to its rows") - } - - func testSwitchingSheetsShowsTheOtherSheet() throws { - let workbook = try XCTUnwrap(Data(base64Encoded: Self.twoSheetWorkbook)) - let app = try launchWithDataFile(named: "book.xlsx", contents: workbook) - let (window, _) = try readyWindow(of: app) - XCTAssertTrue(waitForRowCount("3 rows", in: window), "The workbook must open on its first sheet") let cities = window.radioButtons["Cities"].firstMatch XCTAssertTrue(cities.waitToExist(timeout: 10), "The window must offer the workbook's other sheet") cities.click() - XCTAssertTrue(waitForRowCount("5 rows", in: window), "Choosing a sheet must show that sheet's rows") } - func testAShiftJISExportOpensWithItsJapaneseText() throws { - let export = "商品コード,商品名,在庫管理区分\n10001,ソフトウェア保守,在庫管理する\n10002,ポイント交換,在庫管理しない\n10003,髙橋商店の注文,在庫管理する\n" - let bytes = try XCTUnwrap(export.data(using: .shiftJIS, allowLossyConversion: false)) - let app = try launchWithDataFile(named: "shift-jis.csv", contents: bytes) - let (window, grid) = try readyWindow(of: app) - XCTAssertTrue(waitForRowCount("3 rows", in: window), "The export must open with its three rows") - let format = window.descendants(matching: .any)["data-file-format"].firstMatch - XCTAssertTrue(format.waitToExist(timeout: 15), "The status bar must describe the file format") - XCTAssertTrue( - waitForPredicate(timeout: 15) { "\(format.label) \(format.value ?? "")".contains("Shift JIS") }, - "The status bar must name the detected encoding, not \(format.label) \(String(describing: format.value))" - ) - - cellPoint(in: grid, row: 1).click() - app.typeKey("i", modifierFlags: [.command, .option]) - let header = window.descendants(matching: .any)["data-file-row-details-header"].firstMatch - XCTAssertTrue(header.waitToExist(timeout: 10), "Row Details must open beside the grid") - let name = window.descendants(matching: .any) - .matching(NSPredicate(format: "value == %@ OR label == %@", "ポイント交換", "ポイント交換")) - XCTAssertTrue( - waitForPredicate(timeout: 10) { - name.allElementsBoundByIndex.contains { $0.frame.minX >= header.frame.minX - 1 } - }, - "Row Details must show the Japanese value, not Windows-1252 mojibake" - ) - } - - // MARK: - Helpers - - /// Two sheets, People (3 rows) and Cities (5 rows), with inline strings and a header row each. + // People holds Ann 31, Bob 42 and Cy 27; Cities holds five cities. Each sheet has a header row. private static let twoSheetWorkbook = [ "UEsDBBQAAAAIALxpOF3xqbA++QAAAKQCAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbLWSzU7DMBCEX8XytYo37QEhlKSHAkfgUB5g", "cTaJFf/Jdkt4e5y04oAKCAlOK3tm9htZrraT0exIISpna74WJWdkpWuV7Wv+vL8vrvm2qfZvniLLVhtrPqTkbwCiHMhgFM6TzUrn", @@ -176,32 +57,16 @@ final class DataFileWindowUITests: UITestCase { "xr8AAAD3AQAAGAAAAAAAAAAAAAAAgAFwBAAAeGwvd29ya3NoZWV0cy9zaGVldDIueG1sUEsFBgAAAAAGAAYAiwEAAGUFAAAAAA==" ].joined() - private func readyWindow(of app: XCUIApplication) throws -> (XCUIElement, XCUIElement) { - let window = app.windows.matching(identifier: "main-data-file").firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30), "The data file produced no window") - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The data file window has no grid") - XCTAssertTrue(waitForClickableRows(in: grid), "The data file must load rows") - return (window, grid) - } - - /// A point inside the first data column of `row`, measured from the first row's own frame so - /// it holds at any row height. The row-number gutter sits at the leading edge, so the point - /// starts past it. - private func cellPoint(in grid: XCUIElement, row: Int) -> XCUICoordinate { + // Measured from the first row's own frame, so it holds at any row height, and past the row-number gutter. + private func rowPoint(in grid: XCUIElement, row: Int) -> XCUICoordinate { let firstRow = grid.tableRows.firstMatch.frame let dy = firstRow.minY - grid.frame.minY + firstRow.height * (CGFloat(row) + 0.5) return grid.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: 80, dy: dy)) } - /// A SwiftUI `Text` publishes its string as the element's value, not its label. + // A SwiftUI Text publishes its string as the element's value, not its label. private func waitForRowCount(_ text: String, in window: XCUIElement) -> Bool { let count = window.staticTexts.matching(identifier: "data-file-row-count").firstMatch return waitForPredicate(timeout: 15) { count.exists && (count.value as? String) == text } } - - private func waitForText(_ text: String, in window: XCUIElement) -> Bool { - let match = window.staticTexts.matching(NSPredicate(format: "value == %@", text)).firstMatch - return waitForPredicate(timeout: 15) { match.exists } - } } diff --git a/TableProUITests/DataGridReloadViewportUITests.swift b/TableProUITests/DataGridReloadViewportUITests.swift deleted file mode 100644 index 38ab86739f..0000000000 --- a/TableProUITests/DataGridReloadViewportUITests.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// DataGridReloadViewportUITests.swift -// TableProUITests -// - -import XCTest - -final class DataGridReloadViewportUITests: UITestCase { - private static let table = "Artist" - - func testRefreshKeepsThePlaceAndSortingStartsAtTheFirstRow() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let grid = try openTable(in: window) - XCTAssertTrue(waitForPredicate(timeout: 20) { self.firstRowValue(column: 1, in: grid) == "1" }) - - scrollToBottom(grid) - XCTAssertTrue( - waitForPredicate(timeout: 10) { !self.isFirstRowOnScreen(in: grid) }, - "Scrolling to the bottom must take the first row out of view" - ) - - app.typeKey("r", modifierFlags: [.command]) - XCTAssertFalse( - waitForPredicate(timeout: 6) { self.isFirstRowOnScreen(in: grid) }, - "Refresh must leave the grid where the reader had scrolled it" - ) - - try clickHeader("ArtistId", in: grid) - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.isFirstRowOnScreen(in: grid) }, - "Sorting must start at the first row" - ) - } - - // MARK: - Helpers - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - return window - } - - private func openTable(in window: XCUIElement) throws -> XCUIElement { - let row = objectBrowserRow(Self.table, in: window) - XCTAssertTrue(row.waitToExist(timeout: 30), "The object browser must list \(Self.table)") - clickAtCenter(row) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "\(Self.table) produced no data grid") - XCTAssertTrue(waitForClickableRows(in: grid), "\(Self.table) must load its rows") - return grid - } - - private func clickHeader(_ column: String, in grid: XCUIElement) throws { - let header = grid.buttons - .matching(NSPredicate(format: "label BEGINSWITH %@", "Column: \(column)")) - .firstMatch - XCTAssertTrue(header.waitToExist(timeout: 30), "The grid must publish a \(column) header") - let frame = header.frame - let origin = grid.frame.origin - grid.coordinate(withNormalizedOffset: .zero) - .withOffset(CGVector(dx: frame.midX - origin.x, dy: frame.midY - origin.y)) - .click() - } - - private func scrollToBottom(_ grid: XCUIElement) { - grid.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).scroll(byDeltaX: 0, deltaY: -20_000) - } - - private func firstRowValue(column: Int, in grid: XCUIElement) -> String? { - let row = grid.tableRows.firstMatch - guard row.exists else { return nil } - let cell = row.staticTexts - .matching(NSPredicate(format: "label BEGINSWITH %@", "Row 1, column \(column): ")) - .firstMatch - guard cell.exists else { return nil } - return cell.value as? String - } - - private func isFirstRowOnScreen(in grid: XCUIElement) -> Bool { - let row = grid.tableRows.firstMatch - guard row.exists else { return false } - let rowFrame = row.frame - let gridFrame = grid.frame - return rowFrame.midY > gridFrame.minY + 30 && rowFrame.midY < gridFrame.maxY - } -} diff --git a/TableProUITests/DataSettingsUITests.swift b/TableProUITests/DataSettingsUITests.swift deleted file mode 100644 index 2bc3800180..0000000000 --- a/TableProUITests/DataSettingsUITests.swift +++ /dev/null @@ -1,19 +0,0 @@ -import XCTest - -final class DataSettingsUITests: UITestCase { - func testDataSettingsDoesNotListSavedCustomizations() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let settingsMenuItem = app.menuBars.menuItems["Settings…"] - XCTAssertTrue(settingsMenuItem.waitToExist(timeout: 10)) - settingsMenuItem.click() - - let dataPaneButton = app.toolbars.buttons["Data"] - XCTAssertTrue(dataPaneButton.waitToExist(timeout: 10)) - dataPaneButton.click() - - XCTAssertTrue(app.staticTexts["Query History"].waitToExist(timeout: 10)) - XCTAssertFalse(app.staticTexts["Saved Customizations"].exists) - } -} diff --git a/TableProUITests/DiagramKeyboardZoomUITests.swift b/TableProUITests/DiagramKeyboardZoomUITests.swift deleted file mode 100644 index e6786b0ce5..0000000000 --- a/TableProUITests/DiagramKeyboardZoomUITests.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// DiagramKeyboardZoomUITests.swift -// TableProUITests -// -// View > Zoom In goes to whatever has focus: a diagram once it is clicked, the editor's text size -// otherwise. The plan sits under the SQL editor, which is the case that has to tell the two apart. -// - -import XCTest - -final class DiagramKeyboardZoomUITests: UITestCase { - func testCommandEqualsZoomsTheFocusedPlanAndLeavesItAloneFromTheEditor() throws { - let app = try launchWithSampleDatabase() - runQuery("EXPLAIN QUERY PLAN SELECT * FROM Track JOIN Album ON Track.AlbumId = Album.AlbumId;", in: app) - - let window = app.windows.firstMatch - let canvas = window.scrollViews.matching(identifier: "query-plan-diagram").firstMatch - XCTAssertTrue(canvas.waitToExist(timeout: 20), "Diagram mode must show the plan canvas") - let zoomLevel = window.buttons["Reset Zoom"].firstMatch - XCTAssertTrue(zoomLevel.waitToExist(timeout: 10), "The plan must show its zoom level") - zoomLevel.click() - XCTAssertTrue(waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "100%" }) - - editorTextView(in: app).click() - app.typeKey("=", modifierFlags: .command) - XCTAssertFalse( - waitForPredicate(timeout: 2) { (zoomLevel.value as? String) != "100%" }, - "With the editor focused, Zoom In must leave the plan alone" - ) - app.typeKey("-", modifierFlags: .command) - - canvas.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).click() - app.typeKey("=", modifierFlags: .command) - XCTAssertTrue( - waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "150%" }, - "With the plan focused, Zoom In must step the plan to the next level" - ) - } - - func testCommandEqualsZoomsAClickedERDiagram() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["Database"].click() - menuBar.menuItems["View ER Diagram"].click() - - let canvas = window.scrollViews.matching(identifier: "er-diagram-canvas").firstMatch - XCTAssertTrue(canvas.waitToExist(timeout: 30), "The ER diagram must open on the sample database") - let zoomLevel = window.buttons["Reset Zoom"].firstMatch - XCTAssertTrue(zoomLevel.waitToExist(timeout: 10), "The diagram must show its zoom level") - XCTAssertTrue( - waitForPredicate(timeout: 10) { (zoomLevel.value as? String).map { $0 != "100%" } ?? false }, - "The sample diagram must open fitted to the window" - ) - zoomLevel.click() - XCTAssertTrue(waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "100%" }) - - canvas.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).click() - app.typeKey("-", modifierFlags: .command) - XCTAssertTrue( - waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "75%" }, - "With the diagram focused, Zoom Out must step the diagram to the next level down" - ) - - app.typeKey("t", modifierFlags: .command) - XCTAssertTrue(editorTextView(in: app).waitToExist(timeout: 10), "A new query tab must open") - app.typeKey("[", modifierFlags: [.command, .shift]) - XCTAssertTrue(canvas.waitToExist(timeout: 10), "Show Previous Tab must return to the diagram") - XCTAssertEqual( - zoomLevel.value as? String, - "75%", - "Returning to the diagram tab must keep the zoom it was left at" - ) - app.typeKey("=", modifierFlags: .command) - XCTAssertTrue( - waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "100%" }, - "Returning to a loaded diagram tab must give it focus without a click" - ) - } - - // MARK: - Helpers - - private func runQuery(_ sql: String, in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - typeQuery(sql, in: app) - app.typeKey(.return, modifierFlags: .command) - } -} diff --git a/TableProUITests/DiagramPointerUITests.swift b/TableProUITests/DiagramPointerUITests.swift index bf89db39cf..8da9f90f20 100644 --- a/TableProUITests/DiagramPointerUITests.swift +++ b/TableProUITests/DiagramPointerUITests.swift @@ -1,98 +1,74 @@ -// -// DiagramPointerUITests.swift -// TableProUITests -// -// A click or a drag on a zoomed diagram lands on what is drawn under the pointer. The SwiftUI -// gestures these replaced resolved every point at the pointer's position times the zoom, which only -// a real click through the window shows. -// - import XCTest +// Zoom In reaches a diagram once it is clicked, Command and a scroll zoom it, and a click or a drag on a +// zoomed diagram lands on what is drawn under the pointer, not at the pointer times the zoom. final class DiagramPointerUITests: UITestCase { - func testClickingAndDraggingATableOnAZoomedOutERDiagram() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["Database"].click() - menuBar.menuItems["View ER Diagram"].click() + func testZoomingAndDraggingATableOnTheERDiagram() throws { + let launch = try launchWithFixture() + let window = launch.window + let app = launch.app + XCTAssertTrue(app.menuBars.firstMatch.waitToExist(timeout: 20), "The app must publish its menu bar") + menuItem("View ER Diagram", under: "Database", in: app).click() let canvas = window.scrollViews.matching(identifier: "er-diagram-canvas").firstMatch - XCTAssertTrue(canvas.waitToExist(timeout: 30), "The ER diagram must open on the sample database") - zoomOutToHalf(in: window) - - let table = try XCTUnwrap( - elementWellInside(canvas.descendants(matching: .layoutItem), of: canvas.frame), - "A table must be on screen at 50%" - ) - table.click() + XCTAssertTrue(canvas.waitToExist(timeout: 30), "The ER diagram must open on the fixture") XCTAssertTrue( - waitForPredicate(timeout: 5) { table.isSelected }, - "A click on a table in a zoomed diagram must select that table" + canvas.descendants(matching: .layoutItem).firstMatch.waitToExist(timeout: 30), + "The diagram must draw the fixture's tables" ) - let before = table.frame - let grip = table.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)) - grip.click(forDuration: 0.3, thenDragTo: grip.withOffset(CGVector(dx: 60, dy: 40))) - let followed = waitForPredicate(timeout: 5) { - abs(table.frame.minX - before.minX - 60) < 6 && abs(table.frame.minY - before.minY - 40) < 6 - } + let zoomLevel = window.buttons["Reset Zoom"].firstMatch + XCTAssertTrue(zoomLevel.waitToExist(timeout: 10), "The diagram must show its zoom level") + zoomLevel.click() + XCTAssertTrue(waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "100%" }, "Reset Zoom must land on 100%") + + canvas.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).click() + app.typeShortcut(.zoomIn) XCTAssertTrue( - followed, - "A dragged table must follow the pointer, not move by the zoom's fraction of it: \(before) became \(table.frame)" + waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "150%" }, + "With the diagram focused, Zoom In must step it to the next level" ) - } - - func testClickingAPlanStepOnAZoomedOutDiagramOpensItsDetails() throws { - let app = try launchWithSampleDatabase() - runQuery("EXPLAIN QUERY PLAN SELECT * FROM Track JOIN Album ON Track.AlbumId = Album.AlbumId;", in: app) - let window = app.windows.firstMatch - let canvas = window.scrollViews.matching(identifier: "query-plan-diagram").firstMatch - XCTAssertTrue(canvas.waitToExist(timeout: 20), "Diagram mode must show the plan canvas") - zoomOutToHalf(in: window) - - let step = try XCTUnwrap( - elementWellInside(canvas.buttons, of: canvas.frame), - "The plan must publish its steps as buttons" - ) + XCUIElement.perform(withKeyModifiers: .command) { + canvas.scroll(byDeltaX: 0, deltaY: 6) + } XCTAssertTrue( - waitUntilHittable(step, timeout: 5), - "A pointer query must find the step, or VoiceOver cannot read it under the pointer" + waitForPredicate(timeout: 10) { (zoomLevel.value as? String) != "150%" }, + "Command and a scroll over the diagram must change its zoom, not only scroll it" ) - step.click() - - let detail = app.descendants(matching: .any).matching(identifier: "query-plan-detail-pane").firstMatch - XCTAssertTrue(detail.waitToExist(timeout: 5), "A click on a step in a zoomed diagram must open that step's details") - } - // MARK: - Helpers - - private func zoomOutToHalf(in window: XCUIElement) { - let zoomLevel = window.buttons["Reset Zoom"].firstMatch - XCTAssertTrue(zoomLevel.waitToExist(timeout: 10), "The diagram must show its zoom level") zoomLevel.click() + XCTAssertTrue(waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "100%" }, "Reset Zoom must land on 100%") let zoomOut = window.buttons["Zoom Out"].firstMatch - for _ in 0..<3 { + for _ in 0 ..< 3 { zoomOut.click() } XCTAssertTrue( waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "50%" }, "Three zoom-out steps from 100% must land on 50%" ) - } - /// An element near the canvas edge can start the edge auto-pan, which moves it under a drag for - /// reasons this test is not about. - private func elementWellInside(_ query: XCUIElementQuery, of container: CGRect) -> XCUIElement? { - let inner = container.insetBy(dx: 60, dy: 60) - return query.allElementsBoundByIndex.first { inner.contains($0.frame) } - } + // Clear of the edges, where a drag starts the auto-pan. + let inner = canvas.frame.insetBy(dx: 60, dy: 60) + let table = try XCTUnwrap( + canvas.descendants(matching: .layoutItem).allElementsBoundByIndex.first { inner.contains($0.frame) }, + "A table must be on screen at 50%" + ) + table.click() + XCTAssertTrue( + waitForPredicate(timeout: 5) { table.isSelected }, + "A click on a table in a zoomed diagram must select that table" + ) - private func runQuery(_ sql: String, in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - typeQuery(sql, in: app) - app.typeKey(.return, modifierFlags: .command) + let before = table.frame + let grip = table.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)) + grip.click(forDuration: 0.3, thenDragTo: grip.withOffset(CGVector(dx: 60, dy: 40))) + XCTAssertTrue( + waitForPredicate(timeout: 5) { + abs(table.frame.minX - before.minX - 60) < 6 && abs(table.frame.minY - before.minY - 40) < 6 + }, + "A dragged table must follow the pointer, not move by the zoom's fraction of it: " + + "\(before) became \(table.frame)" + ) } } diff --git a/TableProUITests/DiagramZoomUITests.swift b/TableProUITests/DiagramZoomUITests.swift deleted file mode 100644 index e79e5b3417..0000000000 --- a/TableProUITests/DiagramZoomUITests.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// DiagramZoomUITests.swift -// TableProUITests -// -// Command and a scroll over a diagram zoom it. The unit tests hand events straight to the scroll -// view; this is the one that proves an event from the window reaches it with Command still held. -// - -import XCTest - -final class DiagramZoomUITests: UITestCase { - func testCommandScrollZoomsTheERDiagram() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["Database"].click() - menuBar.menuItems["View ER Diagram"].click() - - let canvas = window.scrollViews.matching(identifier: "er-diagram-canvas").firstMatch - XCTAssertTrue(canvas.waitToExist(timeout: 30), "The ER diagram must open on the sample database") - - let zoomLevel = window.buttons["Reset Zoom"].firstMatch - XCTAssertTrue(zoomLevel.waitToExist(timeout: 10), "The diagram must show its zoom level") - let fitted = settledValue(of: zoomLevel) - - XCUIElement.perform(withKeyModifiers: .command) { - canvas.scroll(byDeltaX: 0, deltaY: 6) - } - - XCTAssertTrue( - waitForPredicate(timeout: 10) { (zoomLevel.value as? String) != fitted }, - "Command and a scroll over the diagram must change its zoom, not only scroll it" - ) - } - - func testZoomOutStopsAtTheLadderFloor() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["Database"].click() - menuBar.menuItems["View ER Diagram"].click() - - let canvas = window.scrollViews.matching(identifier: "er-diagram-canvas").firstMatch - XCTAssertTrue(canvas.waitToExist(timeout: 30), "The ER diagram must open on the sample database") - let zoomLevel = window.buttons["Reset Zoom"].firstMatch - XCTAssertTrue(zoomLevel.waitToExist(timeout: 10), "The diagram must show its zoom level") - _ = settledValue(of: zoomLevel) - zoomLevel.click() - XCTAssertTrue(waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "100%" }) - - let zoomOut = window.buttons["Zoom Out"].firstMatch - for level in ["75%", "67%", "50%", "33%", "25%", "10%", "5%"] { - zoomOut.click() - XCTAssertTrue( - waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == level }, - "Zoom Out must step down the ladder to \(level)" - ) - } - XCTAssertFalse(zoomOut.isEnabled, "Zoom Out must dim at 5% instead of dropping to 1%") - - canvas.click() - let viewMenu = menuBar.menuBarItems["View"] - viewMenu.click() - XCTAssertFalse(viewMenu.menuItems["Zoom Out"].isEnabled, "View > Zoom Out must dim for a diagram at 5%") - app.typeKey(.escape, modifierFlags: []) - XCTAssertEqual(zoomLevel.value as? String, "5%") - } - - /// The diagram fits itself to the window once it has laid out, so the level read straight - /// after opening can still be the 100% it starts at. - private func settledValue(of element: XCUIElement) -> String? { - var previous = element.value as? String - for _ in 0..<20 { - Thread.sleep(forTimeInterval: 0.5) - let current = element.value as? String - if current == previous, current != nil { return current } - previous = current - } - return previous - } -} diff --git a/TableProUITests/EditorAutocompleteFocusUITests.swift b/TableProUITests/EditorAutocompleteFocusUITests.swift index 1d6b618301..0859705b8d 100644 --- a/TableProUITests/EditorAutocompleteFocusUITests.swift +++ b/TableProUITests/EditorAutocompleteFocusUITests.swift @@ -1,136 +1,61 @@ import XCTest final class EditorAutocompleteFocusUITests: UITestCase { - func testTypingInNewTabKeepsEditorFocusWhileAutocompleteAppears() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "New tab editor should start empty") - - app.typeText("select") + // #2444: with the popup already open for `t`, typing the rest of `true` has to re-rank so the + // preselected first row is the exact keyword, which is what Return then commits. + func testAutocompleteKeepsFocusAndCommitsTheKeyword() throws { + let launch = try launchWithFixture() + let app = launch.app + let window = launch.window + + app.typeShortcut(.newTab) + let editor = editorTextView(in: window) + XCTAssertTrue(editor.waitToExist(timeout: 10), "Cmd+T must open a query tab") + XCTAssertTrue(waitForEditorText(editor) { $0.isEmpty }, "A new tab starts with an empty editor") + app.typeText("sel") + XCTAssertTrue(suggestion("select", in: app).waitToExist(timeout: 10), "Typing must raise the completion popup") + app.typeText("ect") XCTAssertTrue( - waitForValue("select", in: editor, timeout: 5), - "All typed characters must land in the editor; got '\(editor.value as? String ?? "nil")'" + waitForEditorText(editor) { $0 == "select" }, + "Keys typed while the popup is up must land in the editor, got '\(editorText(editor))'" ) - } - - /// #2444: with the popup already open for `t`, typing the rest of `true` has to re-rank so the - /// preselected first row is the exact keyword. The popup is a borderless panel whose rows are - /// not reliably queryable, so this asserts the text Return actually inserts. - func testTypingToAnExactKeywordCommitsThatKeyword() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "New tab editor should start empty") - app.typeText("select * from t where t") + app.typeText(" * from t where t") XCTAssertTrue( - waitForValue(in: editor, timeout: 5) { $0.lowercased() == "select * from t where t" }, - "Editor should hold the opening prefix; got '\(editor.value as? String ?? "nil")'" + waitForEditorText(editor) { $0.lowercased() == "select * from t where t" }, + "The editor must hold the opening prefix, got '\(editorText(editor))'" ) - app.typeText("rue") - RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.0)) + XCTAssertTrue(suggestion("true", in: app).waitToExist(timeout: 10), "The popup must offer the typed keyword") app.typeKey(.return, modifierFlags: []) - let committed = waitForValue(in: editor, timeout: 5) { - $0.lowercased().hasSuffix("true") - } - XCTAssertTrue( - committed, - "Return should commit the keyword the typed token completes; got " - + "'\(editor.value as? String ?? "nil")'" + waitForEditorText(editor) { $0.lowercased() == "select * from t where true" }, + "Return must commit the keyword the typed token completes, got '\(editorText(editor))'" ) } - /// #2833: the committed keyword takes the case of the typed prefix. Asserted on the exact - /// string rather than a case-folded comparison, which is what the two tests above use and - /// what would have let the old always-uppercase behaviour through. - func testCommittedKeywordTakesTheTypedCase() throws { - let app = try launchWithSampleDatabase() - - let editor = editorTextView(in: app) - for (typed, expected) in [("sel", "select"), ("SEL", "SELECT")] { - app.typeKey("t", modifierFlags: .command) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "New tab editor should start empty") - - app.typeText(typed) - RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.0)) - app.typeKey(.return, modifierFlags: []) - - XCTAssertTrue( - waitForValue(expected, in: editor, timeout: 5), - "Typing '\(typed)' and accepting should commit '\(expected)'; got " - + "'\(editor.value as? String ?? "nil")'" + // The popup is a panel of its own, so it is looked for outside the connection window and the menu bar, + // where the editor's own text would match too. + private func suggestion(_ text: String, in app: XCUIApplication) -> XCUIElement { + app.children(matching: .any) + .matching( + NSPredicate( + format: "identifier != %@ AND elementType != %lu", + "main", + XCUIElement.ElementType.menuBar.rawValue + ) ) - } - } - - /// #2915: a request that came back with nothing used to leave the model claiming the editor, - /// and every later prefix the stale candidates could still rank updated a panel that was no - /// longer on screen. Measured before the fix: after `zqxj`, typing `sel` on an emptied editor - /// made no request and showed no popup, so Return committed nothing. - func testPopupReturnsAfterARequestWithNoMatches() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "New tab editor should start empty") - - app.typeText("zqxj") - XCTAssertTrue( - waitForValue("zqxj", in: editor, timeout: 5), - "Editor should hold the no-match prefix; got '\(editor.value as? String ?? "nil")'" - ) - RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.0)) - - app.typeKey("a", modifierFlags: .command) - app.typeKey(.delete, modifierFlags: []) - XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "Editor should be empty again") - - app.typeText("sel") - RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.0)) - app.typeKey(.return, modifierFlags: []) - - XCTAssertTrue( - waitForValue("select", in: editor, timeout: 5), - "A no-match prefix must not leave the popup dead; got " - + "'\(editor.value as? String ?? "nil")'" - ) - } - - private func waitForValue( - in element: XCUIElement, - timeout: TimeInterval, - matching predicate: (String) -> Bool - ) -> Bool { - let deadline = Date(timeIntervalSinceNow: timeout) - while Date() < deadline { - if predicate(element.value as? String ?? "") { return true } - RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) - } - return predicate(element.value as? String ?? "") - } - - private func waitForValue(_ expected: String, in element: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date(timeIntervalSinceNow: timeout) - while Date() < deadline { - if (element.value as? String) == expected { - return true - } - RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) - } - return (element.value as? String) == expected + .descendants(matching: .any) + .matching( + NSPredicate( + format: "label CONTAINS[c] %@ OR (elementType == %lu AND value CONTAINS[c] %@)", + text, + XCUIElement.ElementType.staticText.rawValue, + text + ) + ) + .firstMatch } } diff --git a/TableProUITests/EditorClipboardUITests.swift b/TableProUITests/EditorClipboardUITests.swift deleted file mode 100644 index e81fc0bf3a..0000000000 --- a/TableProUITests/EditorClipboardUITests.swift +++ /dev/null @@ -1,63 +0,0 @@ -import XCTest - -/// #2172. Command+C in the query editor with nothing selected used to clear the pasteboard and -/// write an empty string, so the next Command+V pasted nothing and the user's clipboard was gone. -/// The test never writes the pasteboard itself; every copy and paste happens inside the app, so -/// nothing here depends on the runner handing content across the process boundary. -final class EditorClipboardUITests: UITestCase { - private let query = "SELECT * FROM Genre;" - - func testCopyWithNoSelectionKeepsSomethingOnTheClipboard() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText(query) - XCTAssertTrue(waitForValue(query, in: editor, timeout: 5)) - - app.typeKey("a", modifierFlags: .command) - app.typeKey("c", modifierFlags: .command) - - app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: .command) - app.typeKey("c", modifierFlags: .command) - - app.typeKey("a", modifierFlags: .command) - app.typeKey("v", modifierFlags: .command) - - XCTAssertTrue( - waitForPredicate(timeout: 5) { (editor.value as? String)?.contains("Genre") == true }, - "Command+C with no selection must not empty the clipboard; editor holds " - + "'\(editor.value as? String ?? "nil")'" - ) - } - - func testPasteReplacesTheSelectionInTheEditor() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText(query) - XCTAssertTrue(waitForValue(query, in: editor, timeout: 5)) - - app.typeKey("a", modifierFlags: .command) - app.typeKey("c", modifierFlags: .command) - app.typeKey("a", modifierFlags: .command) - app.typeKey("v", modifierFlags: .command) - - XCTAssertTrue( - waitForValue(query, in: editor, timeout: 5), - "Command+V must replace the selection with the copied query; editor holds " - + "'\(editor.value as? String ?? "nil")'" - ) - } - - private func openQueryTab(in app: XCUIApplication) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "A new tab starts with an empty editor") - return editor - } - - private func waitForValue(_ expected: String, in element: XCUIElement, timeout: TimeInterval) -> Bool { - waitForPredicate(timeout: timeout) { (element.value as? String) == expected } - } -} diff --git a/TableProUITests/EditorTabDetachUITests.swift b/TableProUITests/EditorTabDetachUITests.swift index b0505d42c6..9dcaa16de4 100644 --- a/TableProUITests/EditorTabDetachUITests.swift +++ b/TableProUITests/EditorTabDetachUITests.swift @@ -1,153 +1,50 @@ -import AppKit import XCTest -/// Moving a tab into a window of its own, and the two things that go wrong once one connection is -/// hosted by two windows: the tab is lost from both, or the shared session goes down with whichever -/// window closes first. -/// -/// Three tables, not two. `ConnectionWindowPaneResolver.showsTabStrip` hides the strip at one tab, -/// the way Safari hides its tab bar, so a source window left holding a single tab would have no -/// strip for these to read. The detached window has none either, which is why it is identified by -/// its title rather than by a tab element. +// Once one connection is hosted by two windows, the tab must not be lost from both, and the shared +// session must not go down with whichever window closes first. final class EditorTabDetachUITests: UITestCase { - private static let tables = ["Album", "Artist", "Customer"] + func testADetachedTabWorksInItsOwnWindowAndTheOriginalKeepsWorking() throws { + let tables = ["album", "artist", "genre"] + let launch = try launchWithFixture([ConnectionSeed(tabs: tables.map { SeededTab.table($0) })]) + let app = launch.app + let window = launch.window - func testMoveTabToNewWindowTakesTheTabOutOfTheStrip() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - openTables(Self.tables, in: window) XCTAssertTrue( - waitForPredicate(timeout: 25) { self.tabLabels(in: window).count >= 3 }, - "The strip must show a tab per open table, got \(tabLabels(in: window))" - ) - - let before = tabLabels(in: window) - let moved = try XCTUnwrap(before.last, "The strip must hold a tab to move") - - detach(tabNamed: moved, in: window, of: app) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { app.windows.count >= 2 }, - "Move Tab to New Window must open a second window" - ) - /// Asked of every window rather than of `window`, which is a query and re-resolves: once - /// the move opens a second window, `app.windows.firstMatch` can be that one, and the - /// assertion then reads the tab it was looking for in the window it was moved into. - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.someWindow(in: app, holds: before.filter { $0 != moved }) }, - "A window must hold the tabs that stayed behind, windows show \(self.tabsPerWindow(in: app))" - ) + waitForPredicate(timeout: 20) { self.editorTabTitles(in: window) == tables }, + "The strip must restore a tab per table, got \(editorTabTitles(in: window))" + ) + let moved = editorTab(titled: "genre", in: window) + XCTAssertTrue(waitUntilHittable(moved, timeout: 20), "The tab to move must be on screen") + moved.rightClick() + let command = contextMenuItem("Move Tab to New Window", in: app) + XCTAssertTrue(command.waitToExist(timeout: 5), "The tab's menu must offer Move Tab to New Window") + command.click() + + // `mainWindow(in:)` re-resolves to either window once there are two, so each is found by what it holds. + let windows = app.children(matching: .window) + XCTAssertTrue(waitForPredicate(timeout: 20) { windows.count == 2 }, "The move must open a second window") + // A window holding one tab hides its strip, so the detached one is known by its title. + let detached = windows.matching(NSPredicate(format: "title CONTAINS %@", "genre")).firstMatch + XCTAssertTrue(detached.waitToExist(timeout: 20), "A window must be titled for the moved tab") XCTAssertTrue( waitForPredicate(timeout: 20) { - app.windows.allElementsBoundByIndex.contains { $0.title.contains(moved) } + windows.allElementsBoundByIndex.contains { self.editorTabTitles(in: $0) == ["album", "artist"] } }, - "A window must be titled for the moved tab, titles are \(windowTitles(in: app))" + "The original window must keep the tabs that stayed behind" ) - } - /// The session belongs to the connection, not to either window. - func testClosingTheDetachedWindowLeavesTheOriginalWorking() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - openTables(Self.tables, in: window) - XCTAssertTrue(waitForPredicate(timeout: 25) { self.tabLabels(in: window).count >= 3 }) - - let before = tabLabels(in: window) - let moved = try XCTUnwrap(before.last) - detach(tabNamed: moved, in: window, of: app) - XCTAssertTrue(waitForPredicate(timeout: 20) { app.windows.count >= 2 }) - - let detached = try XCTUnwrap( - app.windows.allElementsBoundByIndex.first { $0.title.contains(moved) }, - "The detached window must be findable by title" - ) detached.buttons[XCUIIdentifierCloseWindow].click() - XCTAssertTrue( - waitForPredicate(timeout: 20) { app.windows.count == 1 }, + waitForPredicate(timeout: 20) { windows.count == 1 }, "Closing the detached window must close it rather than empty it" ) - /// Compared against what the strip held before the move rather than against a number: the - /// sample database opens a tab of its own, so the total is not the count of tables opened - /// here. - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.someWindow(in: app, holds: before.filter { $0 != moved }) }, - "The remaining window keeps the other tabs, windows show \(self.tabsPerWindow(in: app)) of \(before)" - ) - } - - /// A tab with a query still running is claimed by the coordinator that started it, so the - /// command stands down rather than orphaning the result. - func testTheCommandIsOfferedOnAnIdleTab() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - openTables(Self.tables, in: window) - XCTAssertTrue(waitForPredicate(timeout: 25) { self.tabLabels(in: window).count >= 3 }) - - let target = try XCTUnwrap(tabLabels(in: window).last) - tab(named: target, in: window).rightClick() - let item = app.menuItems.matching(identifier: "Move Tab to New Window").firstMatch - XCTAssertTrue(item.waitToExist(timeout: 5), "The command must be listed") - XCTAssertTrue(item.isEnabled, "It must be offered on an idle tab among others") - app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) - } - - // MARK: - Helpers - - private func detach(tabNamed name: String, in window: XCUIElement, of app: XCUIApplication) { - let target = tab(named: name, in: window) - XCTAssertTrue(waitUntilHittable(target, timeout: 20), "The tab must be hittable") - target.rightClick() - let item = app.menuItems.matching(identifier: "Move Tab to New Window").firstMatch - XCTAssertTrue(item.waitToExist(timeout: 5), "The command must be listed") - item.click() - } - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) + let original = mainWindow(in: app) + XCTAssertEqual(editorTabTitles(in: original), ["album", "artist"], "The remaining window keeps its tabs") + selectEditorTab(titled: "artist", in: original) XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" + gridCell(showing: "Ada", in: dataGrid(in: original)).waitToExist(timeout: 20), + "The connection must still load a table after the other window closed" ) - return window - } - - private func openTables(_ names: [String], in window: XCUIElement) { - for name in names { - let row = objectBrowserRow(name, in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(name)") - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - } - } - - private func tabLabels(in window: XCUIElement) -> [String] { - window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .allElementsBoundByIndex - .sorted { $0.frame.minX < $1.frame.minX } - .map { $0.label } - } - - /// Whether any window's strip holds exactly these tabs, in this order. - private func someWindow(in app: XCUIApplication, holds tabs: [String]) -> Bool { - app.windows.allElementsBoundByIndex.contains { tabLabels(in: $0) == tabs } - } - - private func tabsPerWindow(in app: XCUIApplication) -> [[String]] { - app.windows.allElementsBoundByIndex.map { tabLabels(in: $0) } - } - - private func windowTitles(in app: XCUIApplication) -> [String] { - app.windows.allElementsBoundByIndex.map { $0.title } - } - - private func tab(named name: String, in window: XCUIElement) -> XCUIElement { - window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .matching(NSPredicate(format: "label == %@", name)) - .firstMatch } } diff --git a/TableProUITests/EditorTabKeepOpenUITests.swift b/TableProUITests/EditorTabKeepOpenUITests.swift deleted file mode 100644 index 2174d048ae..0000000000 --- a/TableProUITests/EditorTabKeepOpenUITests.swift +++ /dev/null @@ -1,112 +0,0 @@ -import AppKit -import XCTest - -/// Issue #2436. A table already open in a preview tab could only be kept by closing it and -/// double-clicking it again in the sidebar. Double-clicking the tab itself now keeps it, the way -/// TablePlus, VS Code, DataGrip and Xcode all do. -final class EditorTabKeepOpenUITests: UITestCase { - func testDoubleClickingAPreviewTabKeepsItSoTheNextTableGetsItsOwn() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - /// Two tables, so the strip is drawn at all: it stays hidden while a connection holds one - /// tab. Album is kept first so Artist lands in the preview tab that this test promotes. - doubleClick(row("Album", in: window)) - click(row("Artist", in: window)) - /// Counted only once the strip is up. Sampling before the wait reads zero on a strip that - /// has not been drawn, and `> beforeKeeping` then passes without the feature running. - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.tab(named: "Artist", in: window).exists }, - "The strip must show the preview tab for Artist" - ) - let beforeKeeping = tabCount(in: window) - - doubleClick(tab(named: "Artist", in: window)) - click(row("Customer", in: window)) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.tabCount(in: window) > beforeKeeping }, - """ - Double-clicking a preview tab keeps it, so opening a third table must add a tab rather \ - than take the kept one over. Tabs before: \(beforeKeeping), after: \ - \(tabCount(in: window)). - """ - ) - } - - func testASingleClickOnAPreviewTabDoesNotKeepIt() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - doubleClick(row("Album", in: window)) - click(row("Artist", in: window)) - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.tab(named: "Artist", in: window).exists }, - "The strip must show the preview tab for Artist" - ) - let beforeSelecting = tabCount(in: window) - - /// Selecting the tab it is already on: the preview tab must stay disposable, or every - /// click in the strip would quietly keep a tab. - click(tab(named: "Artist", in: window)) - click(row("Customer", in: window)) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.tabCount(in: window) == beforeSelecting }, - """ - A single click only selects, so the preview tab must still be reused by the next table. \ - Tabs before: \(beforeSelecting), after: \(tabCount(in: window)). - """ - ) - } - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - return window - } - - /// The tree draws its rows as hosted cells, so the name arrives as the static text's `value` - /// rather than as a label or an identifier. - private func row(_ name: String, in window: XCUIElement) -> XCUIElement { - let match = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: \(name)")) - .firstMatch - XCTAssertTrue(match.waitToExist(timeout: 20), "The object browser must list \(name)") - return match - } - - private func tab(named name: String, in window: XCUIElement) -> XCUIElement { - window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .matching(NSPredicate(format: "label == %@", name)) - .firstMatch - } - - private func click(_ element: XCUIElement) { - clickAtCenter(element) - settleBetweenClicks() - } - - private func doubleClick(_ element: XCUIElement) { - element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - settleBetweenClicks() - } - - /// A genuine delay, not a poll: there is no state to observe between two clicks, and the only - /// thing being waited out is the window in which macOS would coalesce the next click into this - /// one. - private func settleBetweenClicks() { - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - } - - /// The strip is drawn only once a connection holds more than one tab, so one tab reads as zero - /// here. The tests compare counts rather than read a total, which holds either way. - private func tabCount(in window: XCUIElement) -> Int { - window.descendants(matching: .any).matching(identifier: "editor-tab").count - } -} diff --git a/TableProUITests/EditorTabReorderUITests.swift b/TableProUITests/EditorTabReorderUITests.swift index ef4ab5cb9f..03d909e148 100644 --- a/TableProUITests/EditorTabReorderUITests.swift +++ b/TableProUITests/EditorTabReorderUITests.swift @@ -1,250 +1,37 @@ -import AppKit import XCTest -/// Reordering by drag, now that AppKit owns the press rather than arbitrating for it. -/// -/// The window's origin is asserted here. The strip lives in a titlebar accessory, so AppKit's own -/// window drag is a claimant on every press that lands on a tab, and `EditorTabInteractionView` -/// answers `false` to `mouseDownCanMoveWindow` precisely so it can never win. A test that only -/// checks the order would pass just as happily with the window sliding across the screen. +// The strip lives in a titlebar accessory, so AppKit's own window drag claims every press on a tab +// unless `EditorTabInteractionView` refuses `mouseDownCanMoveWindow`. Checking the order alone would +// pass with the window sliding across the screen (#2438). final class EditorTabReorderUITests: UITestCase { - func testDraggingATabReordersTheStrip() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) + func testDraggingATabReordersTheStripWithoutMovingTheWindow() throws { + let tables = ["album", "artist", "genre"] + let launch = try launchWithFixture([ConnectionSeed(tabs: tables.map { SeededTab.table($0) })]) + let window = launch.window - openTables(["Album", "Artist", "Customer"], in: window) XCTAssertTrue( - waitForPredicate(timeout: 20) { self.tabLabels(in: window).count >= 3 }, - "The strip must show a tab per open table, got \(tabLabels(in: window))" + waitForPredicate(timeout: 20) { self.editorTabTitles(in: window) == tables }, + "The strip must restore a tab per table, got \(editorTabTitles(in: window))" ) - - let before = tabLabels(in: window) - - drag(tab(named: before[0], in: window), onto: tab(named: before[2], in: window)) - - XCTAssertTrue( - waitForTabOrder(toChangeFrom: before, in: window), - "Dragging the first tab across the strip must change the tab order, was \(before)" - ) - } - - /// An unselected tab draws no surface of its own, so it is the one whose reorder is easiest to - /// break by accident. - func testDraggingAnUnselectedTabReordersTheStrip() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - openTables(["Album", "Artist", "Customer"], in: window) - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.tabLabels(in: window).count >= 3 }, - "The strip must show a tab per open table, got \(tabLabels(in: window))" - ) - - let before = tabLabels(in: window) - let selected = selectedTabLabel(in: window) - let unselected = try XCTUnwrap( - before.first { $0 != selected }, - "The strip must hold a tab other than the selected one" - ) - - drag(tab(named: unselected, in: window), onto: tab(named: before[before.count - 1], in: window)) - - XCTAssertTrue( - waitForTabOrder(toChangeFrom: before, in: window), - "Dragging an unselected tab must change the tab order, was \(before)" - ) - } - - /// The control for the two above. If this fails too, the harness is not driving the strip at - /// all and their results prove nothing. - func testDraggingTheSelectedTabReordersTheStrip() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - openTables(["Album", "Artist", "Customer"], in: window) - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.tabLabels(in: window).count >= 3 }, - "The strip must show a tab per open table, got \(tabLabels(in: window))" - ) - - let before = tabLabels(in: window) - let selected = try XCTUnwrap(selectedTabLabel(in: window), "The strip must report a selected tab") - let target = try XCTUnwrap( - before.first { $0 != selected }, - "The strip must hold a tab other than the selected one" - ) - - drag(tab(named: selected, in: window), onto: tab(named: target, in: window)) - - XCTAssertTrue( - waitForTabOrder(toChangeFrom: before, in: window), - "Dragging the selected tab must change the tab order, was \(before)" - ) - } - - /// Tabs stop shrinking at `minimumTabWidth` and the track scrolls instead, which moves the - /// pointer's position in the viewport away from its position in the run of tabs. The reorder - /// measures the pointer in the scroll view's content space for that reason; measured in the - /// viewport it would place the drag one tab further along for every tab scrolled past. - func testDraggingATabReordersAnOverflowingStrip() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - openTables( - ["Album", "Artist", "Customer", "Employee", "Genre", "Invoice", "MediaType", "Playlist"], - in: window - ) - XCTAssertTrue( - waitForPredicate(timeout: 30) { self.tabLabels(in: window).count >= 8 }, - "The strip must overflow before this proves anything, got \(tabLabels(in: window))" - ) - - let before = tabLabels(in: window) - - // Not `before[0]`: once the track scrolls, the leading tabs stay in the accessibility tree - // at frames outside the viewport, and the pointer cannot land on one. - let reachable = onScreenTabs(in: window).map { $0.label } - XCTAssertGreaterThanOrEqual( - reachable.count, - 4, - "The viewport must hold four tabs to drag between, showed \(reachable) of \(before)" - ) - - // Interior tabs only. A tab at either edge of a scrolled track is half outside the - // viewport, and a click at its centre lands on the chrome beside the track rather than on - // the tab, so the drag never reaches the strip at all. - drag(tab(named: reachable[1], in: window), onto: tab(named: reachable[reachable.count - 2], in: window)) - - XCTAssertTrue( - waitForTabOrder(toChangeFrom: before, in: window), - "Dragging a tab in an overflowing strip must change the tab order, was \(before)" - ) - } - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - return window - } - - /// Double-clicked rather than clicked, because a single click opens a preview tab that the next - /// table takes over, which would leave the strip holding one tab however many tables are opened. - private func openTables(_ names: [String], in window: XCUIElement) { - for name in names { - let row = objectBrowserRow(name, in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(name)") - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - } - } - - private func tabElements(in window: XCUIElement) -> [XCUIElement] { - window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .allElementsBoundByIndex - .sorted { $0.frame.minX < $1.frame.minX } - } - - private func tabLabels(in window: XCUIElement) -> [String] { - tabElements(in: window).map { $0.label } - } - - private func selectedTabLabel(in window: XCUIElement) -> String? { - tabElements(in: window).first { $0.isSelected }?.label - } - - private func tab(named name: String, in window: XCUIElement) -> XCUIElement { - window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .matching(NSPredicate(format: "label == %@", name)) - .firstMatch - } - - /// `click(forDuration:thenDragTo:)` is the macOS spelling. The iOS `press(forDuration:...)` - /// compiles here and drives nothing: measured at HEAD, it left the tab order unchanged even on - /// the selected tab, which real events do reorder, so it was reading the harness rather than - /// the strip. - private func drag(_ source: XCUIElement, onto destination: XCUIElement, hold: TimeInterval = 0.6) { - XCTAssertTrue(waitUntilHittable(source, timeout: 20), "The dragged tab must be hittable") - XCTAssertTrue(waitUntilHittable(destination, timeout: 20), "The drop target tab must be hittable") - source.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) - .click( - forDuration: hold, - thenDragTo: destination.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) - ) - } - - /// The strip animates the move and commits it on release, so the new order arrives some time - /// after the gesture returns. Waiting for it rather than sleeping a fixed amount is what keeps - /// this readable on a loaded machine: a sleep long enough for CI is dead time on every local - /// run, and one short enough for a local run reads the pre-drag order on CI and reports the - /// reorder as broken. - private func waitForTabOrder(toChangeFrom before: [String], in window: XCUIElement) -> Bool { - waitForPredicate(timeout: 15) { self.tabLabels(in: window) != before } - } - - /// The guard the strip's home makes necessary. A press on a tab inside a titlebar accessory is - /// one AppKit would otherwise turn into a window drag, and this is the assertion that says it - /// does not: the reported symptom of #2438 was the whole window travelling with the pointer. - func testDraggingATabNeverMovesTheWindow() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - openTables(["Album", "Artist", "Customer"], in: window) - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.tabLabels(in: window).count >= 3 }, - "The strip must show a tab per open table, got \(tabLabels(in: window))" - ) - - let before = tabLabels(in: window) let origin = window.frame.origin + let dragged = editorTab(titled: "album", in: window) + let target = editorTab(titled: "genre", in: window) + XCTAssertTrue(waitUntilHittable(dragged, timeout: 20), "The dragged tab must be hittable") + XCTAssertTrue(waitUntilHittable(target, timeout: 20), "The drop target tab must be hittable") - drag(tab(named: before[0], in: window), onto: tab(named: before[2], in: window), hold: 0.02) + // A press and drag in one movement, the way a person does it. `press(forDuration:...)` is the iOS + // spelling: it compiles on macOS and drives nothing. + dragged.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + .click(forDuration: 0.02, thenDragTo: target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))) + // Same tabs in a new order: a drag that tore the tab off would change the order too. XCTAssertTrue( - waitForTabOrder(toChangeFrom: before, in: window), - "Dragging a tab must change the tab order, was \(before)" - ) - XCTAssertEqual( - window.frame.origin, - origin, - "Dragging a tab must move the tab, never the window" + waitForPredicate(timeout: 15) { + let titles = self.editorTabTitles(in: window) + return titles.first != "album" && titles.sorted() == tables.sorted() + }, + "Dragging album across the strip must reorder it, was \(tables), now \(editorTabTitles(in: window))" ) - } - - /// A user presses and drags in one movement. The suite's other cases hold for 0.6s first, - /// which is not a gesture anybody makes, and the arbitration this replaces behaved differently - /// under the two. - func testAFastDragReordersTheStrip() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - openTables(["Album", "Artist", "Customer"], in: window) - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.tabLabels(in: window).count >= 3 }, - "The strip must show a tab per open table, got \(tabLabels(in: window))" - ) - - let before = tabLabels(in: window) - - drag(tab(named: before[0], in: window), onto: tab(named: before[1], in: window), hold: 0.02) - - XCTAssertTrue( - waitForTabOrder(toChangeFrom: before, in: window), - "A drag with no hold before it must reorder, was \(before)" - ) - } - - /// The tabs the pointer can actually reach. - /// - /// Once the strip overflows, the track scrolls and the tabs outside the viewport stay in the - /// accessibility tree with frames the pointer cannot land on. Dragging one of those is not a - /// weaker version of the gesture, it is no gesture at all. - private func onScreenTabs(in window: XCUIElement) -> [XCUIElement] { - tabElements(in: window).filter { $0.exists && $0.isHittable } + XCTAssertEqual(window.frame.origin, origin, "Dragging a tab must move the tab, never the window") } } diff --git a/TableProUITests/EditorTrailingClickUITests.swift b/TableProUITests/EditorTrailingClickUITests.swift deleted file mode 100644 index 1185d9b56b..0000000000 --- a/TableProUITests/EditorTrailingClickUITests.swift +++ /dev/null @@ -1,54 +0,0 @@ -import XCTest - -/// #2156: the trailing strip of the SQL editor, where a hidden minimap still sat, took clicks and -/// did nothing with them. Clicking there must move the caret to the clicked line like any other -/// empty space in the editor. -final class EditorTrailingClickUITests: UITestCase { - private let query = "SELECT 1;" - - func testClickingTheTrailingEdgePutsTheCaretAtTheEndOfThatLine() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText(query) - XCTAssertTrue(waitForValue(query, in: editor)) - - let frame = editor.frame - XCTAssertGreaterThan( - frame.width, 200, - "The editor must be wide enough for the trailing strip to be distinct from the text" - ) - - // Click inside the text, which also dismisses any completion popup. Escape is not usable - // here: with no popup open the editor treats it as a request to show completions. - click(on: editor, dx: frame.width / 2, dy: 10) - app.typeKey(XCUIKeyboardKey.leftArrow.rawValue, modifierFlags: .command) - - click(on: editor, dx: frame.width - 12, dy: 10) - app.typeText("X") - - XCTAssertTrue( - waitForValue(query + "X", in: editor), - "A click on the editor's trailing edge must put the caret at the end of that line; got " - + "'\(editor.value as? String ?? "nil")'" - ) - } - - private func click(on element: XCUIElement, dx: CGFloat, dy: CGFloat) { - element.coordinate(withNormalizedOffset: .zero) - .withOffset(CGVector(dx: dx, dy: dy)) - .click() - } - - private func openQueryTab(in app: XCUIApplication) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue(waitForValue("", in: editor), "A new tab starts with an empty editor") - return editor - } - - private func waitForValue(_ expected: String, in element: XCUIElement) -> Bool { - waitForPredicate(timeout: 5) { (element.value as? String) == expected } - } -} diff --git a/TableProUITests/ExportOptionsUITests.swift b/TableProUITests/ExportOptionsUITests.swift deleted file mode 100644 index 0d6142716a..0000000000 --- a/TableProUITests/ExportOptionsUITests.swift +++ /dev/null @@ -1,64 +0,0 @@ -import XCTest - -/// Issue #2533. The SQL export closed every INSERT on a row count alone, so a table of wide rows -/// wrote one statement past the server's `max_allowed_packet` and the restore was rejected. The -/// pane now carries a size limit beside the row count, and it has to arrive at its default: a -/// control that ships set to No limit reproduces the bug for every user who never opens the menu. -final class ExportOptionsUITests: UITestCase { - func testTheSQLExportPaneOffersASizeLimitSetToOneMegabyte() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - - /// Nothing probes for the menu item first. XCUITest resolves one by opening its parent, so a - /// probe leaves that menu open and the click's own traversal then waits out a ten second - /// watchdog. Waiting on the menu bar costs nothing, per the note on `showStructure`. - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 20), "The app must publish its menu bar") - menuBar.menuItems["Export Tables…"].click() - - /// The format has to be SQL before the pane shows any of these, and SQLite's default format - /// is not guaranteed, so the format menu is set rather than assumed. - let formatMenu = app.popUpButtons["Format"].firstMatch - XCTAssertTrue(formatMenu.waitToExist(timeout: 20), "The export sheet must open with a format menu") - if formatMenu.value as? String != "SQL" { - formatMenu.click() - let sql = app.menuItems["SQL"].firstMatch - XCTAssertTrue(sql.waitToExist(timeout: 10), "SQL must be an available export format") - sql.click() - } - - let sizeLimit = app.popUpButtons["Max INSERT size"].firstMatch - XCTAssertTrue( - sizeLimit.waitToExist(timeout: 20), - "The SQL options pane must carry a Max INSERT size menu" - ) - XCTAssertEqual( - sizeLimit.value as? String, "1 MB", - "The limit must ship on, or a wide-row export still writes one oversized INSERT" - ) - - let rowsPerInsert = app.popUpButtons["Rows per INSERT"].firstMatch - XCTAssertTrue( - rowsPerInsert.exists, - "The size limit sits beside the row count, and both must remain reachable" - ) - - /// The menu has to offer turning the limit off, because that is the only way back to the - /// output an existing export profile produced. - sizeLimit.click() - let noLimit = app.menuItems["No limit"].firstMatch - XCTAssertTrue(noLimit.waitToExist(timeout: 10), "The menu must offer No limit") - app.typeKey(.escape, modifierFlags: []) - - app.buttons["Cancel"].firstMatch.click() - XCTAssertTrue( - waitForPredicate(timeout: 15) { !sizeLimit.exists }, - "Cancel must close the export sheet" - ) - } -} diff --git a/TableProUITests/ExportUITests.swift b/TableProUITests/ExportUITests.swift new file mode 100644 index 0000000000..a01b384b21 --- /dev/null +++ b/TableProUITests/ExportUITests.swift @@ -0,0 +1,77 @@ +import XCTest + +final class ExportUITests: UITestCase { + func testExportingATableWritesACSVFile() throws { + let launch = try launchWithFixture() + let window = launch.window + let app = launch.app + let destination = try XCTUnwrap(sandboxRoot) + let exported = destination.appendingPathComponent("album-export.csv") + + // The object browser's selection is what the export preselects. + _ = openFromObjectBrowser("album", in: launch) + app.typeShortcut(.export) + + let sheet = window.children(matching: .sheet).firstMatch + XCTAssertTrue( + sheet.staticTexts["1 table to export"].waitToExist(timeout: 30), + "Export Tables must open with the selected table, album, chosen" + ) + let format = element(sheet.popUpButtons, labelEndingWith: "Format") + XCTAssertTrue(format.waitToExist(timeout: 10), "The export sheet must offer a format menu") + if (format.value as? String) != "CSV" { + format.click() + let csv = app.menuItems["CSV"].firstMatch + XCTAssertTrue(csv.waitToExist(timeout: 10), "CSV must be an export format") + csv.click() + } + XCTAssertTrue(waitForPredicate(timeout: 10) { (format.value as? String) == "CSV" }, "The format must be CSV") + + let export = sheet.buttons["Export…"].firstMatch + XCTAssertTrue(waitForPredicate(timeout: 15) { export.exists && export.isEnabled }, "Export must be offered") + export.click() + + let panel = sheet.descendants(matching: .sheet).matching(identifier: "save-panel").firstMatch + let nameField = panel.textFields["saveAsNameTextField"].firstMatch + XCTAssertTrue(panel.waitToExist(timeout: 30), "Export must ask where to save") + // The panel takes keys only once it is key, and the name field takes the keyboard in that moment. + XCTAssertTrue( + waitForPredicate(timeout: 15) { self.holdsKeyboardFocus(nameField) }, + "The save panel must become key with the keyboard in its name field" + ) + panel.typeKey("a", modifierFlags: .command) + panel.typeText(exported.lastPathComponent) + + panel.typeKey("g", modifierFlags: [.command, .shift]) + XCTAssertTrue( + waitForPredicate(timeout: 10) { !self.holdsKeyboardFocus(nameField) }, + "Go to Folder must take the keyboard" + ) + app.typeText(destination.path) + app.typeKey(.return, modifierFlags: []) + let folder = panel.popUpButtons.matching(NSPredicate(format: "value == %@", destination.lastPathComponent)) + XCTAssertTrue( + folder.firstMatch.waitToExist(timeout: 15), + "The panel must move to the sandbox folder" + ) + // With the extension hidden, the field shows the name without it. + XCTAssertTrue( + (nameField.value as? String)?.hasPrefix("album-export") == true, + "Moving folder must keep the typed name, the field holds '\(nameField.value as? String ?? "nil")'" + ) + panel.buttons["OKButton"].firstMatch.click() + + XCTAssertTrue( + waitForPredicate(timeout: 30) { FileManager.default.fileExists(atPath: exported.path) }, + "Export must write \(exported.lastPathComponent) into the chosen folder" + ) + let lines = try String(contentsOf: exported, encoding: .utf8) + .split(separator: "\n") + .map(String.init) + XCTAssertEqual( + lines, + ["id,title,artist_id", "1,North,1", "2,South,2", "3,East,2", "4,West,3", "5,Up,4", "6,Down,5"], + "The file must hold album's header and every row" + ) + } +} diff --git a/TableProUITests/ExternalLinkFilterUITests.swift b/TableProUITests/ExternalLinkFilterUITests.swift index b9bee5292f..5717cae53b 100644 --- a/TableProUITests/ExternalLinkFilterUITests.swift +++ b/TableProUITests/ExternalLinkFilterUITests.swift @@ -2,12 +2,6 @@ import XCTest final class ExternalLinkFilterUITests: UITestCase { func testALinkWithAFilterShowsTheWholeFilterAndOffersNoAlwaysAllow() throws { - guard #available(macOS 13.3, *) else { - throw XCTSkip("XCUIApplication.open(_:) needs macOS 13.3") - } - let app = try launchApp(arguments: ["-AppleLanguages", "(en)"]) - XCTAssertTrue(app.windows["welcome"].waitToExist(timeout: 10)) - let condition = "status = 'pending' AND total > 1000" var components = URLComponents() components.scheme = "postgresql" @@ -19,9 +13,11 @@ final class ExternalLinkFilterUITests: UITestCase { URLQueryItem(name: "table", value: "orders"), URLQueryItem(name: "raw", value: condition) ] - app.open(try XCTUnwrap(components.url)) + let link = try XCTUnwrap(components.url) + let app = try launchOpeningURL(link.absoluteString) - XCTAssertTrue(app.buttons["Connect"].waitToExist(timeout: 10), "A link with a filter must ask before connecting") + XCTAssertTrue(app.buttons["Connect"].waitToExist(timeout: 15), "A link with a filter must ask before connecting") + // With no key window the prompt runs app-modal; over a window it is a sheet. let alert = app.dialogs.firstMatch.exists ? app.dialogs.firstMatch : app.sheets.firstMatch let shownFilter = alert.textViews.matching(NSPredicate(format: "value == %@", condition)).firstMatch XCTAssertTrue(shownFilter.waitToExist(timeout: 5), "The alert must show the whole filter") @@ -29,5 +25,6 @@ final class ExternalLinkFilterUITests: UITestCase { alert.buttons["Cancel"].click() XCTAssertTrue(waitForPredicate(timeout: 5) { !alert.exists }, "Cancel must close the alert") + XCTAssertFalse(mainWindow(in: app).exists, "Cancel must not open a connection") } } diff --git a/TableProUITests/FeatureTipsUITests.swift b/TableProUITests/FeatureTipsUITests.swift deleted file mode 100644 index 54cd7b8fb7..0000000000 --- a/TableProUITests/FeatureTipsUITests.swift +++ /dev/null @@ -1,23 +0,0 @@ -import XCTest - -final class FeatureTipsUITests: UITestCase { - func testTipsStayHiddenUnderTheUITestSandbox() throws { - let app = try launchWithSampleDatabase() - let window = app.children(matching: .window).firstMatch - - XCTAssertFalse( - window.staticTexts["Open Any Table by Name"].waitToExist(timeout: 3), - "A UI test must never find a tip it did not ask for" - ) - } - - func testARequestedTipShowsInTheSidebar() throws { - let app = try launchWithSampleDatabase(environment: ["TABLEPRO_UI_TEST_SHOW_TIPS": "open-quickly"]) - let window = app.children(matching: .window).firstMatch - - XCTAssertTrue( - window.staticTexts["Open Any Table by Name"].waitToExist(timeout: 15), - "The Open Quickly tip must show at the top of the sidebar" - ) - } -} diff --git a/TableProUITests/FilterBarReturnUITests.swift b/TableProUITests/FilterBarReturnUITests.swift index 9a2716fd7a..d9822bee40 100644 --- a/TableProUITests/FilterBarReturnUITests.swift +++ b/TableProUITests/FilterBarReturnUITests.swift @@ -1,65 +1,74 @@ import XCTest -/// #2927: the completion list over a filter field preselected its first row the moment it opened, -/// so `Return` accepted a suggestion the user never asked for and the filter went unapplied until -/// they pressed `Escape` first. -/// -/// The list itself is not asserted on. Its rows are a SwiftUI popover the runner does not resolve -/// reliably, which is the same reason `EditorAutocompleteFocusUITests` asserts on committed text. -/// **Clear** is the observable proof instead: it is enabled only once a filter has been applied. +// #2927: the completion list over a filter field preselected its first row, so Return took a suggestion +// and left the filter unapplied. #3066: a cell's menu filters the table by the clicked value. +// Clear proves a filter ran: it is enabled only while one is applied. final class FilterBarReturnUITests: UITestCase { - func testReturnAppliesAFilterWhoseLastTokenStillMatchesSuggestions() throws { - let app = try launchWithSampleDatabase() - let window = try openAlbum(in: app) - - openFilterBar(in: app) - app.typeText("ArtistId IS NOT NULL") - settleCompletion() - app.typeKey(.return, modifierFlags: []) + func testFilteringFromACellAndFromTheFilterBar() throws { + let (launch, grid) = try launchWithFixtureTable("album") + let window = launch.window + let app = launch.app + let clear = window.buttons["Clear"].firstMatch + app.typeShortcut(.toggleFilters) + // A raw SQL row wraps its text, so its field is not assumed to be a single-line text field. + let whereField = window.descendants(matching: .any) + .matching(NSPredicate( + format: "label == %@ AND (elementType == %lu OR elementType == %lu)", + "WHERE clause", + XCUIElement.ElementType.textField.rawValue, + XCUIElement.ElementType.textView.rawValue + )) + .firstMatch XCTAssertTrue( - waitForPredicate(timeout: 10) { window.buttons["Clear"].isEnabled }, - "Return must apply the filter while the completion list is open on the NULL keyword" + waitForPredicate(timeout: 15) { self.holdsKeyboardFocus(whereField) }, + "The filter bar must open on a raw SQL row with the keyboard in it" + ) + let filter = "artist_id > 2 AND title IS NOT NULL" + app.typeText(filter) + XCTAssertTrue( + waitForPredicate(timeout: 10) { (whereField.value as? String) == filter }, + "The typed filter must reach the WHERE field" + ) + XCTAssertTrue( + window.popovers.firstMatch.waitToExist(timeout: 10), + "The completion list must be open on the NULL keyword when Return is pressed" ) - } - - func testReturnAppliesAFilterThatEndsOnAClosingQuote() throws { - let app = try launchWithSampleDatabase() - let window = try openAlbum(in: app) - - openFilterBar(in: app) - app.typeText("Title='Let There Be Rock'") - settleCompletion() app.typeKey(.return, modifierFlags: []) XCTAssertTrue( - waitForPredicate(timeout: 10) { window.buttons["Clear"].isEnabled }, - "Return must apply a filter that ends on a closing quote, with no Escape first" + waitForPredicate(timeout: 15) { clear.exists && clear.isEnabled }, + "Return must apply the filter while the completion list is open" + ) + XCTAssertTrue( + waitForPredicate(timeout: 20) { + grid.value(row: 1, column: 2) == "West" && !grid.cell(row: 4, column: 2).exists + }, + "The filter must leave the three albums of artists 3 to 5" ) - } - - private func openAlbum(in app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) - - let row = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: Album")) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(row) - return window - } - /// The panel adds a row and focuses it in `onAppear`, and a new row starts in raw SQL, so the - /// text goes straight to the field the popup hangs off. - private func openFilterBar(in app: XCUIApplication) { - app.typeKey("f", modifierFlags: [.command, .shift]) - RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.0)) - } + let cell = grid.cellPoint(row: 1, column: 2) + cell.click() + cell.rightClick() + let filterMenu = window.menus.menuItems["Filter"].firstMatch + XCTAssertTrue(filterMenu.waitToExist(timeout: 15), "A cell's context menu must offer Filter") + filterMenu.hover() + let equalsItems = app.menuItems.matching(NSPredicate(format: "title CONTAINS %@", " = \u{201C}West")) + var equals: XCUIElement? + XCTAssertTrue( + waitForPredicate(timeout: 10) { + equals = equalsItems.allElementsBoundByIndex.first { $0.isHittable } + return equals != nil + }, + "The Filter submenu must offer an equals condition built from the clicked value" + ) + try XCTUnwrap(equals).click() - /// The request is debounced 50ms and then makes a schema round trip, so the list is not up on - /// the keystroke that asked for it. - private func settleCompletion() { - RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.5)) + XCTAssertTrue( + waitForPredicate(timeout: 20) { !grid.cell(row: 2, column: 2).exists }, + "Choosing the condition must narrow the table to the clicked row" + ) + XCTAssertEqual(grid.value(row: 1, column: 2), "West", "The row that was clicked must stay") + XCTAssertTrue(clear.isEnabled, "The filter bar must report a filter applied") } } diff --git a/TableProUITests/FilterPersistenceUITests.swift b/TableProUITests/FilterPersistenceUITests.swift deleted file mode 100644 index fb6a4b7001..0000000000 --- a/TableProUITests/FilterPersistenceUITests.swift +++ /dev/null @@ -1,58 +0,0 @@ -import XCTest - -/// #3006: a filter that is in the bar but not running had nowhere to be saved, so **Clear** deleted -/// the table's rows along with the query, and reopening the table brought back an empty bar. -/// -/// **Clear** is the observable state on both counts: it exists only inside the filter bar, so its -/// presence proves the bar came back with rows in it, and it is enabled only while a filter is -/// applied, so its being dimmed proves nothing is running. -final class FilterPersistenceUITests: UITestCase { - func testClearedFilterRowsComeBackWhenTheTableIsReopened() throws { - let app = try launchWithSampleDatabase() - let window = try openTable("Album", in: app) - - app.typeKey("f", modifierFlags: [.command, .shift]) - RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.0)) - app.typeText("ArtistId IS NOT NULL") - RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.5)) - app.typeKey(.return, modifierFlags: []) - - XCTAssertTrue( - waitForPredicate(timeout: 10) { window.buttons["Clear"].isEnabled }, - "The filter must apply before the rest of the test means anything" - ) - - clickAtCenter(window.buttons["Clear"]) - XCTAssertTrue( - waitForPredicate(timeout: 10) { - window.buttons["Clear"].exists && !window.buttons["Clear"].isEnabled - }, - "Clear must stop filtering and leave the rows in the bar" - ) - - _ = try openTable("Artist", in: app) - _ = try openTable("Album", in: app) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { window.buttons["Clear"].exists }, - "Reopening the table must bring the cleared filter rows back in the bar" - ) - XCTAssertFalse( - window.buttons["Clear"].isEnabled, - "The restored rows must not be running" - ) - } - - private func openTable(_ tableName: String, in app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) - - let row = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: \(tableName)")) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(tableName)") - clickAtCenter(row) - RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.5)) - return window - } -} diff --git a/TableProUITests/FindBarTabSwitchUITests.swift b/TableProUITests/FindBarTabSwitchUITests.swift deleted file mode 100644 index 1464a04204..0000000000 --- a/TableProUITests/FindBarTabSwitchUITests.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// FindBarTabSwitchUITests.swift -// TableProUITests -// -// The find bar holds its field text in the view's own `@State`, seeded once from `onAppear`. With -// find open on both tabs the view kept its identity across a tab switch, so nothing re-seeded it -// and the field showed the other tab's term. (#2667) -// - -import XCTest - -final class FindBarTabSwitchUITests: UITestCase { - func testTheFindTermDoesNotFollowTheReaderToAnotherTab() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The sample table must produce a grid") - XCTAssertTrue(waitForClickableRows(in: grid), "The rows must be in before find can match any") - - openFind(in: app) - let field = findField(in: window) - XCTAssertTrue(field.waitToExist(timeout: 15), "Command F must open the find bar") - field.click() - app.typeText("alice") - XCTAssertTrue( - waitForPredicate(timeout: 15) { Self.text(of: field) == "alice" }, - "The first tab's term must reach its field, got \(Self.text(of: field))" - ) - - /// A second table tab, with find open too. That is the case the fix is about: when the - /// incoming tab has no find bar the view is destroyed anyway and the term reseeds itself. - openSecondTableTab(in: app, window: window) - XCTAssertTrue(waitForClickableRows(in: grid), "The second tab must load its rows") - openFind(in: app) - let secondField = findField(in: window) - XCTAssertTrue(secondField.waitToExist(timeout: 15), "The second tab must offer its own find bar") - secondField.click() - app.typeText("berlin") - XCTAssertTrue( - waitForPredicate(timeout: 15) { Self.text(of: secondField) == "berlin" }, - "The second tab's term must reach its field, got \(Self.text(of: secondField))" - ) - - showPreviousTab(in: app) - - let returned = findField(in: window) - XCTAssertTrue(returned.waitToExist(timeout: 15), "The first tab must come back with its find bar") - XCTAssertTrue( - waitForPredicate(timeout: 15) { Self.text(returned) == "alice" }, - "Returning to the first tab must show its own term, not the other tab's. Got " - + Self.text(of: returned) - ) - } - - // MARK: - Helpers - - private static func text(of element: XCUIElement) -> String { - guard element.exists else { return "" } - return (element.value as? String) ?? "" - } - - private static func text(_ element: XCUIElement) -> String { text(of: element) } - - private func findField(in window: XCUIElement) -> XCUIElement { - window.searchFields["find-in-results-field"].firstMatch - } - - private func openFind(in app: XCUIApplication) { - app.typeKey("f", modifierFlags: .command) - } - - private func mainWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - return window - } - - /// A second table tab, through the tree's own "Open in New Tab". A double-click would not do: - /// without active work in the current tab, opening a table replaces it rather than adding one, - /// and this test needs both tabs to exist at once. - /// - /// The tree draws its rows as hosted cells, so the name arrives as the static text's `value` - /// rather than as a label, and the rows report as disabled. Matching on `value` finds them and a - /// coordinate click reaches them. - private func openSecondTableTab(in app: XCUIApplication, window: XCUIElement) { - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - let row = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: Album")) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list a second table") - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).rightClick() - - let item = app.menuItems["Open in New Tab"].firstMatch - XCTAssertTrue(item.waitToExist(timeout: 15), "The tree menu must offer Open in New Tab") - item.click() - } - - /// The menu item rather than its key equivalent, which is user-rebindable. `firstMatch` because - /// the title is published more than once once AppKit's own window-tab machinery is involved. - private func showPreviousTab(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 20), "The app must publish its menu bar") - menuBar.menuBarItems["Window"].click() - let item = menuBar.menuItems["Show Previous Tab"].firstMatch - XCTAssertTrue(item.waitToExist(timeout: 10), "The Window menu must offer Show Previous Tab") - item.click() - } -} diff --git a/TableProUITests/ForeignKeyNavigationUITests.swift b/TableProUITests/ForeignKeyNavigationUITests.swift index 5397de3a6e..5b7a9fdfd3 100644 --- a/TableProUITests/ForeignKeyNavigationUITests.swift +++ b/TableProUITests/ForeignKeyNavigationUITests.swift @@ -1,93 +1,39 @@ -// -// ForeignKeyNavigationUITests.swift -// TableProUITests -// -// Following a foreign key leaves the tab it was followed from open. Chinook's Album.ArtistId -// references Artist, and Album is the last column of that table, which is what the cell cursor -// reaches by walking right. -// - -import AppKit import XCTest final class ForeignKeyNavigationUITests: UITestCase { + // album opens from the object browser as a preview tab, the kind a reference used to retarget. func testFollowingAReferenceKeepsTheTabItCameFrom() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let grid = try albumGrid(in: app, window: window) - - /// Counted rather than asserted against a number: the sample database opens a tab of its - /// own before this suite opens Album, and the strip publishes nothing while one tab is - /// open, so what this pins is the increase. - let tabsBefore = window.descendants(matching: .any) - .matching(identifier: "editor-tab").count - - focusTheArtistIdCell(in: app, grid: grid) - app.typeKey(XCUIKeyboardKey.space.rawValue, modifierFlags: []) - - let openArtist = window.buttons["Open Artist"].firstMatch + let launch = try launchWithFixture() + let window = launch.window + let app = launch.app + let grid = openFromObjectBrowser("album", in: launch) + let tabsBefore = editorTabCount(in: window) + + grid.clickCell(row: 1, column: 2) + app.typeKey(.rightArrow, modifierFlags: []) + app.typeKey(.rightArrow, modifierFlags: []) + app.typeKey(.space, modifierFlags: []) + + let openArtist = window.buttons["Open artist"].firstMatch + XCTAssertTrue(openArtist.waitToExist(timeout: 30), "Preview FK Reference must offer to open artist") XCTAssertTrue( - openArtist.waitToExist(timeout: 30), - "Preview FK Reference must offer to open the referenced table" + waitForPredicate(timeout: 20) { openArtist.isEnabled }, + "The preview must load the referenced row before it can be opened" ) clickAtCenter(openArtist) XCTAssertTrue( - waitForPredicate(timeout: 30) { - window.descendants(matching: .any) - .matching(identifier: "editor-tab").count > tabsBefore - }, - "Following the reference must add a tab rather than retarget the one it came from" + waitForPredicate(timeout: 30) { self.tabsTitled("artist", in: window).firstMatch.exists }, + "Following the reference must open artist in a tab" ) - /// Addressed as the tab rather than as a static text: the strip combines each tab's - /// children into one accessibility element, so the drawn title is that element's label and - /// is never published as a text of its own. - XCTAssertTrue( - waitForPredicate(timeout: 20) { - window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .matching(NSPredicate(format: "label == %@", "Album")) - .firstMatch.exists - }, - "The Album tab must still be in the strip after the reference opened" + XCTAssertGreaterThan( + editorTabCount(in: window), + tabsBefore, + "Following the reference must add a tab rather than retarget the one it came from" ) - } - - // MARK: - Helpers - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" + tabsTitled("album", in: window).firstMatch.exists, + "The album tab must still be in the strip" ) - return window - } - - private func albumGrid(in app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { - let row = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: Album")) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(row) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "Album produced no data grid") - XCTAssertTrue(waitForClickableRows(in: grid), "Album must load rows before a cell can be read") - return grid - } - - /// A point offset from the grid rather than a row or cell element, which XCUITest reads as - /// obscured by the columns published beside them, with `dy` clearing the 42pt header. The cell - /// cursor then walks right until it stops, which is Album's last column, `ArtistId`. - private func focusTheArtistIdCell(in app: XCUIApplication, grid: XCUIElement) { - grid.coordinate(withNormalizedOffset: .zero) - .withOffset(CGVector(dx: 60, dy: 70)) - .click() - - for _ in 0 ..< 5 { - app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: []) - } } } diff --git a/TableProUITests/ForeignKeyPickerUITests.swift b/TableProUITests/ForeignKeyPickerUITests.swift index 2d79066534..278e571355 100644 --- a/TableProUITests/ForeignKeyPickerUITests.swift +++ b/TableProUITests/ForeignKeyPickerUITests.swift @@ -1,192 +1,27 @@ -// -// ForeignKeyPickerUITests.swift -// TableProUITests -// -// Editing a foreign key cell picks a row from the referenced table. Chinook's -// Album.ArtistId references Artist.ArtistId, whose Name column is what the picker labels with, -// and Customer.SupportRepId references Employee, whose rows read as a name only once both -// LastName and FirstName are chosen. -// - -import AppKit import XCTest final class ForeignKeyPickerUITests: UITestCase { - func testEditingAForeignKeyCellListsTheReferencedRows() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let grid = try albumGrid(in: app, window: window) - - openPicker(in: app, grid: grid) - - XCTAssertTrue( - searchField(in: window).waitToExist(timeout: 20), - "Editing a foreign key cell must open the value picker" - ) - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.staticTexts["AC/DC"].exists }, - "The picker must list Artist rows labelled with their Name column" - ) - } - - func testTypingNarrowsTheListToMatchingRows() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let grid = try albumGrid(in: app, window: window) - - openPicker(in: app, grid: grid) - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.staticTexts["AC/DC"].exists }, - "The picker must load its first rows before a search can narrow them" - ) - - app.typeText("Accept") - - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.staticTexts["Accept"].exists }, - "Searching Accept must reach the Artist row of that name" - ) - XCTAssertTrue( - waitForPredicate(timeout: 20) { !window.staticTexts["AC/DC"].exists }, - "A row the search term does not match must leave the list" - ) - } - - /// The reporter's shape on Chinook: one Employee column names nobody, and the pair does. - func testChoosingTwoLabelColumnsShowsBothBesideTheKey() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let grid = try grid(forTable: "Customer", in: app, window: window) - - openPicker(in: app, grid: grid) - XCTAssertTrue( - searchField(in: window).waitToExist(timeout: 20), - "Editing Customer.SupportRepId must open the value picker" - ) - - chooseLabelColumns(["LastName", "FirstName"], in: app, window: window) - - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.staticTexts["Peacock, Jane"].exists }, - "Both chosen label columns must read as one line beside the key" - ) - } + func testEditingAForeignKeyCellListsAndNarrowsTheReferencedRows() throws { + let (launch, grid) = try launchWithFixtureTable("album") + let window = launch.window + let app = launch.app - func testSearchReachesEveryChosenLabelColumn() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let grid = try grid(forTable: "Customer", in: app, window: window) + grid.clickCell(row: 1, column: 2) + app.typeKey(.rightArrow, modifierFlags: []) + app.typeKey(.rightArrow, modifierFlags: []) + app.typeKey(.return, modifierFlags: []) - openPicker(in: app, grid: grid) + let search = window.searchFields.matching(identifier: "fk-picker-search").firstMatch + XCTAssertTrue(search.waitToExist(timeout: 20), "Editing album.artist_id must open the value picker") XCTAssertTrue( - searchField(in: window).waitToExist(timeout: 20), - "Editing Customer.SupportRepId must open the value picker" + waitForPredicate(timeout: 30) { window.staticTexts["Ada"].exists && window.staticTexts["Cole"].exists }, + "The picker must list artist rows labelled with their name" ) - chooseLabelColumns(["LastName", "FirstName"], in: app, window: window) + app.typeText("Cole") XCTAssertTrue( - waitForPredicate(timeout: 30) { window.staticTexts["Peacock, Jane"].exists }, - "The picker must list the Employee rows before a search can narrow them" + waitForPredicate(timeout: 20) { !window.staticTexts["Ada"].exists && window.staticTexts["Cole"].exists }, + "Searching Cole must keep the artist of that name and drop every row it does not match" ) - - let search = searchField(in: window) - XCTAssertTrue(waitUntilHittable(search, timeout: 20), "The picker must offer its search field") - search.click() - app.typeText("Jane") - - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.staticTexts["Peacock, Jane"].exists }, - "A term living only in the second chosen column must still reach its row" - ) - XCTAssertTrue( - waitForPredicate(timeout: 20) { !window.staticTexts["Adams, Andrew"].exists }, - "A row no chosen column matches must leave the list" - ) - } - - // MARK: - Helpers - - /// Clears whatever the heuristic chose, then ticks each name in turn, so the assertion is about - /// the chosen columns rather than about what the table happened to default to. - private func chooseLabelColumns(_ names: [String], in app: XCUIApplication, window: XCUIElement) { - let labelButton = window.descendants(matching: .any) - .matching(identifier: "fk-picker-label") - .firstMatch - XCTAssertTrue(waitUntilHittable(labelButton, timeout: 20), "The picker must offer its Label control") - labelButton.click() - - let clear = window.descendants(matching: .any) - .matching(identifier: "fk-picker-label-none") - .firstMatch - XCTAssertTrue(waitUntilHittable(clear, timeout: 20), "The label chooser must offer None") - clear.click() - - for name in names { - let column = window.checkBoxes - .matching(identifier: "fk-picker-label-column-\(name)") - .firstMatch - XCTAssertTrue(waitUntilHittable(column, timeout: 20), "The label chooser must list \(name)") - column.click() - } - - let done = window.buttons.matching(identifier: "fk-picker-label-done").firstMatch - XCTAssertTrue(waitUntilHittable(done, timeout: 20), "The label chooser must offer Done") - done.click() - } - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - return window - } - - private func albumGrid(in app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { - try grid(forTable: "Album", in: app, window: window) - } - - private func grid( - forTable table: String, - in app: XCUIApplication, - window: XCUIElement - ) throws -> XCUIElement { - let row = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: \(table)")) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(table)") - clickAtCenter(row) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "\(table) produced no data grid") - XCTAssertTrue( - waitForClickableRows(in: grid), - "\(table) must load rows before a cell can be edited" - ) - return grid - } - - /// A point offset from the grid rather than a row or cell element, which XCUITest reads as - /// obscured by the columns published beside them, with `dy` clearing the 42pt header. - /// - /// The cell cursor then walks right until it stops, which lands on the table's last column - /// whatever the click hit and whatever the columns are sized to. That column is `ArtistId` on - /// Album and `SupportRepId` on Customer, both of them the reference these drive, and Return - /// opens the editor the cursor is on. - private func openPicker(in app: XCUIApplication, grid: XCUIElement) { - grid.coordinate(withNormalizedOffset: .zero) - .withOffset(CGVector(dx: 60, dy: 70)) - .click() - - for _ in 0 ..< 20 { - app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: []) - } - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - } - - private func searchField(in window: XCUIElement) -> XCUIElement { - window.searchFields.matching(identifier: "fk-picker-search").firstMatch } } diff --git a/TableProUITests/GridSelectionAcrossTabsUITests.swift b/TableProUITests/GridSelectionAcrossTabsUITests.swift deleted file mode 100644 index d3cd9eb1a5..0000000000 --- a/TableProUITests/GridSelectionAcrossTabsUITests.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// GridSelectionAcrossTabsUITests.swift -// TableProUITests -// -// Switching editor tabs destroys the outgoing tab's grid, so its selection only survives if -// something keeps it off the view. Nothing did: `handleTabChange` restored a field no code ever -// wrote, so returning to a tab replayed an empty set over whatever the reader had selected. (#2667) -// - -import XCTest - -final class GridSelectionAcrossTabsUITests: UITestCase { - /// The status readout is the assertion target because the grid's own rows cannot be queried - /// cheaply: asking for them activates the accessibility cells and makes the table view prepare - /// every row of the page. The readout says how many rows are selected and is one static text. - func testARowSelectionSurvivesLeavingTheTabAndComingBack() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The sample table must produce a grid") - XCTAssertTrue( - waitForClickableRows(in: grid), - "The sample table must have its rows in before one of them can be clicked" - ) - - let readout = window.staticTexts["result-status-readout"] - XCTAssertTrue(readout.waitToExist(timeout: 30), "The status bar must report the result") - - /// Retried rather than clicked once. `waitForClickableRows` proves the rows exist, not that - /// the grid has finished laying them out, and a click that lands early selects nothing and - /// reports no failure of its own. - XCTAssertTrue( - waitForPredicate(timeout: 30) { - if Self.reportsASelection(readout) { return true } - gridPoint(in: grid, of: window, dy: 70).click() - return Self.reportsASelection(readout) - }, - "Clicking a row must put the status bar into its selection readout, got " - + Self.describe(readout) - ) - let selected = Self.describe(readout) - - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 20), "Command T must open a second editor tab") - XCTAssertTrue( - waitForPredicate(timeout: 15) { !Self.reportsASelection(readout) }, - "The new tab has nothing selected, so it must not still show the first tab's readout" - ) - - showPreviousTab(in: app) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { Self.describe(readout) == selected }, - "Returning to the tab must restore the selection it was left with. Expected " - + "\(selected), got \(Self.describe(readout))" - ) - } - - // MARK: - Helpers - - private static func describe(_ readout: XCUIElement) -> String { - guard readout.exists else { return "" } - let value = (readout.value as? String) ?? "" - return value.isEmpty ? readout.label : value - } - - private static func reportsASelection(_ readout: XCUIElement) -> Bool { - describe(readout).localizedCaseInsensitiveContains("selected") - } - - private func mainWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - return window - } - - /// The menu item rather than its key equivalent, because the shortcut is user-rebindable and a - /// rebound one would fail this suite for a reason that has nothing to do with selection. - private func showPreviousTab(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 20), "The app must publish its menu bar") - menuBar.menuBarItems["Window"].click() - /// `firstMatch`, because the title is published more than once: the Window menu carries the - /// item and AppKit's own window-tab machinery carries another with the same title. - let item = menuBar.menuItems["Show Previous Tab"].firstMatch - XCTAssertTrue(item.waitToExist(timeout: 10), "The Window menu must offer Show Previous Tab") - item.click() - } -} diff --git a/TableProUITests/GridTimeZoneOffsetUITests.swift b/TableProUITests/GridTimeZoneOffsetUITests.swift deleted file mode 100644 index ada54d8bf4..0000000000 --- a/TableProUITests/GridTimeZoneOffsetUITests.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// GridTimeZoneOffsetUITests.swift -// TableProUITests -// -// Covers #2702: a value carrying a UTC offset reached the grid and lost it, so a -// `timestamp with time zone` read exactly like a naive one. -// - -import AppKit -import XCTest - -final class GridTimeZoneOffsetUITests: UITestCase { - /// The sample database is copied into the test sandbox before it opens, so the table this - /// creates lives and dies with the case. - /// - /// SQLite is the only engine reachable without a server, and it is enough: the column's - /// declared type is what routes the cell into date formatting, and `sqlite3_column_decltype` - /// reports `TIMESTAMP` for a `SELECT` over a declared column. A bare `SELECT '…'` would not, - /// so the value has to come from a real table. - private static let setup = """ - CREATE TABLE IF NOT EXISTS tz_offset_probe (ts TIMESTAMP); - DELETE FROM tz_offset_probe; - INSERT INTO tz_offset_probe VALUES ('2024-12-31 23:59:59+07'); - SELECT ts FROM tz_offset_probe; - """ - - private static let expected = "2024-12-31 23:59:59+07" - - func testAnOffsetBearingValueKeepsItsOffsetInTheGrid() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - paste(Self.setup, into: app) - - openRunMenu(in: app).menuItems["Run All Statements"].click() - confirmDestructiveExecution(in: app) - - guard waitForPredicate(timeout: 60, { self.offsetCell(in: app) != nil }) else { - throw XCTSkip("No result grid published a first row") - } - XCTAssertEqual( - offsetCell(in: app), - Self.expected, - "The cell must print the offset the value arrived with, not the wall clock alone" - ) - } - - // MARK: - Helpers - - /// A run that creates a table and inserts a row is a destructive operation, so the execution - /// gate puts a sheet in front of it before anything reaches the database. - private func confirmDestructiveExecution(in app: XCUIApplication) { - let confirm = app.windows.firstMatch.sheets.firstMatch.buttons["Execute"] - XCTAssertTrue(confirm.waitToExist(timeout: 15), "The execution gate must confirm the write before it runs") - confirm.click() - } - - /// Typed text would go through the editor's own bracket pairing and completion panel, which - /// rewrite parentheses and quotes as they are typed. The pasteboard reaches the same buffer - /// with the statement intact. - private func paste(_ text: String, into app: XCUIApplication) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - app.typeKey("v", modifierFlags: .command) - } - - /// Every result the run produced is searched rather than only the first, because running four - /// statements leaves four results in the tree and only the `SELECT` has a row. - /// - /// Column 1 is the first data column; the row-number gutter is not published as a cell. - private func offsetCell(in app: XCUIApplication) -> String? { - let grids = app.windows.firstMatch.tables.matching(identifier: "data-grid") - for index in 0 ..< grids.count { - let row = grids.element(boundBy: index).tableRows.firstMatch - guard row.exists else { continue } - let cell = row.staticTexts - .matching(NSPredicate(format: "label BEGINSWITH %@", "Row 1, column 1: ")) - .firstMatch - if cell.exists, let value = cell.value as? String { - return value - } - } - return nil - } - - /// The control is a split button: its leading half runs the query and only its trailing - /// chevron opens the menu, so a plain `click()` would run instead of opening. - /// - /// The opened menu is scoped to the window rather than matched by identifier, because the CI - /// runner's macOS build exposes a just-opened menu without one. - private func openRunMenu(in app: XCUIApplication) -> XCUIElement { - let window = app.windows.firstMatch - let executeMenu = window.descendants(matching: .any) - .matching(identifier: "query-run-menu") - .firstMatch - XCTAssertTrue( - waitUntilHittable(executeMenu, timeout: 10), - "The editor toolbar must expose the Run split button" - ) - executeMenu.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).click() - return window.menus.firstMatch - } -} diff --git a/TableProUITests/GroupedConnectionSwitcherUITests.swift b/TableProUITests/GroupedConnectionSwitcherUITests.swift index 22a8178cd0..ca7e49dde6 100644 --- a/TableProUITests/GroupedConnectionSwitcherUITests.swift +++ b/TableProUITests/GroupedConnectionSwitcherUITests.swift @@ -1,84 +1,59 @@ import XCTest -/// Reaching a saved connection from the switcher switches the window it is already in rather than -/// opening another, which is the promise #1311 asked for and #2097 made. -/// -/// The connections are written into the sandbox before launch instead of driven through the UI. -/// A store with no integrity tag beside it is adopted as it stands, the path an install predating -/// the tag takes, so a test can seed one without the app's own writer. -/// -/// The group they belong to is not seeded, and cannot be from here: groups live in a `UserDefaults` -/// suite, the runner is sandboxed and the app is not, so the suite resolves to -/// `/Data/Library/Preferences/com.TablePro.uitest.plist` for one and -/// `~/Library/Preferences/com.TablePro.uitest.plist` for the other. Anything this process writes -/// there, the app never reads. `ConnectionSwitcherSectionsTests` covers the grouping itself. -/// -/// The sample database supplies the main window. Switch Connection belongs to that window, so on -/// the welcome screen the shortcut reaches nothing and the seeded connections stay unreachable. +// ConnectionSwitcherSectionsTests covers the grouping; this covers reaching a saved connection from the window. final class GroupedConnectionSwitcherUITests: UITestCase { - private let savedConnectionName = "acme-staging" - - /// One window hosts every connection, so reaching a second one must not open another. - func testReachingASavedConnectionKeepsOneWindow() throws { - try seedConnections() - let app = try launchWithSampleDatabase() - let windowsBefore = app.windows.count - - app.typeKey("c", modifierFlags: [.command, .control]) + private let savedName = "Ledger" + private let savedTable = "ledger_entry" + + func testTheSwitcherReachesASavedConnectionInTheSameWindow() throws { + let launch = try launchWithFixture([ + ConnectionSeed(), + ConnectionSeed( + name: savedName, + databaseSQL: "CREATE TABLE \(savedTable) (id INTEGER PRIMARY KEY, amount INTEGER NOT NULL);", + tabs: [], + isOpen: false + ), + ]) + let app = launch.app + XCTAssertEqual(mainWindowCount(in: app), 1) + + app.typeShortcut(.switchConnection) let field = connectionSearchField(in: app) - XCTAssertTrue(field.waitToExist(timeout: 15), "The connection switcher never opened") + XCTAssertTrue(field.waitToExist(timeout: 15), "Switch Connection must open the switcher") + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue( + waitForPredicate(timeout: 5) { !field.exists }, + "Escape on an empty filter must dismiss the switcher" + ) - app.typeText(savedConnectionName) - app.typeKey(.return, modifierFlags: []) + // A stuck isPresented binding makes the second press toggle the popover off instead of showing it. + app.typeShortcut(.switchConnection) + XCTAssertTrue(field.waitToExist(timeout: 10), "The switcher must reopen after an Escape dismissal") + app.typeText(savedName) + app.typeKey(.return, modifierFlags: []) XCTAssertTrue( - field.waitForNonExistence(timeout: 10), + waitForPredicate(timeout: 10) { !field.exists }, "Activating a connection must dismiss the switcher" ) - XCTAssertEqual(app.windows.count, windowsBefore, "Switching connection must not open a window") - } - // MARK: - Fixture + XCTAssertTrue( + objectBrowserRow(savedTable, in: launch.window).waitToExist(timeout: 30), + "The window the switcher was opened from must show the saved connection" + ) + XCTAssertEqual(mainWindowCount(in: app), 1, "Switching connection must not open a window") + } - /// Matched on its placeholder, never `searchFields.firstMatch`: the object browser carries a - /// filter field of its own that is always on screen, so the loose query answers with that one - /// and every wait on it passes without the switcher ever opening. + // Matched on the placeholder: every NativeSearchField publishes the same default identifier, + // and the object browser's filter field is always on screen. private func connectionSearchField(in app: XCUIApplication) -> XCUIElement { app.searchFields.matching( NSPredicate(format: "placeholderValue BEGINSWITH[c] %@", "Search connections") ).firstMatch } - private func seedConnections() throws { - let root = try XCTUnwrap(sandboxRoot, "setUpWithError did not prepare a sandbox") - let supportDirectory = root.appendingPathComponent("TablePro", isDirectory: true) - try FileManager.default.createDirectory(at: supportDirectory, withIntermediateDirectories: true) - - let connections = ["acme-local", savedConnectionName, "acme-prod"].enumerated().map { - connectionPayload(name: $0.element, sortOrder: $0.offset) - } - try JSONSerialization.data(withJSONObject: connections, options: [.sortedKeys]) - .write(to: supportDirectory.appendingPathComponent("connections.json"), options: .atomic) - } - - /// Every key the stored form decodes without a default. A key added there without one fails - /// this test rather than silently seeding nothing, which is the failure worth having: it is - /// also the key every store already on a user's disk would be missing. - private func connectionPayload(name: String, sortOrder: Int) -> [String: Any] { - [ - "id": UUID().uuidString, - "name": name, - "host": "127.0.0.1", - "port": 3_306, - "database": "app", - "username": "root", - "type": "MySQL", - "sshEnabled": false, - "sshHost": "", - "sshUsername": "", - "sshAuthMethod": "password", - "sshPrivateKeyPath": "", - "sortOrder": sortOrder, - ] + private func mainWindowCount(in app: XCUIApplication) -> Int { + app.children(matching: .window).matching(identifier: "main").count } } diff --git a/TableProUITests/HeaderSortUITests.swift b/TableProUITests/HeaderSortUITests.swift index e97fca5370..a9bf7f0802 100644 --- a/TableProUITests/HeaderSortUITests.swift +++ b/TableProUITests/HeaderSortUITests.swift @@ -1,139 +1,42 @@ -// -// HeaderSortUITests.swift -// TableProUITests -// - import XCTest final class HeaderSortUITests: UITestCase { - /// `MediaType` is the smallest table the sample ships: five rows, two columns. Reading a cell - /// activates `DataGridCellAccessibilityView`, which makes the table mount a view for every row of - /// the page, so this suite costs whatever the table is tall. Its five rows put the server order, - /// ascending and descending on three different values, which is all the assertions need. - private static let table = "MediaType" - private static let sortedColumn = "Name" - private static let sortedColumnPosition = 2 - - /// One launch for the whole cycle and for Don't Sort, because both need the same "sorted by Name" - /// precondition and a second `launchWithSampleDatabase()` would pay the app launch and the sample - /// open again for it. + // album's titles put the server order, ascending and descending on three different first rows. func testHeaderSortCyclesBothDirectionsAndDontSortSurvivesAReload() throws { - let app = try launchWithSampleDatabase() - let grid = try openSmallTable(in: app, window: app.windows.firstMatch) - - let serverOrder = try firstSortedCell(in: grid) + let (launch, grid) = try launchWithFixtureTable("album") + XCTAssertTrue(grid.waitForValue("North", row: 1, column: 2), "album must open in the order the server sent") - try clickHeader(in: grid) - let ascending = try waitForFirstCellToChange(in: grid, from: serverOrder) + grid.clickHeader("title") + XCTAssertTrue(grid.waitForValue("Down", row: 1, column: 2), "The first click must sort ascending") - try clickHeader(in: grid) - let descending = try waitForFirstCellToChange(in: grid, from: ascending) - - /// Ordinal, not localized: `buildOrderByClause` emits no `COLLATE`, so SQLite orders under - /// BINARY and a locale-aware comparison can disagree with it on case and punctuation. - XCTAssertTrue( - ascending < descending, - "The first click must sort ascending and the second descending, got \(ascending) then \(descending)" - ) + grid.clickHeader("title") + XCTAssertTrue(grid.waitForValue("West", row: 1, column: 2), "The second click must sort descending") - try clickHeader(in: grid) + grid.clickHeader("title") XCTAssertTrue( - waitForPredicate(timeout: 20) { self.currentFirstSortedCell(in: grid) == serverOrder }, + grid.waitForValue("North", row: 1, column: 2), "A third click must return to the order the server sent" ) - try clickHeader(in: grid) - _ = try waitForFirstCellToChange(in: grid, from: serverOrder) + grid.clickHeader("title") + XCTAssertTrue(grid.waitForValue("Down", row: 1, column: 2), "A fourth click must sort ascending again") - try clickHeader(in: grid, rightClick: true) - let dontSort = contextMenuItem("Don't Sort", in: app) + grid.clickHeader("title", rightClick: true) + let dontSort = contextMenuItem("Don't Sort", in: launch.app) XCTAssertTrue(dontSort.waitToExist(timeout: 10), "The header menu must offer Don't Sort while sorting") dontSort.click() - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.currentFirstSortedCell(in: grid) == serverOrder }, + grid.waitForValue("North", row: 1, column: 2), "Don't Sort must return to the order the server sent" ) - app.typeKey("r", modifierFlags: [.command]) - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.currentFirstSortedCell(in: grid) == serverOrder }, - "A refresh must not put the app default sort back over Don't Sort" - ) - } - - // MARK: - Helpers - - /// The header is read for its geometry and clicked through a coordinate, never through the - /// element. - /// - /// A column header is one more thing inside the grid that XCUITest will not click, alongside - /// the rows and cells `gridPoint` exists for. Measured on the runner and on a developer Mac: - /// the header element exists, reports `isEnabled` true and a correct frame inside the grid, and - /// nothing in the failure's own element tree or screen recording overlaps it, yet `isHittable` - /// stays false for a full thirty seconds. `waitUntilHittable` therefore waits out its timeout - /// on a header that is on screen, which is how this suite was merged red (#2670) and stayed - /// red. A coordinate taken off the grid clicks it every time, including the right-click that - /// raises the header menu. - /// - /// The offset is measured from the grid rather than assumed, because the column's position - /// moves with the row-number gutter's width and with the sidebar. - private func clickHeader(in grid: XCUIElement, rightClick: Bool = false) throws { - let header = grid.buttons - .matching(NSPredicate(format: "label BEGINSWITH %@", "Column: \(Self.sortedColumn)")) - .firstMatch - XCTAssertTrue( - header.waitToExist(timeout: 30), - "The grid must publish a \(Self.sortedColumn) header" - ) - let frame = header.frame - let origin = grid.frame.origin - XCTAssertTrue(frame.width > 0, "The \(Self.sortedColumn) header must be laid out") - let point = grid.coordinate(withNormalizedOffset: .zero) - .withOffset(CGVector(dx: frame.midX - origin.x, dy: frame.midY - origin.y)) - if rightClick { - point.rightClick() - } else { - point.click() - } - } - - private func openSmallTable(in app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { - let row = objectBrowserRow(Self.table, in: window) - XCTAssertTrue(row.waitToExist(timeout: 30), "The object browser must list \(Self.table)") - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(waitForClickableRows(in: grid, timeout: 30), "\(Self.table) must load its rows") - return grid - } - - /// Scoped to the first row rather than the whole grid, and non-waiting so it can sit inside a - /// `waitForPredicate` without nesting one timeout inside another. Asking the grid broadly makes - /// the query walk every mounted row and column on every poll tick. - private func currentFirstSortedCell(in grid: XCUIElement) -> String? { - let row = grid.tableRows.firstMatch - guard row.exists else { return nil } - let cell = row.staticTexts - .matching(NSPredicate(format: "label BEGINSWITH %@", "Row 1, column \(Self.sortedColumnPosition): ")) - .firstMatch - guard cell.exists else { return nil } - return cell.value as? String - } - - private func firstSortedCell(in grid: XCUIElement) throws -> String { - guard waitForPredicate(timeout: 20, { self.currentFirstSortedCell(in: grid) != nil }), - let value = currentFirstSortedCell(in: grid) else { - throw XCTSkip("The grid did not publish its first row's cells") - } - return value - } - - private func waitForFirstCellToChange(in grid: XCUIElement, from previous: String) throws -> String { + // Renamed behind the app's back, so the first row shows the refresh landed and in which order. + XCTAssertTrue(FixtureFile.execute("UPDATE album SET title = 'Norte' WHERE id = 1", in: launch.database)) + launch.app.typeShortcut(.refresh) XCTAssertTrue( - waitForPredicate(timeout: 20) { self.currentFirstSortedCell(in: grid).map { $0 != previous } ?? false }, - "The click must re-run the query and change the first row, still \(previous)" + grid.waitForValue("Norte", row: 1, column: 2), + "A refresh must keep the server's order rather than put a sort back over Don't Sort, " + + "row 1 reads '\(grid.value(row: 1, column: 2) ?? "nil")'" ) - return try firstSortedCell(in: grid) } } diff --git a/TableProUITests/HighlightRulesUITests.swift b/TableProUITests/HighlightRulesUITests.swift deleted file mode 100644 index 4ba9279045..0000000000 --- a/TableProUITests/HighlightRulesUITests.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// HighlightRulesUITests.swift -// TableProUITests -// - -import AppKit -import XCTest - -final class HighlightRulesUITests: UITestCase { - func testAddingARuleFromTheStatusBarKeepsItForTheTable() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - _ = try albumGrid(in: window) - - openRulesFromStatusBar(in: window) - let add = window.buttons["highlight-rules-add"].firstMatch - XCTAssertTrue(waitUntilHittable(add, timeout: 10), "The popover must offer Add Rule") - XCTAssertFalse(ruleCheckbox(in: window).exists, "A table nobody highlighted starts with no rules") - add.click() - XCTAssertTrue(ruleCheckbox(in: window).waitToExist(timeout: 10), "Add Rule must list a new rule") - app.typeText("1") - - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(add.waitForNonExistence(timeout: 5), "Escape in the value field must close the popover") - - openRulesFromStatusBar(in: window) - XCTAssertTrue( - ruleCheckbox(in: window).waitToExist(timeout: 10), - "A rule belongs to the table, so reopening the popover lists it again" - ) - - let reopenedAdd = window.buttons["highlight-rules-add"].firstMatch - XCTAssertTrue(waitUntilHittable(reopenedAdd, timeout: 10)) - reopenedAdd.click() - XCTAssertTrue(waitForPredicate(timeout: 10) { self.ruleCheckboxes(in: window).count == 2 }) - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(reopenedAdd.waitForNonExistence(timeout: 5)) - - openRulesFromStatusBar(in: window) - XCTAssertTrue(ruleCheckbox(in: window).waitToExist(timeout: 10)) - XCTAssertEqual(ruleCheckboxes(in: window).count, 1, "A rule closed without a value is not kept") - } - - func testChangingARulesColumnKeepsTheColumnItWasGiven() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - _ = try albumGrid(in: window) - - openRulesFromStatusBar(in: window) - let add = window.buttons["highlight-rules-add"].firstMatch - XCTAssertTrue(waitUntilHittable(add, timeout: 10), "The popover must offer Add Rule") - add.click() - - let column = window.popUpButtons.matching(identifier: "highlight-rule-column").firstMatch - XCTAssertTrue(waitUntilHittable(column, timeout: 10), "A rule must offer its column") - let initial = column.value as? String - XCTAssertNotNil(initial, "The column pull-down must publish the column it is set to") - - let chosen = initial == "Title" ? "AlbumId" : "Title" - column.click() - let item = app.menuItems[chosen].firstMatch - XCTAssertTrue(waitUntilHittable(item, timeout: 10), "The column menu must offer \(chosen)") - item.click() - - XCTAssertTrue( - waitForPredicate(timeout: 10) { column.value as? String == chosen }, - "Picking \(chosen) must leave the rule on it, not revert to \(initial ?? "")" - ) - } - - func testChangingARulesOperatorKeepsTheOperatorItWasGiven() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - _ = try albumGrid(in: window) - - openRulesFromStatusBar(in: window) - let add = window.buttons["highlight-rules-add"].firstMatch - XCTAssertTrue(waitUntilHittable(add, timeout: 10), "The popover must offer Add Rule") - add.click() - - let operatorMenu = window.descendants(matching: .any) - .matching(identifier: "highlight-rule-operator").firstMatch - XCTAssertTrue(waitUntilHittable(operatorMenu, timeout: 10), "A rule must offer its operator") - XCTAssertEqual(operatorMenu.value as? String, "equals", "A new rule starts on equals") - - operatorMenu.click() - let contains = app.menuItems["contains"].firstMatch - XCTAssertTrue(waitUntilHittable(contains, timeout: 10), "The operator menu must list contains") - contains.click() - - XCTAssertTrue( - waitForPredicate(timeout: 10) { operatorMenu.value as? String == "contains" }, - "Picking contains must leave the rule on contains, not revert to equals" - ) - } - - func testTheCellMenuOffersHighlightAndOpensTheRules() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let grid = try albumGrid(in: window) - - let cell = gridPoint(in: grid, of: window, dy: 70) - cell.click() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - cell.rightClick() - - let highlight = window.menus.menuItems["Highlight"].firstMatch - XCTAssertTrue(highlight.waitToExist(timeout: 15), "A cell's context menu must offer Highlight") - highlight.hover() - - let showRules = contextMenuItem("Highlight Rules…", in: app) - XCTAssertTrue(waitUntilHittable(showRules, timeout: 10), "The Highlight submenu must offer Highlight Rules…") - showRules.click() - - XCTAssertTrue( - window.buttons["highlight-rules-add"].firstMatch.waitToExist(timeout: 10), - "Highlight Rules… must open the rules popover" - ) - } - - // MARK: - Helpers - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - return window - } - - private func albumGrid(in window: XCUIElement) throws -> XCUIElement { - let row = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: Album")) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(row) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "Album produced no data grid") - XCTAssertTrue(waitForClickableRows(in: grid), "Album must load rows before a cell can be highlighted") - return grid - } - - private func openRulesFromStatusBar(in window: XCUIElement) { - let button = window.buttons["result-status-highlight"] - XCTAssertTrue(waitUntilHittable(button, timeout: 15), "The status bar must offer Highlight") - button.click() - } - - private func ruleCheckbox(in window: XCUIElement) -> XCUIElement { - ruleCheckboxes(in: window).firstMatch - } - - private func ruleCheckboxes(in window: XCUIElement) -> XCUIElementQuery { - window.checkBoxes.matching(identifier: "highlight-rule-enabled") - } -} diff --git a/TableProUITests/ImportFromAWSUITests.swift b/TableProUITests/ImportFromAWSUITests.swift deleted file mode 100644 index 3009dfe108..0000000000 --- a/TableProUITests/ImportFromAWSUITests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import XCTest - -/// The discovery call itself needs a live AWS account, so these drive the part that does not: the -/// menu route into the sheet, and the gate that keeps Continue disabled until the request AWS -/// needs is complete. `AWS_CONFIG_FILE` points the app at a fixture so the assertions do not -/// depend on whatever profiles the machine running the test happens to have. -final class ImportFromAWSUITests: UITestCase { - private static let fixtureConfig = """ - [sso-session corp] - sso_start_url = https://example.awsapps.com/start - sso_region = us-east-1 - - [profile corp] - sso_session = corp - sso_account_id = 111122223333 - sso_role_name = ReadOnly - region = eu-west-1 - """ - - func testImportFromAWSOpensTheSheetAndGatesContinue() throws { - let app = try launchWithAWSFixture() - - openImportFromAWS(in: app) - - let profileField = app.comboBoxes["aws-import-profile"] - XCTAssertTrue( - profileField.waitToExist(timeout: 10), - "File > Import > Import from AWS must open the discovery sheet" - ) - - /// A region row is a checkbox whose label joins the region's name and its id - /// (`US East (N. Virginia), us-east-1`). The two `Text`s inside it are not published as - /// elements of their own, so matching a bare static text finds nothing. - let region = app.sheets.firstMatch.checkBoxes - .matching(NSPredicate(format: "label BEGINSWITH %@", "US East (N. Virginia)")) - .firstMatch - XCTAssertTrue(region.waitToExist(timeout: 5), "The sheet must list the AWS regions to search") - - let continueButton = app.buttons["aws-import-continue"] - XCTAssertTrue(continueButton.waitToExist(timeout: 5), "The sheet must offer Continue") - - app.buttons["Cancel"].firstMatch.click() - XCTAssertFalse( - app.buttons["aws-import-continue"].waitToExist(timeout: 3), - "Cancel must dismiss the discovery sheet" - ) - } - - func testContinueIsDisabledWithoutARegion() throws { - let app = try launchWithAWSFixture(profileRegion: false) - - openImportFromAWS(in: app) - - let continueButton = app.buttons["aws-import-continue"] - XCTAssertTrue(continueButton.waitToExist(timeout: 10), "The sheet must offer Continue") - XCTAssertFalse( - continueButton.isEnabled, - "AWS has no default region, so Continue stays disabled until one is chosen" - ) - - app.buttons["Cancel"].firstMatch.click() - } - - private func launchWithAWSFixture(profileRegion: Bool = true) throws -> XCUIApplication { - let root = try XCTUnwrap(sandboxRoot, "setUpWithError did not prepare a sandbox") - let configURL = root.appendingPathComponent("aws-config") - var contents = Self.fixtureConfig - if !profileRegion { - contents = contents.replacingOccurrences(of: "region = eu-west-1", with: "") - } - try contents.write(to: configURL, atomically: true, encoding: .utf8) - return try launchApp(environment: ["AWS_CONFIG_FILE": configURL.path]) - } - - private func openImportFromAWS(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["File"].click() - menuBar.menuItems["Import"].click() - menuBar.menuItems["Import from AWS…"].click() - } -} diff --git a/TableProUITests/InspectorEditReachesGridUITests.swift b/TableProUITests/InspectorEditReachesGridUITests.swift index 5a3ada7a9d..88f0684f52 100644 --- a/TableProUITests/InspectorEditReachesGridUITests.swift +++ b/TableProUITests/InspectorEditReachesGridUITests.swift @@ -1,78 +1,43 @@ -// -// InspectorEditReachesGridUITests.swift -// TableProUITests -// -// The row inspector recorded an edit and stopped there, so the grid went on drawing the value the -// row no longer had and the inspector dropped the typed text the next time a row was selected -// (#2851). This types into the pane and reads the cell back out of the grid. -// - import XCTest +// #2851: an inspector edit stopped at the inspector, so the grid kept drawing the old value. #3051: the +// JSON field put the previous value back after every keystroke and pushed it into the store again. final class InspectorEditReachesGridUITests: UITestCase { - func testTypingInAnInspectorFieldReachesTheGridCell() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - let grid = try openFirstTableRow(in: app, window: window) - - let field = window.textFields.firstMatch - XCTAssertTrue(field.waitToExist(timeout: 30), "The inspector must be showing an editable field") - field.click() - - let marker = "Zq7" - app.typeText(marker) - - let cell = grid - .descendants(matching: .any) - .matching(NSPredicate(format: "value CONTAINS %@", marker)) - .firstMatch + func testInspectorEditsReachTheGrid() throws { + let (launch, grid) = try launchWithFixtureTable("document") + let window = launch.window + let app = launch.app + + grid.clickCell(row: 1, column: 2) + app.typeShortcut(.toggleInspector) + + let labelField = window.textFields.matching(NSPredicate(format: "value == %@", "First")).firstMatch + XCTAssertTrue(waitUntilHittable(labelField, timeout: 30), "The inspector must show row 1's label field") + labelField.click() + app.typeText("Zq7") XCTAssertTrue( - cell.waitToExist(timeout: 30), - """ - A field edited in the inspector belongs to the same row the grid is drawing, so the \ - cell has to carry it without a save. The grid published no cell holding "\(marker)". - """ + waitForPredicate(timeout: 30) { grid.value(row: 1, column: 2)?.contains("Zq7") == true }, + "A field edited in the inspector must reach the grid cell without a save, the cell holds " + + "'\(grid.value(row: 1, column: 2) ?? "nil")'" ) - } - - // MARK: - Helpers - /// A row is as wide as the grid and a column is published as its sibling, so XCUITest reads - /// every row and cell as obscured and refuses to click either. A point offset from the grid is - /// the way in, clear of the object browser that overlaps the grid on the 1024x768 runner and - /// below the 42pt header. - private func openFirstTableRow(in app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The sample table must produce a grid") + let jsonField = window.textViews.matching(identifier: "inspector-json-field").firstMatch + XCTAssertTrue(jsonField.waitToExist(timeout: 20), "payload holds a JSON object, so it must open the JSON editor") XCTAssertTrue( - waitForClickableRows(in: grid), - "The sample table must have its rows in before one of them can be clicked" + (jsonField.value as? String)?.contains("\"seats\"") == true, + "The JSON editor must hold row 1's payload" ) + jsonField.click() + app.typeText("ZZTOP") - gridPoint(in: grid, of: window, dy: 70).click() - showInspector(in: app) - return grid - } - - private func mainWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - return window - } - - /// The inspector remembers whether it was open, so the starting state is whatever the previous - /// launch left. The View menu item reads Hide Inspector once it is showing, which is the only - /// handle on that state. - private func showInspector(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - - let show = menuBar.menuItems["Show Inspector"] - if show.waitToExist(timeout: 5) { - show.click() - return - } - app.typeKey(.escape, modifierFlags: []) + // The grid holding the text means the edit made the round trip through the store that used to undo it. + XCTAssertTrue( + waitForPredicate(timeout: 30) { grid.value(row: 1, column: 3)?.contains("ZZTOP") == true }, + "Text typed in the JSON field must reach the grid cell" + ) + XCTAssertTrue( + (jsonField.value as? String)?.contains("ZZTOP") == true, + "The JSON field must keep what was typed, it holds '\(jsonField.value as? String ?? "nil")'" + ) } } diff --git a/TableProUITests/InspectorFieldAffordanceUITests.swift b/TableProUITests/InspectorFieldAffordanceUITests.swift deleted file mode 100644 index ca54a93f9b..0000000000 --- a/TableProUITests/InspectorFieldAffordanceUITests.swift +++ /dev/null @@ -1,158 +0,0 @@ -// -// InspectorFieldAffordanceUITests.swift -// TableProUITests -// -// The inspector's value menu used to render only inside a hover overlay, so Set NULL, Set DEFAULT, -// Set EMPTY and the SQL functions were reachable by pointer and by nothing else. These drive the -// pane with no mouse events over a field at all. -// - -import XCTest - -final class InspectorFieldAffordanceUITests: UITestCase { - func testTheValueMenuIsPresentWithoutHoveringAField() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - _ = try openFirstTableRow(in: app, window: window) - - let menu = window.descendants(matching: .any)["inspector-value-menu"].firstMatch - XCTAssertTrue( - menu.waitToExist(timeout: 30), - "Every inspector field draws its own value menu. The pointer is nowhere near one here, " - + "so a hover-gated control would never appear." - ) - } - - func testTheFieldSearchIsPresentWithoutOpeningAnything() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - _ = try openFirstTableRow(in: app, window: window) - - let search = window.searchFields["inspector-field-search"] - XCTAssertTrue( - search.waitToExist(timeout: 30), - "The field search is part of the inspector, not something a menu reveals." - ) - } - - /// The inspector names what it is inspecting, above its fields and under the pane's shared - /// header. The pane carried no title at all before, because it multiplexed three unrelated - /// surfaces behind a picker. - func testTheInspectorNamesTheTableAndTheRow() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - _ = try openFirstTableRow(in: app, window: window) - - let subtitle = window.staticTexts["inspector-subject-subtitle"] - XCTAssertTrue( - subtitle.waitToExist(timeout: 30), - "The inspector reports which row of how many is selected, above its fields." - ) - } - - /// A field's value sits below its name, not beside it, so neither has to give the other room. - /// Asserted as geometry rather than as text, because whether any particular value happens to be - /// elided depends on the pane's width and on the row the runner selected, while "the editor - /// starts below the name" is true of the stacked shape at every width and for every value. - /// - /// The shape this replaced put both on one line and settled the contest with `layoutPriority` - /// on the name, so a long column name left its value showing two characters and an ellipsis. - func testAFieldsValueIsBelowItsNameRatherThanBesideIt() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - _ = try openFirstTableRow(in: app, window: window) - - let menu = window.descendants(matching: .any)["inspector-value-menu"].firstMatch - XCTAssertTrue(menu.waitToExist(timeout: 30), "The inspector must be showing a field") - - let editor = window.textFields.firstMatch - XCTAssertTrue(editor.waitToExist(timeout: 15), "The inspector must be showing a field editor") - - /// The menu sits at the trailing end of the name's line, so in the stacked shape its whole - /// height clears the editor beneath it. In the shape this replaced the two shared one line - /// and overlapped almost exactly, which is what let the name take the width from the value. - XCTAssertLessThanOrEqual( - menu.frame.maxY, - editor.frame.minY + 1, - """ - The value menu belongs to the name's line and the editor to the line below it. \ - Overlapping vertically means the row put the name and the value back on one line, \ - where a long column name leaves its value showing two characters and an ellipsis. \ - menu=\(menu.frame) editor=\(editor.frame) - """ - ) - } - - /// The editor spans the row rather than sitting in a trailing lane, so every value in the pane - /// starts at the same x whatever its column is called. - func testEveryFieldEditorStartsAtTheSameLeadingEdge() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - _ = try openFirstTableRow(in: app, window: window) - - let menu = window.descendants(matching: .any)["inspector-value-menu"].firstMatch - XCTAssertTrue(menu.waitToExist(timeout: 30), "The inspector must be showing a field") - - let editors = window.textFields.allElementsBoundByIndex.filter { $0.frame.width > 0 } - try XCTSkipIf(editors.count < 2, "This row shows fewer than two text editors") - - let leadingEdges = Set(editors.map { $0.frame.minX.rounded() }) - XCTAssertEqual( - leadingEdges.count, - 1, - """ - Every field editor starts at one x. More than one means the rows are negotiating \ - their widths separately again, which is what left nine consecutive values starting \ - at nine different positions. edges=\(leadingEdges.sorted()) - """ - ) - } - - // MARK: - Helpers - - /// A row is as wide as the grid and a column is published as its sibling, so XCUITest reads - /// every row and cell as obscured and refuses to click either. A point offset from the grid is - /// the way in. `gridPoint` is what keeps that point clear of the object browser, which overlaps - /// the grid on the 1024x768 runner, and `dy` clears the 42pt header so the click does not open - /// the column menu. The row is selected before the inspector opens, so the reveal cannot move - /// the grid out from under the coordinate. - /// - /// The rows have to be in before the click, or it lands on empty grid and selects nothing: the - /// inspector then opens on its no-selection state, which draws no field and so no value menu, - /// and the failure reads as a missing menu rather than as a missed row. That is what made this - /// suite fail on a contended runner and pass on its retry. - private func openFirstTableRow(in app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The sample table must produce a grid") - XCTAssertTrue( - waitForClickableRows(in: grid), - "The sample table must have its rows in before one of them can be clicked" - ) - - gridPoint(in: grid, of: window, dy: 70).click() - showInspector(in: app) - return grid - } - - private func mainWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - return window - } - - /// The inspector remembers whether it was open, so the starting state is whatever the previous - /// launch left. The View menu item reads Hide Inspector once it is showing, which is the only - /// handle on that state. - private func showInspector(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - - let show = menuBar.menuItems["Show Inspector"] - if show.waitToExist(timeout: 5) { - show.click() - return - } - app.typeKey(.escape, modifierFlags: []) - } -} diff --git a/TableProUITests/InspectorGeometryFieldUITests.swift b/TableProUITests/InspectorGeometryFieldUITests.swift deleted file mode 100644 index 9d56b6bf98..0000000000 --- a/TableProUITests/InspectorGeometryFieldUITests.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// InspectorGeometryFieldUITests.swift -// TableProUITests -// - -import XCTest - -/// A geometry field in the row inspector opens on a map of its value, with the stored text one -/// segment away. SQLite reports a declared type verbatim, so `POLYGON` and `POINT` columns classify -/// as spatial with no extension and no server. -final class InspectorGeometryFieldUITests: UITestCase { - private static let databaseSQL = """ - CREATE TABLE map_probe (name TEXT, shape POLYGON); - INSERT INTO map_probe VALUES ('block', 'SRID=4326;POLYGON((-122.52 37.70,-122.35 37.70,-122.35 37.83,-122.52 37.83,-122.52 37.70))'); - CREATE TABLE map_empty (name TEXT, shape POINT); - INSERT INTO map_empty VALUES ('nowhere', 'POINT EMPTY'); - """ - - private let typedMarker = "ZZTOP" - private let typedPoint = "POINT(1 2)" - - func testAGeometryFieldOpensOnItsMapWithTheTextOneSegmentAway() throws { - let (app, window) = try openFirstRow(of: "map_probe") - - let map = mapElement(in: window) - XCTAssertTrue(map.waitToExist(timeout: 30), "A drawable geometry must open on its map") - - choose("Text", in: window) - let stored = textView(holding: "POLYGON", in: window) - XCTAssertTrue(stored.waitToExist(timeout: 20), "Text must show the stored value") - XCTAssertTrue( - waitForPredicate(timeout: 10) { !map.exists }, - "The map belongs to the Map segment and must leave with it" - ) - - choose("Map", in: window) - XCTAssertTrue(map.waitToExist(timeout: 20), "Switching back must show the map again") - - choose("Text", in: window) - XCTAssertTrue(stored.waitToExist(timeout: 20)) - stored.click() - app.typeText(typedMarker) - /// Looked up by what was typed: the caret lands wherever the click did, which may split the - /// keyword the first lookup matched on. - XCTAssertTrue( - textView(holding: typedMarker, in: window).waitToExist(timeout: 20), - "The text must stay editable, so what is typed has to land in it" - ) - } - - /// The mode is decided when the field appears. One that followed the text would replace the - /// editor with a map at the closing parenthesis, under the caret. - func testTypingADrawableValueLeavesTheFieldOnText() throws { - let (app, window) = try openFirstRow(of: "map_empty") - - let stored = textView(holding: "POINT EMPTY", in: window) - XCTAssertTrue( - stored.waitToExist(timeout: 30), - "An empty geometry has nothing to draw, so its field opens on Text" - ) - XCTAssertTrue(modePicker(in: window).exists, "The field still offers its Map segment") - let map = mapElement(in: window) - XCTAssertFalse(map.exists, "Nothing is drawn for an empty geometry") - - stored.click() - app.typeKey("a", modifierFlags: .command) - app.typeText(typedPoint) - - let typed = textView(holding: typedPoint, in: window) - XCTAssertTrue(typed.waitToExist(timeout: 20), "The typed value must land in the field") - XCTAssertFalse(map.waitToExist(timeout: 3), "A value that became drawable must not move the field to Map") - XCTAssertTrue(typed.exists, "The editor being typed in must stay on screen") - - choose("Map", in: window) - XCTAssertTrue(map.waitToExist(timeout: 20), "The map draws the typed value before it is saved") - } - - // MARK: - Helpers - - private func openFirstRow(of table: String) throws -> (XCUIApplication, XCUIElement) { - try seedSQLiteSession(connectionNames: ["Geometry"], databaseSQL: Self.databaseSQL) - let app = try launchApp() - let window = app.windows.firstMatch - - let row = objectBrowserRow(table, in: window) - XCTAssertTrue(row.waitToExist(timeout: 60), "The restored connection must list \(table)") - clickAtCenter(row) - showInspector(in: app) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "\(table) must produce a grid") - XCTAssertTrue(waitForClickableRows(in: grid), "\(table) must return its row") - /// Past the 28pt header and inside the first row at every row height the setting offers. - gridPoint(in: grid, of: window, dy: 40).click() - return (app, window) - } - - private func textView(holding text: String, in window: XCUIElement) -> XCUIElement { - window.textViews.matching(NSPredicate(format: "value CONTAINS %@", text)).firstMatch - } - - private func mapElement(in window: XCUIElement) -> XCUIElement { - window.descendants(matching: .any)["inspector-geometry-map"].firstMatch - } - - private func modePicker(in window: XCUIElement) -> XCUIElement { - window.radioGroups["inspector-geometry-mode"].firstMatch - } - - private func choose(_ segment: String, in window: XCUIElement) { - let button = modePicker(in: window).radioButtons[segment].firstMatch - XCTAssertTrue(waitUntilHittable(button, timeout: 20), "The field must offer a \(segment) segment") - button.click() - } - - /// The inspector remembers whether it was open, and the menu item reads Hide Inspector once it - /// is, which is the only handle on that state. - private func showInspector(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - - let show = menuBar.menuItems["Show Inspector"] - if show.waitToExist(timeout: 5) { - show.click() - return - } - app.typeKey(.escape, modifierFlags: []) - } -} diff --git a/TableProUITests/InspectorLongTextFieldUITests.swift b/TableProUITests/InspectorLongTextFieldUITests.swift deleted file mode 100644 index 13d1a019c9..0000000000 --- a/TableProUITests/InspectorLongTextFieldUITests.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// InspectorLongTextFieldUITests.swift -// TableProUITests -// -// The row inspector rendered a plain string in an NSTextField, which carries no scroll view at -// any axis setting, so a value past the line limit was clipped with no way to reach the rest. -// The element type is what separates the fix from the bug: a text view scrolls, a text field -// cannot, and both report the same accessibility value. -// - -import XCTest - -final class InspectorLongTextFieldUITests: UITestCase { - /// 1,998 characters on one line, produced by the sample database itself so the test needs no - /// fixture. XCUITest synthesizes typing at roughly 113ms per character, so the query is short - /// even though its result is not. - private let query = "SELECT hex(zeroblob(999));" - private let shortestAcceptedValue = 1_500 - - func testALongValueOpensInAScrollingTextViewInTheInspector() throws { - let app = try launchWithSampleDatabase() - let editor = try runQuery(in: app) - - let window = app.windows.firstMatch - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The query must produce a result grid") - XCTAssertTrue( - waitForPredicate(timeout: 30) { !grid.tableRows.allElementsBoundByIndex.isEmpty }, - "The query must return a row; the editor holds '\(editor.value as? String ?? "nil")'" - ) - - showInspector(in: app) - clickAtCenter(grid.tableRows.firstMatch) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.longValueTextView(in: window) != nil }, - "A \(shortestAcceptedValue)+ character value must render in a text view, which " - + "scrolls, rather than in a text field, which clips. Text view lengths present: " - + "\(textViewLengths(in: window))" - ) - } - - /// The editor has live autocompletion, and it costs this flow twice. Typing into a tab that is - /// not ready yet loses and reorders characters, and a suggestion list still open when Command - /// Return arrives takes the Return as an acceptance, so the run executed - /// `SELECT hex(zeroblob(999));Total`. Waiting for the empty editor, then for the exact query, - /// then dismissing the list, is what makes it deterministic; without it the failure surfaces - /// much later as a query that returned no rows. - private func runQuery(in app: XCUIApplication) throws -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 15)) - XCTAssertTrue( - waitForValue("", in: editor, timeout: 15), - "A new tab starts with an empty editor; got '\(editor.value as? String ?? "nil")'" - ) - - app.typeText(query) - XCTAssertTrue( - waitForValue(query, in: editor, timeout: 15), - "Every typed character must land in the editor; got '\(editor.value as? String ?? "nil")'" - ) - - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue( - waitForValue(query, in: editor, timeout: 5), - "Dismissing the suggestion list must leave the query alone; got " - + "'\(editor.value as? String ?? "nil")'" - ) - - app.typeKey(.return, modifierFlags: .command) - return editor - } - - /// The inspector remembers whether it was open, so the starting state is whatever the previous - /// launch left. The View menu item reads Hide Inspector once it is showing, which is the only - /// handle on that state. - private func showInspector(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - - let show = menuBar.menuItems["Show Inspector"] - if show.waitToExist(timeout: 5) { - show.click() - return - } - app.typeKey(.escape, modifierFlags: []) - } - - private func waitForValue(_ expected: String, in element: XCUIElement, timeout: TimeInterval) -> Bool { - waitForPredicate(timeout: timeout) { (element.value as? String) == expected } - } - - private func longValueTextView(in window: XCUIElement) -> XCUIElement? { - window.textViews.allElementsBoundByIndex.first { element in - (element.value as? String)?.count ?? 0 >= shortestAcceptedValue - } - } - - private func textViewLengths(in window: XCUIElement) -> [Int] { - window.textViews.allElementsBoundByIndex.map { ($0.value as? String)?.count ?? -1 } - } -} diff --git a/TableProUITests/InvisibleCharacterUITests.swift b/TableProUITests/InvisibleCharacterUITests.swift deleted file mode 100644 index 7d613b88b5..0000000000 --- a/TableProUITests/InvisibleCharacterUITests.swift +++ /dev/null @@ -1,59 +0,0 @@ -import XCTest - -final class InvisibleCharacterUITests: UITestCase { - func testControlChordDoesNotTypeABackspaceCharacter() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText("SELECT 1") - app.typeKey("h", modifierFlags: [.control, .option]) - app.typeText(" AS a") - - XCTAssertTrue( - waitForValue("SELECT 1 AS a", in: editor), - "Control+Option+H must not leave U+0008 in the query; editor holds " - + "'\(debugValue(of: editor))'" - ) - } - - func testRemoveInvisibleCharactersCleansTheQuery() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText("SELECT") - app.typeKey(" ", modifierFlags: .option) - app.typeText("1") - XCTAssertTrue( - waitForValue("SELECT\u{A0}1", in: editor), - "Option+Space types a non-breaking space; editor holds '\(debugValue(of: editor))'" - ) - - let item = app.menuBars.menuItems["Remove Invisible Characters"] - XCTAssertTrue(item.waitToExist(timeout: 10), "Query > Remove Invisible Characters must exist") - item.click() - - XCTAssertTrue( - waitForValue("SELECT 1", in: editor), - "The non-breaking space must become a space; editor holds '\(debugValue(of: editor))'" - ) - } - - private func openQueryTab(in app: XCUIApplication) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue(waitForValue("", in: editor), "A new tab starts with an empty editor") - return editor - } - - private func waitForValue(_ expected: String, in element: XCUIElement) -> Bool { - waitForPredicate(timeout: 10) { (element.value as? String) == expected } - } - - private func debugValue(of element: XCUIElement) -> String { - let value = (element.value as? String) ?? "nil" - return value.unicodeScalars.map { scalar in - scalar.isASCII && scalar.value >= 0x20 ? String(scalar) : String(format: "", scalar.value) - }.joined() - } -} diff --git a/TableProUITests/JSONRowInspectorUITests.swift b/TableProUITests/JSONRowInspectorUITests.swift deleted file mode 100644 index 47c2c488ba..0000000000 --- a/TableProUITests/JSONRowInspectorUITests.swift +++ /dev/null @@ -1,165 +0,0 @@ -// -// JSONRowInspectorUITests.swift -// TableProUITests -// -// The JSON tab shows the selected row as JSON, a foreign key in it fetches the row it -// references, and a value that is a link offers to open it. Chinook's Album.ArtistId is the -// reference this drives. -// - -import AppKit -import XCTest - -final class JSONRowInspectorUITests: UITestCase { - func testShowRowAsJSONOpensTheInspectorOnTheJSONTab() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let grid = try albumGrid(in: app, window: window) - - openRowAsJSON(in: window, grid: grid) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.jsonTab(in: window).exists }, - "Show Row as JSON must reveal the inspector's JSON tab" - ) - XCTAssertTrue( - waitForPredicate(timeout: 20) { window.staticTexts["\"Title\""].exists }, - "The JSON tab must print the row's own keys; Album has a Title column" - ) - } - - func testExpandingAForeignKeyFetchesTheRowItReferences() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - let grid = try albumGrid(in: app, window: window) - - openRowAsJSON(in: window, grid: grid) - - /// Album's own keys hold no container, so the only closed disclosure in the tree is the - /// foreign key on ArtistId. Its expansion is a query, which is the whole point of the test. - let expand = window.buttons["Expand"] - XCTAssertTrue( - expand.waitToExist(timeout: 20), - "A foreign key column must offer a disclosure control of its own" - ) - clickAtCenter(expand) - - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.staticTexts["\"Name\""].exists }, - "Expanding Album.ArtistId must fetch the Artist row, whose columns include Name" - ) - } - - /// Chinook holds no address, so the row comes from a database the test builds. Six rows, so - /// the point `openRowAsJSON` clicks lands on one whichever row that is. - private static let bookmarks = """ - CREATE TABLE bookmark (id INTEGER PRIMARY KEY, site TEXT); - INSERT INTO bookmark (site) VALUES - ('https://example.com/docs/1'), ('https://example.com/docs/2'), ('https://example.com/docs/3'), - ('https://example.com/docs/4'), ('https://example.com/docs/5'), ('https://example.com/docs/6'); - """ - - func testALinkValueOffersOpenLinkInItsRowMenu() throws { - try seedSQLiteSession(connectionNames: ["Bookmarks"], databaseSQL: Self.bookmarks) - let app = try launchApp() - let window = app.windows.firstMatch - - let table = objectBrowserRow("bookmark", in: window) - XCTAssertTrue(table.waitToExist(timeout: 60), "The restored connection must list bookmark") - clickAtCenter(table) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "bookmark produced no data grid") - XCTAssertTrue( - waitForClickableRows(in: grid), - "bookmark must load rows before a row can be inspected" - ) - - openRowAsJSON(in: window, grid: grid) - - /// Selectable text publishes the key twice, nested, so a coordinate needs one of them. - let key = window.staticTexts["\"site\""].firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 20) { key.exists }, - "The JSON tab must print the site column" - ) - - /// A right-click on selectable text raises the text's own menu, measured. The row's menu - /// answers on the row around it, and the gutter left of the key is row and nothing else. - key.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0.5)) - .withOffset(CGVector(dx: -7, dy: 0)) - .rightClick() - - /// Found, never clicked: the default opener would hand the address to the browser. - XCTAssertTrue( - contextMenuItem("Open Link", in: app).waitToExist(timeout: 15), - "A value that is a link must offer Open Link in its row's menu" - ) - XCTAssertTrue( - contextMenuItem("Copy Link", in: app).exists, - "A value that is a link must offer Copy Link beside Open Link" - ) - app.typeKey(.escape, modifierFlags: []) - } - - // MARK: - Helpers - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - return window - } - - private func albumGrid(in app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { - let row = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: Album")) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(row) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "Album produced no data grid") - XCTAssertTrue( - waitForClickableRows(in: grid), - "Album must load rows before a row can be inspected" - ) - return grid - } - - /// The grid publishes a column as a sibling of its rows, each as tall as every row it spans, so - /// XCUITest reads every row and cell as obscured and refuses to click one. A point offset from - /// the grid itself is the way in, which is what the drawn-cell grid's other suites do too. - /// - /// `dy` has to clear the header, which the grid draws at 42pt to fit a column comment. A point - /// inside it opens the header's own column menu, which offers no row command at all. `dx` comes - /// from `gridPoint`, which clears the object browser: the browser overlaps the grid's leading - /// edge on the 1024x768 runner, so a fixed offset right-clicks the browser and opens its menu - /// instead of the row's. - private func openRowAsJSON(in window: XCUIElement, grid: XCUIElement) { - let firstRow = gridPoint(in: grid, of: window, dy: 70) - firstRow.click() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - firstRow.rightClick() - - /// A contextual menu opens inside the window and the menu-bar menus hang off `MenuBar`, so - /// scoping to the window isolates the one that just opened rather than searching both. - /// - /// The item is matched across the window's menus rather than inside `menus.firstMatch`: - /// every inspector field now draws its own value menu, so the first menu under the window - /// is no longer reliably the contextual one that just opened. - let item = window.menus.menuItems["Show Row as JSON"].firstMatch - XCTAssertTrue(item.waitToExist(timeout: 15), "The row's context menu must offer Show Row as JSON") - item.click() - } - - /// The JSON rendering's own filter field, which only that rendering draws. The Fields / JSON - /// choice moved into the pane header's menu, where it is not on screen to be found, so the test - /// asks for the rendering it selects rather than for the control that selected it. - private func jsonTab(in window: XCUIElement) -> XCUIElement { - window.searchFields["json-row-filter"] - } -} diff --git a/TableProUITests/JSONTreeSelectionUITests.swift b/TableProUITests/JSONTreeSelectionUITests.swift deleted file mode 100644 index 560b644d4a..0000000000 --- a/TableProUITests/JSONTreeSelectionUITests.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// JSONTreeSelectionUITests.swift -// TableProUITests -// -// The JSON viewer's Tree mode selects rows like any outline: a click selects, Command-C copies -// the row, a link row offers Open Link, and Escape clears the selection before it closes the -// popover. -// -// The value comes from a query of a literal, so it is read-only and opens the viewer rather than -// the editor. Chinook has no JSON value with a link in it. -// - -import AppKit -import XCTest - -final class JSONTreeSelectionUITests: UITestCase { - private static let query = #"SELECT '{"name":"Acme","site":"https://example.com/docs"}' AS payload;"# - - func testCommandCCopiesTheSelectedRow() throws { - let app = try launchWithSampleDatabase() - let (popover, outline) = try openTreeViewer(in: app) - let nameRow = outline.outlineRows.element(boundBy: 0) - - clickAtCenter(nameRow) - XCTAssertTrue( - waitForPredicate(timeout: 10) { nameRow.isSelected }, - "A click on a tree row must select it" - ) - app.typeKey("c", modifierFlags: .command) - - /// The copy is read back inside the app: pasted into the tree's own filter field. - let filter = popover.searchFields["tree-filter"].firstMatch - XCTAssertTrue(filter.waitToExist(timeout: 10), "The tree must keep its filter field") - filter.click() - app.typeKey("v", modifierFlags: .command) - XCTAssertTrue( - waitForPredicate(timeout: 10) { (filter.value as? String) == "Acme" }, - "Command-C on the name row must copy its value; the filter holds '\(filter.value as? String ?? "nil")'" - ) - } - - func testALinkRowOffersOpenLink() throws { - let app = try launchWithSampleDatabase() - let (_, outline) = try openTreeViewer(in: app) - let siteRow = outline.outlineRows.element(boundBy: 1) - - siteRow.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).rightClick() - - XCTAssertTrue( - contextMenuItem("Open Link", in: app).waitToExist(timeout: 10), - "The menu of a row whose value is a link must offer Open Link" - ) - XCTAssertTrue(contextMenuItem("Copy Link", in: app).exists, "and Copy Link beside it") - app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) - } - - func testEscapeClearsTheSelectionBeforeItClosesThePopover() throws { - let app = try launchWithSampleDatabase() - let (popover, outline) = try openTreeViewer(in: app) - let nameRow = outline.outlineRows.element(boundBy: 0) - - clickAtCenter(nameRow) - XCTAssertTrue(waitForPredicate(timeout: 10) { nameRow.isSelected }, "A click on a tree row must select it") - - app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) - XCTAssertTrue( - waitForPredicate(timeout: 10) { !nameRow.isSelected }, - "The first Escape must clear the row selection" - ) - XCTAssertTrue(popover.exists, "The first Escape must leave the popover open") - - app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) - XCTAssertTrue( - waitForPredicate(timeout: 10) { !popover.exists }, - "With nothing selected, Escape must close the popover" - ) - } - - // MARK: - Helpers - - /// Runs the query, shows its column as JSON, opens the cell with Return and switches the - /// viewer to Tree. - private func openTreeViewer(in app: XCUIApplication) throws -> (popover: XCUIElement, outline: XCUIElement) { - let window = app.windows.firstMatch - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10), "A new tab must open on a query editor") - editor.click() - paste(Self.query, into: app) - XCTAssertTrue( - waitForPredicate(timeout: 5) { (editor.value as? String)?.contains("payload") == true }, - "The editor must hold the query; got '\(editor.value as? String ?? "nil")'" - ) - - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: .command) - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 20), "The query must produce a result grid") - XCTAssertTrue(waitForClickableRows(in: grid), "The query must return its row") - - showAsJSON(column: "payload", in: grid, app: app) - - gridPoint(in: grid, of: window, dy: 52).click() - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - - let popover = window.popovers.firstMatch - XCTAssertTrue(popover.waitToExist(timeout: 15), "Return on a JSON cell must open the JSON viewer") - let tree = popover.radioButtons["Tree"].firstMatch - let treeSegment = tree.exists ? tree : popover.buttons["Tree"].firstMatch - XCTAssertTrue(treeSegment.waitToExist(timeout: 10), "The viewer must offer a Tree mode") - treeSegment.click() - - let outline = popover.outlines.matching(identifier: "tree-outline").firstMatch - XCTAssertTrue(outline.waitToExist(timeout: 10), "Tree mode must show the value as an outline") - XCTAssertTrue( - waitForPredicate(timeout: 10) { outline.outlineRows.count >= 2 }, - "The outline must list the name and site keys" - ) - return (popover, outline) - } - - /// A header is clicked through a coordinate taken off the grid, as `HeaderSortUITests` explains. - private func showAsJSON(column: String, in grid: XCUIElement, app: XCUIApplication) { - let header = grid.buttons - .matching(NSPredicate(format: "label BEGINSWITH %@", "Column: \(column)")) - .firstMatch - XCTAssertTrue(header.waitToExist(timeout: 20), "The grid must publish a \(column) header") - let frame = header.frame - let origin = grid.frame.origin - grid.coordinate(withNormalizedOffset: .zero) - .withOffset(CGVector(dx: frame.midX - origin.x, dy: frame.midY - origin.y)) - .rightClick() - - let displayAs = contextMenuItem("Display As", in: app) - XCTAssertTrue(displayAs.waitToExist(timeout: 10), "The header menu of a text column must offer Display As") - displayAs.click() - let json = contextMenuItem("JSON", in: app) - XCTAssertTrue(json.waitToExist(timeout: 10), "Display As must offer JSON for a text column") - json.click() - } - - /// Typed text would go through the editor's quote and bracket pairing, which rewrites a JSON - /// literal as it is typed. The pasteboard reaches the editor with the statement intact. - private func paste(_ text: String, into app: XCUIApplication) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - app.typeKey("v", modifierFlags: .command) - } -} diff --git a/TableProUITests/LicenseSettingsUITests.swift b/TableProUITests/LicenseSettingsUITests.swift deleted file mode 100644 index efc30fe362..0000000000 --- a/TableProUITests/LicenseSettingsUITests.swift +++ /dev/null @@ -1,55 +0,0 @@ -import XCTest - -/// The License pane as an unlicensed install sees it, which is what a UI test run always is: -/// `UITestCase` launches against throwaway storage with no license activated. -final class LicenseSettingsUITests: UITestCase { - func testLicensePaneOffersActivationWhenUnlicensed() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let settingsMenuItem = app.menuBars.menuItems["Settings…"] - XCTAssertTrue(settingsMenuItem.waitToExist(timeout: 10)) - settingsMenuItem.click() - - let licensePaneButton = app.toolbars.buttons["License"] - XCTAssertTrue(licensePaneButton.waitToExist(timeout: 10)) - licensePaneButton.click() - - let keyField = app.textFields["license-key-field"] - XCTAssertTrue(keyField.waitToExist(timeout: 10), "An unlicensed pane must offer a key field") - - let activateButton = app.buttons["license-activate-button"] - XCTAssertTrue(activateButton.exists) - XCTAssertFalse(activateButton.isEnabled, "Activate stays disabled until a key is typed") - - XCTAssertFalse( - app.staticTexts["Devices"].exists, - "Seats belong to a license; an unlicensed pane has none to show" - ) - } - - func testSyncMovedOutOfTheLicensePane() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let settingsMenuItem = app.menuBars.menuItems["Settings…"] - XCTAssertTrue(settingsMenuItem.waitToExist(timeout: 10)) - settingsMenuItem.click() - - let syncPaneButton = app.toolbars.buttons["Sync"] - XCTAssertTrue(syncPaneButton.waitToExist(timeout: 10), "Sync now owns its own pane") - syncPaneButton.click() - - XCTAssertTrue(app.staticTexts["iCloud Sync"].waitToExist(timeout: 10)) - - let licensePaneButton = app.toolbars.buttons["License"] - XCTAssertTrue(licensePaneButton.exists) - licensePaneButton.click() - - XCTAssertTrue(app.textFields["license-key-field"].waitToExist(timeout: 10)) - XCTAssertFalse( - app.staticTexts["iCloud Sync"].exists, - "Sync left the license pane; being gated by a license is not a reason to live in it" - ) - } -} diff --git a/TableProUITests/LoadableExtensionListUITests.swift b/TableProUITests/LoadableExtensionListUITests.swift deleted file mode 100644 index a0bf3911bf..0000000000 --- a/TableProUITests/LoadableExtensionListUITests.swift +++ /dev/null @@ -1,86 +0,0 @@ -import XCTest - -/// The Extensions list is a connection field the app renders with its own editor, in its own -/// section of the Options tab, and only for the types whose plugin declares one. Nothing below the -/// view model proves the section reaches the screen for SQLite and stays off it for other engines, -/// so this drives the real form. -/// -/// Adding a row goes through the system file panel, which a UI test cannot drive deterministically: -/// the panel's content types dim anything that is not a library and no library sits at a fixed path -/// on every runner. The list's own add, remove and reorder arithmetic is covered by -/// `LoadableExtensionListModelTests` instead. -final class LoadableExtensionListUITests: UITestCase { - private let addButton = "connection-extensions-add" - private let removeButton = "connection-extensions-remove" - - func testSQLiteOptionsOfferAnEmptyExtensionsList() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "SQLite", in: app) - selectConnectionFormTab("options", in: form) - - let add = form.buttons[addButton] - XCTAssertTrue(add.waitToExist(timeout: 10), "SQLite's Options tab should offer an Extensions list") - XCTAssertEqual(add.label, "Add Extension…") - XCTAssertTrue(add.isEnabled) - - let remove = form.buttons[removeButton] - XCTAssertTrue(remove.exists) - XCTAssertEqual(remove.label, "Remove Extension") - XCTAssertFalse(remove.isEnabled, "Remove should be dimmed while nothing is selected") - - XCTAssertTrue( - text("No Extensions", in: form).waitToExist(timeout: 5), - "An empty list should say so" - ) - } - - func testMySQLOptionsOfferNoExtensionsList() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let form = try openConnectionForm(for: "MySQL", in: app) - selectConnectionFormTab("options", in: form) - - let startupCommands = form.descendants(matching: .any) - .matching(identifier: "connection-form-startup-commands") - .firstMatch - XCTAssertTrue(startupCommands.waitToExist(timeout: 10), "The Options tab should have loaded") - XCTAssertFalse(form.buttons[addButton].exists, "MySQL declares no extension list") - } - - /// SwiftUI text carries its string in `value` with no label, so a subscript by title finds nothing. - private func text(_ value: String, in form: XCUIElement) -> XCUIElement { - form.staticTexts.matching(NSPredicate(format: "value == %@", value)).firstMatch - } - - private func openConnectionForm(for driver: String, in app: XCUIApplication) throws -> XCUIElement { - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 10)) - newConnection.click() - - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") - let search = sheet.searchFields.firstMatch - XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") - XCTAssertTrue(waitUntilHittable(search, timeout: 10)) - search.click() - search.typeText(driver) - XCTAssertTrue( - waitForPredicate(timeout: 10) { (search.value as? String) == driver }, - "Typing should reach the chooser's search field" - ) - - let row = sheet.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", driver)) - .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 10), "The chooser should list \(driver)") - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let form = app.windows["connection-form"] - XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing \(driver) should open the connection form") - return form - } -} diff --git a/TableProUITests/MoveTabToConnectionUITests.swift b/TableProUITests/MoveTabToConnectionUITests.swift deleted file mode 100644 index 5f2610589a..0000000000 --- a/TableProUITests/MoveTabToConnectionUITests.swift +++ /dev/null @@ -1,106 +0,0 @@ -import XCTest - -/// Move Tab to Connection takes the query tab the user is in to another connection, without running -/// it, and the connection it left no longer holds it. -/// -/// The target is a saved SQLite connection seeded into the sandbox on an empty file, which SQLite -/// opens as an empty database. See `GroupedConnectionSwitcherUITests` for why seeding goes through -/// the connection store rather than the UI. -final class MoveTabToConnectionUITests: UITestCase { - private let targetName = "move-target" - private let sampleName = "Chinook (Sample)" - private let query = "SELECT 42 AS moved" - - func testMovedQueryTabArrivesOnTheOtherConnectionAndLeavesTheFirst() throws { - try seedTargetConnection() - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - app.typeKey("t", modifierFlags: .command) - typeQuery(query, in: app) - - app.typeKey("c", modifierFlags: [.command, .control, .option]) - let field = connectionSearchField(in: app) - XCTAssertTrue(field.waitToExist(timeout: 15), "Move Tab to Connection never opened its picker") - /// Any element type: the header trait publishes the title as a heading, not static text. - XCTAssertTrue( - app.descendants(matching: .any)["connection-switcher-move-header"].waitToExist(timeout: 5), - "The picker must say it is moving a tab, not switching connection" - ) - - app.typeText(targetName) - app.typeKey(.return, modifierFlags: []) - XCTAssertTrue(field.waitForNonExistence(timeout: 10), "Picking a connection must dismiss the picker") - - let targetRow = railCell(named: targetName, in: window) - XCTAssertTrue( - waitForPredicate(timeout: 30) { targetRow.isSelected }, - "The window must switch to the connection the tab moved to" - ) - let editor = editorTextView(in: app) - XCTAssertTrue( - waitForPredicate(timeout: 15) { (editor.value as? String) == query }, - "The moved tab must hold the SQL it left with" - ) - - app.typeKey("c", modifierFlags: [.command, .control]) - XCTAssertTrue(field.waitToExist(timeout: 15), "The connection switcher never opened") - app.typeText(sampleName) - app.typeKey(.return, modifierFlags: []) - XCTAssertTrue(field.waitForNonExistence(timeout: 10)) - let sampleRow = railCell(named: sampleName, in: window) - XCTAssertTrue( - waitForPredicate(timeout: 30) { sampleRow.isSelected }, - "Switching back must show the sample connection" - ) - let sampleEditor = editorTextView(in: app) - XCTAssertFalse( - sampleEditor.exists && (sampleEditor.value as? String) == query, - "The connection the tab left must not still hold it" - ) - } - - // MARK: - Fixture - - /// The connections rail selects the connection the window shows. - private func railCell(named name: String, in window: XCUIElement) -> XCUIElement { - window.tables["workspace-rail"].cells - .matching(NSPredicate(format: "label BEGINSWITH %@", name)) - .firstMatch - } - - /// Matched on its placeholder, never `searchFields.firstMatch`: the object browser has a filter - /// field of its own that is always on screen. - private func connectionSearchField(in app: XCUIApplication) -> XCUIElement { - app.searchFields.matching( - NSPredicate(format: "placeholderValue BEGINSWITH[c] %@", "Search connections") - ).firstMatch - } - - private func seedTargetConnection() throws { - let root = try XCTUnwrap(sandboxRoot, "setUpWithError did not prepare a sandbox") - let supportDirectory = root.appendingPathComponent("TablePro", isDirectory: true) - try FileManager.default.createDirectory(at: supportDirectory, withIntermediateDirectories: true) - - let databaseURL = root.appendingPathComponent("\(targetName).sqlite") - try Data().write(to: databaseURL) - - let connection: [String: Any] = [ - "id": UUID().uuidString, - "name": targetName, - "host": "", - "port": 0, - "database": databaseURL.path, - "username": "", - "type": "SQLite", - "sshEnabled": false, - "sshHost": "", - "sshUsername": "", - "sshAuthMethod": "password", - "sshPrivateKeyPath": "", - "sortOrder": 0, - ] - try JSONSerialization.data(withJSONObject: [connection], options: [.sortedKeys]) - .write(to: supportDirectory.appendingPathComponent("connections.json"), options: .atomic) - } -} diff --git a/TableProUITests/NavigationHistoryUITests.swift b/TableProUITests/NavigationHistoryUITests.swift deleted file mode 100644 index c305965718..0000000000 --- a/TableProUITests/NavigationHistoryUITests.swift +++ /dev/null @@ -1,131 +0,0 @@ -import AppKit -import XCTest - -/// Issue #2316. Following a reference used to be one-way: the view you came from was gone, with no -/// control anywhere that brought it back. Back and Forward are driven here through the View menu's -/// own items rather than the toolbar buttons: the item has to validate as enabled, and then its key -/// equivalent goes through `NSMenu.performKeyEquivalent`, which validates it again and sends its -/// action. -final class NavigationHistoryUITests: UITestCase { - func testBackReturnsToTheTableTheTabWasRetargetedAwayFrom() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - click(row("Album", in: window)) - XCTAssertTrue( - waitForPredicate(timeout: 20) { title(of: window).contains("Album") }, - "The tab must be showing Album before anything retargets it. Title: \(title(of: window))" - ) - - click(row("Artist", in: window)) - XCTAssertTrue( - waitForPredicate(timeout: 20) { title(of: window).contains("Artist") }, - "Clicking a second table must retarget the preview tab. Title: \(title(of: window))" - ) - - try performViewMenuItem("Back", keyEquivalent: "[", in: app) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { title(of: window).contains("Album") }, - "Back must put the table the tab was retargeted away from back. Title: \(title(of: window))" - ) - } - - func testBackIsDisabledUntilSomethingHasBeenNavigatedAwayFrom() throws { - let app = try launchWithSampleDatabase() - _ = try readyWindow(of: app) - - let back = try viewMenuItem("Back", in: app) - XCTAssertFalse( - back.isEnabled, - "Back must be offered and disabled, not hidden, while the tab has no history" - ) - app.typeKey(.escape, modifierFlags: []) - } - - func testForwardReturnsToTheTableBackSteppedAwayFrom() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - click(row("Album", in: window)) - _ = waitForPredicate(timeout: 20) { title(of: window).contains("Album") } - click(row("Artist", in: window)) - _ = waitForPredicate(timeout: 20) { title(of: window).contains("Artist") } - - try performViewMenuItem("Back", keyEquivalent: "[", in: app) - XCTAssertTrue(waitForPredicate(timeout: 20) { title(of: window).contains("Album") }) - - try performViewMenuItem("Forward", keyEquivalent: "]", in: app) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { title(of: window).contains("Artist") }, - "Forward must return to the table Back stepped away from. Title: \(title(of: window))" - ) - } - - // MARK: - Helpers - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - return window - } - - /// The tree draws its rows as hosted cells, so the name arrives as the static text's `value` - /// rather than as a label, and the rows themselves report as disabled. Matching on `value` is - /// what finds them; clicking through a coordinate is what reaches them. - private func row(_ name: String, in window: XCUIElement) -> XCUIElement { - let match = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: \(name)")) - .firstMatch - XCTAssertTrue(match.waitToExist(timeout: 20), "The object browser must list \(name)") - return match - } - - private func click(_ element: XCUIElement) { - clickAtCenter(element) - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - } - - private func title(of window: XCUIElement) -> String { - window.title - } - - private func viewMenuItem(_ name: String, in app: XCUIApplication) throws -> XCUIElement { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - let viewMenu = menuBar.menuBarItems["View"] - XCTAssertTrue(viewMenu.waitToExist(timeout: 10)) - viewMenu.click() - let item = viewMenu.menuItems[name] - XCTAssertTrue(item.waitToExist(timeout: 10), "View must offer \(name)") - return item - } - - /// Waits for the item to validate as enabled in the open menu, closes the menu, and sends the - /// item's key equivalent rather than clicking it. - /// - /// The View menu is 878pt tall, and on the runner's 1024x768 screen it gets 671pt, so AppKit - /// makes it scroll. Forward sits under the bottom scroll zone: XCUITest hovers it, the hover - /// scrolls the menu, and the click lands where Forward used to be, on the disabled Show - /// Previous Connection, or at no point at all once the menu has scrolled to its end. Nothing - /// fails there, the menu stays open and Forward is never sent (runs 35345089994, 35363502950 - /// and 35375539350). A key equivalent carries no geometry. - private func performViewMenuItem( - _ name: String, - keyEquivalent: String, - in app: XCUIApplication - ) throws { - let item = try viewMenuItem(name, in: app) - XCTAssertTrue( - waitForPredicate(timeout: 10) { item.isEnabled }, - "View > \(name) must be enabled once the tab has a history" - ) - app.typeKey(.escape, modifierFlags: []) - app.typeKey(keyEquivalent, modifierFlags: [.command, .control]) - } -} diff --git a/TableProUITests/NewConnectionCommandUITests.swift b/TableProUITests/NewConnectionCommandUITests.swift index ae04e629b5..374578d150 100644 --- a/TableProUITests/NewConnectionCommandUITests.swift +++ b/TableProUITests/NewConnectionCommandUITests.swift @@ -1,24 +1,84 @@ import XCTest final class NewConnectionCommandUITests: UITestCase { - func testNewConnectionOpensTheChooserAfterTheWelcomeWindowIsClosed() throws { + func testCommandNWithNoWindowCreatesAndConnectsANewSQLiteFile() throws { + let root = try XCTUnwrap(sandboxRoot) + let fileName = "scratch.sqlite" + let databaseURL = root.appendingPathComponent(fileName) + let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) + let welcome = welcomeWindow(in: app) + XCTAssertTrue(welcome.waitToExist(timeout: 10)) + app.typeShortcut(.closeTab) + XCTAssertTrue( + waitForPredicate(timeout: 10) { !welcome.exists }, + "The welcome window must close: New Connection used to break with no window open" + ) + + app.typeShortcut(.newConnection) + let chooser = welcome.sheets.firstMatch + XCTAssertTrue(chooser.waitToExist(timeout: 10), "New Connection must present the database chooser") + let search = chooser.searchFields["database-type-chooser-search"] + XCTAssertTrue(waitUntilHittable(search, timeout: 10), "The chooser must offer its search field") + search.click() + search.typeText("SQLite") + let proceed = chooser.buttons["Continue"] + XCTAssertTrue( + waitForPredicate(timeout: 10) { proceed.exists && proceed.isEnabled }, + "An exact type name must arm Continue" + ) + proceed.click() - app.typeKey("w", modifierFlags: .command) + let form = window("connection-form", in: app) + XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing SQLite must open the connection form") + replaceText(in: form.textFields["connection-form-name"], with: "Scratch") + let path = form.textFields["connection-form-file-path"] + replaceText(in: path, with: databaseURL.path) + + let new = form.buttons["connection-form-file-new"] + XCTAssertTrue(waitUntilHittable(new, timeout: 10), "SQLite creates a missing file, so New… must be offered") + new.click() + let panel = form.sheets.firstMatch + XCTAssertTrue(panel.waitToExist(timeout: 10), "New… must open a save panel on the form") + // The sheet is in the tree before it takes input; its name field taking the keyboard is the signal. + XCTAssertTrue( + waitForPredicate(timeout: 10) { self.holdsKeyboardFocus(panel.textFields["saveAsNameTextField"]) }, + "The save panel must become key with the keyboard in its name field" + ) + let create = panel.buttons["Create"] + XCTAssertTrue( + waitForPredicate(timeout: 10) { create.exists && create.isEnabled }, + "A name that does not exist yet must leave Create enabled" + ) + create.click() + XCTAssertTrue(waitForPredicate(timeout: 10) { !panel.exists }, "Create must close the panel") XCTAssertTrue( - app.windows.firstMatch.waitForNonExistence(timeout: 10), - "The welcome window should close, which is the state that broke New Connection" + waitForPredicate(timeout: 5) { (path.value as? String)?.hasSuffix("/\(fileName)") == true }, + "The panel must put the named path back in the field" + ) + XCTAssertFalse( + FileManager.default.fileExists(atPath: databaseURL.path), + "New… only names the file; nothing is written before the connection opens" ) - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 5)) - XCTAssertTrue(newConnection.isEnabled) - newConnection.click() + let connect = form.buttons["Save & Connect"] + XCTAssertTrue( + waitForPredicate(timeout: 10) { connect.exists && connect.isEnabled }, + "A named connection with a file path must be ready to connect" + ) + connect.click() + XCTAssertTrue(mainWindow(in: app).waitToExist(timeout: 30), "Save & Connect must open a connection window") XCTAssertTrue( - app.staticTexts["Choose a Database"].waitToExist(timeout: 10), - "New Connection must present the database chooser with no welcome window open" + waitForPredicate(timeout: 15) { FileManager.default.fileExists(atPath: databaseURL.path) }, + "Connecting must create the database file" ) } + + private func replaceText(in field: XCUIElement, with text: String) { + XCTAssertTrue(waitUntilHittable(field, timeout: 10)) + field.click() + field.typeKey("a", modifierFlags: .command) + field.typeText(text) + } } diff --git a/TableProUITests/OpenQuicklyCommandUITests.swift b/TableProUITests/OpenQuicklyCommandUITests.swift index 98a9f3d1f3..7e1b6d65cd 100644 --- a/TableProUITests/OpenQuicklyCommandUITests.swift +++ b/TableProUITests/OpenQuicklyCommandUITests.swift @@ -1,211 +1,97 @@ import XCTest final class OpenQuicklyCommandUITests: UITestCase { - /// One launch for every assertion about the panel itself. - /// - /// Each of these was its own test method, and each paid a full app launch and a sample-database - /// load, roughly forty seconds, to reach the same screen the last one had just left. They all - /// observe one panel and none of them leaves durable state behind, so the launch is the only - /// thing they were not sharing. - /// - /// `continueAfterFailure` is on because the phases are independent: with it off, a broken - /// Escape contract would hide whether the scopes are still reachable, and one CI run would - /// report one of the five problems it could have reported. - func testTheOpenQuicklyPanelOpensScopesAndHonoursEscape() throws { - continueAfterFailure = true - let app = try launchWithSampleDatabase() - XCTAssertTrue( - app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - .waitToExist(timeout: 30) - ) - app.activate() + // A segment has no identifier of its own; `containers` is titled Databases. + private static let scopeTitles = ["All", "Tables", "Databases", "Queries", "Connections"] + func testOpenQuicklyFindsScopesAndOpensATable() throws { + let launch = try launchWithFixture() + let app = launch.app + let window = launch.window + + app.typeShortcut(.openQuickly) let panel = switcherPanel(in: app) let searchField = panel.textFields["quick-switcher-search-field"] + XCTAssertTrue(searchField.waitToExist(timeout: 15), "Cmd+Shift+O must open the panel") - /// The command moved from the Database menu to the File menu and was renamed from - /// "Quick Switcher..." to "Open Quickly...", so the menu path itself is the assertion. - /// Resolved and clicked without opening File first; see the note in - /// QueryStatementNavigationUITests for why the parent click only costs a traversal. - let openQuickly = app.menuBars.menuItems["Open Quickly…"] - XCTAssertTrue(openQuickly.waitToExist(timeout: 5)) - openQuickly.click() - XCTAssertTrue(searchField.waitToExist(timeout: 15), "The File menu item must open the panel") - XCTAssertFalse( - app.menuBars.menuBarItems["Database"].menuItems["Quick Switcher..."].exists, - "The old Database menu item must not survive the move" - ) - - /// Every scope stays pickable with the mouse at all times. The scope controls used to be - /// removed from the view tree by `if !showsResultSurface`, so a single Recent row, which is - /// the panel's first frame on any connection with history, left four of the five scopes - /// with no mouse affordance at all. Typing is the state that has to be asserted, because - /// that is the state the old panel could never show them in. - let scopes = scopePicker(in: panel) + // The scopes used to leave the view tree once a result was listed, so they are checked while typing. + let scopes = panel.radioGroups["quick-switcher-scope-picker"].firstMatch XCTAssertTrue(scopes.waitToExist(timeout: 10)) - for title in Self.scopeTitles { - XCTAssertTrue( - scopes.radioButtons[title].waitToExist(timeout: 5), - "the \(title) segment is missing on an empty query" - ) - } XCTAssertTrue(isSelected(scopes.radioButtons["All"]), "The panel opens in the All scope") - searchField.typeText("a") - XCTAssertTrue(panel.buttons["Album"].waitToExist(timeout: 10)) + XCTAssertTrue(panel.buttons["album"].waitToExist(timeout: 10), "Typing must list matching tables") for title in Self.scopeTitles { XCTAssertTrue( waitUntilHittable(scopes.radioButtons[title], timeout: 5), - "the \(title) segment must stay clickable once results are listed" + "The \(title) scope must stay clickable once results are listed" ) } - /// Clicking a scope must not end typing. The search field claims first responder once when - /// the panel opens and nothing re-claims it, so a control that took focus on click would - /// leave the user unable to type without clicking back into the field. scopes.radioButtons["Tables"].click() - XCTAssertTrue( - waitForPredicate(timeout: 5) { isSelected(scopes.radioButtons["Tables"]) }, - "Clicking a segment selects it" - ) - /// Application-scoped on purpose. `XCUIElement.typeText` focuses its element first, so - /// typing into the field would restore the focus this is trying to prove was never lost, - /// and the assertion could not fail. Typing at the application goes wherever focus actually - /// is, which is the question. + XCTAssertTrue(waitForPredicate(timeout: 5) { self.isSelected(scopes.radioButtons["Tables"]) }) + // Typed at the application: `typeText` on the field would focus it first and hide a lost focus. app.typeText("lbum") XCTAssertTrue( - waitForPredicate(timeout: 5) { (searchField.value as? String ?? "") == "album" }, - "The search field still has keyboard focus after a scope click" + waitForPredicate(timeout: 5) { (searchField.value as? String) == "album" }, + "A scope click must leave the keyboard in the search field" ) - /// The two-step Escape shipped as #1490: the first clears a typed query, the second - /// dismisses. + // #1490: the first Escape clears a typed query, the second dismisses. app.typeKey(.escape, modifierFlags: []) XCTAssertTrue( - waitForPredicate(timeout: 5) { (searchField.value as? String ?? "").isEmpty }, + waitForPredicate(timeout: 5) { ((searchField.value as? String) ?? "").isEmpty }, "The first Escape clears the query" ) XCTAssertTrue(searchField.exists, "The first Escape must not dismiss the panel") app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue( - searchField.waitForNonExistence(timeout: 5), - "The second Escape dismisses the panel" - ) + XCTAssertTrue(searchField.waitForNonExistence(timeout: 5), "The second Escape dismisses the panel") - /// Reopening is what proves the scope did not survive the presentation. The panel builds a - /// fresh view model every time, and a stale cross-connection scope would go and load every - /// other connection's catalog on open. No single-launch test could assert this before, - /// because each one only ever saw the panel's first presentation. - app.typeKey("o", modifierFlags: [.command, .shift]) - XCTAssertTrue(searchField.waitToExist(timeout: 15), "Command Shift O must open the panel") + app.typeShortcut(.openQuickly) + XCTAssertTrue(searchField.waitToExist(timeout: 15)) XCTAssertTrue( isSelected(scopes.radioButtons["All"]), "A reopened panel must not inherit the scope the last one was left in" ) - - /// A query of nothing but spaces still has something for Escape to clear, because the field - /// editor branches on the raw string. The footer used to branch on the trimmed one, so it - /// promised Close and then cleared. - XCTAssertTrue( - hint(in: panel, "Escape closes Open Quickly").waitToExist(timeout: 5), - "An empty field promises Escape closes the panel" - ) + XCTAssertTrue(hint("Escape closes Open Quickly", in: panel).waitToExist(timeout: 5)) + // The field editor branches on the raw string, so spaces alone are still something to clear. searchField.typeText(" ") XCTAssertTrue( - hint(in: panel, "Escape clears the search text").waitToExist(timeout: 5), + hint("Escape clears the search text", in: panel).waitToExist(timeout: 5), "A whitespace-only query still has something to clear, so the hint must say so" ) app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(searchField.exists, "That first Escape clears the space, it does not dismiss") - XCTAssertTrue( - waitForPredicate(timeout: 5) { (searchField.value as? String ?? "").isEmpty } - ) - - /// Escape on an empty field dismisses, which is the contract the panel opened with. - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue( - searchField.waitForNonExistence(timeout: 5), - "Escape on an empty field must dismiss the panel" - ) - } - - /// Kept apart from the panel assertions above because it is the one that leaves the window - /// changed: two editor tabs open, which every assertion about a freshly presented panel would - /// then be running against a different window. - /// - /// Command+Return joins the Option+Return that already opened a result in a new tab. Both are - /// asserted here so neither can be dropped without a failure. - func testCommandReturnOpensTheResultInANewTab() throws { - let app = try launchWithSampleDatabase() - XCTAssertTrue( - app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - .waitToExist(timeout: 30) - ) - app.activate() - - app.typeKey("o", modifierFlags: [.command, .shift]) - let panel = switcherPanel(in: app) - let searchField = panel.textFields["quick-switcher-search-field"] - XCTAssertTrue(searchField.waitToExist(timeout: 15)) + XCTAssertTrue(waitForPredicate(timeout: 5) { ((searchField.value as? String) ?? "").isEmpty }) + XCTAssertTrue(searchField.exists, "Escape clears the spaces, it does not dismiss") searchField.typeText("album") - XCTAssertTrue(panel.buttons["Album"].waitToExist(timeout: 10)) + XCTAssertTrue(panel.buttons["album"].waitToExist(timeout: 10)) searchField.typeKey(.return, modifierFlags: .command) - XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) - - /// A second tab is what brings the strip out of hiding, so both tabs become assertable. - XCTAssertTrue(tab(in: app, named: "Album").waitToExist(timeout: 10)) - XCTAssertEqual(app.children(matching: .window).count, 1) + XCTAssertTrue(searchField.waitForNonExistence(timeout: 5), "Cmd+Return must commit and close the panel") + XCTAssertTrue(editorTab(titled: "album", in: window).waitToExist(timeout: 10), "Cmd+Return opens a new tab") - app.typeKey("o", modifierFlags: [.command, .shift]) + app.typeShortcut(.openQuickly) XCTAssertTrue(searchField.waitToExist(timeout: 10)) searchField.typeText("artist") - XCTAssertTrue(panel.buttons["Artist"].waitToExist(timeout: 10)) + XCTAssertTrue(panel.buttons["artist"].waitToExist(timeout: 10)) searchField.typeKey(.return, modifierFlags: .option) XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) - - XCTAssertTrue(tab(in: app, named: "Artist").waitToExist(timeout: 10)) - XCTAssertTrue(tab(in: app, named: "Album").exists) - XCTAssertEqual(app.children(matching: .window).count, 1) + XCTAssertTrue(editorTab(titled: "artist", in: window).waitToExist(timeout: 10), "Option+Return opens a new tab") + XCTAssertTrue(editorTab(titled: "album", in: window).exists, "The second open must keep the first tab") + XCTAssertEqual(app.children(matching: .window).count, 1, "Both open in this window, not a new one") } - // MARK: - Helpers - - /// The footer is one accessibility element whose label is the Escape promise, so the promise is - /// assertable without reading pixels. - private func hint(in panel: XCUIElement, _ label: String) -> XCUIElement { - panel.descendants(matching: .any).matching( - NSPredicate(format: "label == %@", label) - ).firstMatch - } - - /// The five scope titles as the user sees them. Segments carry no accessibility identifier of - /// their own, so a label is the only handle, and the `containers` case is titled "Databases". - private static let scopeTitles = ["All", "Tables", "Databases", "Queries", "Connections"] - - private func scopePicker(in panel: XCUIElement) -> XCUIElement { - panel.radioGroups["quick-switcher-scope-picker"].firstMatch + // A floating panel reports as a dialog, and an app-rooted search walks every window's tree. + private func switcherPanel(in app: XCUIApplication) -> XCUIElement { + app.children(matching: .any).matching(identifier: "quick-switcher-panel").firstMatch } - /// A segment carries its selection in its accessibility VALUE, 1 or 0, not in a selected trait - /// and not in the group's value. Measured on the real control: every segment reports - /// `AXSelected` as nil, and the group's `AXValue` is a reference to the selected child rather - /// than its title, so both `isSelected` and `group.value` read as nothing. + // A segment carries its selection in its value, 1 or 0; `isSelected` and the group's value read nothing. private func isSelected(_ segment: XCUIElement) -> Bool { (segment.value as? NSNumber)?.intValue == 1 } - /// Scoped the same way `QuickSwitcherCrossConnectionUITests` scopes it: rooted at the - /// application, the same query walks the whole accessibility tree and overruns XCTest's - /// evaluation watchdog once a data grid is loaded. `.any` because AppKit gives a floating - /// panel the `AXDialog` subrole, which XCUITest reports as Dialog rather than Window. - private func switcherPanel(in app: XCUIApplication) -> XCUIElement { - app.children(matching: .any).matching(identifier: "quick-switcher-panel").firstMatch - } - - private func tab(in app: XCUIApplication, named name: String) -> XCUIElement { - app.buttons.matching( - NSPredicate(format: "identifier == %@ AND label BEGINSWITH %@", "editor-tab", name) - ).firstMatch + // The footer is one element whose label is the Escape promise. + private func hint(_ label: String, in panel: XCUIElement) -> XCUIElement { + panel.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label)).firstMatch } } diff --git a/TableProUITests/PairingDisplayScopeUITests.swift b/TableProUITests/PairingDisplayScopeUITests.swift deleted file mode 100644 index 609b9570ba..0000000000 --- a/TableProUITests/PairingDisplayScopeUITests.swift +++ /dev/null @@ -1,48 +0,0 @@ -import XCTest - -final class PairingDisplayScopeUITests: UITestCase { - /// The redirect is never opened: these tests leave the sheet unanswered. - private let pairingLink = "tablepro://integrations/pair?client=UI%20Test" - + "&challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" - + "&redirect=http%3A%2F%2F127.0.0.1%3A9%2Fcallback&response_mode=query" - + "&scopes=readOnly%20connections%3Adisplay" - - /// The argument domain overrides the sandbox's stored MCP settings, read as plist data. - private func arguments(mcpSettings json: String) -> [String] { - let hex = Data(json.utf8).map { String(format: "%02x", $0) }.joined() - return ["-AppleLanguages", "(en)", "-com.TablePro.settings.mcp", "<\(hex)>"] - } - - func testWithTheSettingOffTheSheetSaysTheScopeCannotBeGranted() throws { - let app = try launchOpeningURL(pairingLink, arguments: arguments(mcpSettings: #"{"enabled":true}"#)) - - let refusal = app.staticTexts["pairing-hidden-connections-refusal"].firstMatch - XCTAssertTrue(refusal.waitToExist(timeout: 15), "The sheet has to say why the scope is not offered") - XCTAssertFalse( - app.descendants(matching: .any)["pairing-hidden-connections-toggle"].exists, - "The scope must not be grantable while the setting is off" - ) - } - - func testTheSettingStartsOff() throws { - let app = try launchOpeningURL("tablepro://settings/mcp", arguments: arguments(mcpSettings: #"{"enabled":true}"#)) - let settingsWindow = app.windows["settings"] - XCTAssertTrue(settingsWindow.waitToExist(timeout: 10)) - - let setting = settingsWindow.switches["mcp-hidden-connection-listing-toggle"].firstMatch - XCTAssertTrue(setting.waitToExist(timeout: 10)) - XCTAssertFalse(isOn(setting), "The setting has to start off") - } - - func testWithTheSettingOnTheSheetOffersTheScopeUnticked() throws { - let app = try launchOpeningURL( - pairingLink, - arguments: arguments(mcpSettings: #"{"enabled":true,"allowsHiddenConnectionListing":true}"#) - ) - - let grant = app.descendants(matching: .any)["pairing-hidden-connections-toggle"].firstMatch - XCTAssertTrue(grant.waitToExist(timeout: 15), "With the setting on, the sheet has to offer the scope") - XCTAssertFalse(isOn(grant), "The checkbox has to start off") - XCTAssertFalse(app.staticTexts["pairing-hidden-connections-refusal"].exists) - } -} diff --git a/TableProUITests/QueryCodeFoldingUITests.swift b/TableProUITests/QueryCodeFoldingUITests.swift deleted file mode 100644 index 19bc5fab57..0000000000 --- a/TableProUITests/QueryCodeFoldingUITests.swift +++ /dev/null @@ -1,79 +0,0 @@ -import XCTest - -final class QueryCodeFoldingUITests: UITestCase { - private let script = """ - CREATE TABLE users ( - id BIGSERIAL, - name TEXT, - email TEXT - ); - """ - - func testFoldAllCollapsesTheScriptAndUnfoldAllRestoresIt() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText(script) - XCTAssertTrue(waitForEditorText(containing: "BIGSERIAL", in: editor)) - - clickQueryMenuItem("Fold All", in: app) - XCTAssertTrue( - waitForEditorText(containing: "BIGSERIAL", in: editor), - "Folding hides lines visually and must never change the document text" - ) - - clickQueryMenuItem("Unfold All", in: app) - XCTAssertTrue( - waitForEditorText(containing: "BIGSERIAL", in: editor), - "Unfold All must leave the document text intact" - ) - } - - func testToggleFoldRunsWithTheCursorInsideAStatement() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText(script) - XCTAssertTrue(waitForEditorText(containing: "BIGSERIAL", in: editor)) - - clickQueryMenuItem("Toggle Fold", in: app) - clickQueryMenuItem("Toggle Fold", in: app) - - XCTAssertTrue( - waitForEditorText(containing: "BIGSERIAL", in: editor), - "Toggling a fold must never change the document text" - ) - } - - // MARK: - Helpers - - /// Walks the Query menu rather than typing the shortcut, because the fold shortcuts are - /// rebindable and the menu item is the contract the test cares about. - private func clickQueryMenuItem(_ title: String, in app: XCUIApplication) { - let queryMenu = app.menuBars.menuBarItems["Query"] - XCTAssertTrue(queryMenu.waitToExist(timeout: 10), "The Query menu must exist") - queryMenu.click() - - let item = app.menuBars.menuItems[title] - XCTAssertTrue(item.waitToExist(timeout: 10), "Query > \(title) must exist") - XCTAssertTrue(waitUntilHittable(item, timeout: 10), "Query > \(title) must be clickable") - item.click() - } - - private func openQueryTab(in app: XCUIApplication) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - return editor - } - - private func waitForEditorText( - containing needle: String, - in element: XCUIElement, - timeout: TimeInterval = 5 - ) -> Bool { - waitForPredicate(timeout: timeout) { - (element.value as? String)?.contains(needle) == true - } - } -} diff --git a/TableProUITests/QueryErrorBannerUITests.swift b/TableProUITests/QueryErrorBannerUITests.swift index c71b625e62..ceb13036f1 100644 --- a/TableProUITests/QueryErrorBannerUITests.swift +++ b/TableProUITests/QueryErrorBannerUITests.swift @@ -1,161 +1,25 @@ -// -// QueryErrorBannerUITests.swift -// TableProUITests -// -// The sample SQLite database makes a failing query deterministic, with no server to reach: a -// missing table is always "no such table". The app shipped a release where a failed query wrote -// nothing at all, so the banner never appeared (#2120). -// - import XCTest final class QueryErrorBannerUITests: UITestCase { - func testAFailedQueryShowsTheDatabaseError() throws { - let app = try launchWithSampleDatabase() - runQuery("SELECT * FROM nope;", in: app) - - let banner = app.windows.firstMatch.staticTexts["query-error-message"].firstMatch - XCTAssertTrue( - banner.waitToExist(timeout: 20), - "A query the database rejects must show its error, not an empty result area" - ) - XCTAssertTrue( - (banner.value as? String ?? banner.label).localizedCaseInsensitiveContains("no such table"), - "The banner must carry the database's own message, not a generic failure" - ) - } - - func testTheBannerCanBeDismissed() throws { - let app = try launchWithSampleDatabase() - runQuery("SELECT * FROM nope;", in: app) - - let banner = app.windows.firstMatch.staticTexts["query-error-message"].firstMatch - XCTAssertTrue(banner.waitToExist(timeout: 20)) - - app.windows.firstMatch.buttons["Dismiss error"].firstMatch.click() - XCTAssertTrue( - waitForDisappearance(of: banner, timeout: 10), - "Dismissing the banner must remove it" - ) - } - - func testAHiddenCharacterInTheErrorIsNamed() throws { - let app = try launchWithSampleDatabase() - openQueryEditor(in: app) - app.typeText("SELECT") - app.typeKey(" ", modifierFlags: .option) - app.typeText("1;") - app.typeKey(.return, modifierFlags: .command) - - let banner = app.windows.firstMatch.staticTexts["query-error-message"].firstMatch - XCTAssertTrue(banner.waitToExist(timeout: 20)) - let exposed = [banner.value as? String, banner.label].compactMap { $0 } - XCTAssertTrue( - exposed.contains { $0.contains("no-break space") }, - "The no-break space SQLite quotes back must be named, not read as a plain space; got \(exposed)" - ) - } - - /// A failed Run All leaves its error result active, and an error result has no columns. The grid - /// under the banner kept the headings of the previous run's last result over no rows, where the - /// same failure in a fresh tab shows the row-number heading alone. - func testAFailedRunAllKeepsNoHeadingFromThePreviousRun() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - app.typeKey("t", modifierFlags: .command) - - typeQuery("SELECT 1 AS first_col, 2 AS second_col; SELECT 3 AS third_col;", in: app) - app.typeKey(.return, modifierFlags: [.command, .shift]) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - let previousHeading = columnHeading(beginningWith: "Column: third_col", in: grid) - XCTAssertTrue( - previousHeading.waitToExist(timeout: 20), - "The first run must end on its last result, headed third_col" - ) + // A release shipped where a failed query wrote nothing, so no banner ever appeared (#2120). + func testAFailedQueryShowsTheDatabaseErrorUntilDismissed() throws { + let launch = try launchWithFixture() + let window = launch.window - typeQuery("SELECT 1 AS x; SELECT * FROM missing_table;", in: app) - app.typeKey(.return, modifierFlags: [.command, .shift]) + typeQuery("SELECT * FROM nope;", in: window) + launch.app.typeShortcut(.runQuery) let banner = window.staticTexts["query-error-message"].firstMatch - XCTAssertTrue(banner.waitToExist(timeout: 20), "The failed statement must show its error") + XCTAssertTrue(banner.waitToExist(timeout: 20), "A rejected query must show its error, not an empty result area") + let message = (banner.value as? String) ?? banner.label XCTAssertTrue( - (banner.value as? String ?? banner.label).localizedCaseInsensitiveContains("missing_table"), - "The banner must name the failure of the second run" + message.localizedCaseInsensitiveContains("no such table"), + "The banner must carry the database's own message, got '\(message)'" ) - XCTAssertTrue( - waitForPredicate(timeout: 10) { !previousHeading.exists }, - "The grid under the error must not keep the previous run's third_col heading" - ) - XCTAssertFalse( - columnHeading(beginningWith: "Column: ", in: grid).exists, - "An error result has no columns, so the grid under it heads none" - ) - } - - /// The grid skips its update while a cell viewer is open, and Run All still fires from inside - /// the viewer. The same failure run from there kept the previous heading, its row and the - /// viewer over a value the active result does not have. - func testAFailedRunAllFromAnOpenCellViewerKeepsNoHeadingFromThePreviousRun() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - app.typeKey("t", modifierFlags: .command) - - typeQuery("SELECT 1 AS first_col, 2 AS second_col; SELECT 3 AS third_col;", in: app) - app.typeKey(.return, modifierFlags: [.command, .shift]) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - let previousHeading = columnHeading(beginningWith: "Column: third_col", in: grid) - XCTAssertTrue( - previousHeading.waitToExist(timeout: 20), - "The first run must end on its last result, headed third_col" - ) - XCTAssertTrue(waitForClickableRows(in: grid), "The first run's row must be on screen to open") - - typeQuery("SELECT 1 AS x; SELECT * FROM missing_table;", in: app) - gridPoint(in: grid, of: window, dy: 52).click() - app.typeKey(.return, modifierFlags: []) - let viewer = window.textViews.matching(NSPredicate(format: "value == %@", "3")).firstMatch - XCTAssertTrue(viewer.waitToExist(timeout: 10), "Return on the read-only cell must open its viewer") - - app.typeKey(.return, modifierFlags: [.command, .shift]) - - let banner = window.staticTexts["query-error-message"].firstMatch - XCTAssertTrue(banner.waitToExist(timeout: 20), "Run All must fire from the viewer and show its error") - XCTAssertTrue( - waitForPredicate(timeout: 10) { !previousHeading.exists }, - "The grid under the error must not keep the previous run's third_col heading" - ) - XCTAssertFalse( - columnHeading(beginningWith: "Column: ", in: grid).exists, - "An error result has no columns, so the grid under it heads none" - ) - XCTAssertFalse(viewer.exists, "The viewer must close with the row it was open on") - } - - private func columnHeading(beginningWith prefix: String, in grid: XCUIElement) -> XCUIElement { - grid.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", prefix)).firstMatch - } - - private func runQuery(_ sql: String, in app: XCUIApplication) { - openQueryEditor(in: app) - app.typeText(sql) - app.typeKey(.return, modifierFlags: .command) - } - - private func openQueryEditor(in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - } - private func waitForDisappearance(of element: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date(timeIntervalSinceNow: timeout) - while Date() < deadline { - if !element.exists { return true } - usleep(200_000) - } - return !element.exists + let dismiss = window.buttons["Dismiss error"].firstMatch + XCTAssertTrue(waitUntilHittable(dismiss, timeout: 10), "The banner must offer to dismiss itself") + dismiss.click() + XCTAssertTrue(banner.waitForNonExistence(timeout: 10), "Dismissing the banner must remove it") } } diff --git a/TableProUITests/QueryFormatUnterminatedLiteralUITests.swift b/TableProUITests/QueryFormatUnterminatedLiteralUITests.swift deleted file mode 100644 index 0a5c3e08ee..0000000000 --- a/TableProUITests/QueryFormatUnterminatedLiteralUITests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import XCTest - -final class QueryFormatUnterminatedLiteralUITests: UITestCase { - // A backtick, not a quote. `BracketPairs.allValues` auto-closes `'` and `"`, so a typed quote - // gets a closing partner and the document no longer ends in the backslash that trips the - // tokenizer. Backticks are not paired, and they take the same tokenizer branch. - private let query = "select * from t where c like `C:\\" - - func testFormatQuerySurvivesAnUnterminatedLiteralEndingInABackslash() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText(query) - XCTAssertTrue(waitForValue(query, in: editor, timeout: 5)) - - app.typeKey("l", modifierFlags: [.command, .shift]) - - XCTAssertTrue( - app.windows.firstMatch.waitToExist(timeout: 5), - "Formatting an unterminated literal must not bring the app down" - ) - XCTAssertTrue( - editor.waitToExist(timeout: 5), - "The editor must survive formatting an unterminated literal" - ) - let value = editor.value as? String ?? "" - // The typed keywords are lowercase and the formatter uppercases them, so this also proves - // the command ran rather than silently doing nothing. - XCTAssertTrue( - value.contains("SELECT"), - "Format Query must actually reformat the editor; got '\(value)'" - ) - XCTAssertTrue( - value.contains("`C:\\"), - "Formatting must keep the unterminated literal; got '\(value)'" - ) - } - - private func openQueryTab(in app: XCUIApplication) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "A new tab starts with an empty editor") - return editor - } - - private func waitForValue(_ expected: String, in element: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date(timeIntervalSinceNow: timeout) - while Date() < deadline { - if (element.value as? String) == expected { - return true - } - RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) - } - return (element.value as? String) == expected - } -} diff --git a/TableProUITests/QueryHistoryActionsUITests.swift b/TableProUITests/QueryHistoryActionsUITests.swift deleted file mode 100644 index d3e53eadb5..0000000000 --- a/TableProUITests/QueryHistoryActionsUITests.swift +++ /dev/null @@ -1,137 +0,0 @@ -import XCTest - -/// Sending a query from the drawer back to the editor is the reason the drawer exists, and every -/// way of asking for it shipped dead: the buttons, the context menu and the list's own Return all -/// funnelled into one handler that resolved its target through a channel this app cannot serve. -/// Nothing here asserted that a click changed anything, which is why it reached a release. -final class QueryHistoryActionsUITests: UITestCase { - private let marker = "tablepro_probe" - private var probeQuery: String { "SELECT '\(marker)' AS marker;" } - - func testLoadInEditorFillsTheTabInFrontInsteadOfOpeningAnother() throws { - let app = try launchWithSampleDatabase() - runProbeQuery(in: app) - openEmptyTab(in: app) - - showHistoryDrawer(in: app) - selectFirstEntry(in: app) - XCTAssertEqual(editorsShowingMarker(in: app), 1, "Only the drawer's preview shows the query yet") - let tabsBefore = tabCount(in: app) - - let load = app.buttons["query-history-load-in-editor"] - XCTAssertTrue(load.waitToExist(timeout: 10), "The detail pane must offer Load in Editor") - load.click() - - XCTAssertTrue( - waitForPredicate(timeout: 10) { editorsShowingMarker(in: app) == 2 }, - "Load in Editor must put the selected query into the editor" - ) - XCTAssertEqual( - tabCount(in: app), - tabsBefore, - "Load in Editor uses the tab in front rather than opening another one" - ) - } - - /// The list owns Return, so the keyboard reaches the same handler the buttons do. - func testReturnInTheListLoadsTheSelectedEntry() throws { - let app = try launchWithSampleDatabase() - runProbeQuery(in: app) - openEmptyTab(in: app) - - showHistoryDrawer(in: app) - selectFirstEntry(in: app) - - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - - XCTAssertTrue( - waitForPredicate(timeout: 10) { editorsShowingMarker(in: app) == 2 }, - "Return on a selected entry must load it into the editor" - ) - } - - func testRunInNewTabOpensATabAndActuallyRunsTheQuery() throws { - let app = try launchWithSampleDatabase() - runProbeQuery(in: app) - openEmptyTab(in: app) - - showHistoryDrawer(in: app) - selectFirstEntry(in: app) - let tabsBefore = tabCount(in: app) - - let run = app.buttons["query-history-run-in-new-tab"] - XCTAssertTrue(run.waitToExist(timeout: 10), "The detail pane must offer Run in New Tab") - run.click() - - XCTAssertTrue( - waitForPredicate(timeout: 15) { tabCount(in: app) > tabsBefore }, - "Run in New Tab must open another editor tab" - ) - XCTAssertTrue( - waitForPredicate(timeout: 15) { - app.windows.firstMatch.descendants(matching: .any) - .matching(NSPredicate(format: "value == %@", marker)).firstMatch.exists - }, - "Run in New Tab must run the query it opens, not just load it" - ) - } - - // MARK: - Helpers - - private func tabCount(in app: XCUIApplication) -> Int { - app.buttons.matching(identifier: "editor-tab").count - } - - /// The drawer's preview is a text view too, so the marker's own count is what separates - /// "the editor received it" from "the drawer is merely showing it". - private func editorsShowingMarker(in app: XCUIApplication) -> Int { - app.windows.firstMatch.textViews - .matching(NSPredicate(format: "value CONTAINS %@", marker)).count - } - - private func showHistoryDrawer(in app: XCUIApplication) { - app.typeKey("y", modifierFlags: .command) - let detail = app.windows.firstMatch.groups.matching(identifier: "query-history-detail").firstMatch - XCTAssertTrue(detail.waitToExist(timeout: 10), "Cmd+Y must show the query history drawer") - } - - /// Row 0 is the day section header, so the first entry is row 1. - private func selectFirstEntry(in app: XCUIApplication) { - let list = app.windows.firstMatch.tables.matching(identifier: "query-history-list").firstMatch - XCTAssertTrue(list.waitToExist(timeout: 10)) - let row = list.tableRows.element(boundBy: 1) - XCTAssertTrue(row.waitToExist(timeout: 10), "The drawer must list the query just run") - /// Through a coordinate, for the reason `QueryHistoryFocusUITests` gives: `click()` on a row - /// AppKit reports as disabled selects it without a mouse-down, so the list never holds the - /// keyboard and Return reaches the editor instead of the list. - clickAtCenter(row) - - let preview = app.textViews["query-history-detail-query"] - XCTAssertTrue(preview.waitToExist(timeout: 10)) - XCTAssertTrue( - waitForPredicate(timeout: 5) { ((preview.value as? String) ?? "").contains(marker) }, - "Selecting a row must show that row in the preview" - ) - } - - private func openEmptyTab(in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - let editor = app.windows.firstMatch.textViews.firstMatch - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue( - waitForPredicate(timeout: 5) { editorsShowingMarker(in: app) == 0 }, - "A tab opened with Cmd+T starts empty" - ) - } - - private func runProbeQuery(in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - let editor = app.windows.firstMatch.textViews.firstMatch - XCTAssertTrue(editor.waitToExist(timeout: 10)) - app.typeText(probeQuery) - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: .command) - - let results = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(results.waitToExist(timeout: 15), "The query must produce a result grid") - } -} diff --git a/TableProUITests/QueryHistoryFocusUITests.swift b/TableProUITests/QueryHistoryFocusUITests.swift deleted file mode 100644 index 068c5cf828..0000000000 --- a/TableProUITests/QueryHistoryFocusUITests.swift +++ /dev/null @@ -1,79 +0,0 @@ -import XCTest - -final class QueryHistoryFocusUITests: UITestCase { - /// Selecting a row is not activating it. Arrowing through a results list is how a history gets - /// read, so the keyboard has to stay where the user put it until they ask for the query. - func testArrowKeysKeepMovingThroughTheListAfterClickingARow() throws { - let app = try launchWithSampleDatabase() - runQueries(in: app, ["SELECT * FROM Genre;", "SELECT * FROM Album;", "SELECT * FROM Artist;"]) - - let editor = app.windows.firstMatch.textViews.firstMatch - XCTAssertTrue(editor.waitToExist(timeout: 10)) - let editorBeforeClick = editor.value as? String ?? "" - - let list = showHistoryDrawer(in: app) - - /// Row 0 is the "Today" section header, so the first entry is row 1. Waiting for it to be - /// hittable rather than merely to exist is what makes the click land: the drawer animates - /// open, and a row that is still sliding into place is in the tree a good while before its - /// centre hit-tests back to it. The old guard was the detail pane existing, which cannot - /// say any of that, because that pane publishes its identifier in the empty state too. - let row = list.tableRows.element(boundBy: 1) - XCTAssertTrue( - waitUntilHittable(row, timeout: 10), - "The drawer must list the queries just run and finish opening" - ) - /// Through a coordinate. AppKit publishes these rows as disabled, and `click()` on one - /// selects it through accessibility without delivering a mouse-down, so the table never - /// takes first responder and every key after it goes to the SQL editor instead. A person's - /// click is a mouse-down, which is what this sends. - clickAtCenter(row) - - /// The selection is asserted before the preview so a failure says which half broke: the - /// preview is the detail pane drawing a selected row, so its absence alone cannot tell a - /// click that never landed from a pane that never drew. - XCTAssertTrue( - waitForPredicate(timeout: 5) { row.isSelected }, - "Clicking a row must select it" - ) - - let preview = app.textViews["query-history-detail-query"] - XCTAssertTrue(preview.waitToExist(timeout: 10), "A selected row must show in the preview") - let previewAfterClick = preview.value as? String ?? "" - XCTAssertFalse(previewAfterClick.isEmpty, "Clicking a row must show that row in the preview") - - app.typeKey(XCUIKeyboardKey.downArrow.rawValue, modifierFlags: []) - - XCTAssertTrue( - waitForPredicate(timeout: 5) { (preview.value as? String ?? "") != previewAfterClick }, - "Down arrow after clicking a row must move to the next entry" - ) - - let editorAfterTyping = editor.value as? String ?? "" - XCTAssertEqual( - editorAfterTyping, - editorBeforeClick, - "Arrowing through history must not type into the editor" - ) - } - - @discardableResult - private func showHistoryDrawer(in app: XCUIApplication) -> XCUIElement { - app.typeKey("y", modifierFlags: .command) - let list = app.windows.firstMatch.tables.matching(identifier: "query-history-list").firstMatch - XCTAssertTrue(list.waitToExist(timeout: 10), "Cmd+Y must show the query history drawer") - return list - } - - private func runQueries(in app: XCUIApplication, _ queries: [String]) { - for query in queries { - app.typeKey("t", modifierFlags: .command) - let editor = app.windows.firstMatch.textViews.firstMatch - XCTAssertTrue(editor.waitToExist(timeout: 10)) - app.typeText(query) - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: .command) - _ = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - .waitToExist(timeout: 15) - } - } -} diff --git a/TableProUITests/QueryHistoryPanelUITests.swift b/TableProUITests/QueryHistoryPanelUITests.swift index e3c862708e..82085aeb0c 100644 --- a/TableProUITests/QueryHistoryPanelUITests.swift +++ b/TableProUITests/QueryHistoryPanelUITests.swift @@ -1,76 +1,87 @@ import XCTest final class QueryHistoryPanelUITests: UITestCase { - private let query = "SELECT * FROM Genre;" + private static let queries = [ + ("SELECT name FROM genre;", "Rock"), + ("SELECT title FROM album;", "North"), + ("SELECT name FROM artist;", "Ada"), + ] - /// One launch for the whole drawer. - /// - /// Each of these was a separate test that launched the app, opened the sample database, ran a - /// query and pressed Cmd+Y to reach the drawer the previous one had just been looking at. They - /// all observe one drawer, so the preamble was the only thing they were not sharing. - /// - /// Toggling the drawer shut comes last, because it is the one step that leaves the window in a - /// state the other assertions could not run in. - /// - /// `continueAfterFailure` is on because the phases are independent: with it off, a drawer that - /// lost its search field would hide whether the scope picker and the detail pane are still - /// there, and one CI run would report one of the five problems it could have reported. - func testTheHistoryDrawerListsTheQueryCarriesItsFiltersAndTogglesShut() throws { - continueAfterFailure = true - let app = try launchWithSampleDatabase() - runQuery(in: app) - let list = showHistoryDrawer(in: app) + // Sending a query back to the editor is why the drawer exists, and every route to it once shipped dead. + func testTheHistoryDrawerLoadsAnEntryIntoTheEditor() throws { + let launch = try launchWithFixture() + let app = launch.app + let window = launch.window - XCTAssertTrue( - waitForPredicate(timeout: 10) { - list.staticTexts - .matching(NSPredicate(format: "label CONTAINS[c] %@ OR value CONTAINS[c] %@", "Genre", "Genre")) - .firstMatch.exists - }, - "The query that just ran must appear in the drawer" - ) + let grid = dataGrid(in: window) + for (sql, firstValue) in Self.queries { + typeQuery(sql, in: window) + app.typeShortcut(.runQuery) + XCTAssertTrue(gridCell(showing: firstValue, in: grid).waitToExist(timeout: 15), "\(sql) must run") + } - let window = app.windows.firstMatch - let historySearch = window.searchFields.matching(identifier: "query-history-search-field").firstMatch - XCTAssertTrue(historySearch.waitToExist(timeout: 10), "The drawer must own a search field") + app.typeShortcut(.newTab) + let editor = queryEditor(in: window) + XCTAssertTrue(waitForEditorText(editor) { $0.isEmpty }, "Cmd+T must open an empty query tab") + let tabCount = editorTabs(in: window).count - let sidebarFilter = window.searchFields.matching(identifier: "sidebar-filter").firstMatch - XCTAssertTrue(sidebarFilter.exists, "The sidebar filter must keep its own identifier") + app.typeShortcut(.toggleHistory) + let list = window.tables.matching(identifier: "query-history-list").firstMatch + XCTAssertTrue(list.waitToExist(timeout: 10), "Cmd+Y must show the query history drawer") + // Row 0 is the day's section header. + XCTAssertTrue(list.tableRows.element(boundBy: 3).waitToExist(timeout: 10), "The drawer must list all three runs") + XCTAssertTrue( + window.searchFields.matching(identifier: "query-history-search-field").firstMatch.exists, + "The drawer must own a search field" + ) + XCTAssertTrue( + window.searchFields.matching(identifier: "sidebar-filter").firstMatch.exists, + "The sidebar filter must keep its own identifier" + ) let scope = window.popUpButtons.matching(identifier: "query-history-scope-picker").firstMatch - let date = window.popUpButtons.matching(identifier: "query-history-date-picker").firstMatch XCTAssertTrue(scope.waitToExist(timeout: 10), "The drawer must expose the connection scope") - XCTAssertTrue(date.exists, "The drawer must expose the date range") XCTAssertEqual(scope.value as? String, "This Connection", "History starts scoped to this connection") + XCTAssertTrue( + window.popUpButtons.matching(identifier: "query-history-date-picker").firstMatch.exists, + "The drawer must expose the date range" + ) + + let newest = list.tableRows.element(boundBy: 1) + XCTAssertTrue(waitUntilHittable(newest, timeout: 10), "The drawer must finish opening") + // A row AppKit reports as disabled selects through accessibility without a mouse-down, so the + // list never takes the keyboard. A coordinate click is a real mouse-down. + clickAtCenter(newest) + XCTAssertTrue(waitForPredicate(timeout: 5) { newest.isSelected }, "Clicking a row must select it") + let preview = window.textViews.matching(identifier: "query-history-detail-query").firstMatch + XCTAssertTrue( + waitForEditorText(preview) { $0.contains("FROM artist") }, + "The preview must show the newest run, got '\(editorText(preview))'" + ) + + app.typeKey(.downArrow, modifierFlags: []) + XCTAssertTrue( + waitForEditorText(preview) { $0.contains("FROM album") }, + "Down arrow after a click must move to the next entry, got '\(editorText(preview))'" + ) + XCTAssertEqual(editorText(editor), "", "Arrowing through history must not type into the editor") - let detail = window.groups.matching(identifier: "query-history-detail").firstMatch - XCTAssertTrue(detail.waitToExist(timeout: 10), "The drawer is master-detail") + app.typeKey(.return, modifierFlags: []) + XCTAssertTrue( + waitForEditorText(editor) { $0.contains("FROM album") }, + "Return must load the selected entry into the editor, got '\(editorText(editor))'" + ) + XCTAssertEqual(editorTabs(in: window).count, tabCount, "Loading uses the tab in front instead of opening one") - app.typeKey("y", modifierFlags: .command) + app.typeShortcut(.toggleHistory) XCTAssertTrue( waitForPredicate(timeout: 10) { !list.exists || !list.isHittable }, "Cmd+Y a second time must hide the drawer" ) } - // MARK: - Helpers - - @discardableResult - private func showHistoryDrawer(in app: XCUIApplication) -> XCUIElement { - app.typeKey("y", modifierFlags: .command) - let list = app.windows.firstMatch.tables.matching(identifier: "query-history-list").firstMatch - XCTAssertTrue(list.waitToExist(timeout: 10), "Cmd+Y must show the query history drawer") - return list - } - - private func runQuery(in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - app.typeText(query) - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: .command) - - let results = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(results.waitToExist(timeout: 15), "The query must produce a result grid") + // The drawer's preview is a text view too. + private func queryEditor(in window: XCUIElement) -> XCUIElement { + window.textViews.matching(NSPredicate(format: "identifier != %@", "query-history-detail-query")).firstMatch } } diff --git a/TableProUITests/QueryInsightsTabUITests.swift b/TableProUITests/QueryInsightsTabUITests.swift deleted file mode 100644 index e55ab125bb..0000000000 --- a/TableProUITests/QueryInsightsTabUITests.swift +++ /dev/null @@ -1,138 +0,0 @@ -import XCTest - -final class QueryInsightsTabUITests: UITestCase { - private let query = "SELECT * FROM Genre;" - - // One launch for every phase; the history drawer goes last because it changes the window. - func testTheQueryInsightsTabOpensOnceCarriesItsFiltersAndStaysGatedWithoutALicense() throws { - continueAfterFailure = true - let app = try launchWithSampleDatabase() - runQuery(in: app) - openInsights(in: app) - - let window = app.windows.firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 10) { - window.descendants(matching: .any) - .matching(NSPredicate(format: "label CONTAINS[c] %@", "Query Insights")) - .firstMatch.exists - }, - "Database > Query Insights must open a tab named Query Insights" - ) - - for identifier in [ - "query-insights-scope-picker", - "query-insights-source-filter", - "query-insights-date-picker", - "query-insights-refresh-button", - ] { - let control = window.descendants(matching: .any).matching(identifier: identifier).firstMatch - XCTAssertTrue( - control.waitToExist(timeout: 10), - "The insights toolbar must expose \(identifier)" - ) - } - - // The sandbox has no license, and `requiresPro` only dims, so the panels must not be built. - for panel in ["Most Run", "Slowest", "Got Slower", "Failures"] { - let heading = window.descendants(matching: .any) - .matching(NSPredicate(format: "label CONTAINS[c] %@", panel)).firstMatch - XCTAssertFalse( - heading.exists, - "\(panel) must not be built for a run that cannot read it" - ) - } - - func insightsLabelCount() -> Int { - window.descendants(matching: .any) - .matching(NSPredicate(format: "label == %@", "Query Insights")) - .count - } - XCTAssertTrue(waitForPredicate(timeout: 10) { insightsLabelCount() >= 1 }) - let afterFirst = insightsLabelCount() - - openInsights(in: app) - XCTAssertTrue( - waitForPredicate(timeout: 5) { insightsLabelCount() == afterFirst }, - "The insights tab is a singleton per connection, so opening it again must not add another" - ) - - // Reusing the tab is not enough: the command has to select it. - let insightsTab = window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .matching(NSPredicate(format: "label == %@", "Query Insights")) - .firstMatch - XCTAssertTrue(insightsTab.waitToExist(timeout: 10), "The insights tab must reach the strip") - XCTAssertTrue( - waitForPredicate(timeout: 10) { insightsTab.isSelected }, - "Opening it selects it" - ) - - selectTab(otherThan: "Query Insights", in: app) - XCTAssertTrue( - waitForPredicate(timeout: 10) { !insightsTab.isSelected }, - "The window must actually move off the insights tab before the next open is meaningful" - ) - - openInsights(in: app) - XCTAssertTrue( - waitForPredicate(timeout: 10) { insightsTab.isSelected }, - "Database > Query Insights must select the tab it already opened" - ) - - // The drawer and the tab each own a scope picker, under different identifiers. - let insightsScope = window.descendants(matching: .any) - .matching(identifier: "query-insights-scope-picker").firstMatch - XCTAssertTrue(insightsScope.waitToExist(timeout: 10)) - - app.typeKey("y", modifierFlags: .command) - - let historyScope = window.descendants(matching: .any) - .matching(identifier: "query-history-scope-picker").firstMatch - XCTAssertTrue( - historyScope.waitToExist(timeout: 10), - "The drawer keeps its own scope picker identifier while the insights tab is open" - ) - } - - // MARK: - Helpers - - private func selectTab(otherThan label: String, in app: XCUIApplication) { - let strip = app.windows.firstMatch.descendants(matching: .any).matching(identifier: "editor-tab") - XCTAssertTrue( - waitForPredicate(timeout: 10) { strip.count >= 2 }, - "The strip must hold both tabs before one can be clicked" - ) - for index in 0 ..< strip.count { - let tab = strip.element(boundBy: index) - guard tab.label != label else { continue } - XCTAssertTrue(waitUntilHittable(tab, timeout: 10), "A tab in the strip must be clickable") - tab.click() - return - } - XCTFail("The strip holds no tab other than \(label)") - } - - // The Database menu scrolls on the 1024x768 CI screen, where a click near its bottom lands on - // whatever scrolled under the pointer. Type-select has no geometry: Q starts only this item. - private func openInsights(in app: XCUIApplication) { - let database = app.menuBars.menuBarItems["Database"] - database.click() - let item = database.menuItems["Query Insights"] - XCTAssertTrue(item.waitToExist(timeout: 10), "Database > Query Insights must exist") - XCTAssertTrue(item.isEnabled, "Database > Query Insights must be enabled on a live connection") - app.typeKey("q", modifierFlags: []) - app.typeKey(.return, modifierFlags: []) - } - - private func runQuery(in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - app.typeText(query) - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: .command) - - let results = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(results.waitToExist(timeout: 15), "The query must produce a result grid") - } -} diff --git a/TableProUITests/QueryParameterSortUITests.swift b/TableProUITests/QueryParameterSortUITests.swift deleted file mode 100644 index 33648d0c05..0000000000 --- a/TableProUITests/QueryParameterSortUITests.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// QueryParameterSortUITests.swift -// TableProUITests -// -// A header click on a result a parameterized run produced used to re-send the whole editor text with the ORDER BY -// on the end. The script here opens with a statement that cannot run twice on one connection, so a click that -// re-sends it fails with "table sort_probe already exists" instead of passing quietly. -// - -import XCTest - -final class QueryParameterSortUITests: UITestCase { - private static let script = - "CREATE TEMP TABLE sort_probe (id INTEGER); SELECT MediaTypeId, Name FROM MediaType WHERE MediaTypeId > :minId" - private static let sortedColumn = "Name" - private static let sortedColumnPosition = 2 - - func testSortingAParameterizedResultReRunsOnlyItsOwnStatement() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - app.typeKey("t", modifierFlags: .command) - typeQuery(Self.script, in: app) - - runAllStatements(in: app) - let value = window.textFields["query-parameter-value-minId"].firstMatch - XCTAssertTrue(value.waitToExist(timeout: 10), "Running a script with :minId must open the parameter panel") - value.click() - value.typeText("0") - runAllStatements(in: app) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(waitForClickableRows(in: grid, timeout: 30), "The SELECT must show MediaType's rows") - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.firstSortedCell(in: grid) == "MPEG audio file" }, - "The SELECT must come back in MediaTypeId order before the click" - ) - - try clickHeader(in: grid) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.firstSortedCell(in: grid) == "AAC audio file" }, - "The click must re-run the SELECT sorted by Name, got \(firstSortedCell(in: grid) ?? "no rows")" - ) - XCTAssertFalse( - window.staticTexts["query-error-message"].firstMatch.exists, - "The click must not re-send the CREATE TEMP TABLE ahead of the SELECT" - ) - } - - // MARK: - Helpers - - /// Through the Run split button's menu half, the way `QueryRunUITests` reaches it. - private func runAllStatements(in app: XCUIApplication) { - let window = app.windows.firstMatch - let runMenu = window.descendants(matching: .any) - .matching(identifier: "query-run-menu") - .firstMatch - XCTAssertTrue(waitUntilHittable(runMenu, timeout: 10), "The command bar must expose the Run menu") - runMenu.click() - window.menus.firstMatch.menuItems["Run All Statements"].click() - } - - /// Clicked through a coordinate taken off the grid, for the reason `HeaderSortUITests.clickHeader` gives: the - /// header element reports itself as never hittable. - private func clickHeader(in grid: XCUIElement) throws { - let header = grid.buttons - .matching(NSPredicate(format: "label BEGINSWITH %@", "Column: \(Self.sortedColumn)")) - .firstMatch - XCTAssertTrue(header.waitToExist(timeout: 30), "The grid must publish a \(Self.sortedColumn) header") - let frame = header.frame - let origin = grid.frame.origin - grid.coordinate(withNormalizedOffset: .zero) - .withOffset(CGVector(dx: frame.midX - origin.x, dy: frame.midY - origin.y)) - .click() - } - - private func firstSortedCell(in grid: XCUIElement) -> String? { - let row = grid.tableRows.firstMatch - guard row.exists else { return nil } - let cell = row.staticTexts - .matching(NSPredicate(format: "label BEGINSWITH %@", "Row 1, column \(Self.sortedColumnPosition): ")) - .firstMatch - guard cell.exists else { return nil } - return cell.value as? String - } -} diff --git a/TableProUITests/QueryPlanResultUITests.swift b/TableProUITests/QueryPlanResultUITests.swift index df72034653..460cdf5c2d 100644 --- a/TableProUITests/QueryPlanResultUITests.swift +++ b/TableProUITests/QueryPlanResultUITests.swift @@ -1,273 +1,81 @@ -// -// QueryPlanResultUITests.swift -// TableProUITests -// -// A query plan is an ordinary result set now, so it appears in the result tab strip and can be -// pinned. The sample SQLite database makes EXPLAIN QUERY PLAN output deterministic, with no -// server to reach. -// - import XCTest +// A plan is an ordinary result set: it rides the result chooser and can be pinned. SQLite makes the plan +// deterministic, with no server to reach. final class QueryPlanResultUITests: UITestCase { - /// One launch for one plan. - /// - /// All four of these ran the same `EXPLAIN QUERY PLAN` against the same sample database and - /// then looked at a different part of the result, each paying its own app launch to get there. - /// - /// The order follows the plan's own modes: Diagram is what it opens in, Tree is a click away, - /// and pinning comes last because it is the only step that changes the tab. - /// - /// `continueAfterFailure` is on because the phases are independent: with it off, a missing - /// diagram canvas would hide whether Tree mode still lists the plan's steps. - func testAPlanArrivesAsAResultTabWithEveryModeAndCanBePinned() throws { - continueAfterFailure = true - let app = try launchWithSampleDatabase() - runQuery("EXPLAIN QUERY PLAN SELECT * FROM Track;", in: app) + private static let query = "SELECT * FROM track JOIN album ON track.album_id = album.id;" - let chooser = app.windows.firstMatch.descendants(matching: .any) - .matching(identifier: "result-set-menu") - .firstMatch - XCTAssertTrue( - chooser.waitToExist(timeout: 20), - "A plan must arrive as a result set, not as a takeover of the results pane" - ) + func testExplainShowsAPlanWhoseStepsOpenAndZoom() throws { + let launch = try launchWithFixture() + let app = launch.app + let window = launch.window - let modePicker = app.radioGroups["query-plan-mode-picker"].firstMatch - XCTAssertTrue( - modePicker.waitToExist(timeout: 10), - "A parsed plan must offer the Diagram, Tree, Raw and Compare modes" - ) + typeQuery(Self.query, in: window) + app.typeShortcut(.explainQuery) - let canvas = app.descendants(matching: .any).matching(identifier: "query-plan-diagram").firstMatch - XCTAssertTrue(canvas.waitToExist(timeout: 20), "Diagram mode must show the plan canvas") - - modePicker.radioButtons["Tree"].click() - let outline = app.outlines["query-plan-outline"].firstMatch - XCTAssertTrue(outline.waitToExist(timeout: 10), "Tree mode must show the plan outline") - XCTAssertGreaterThan(outline.outlineRows.count, 0, "The outline must list the plan's steps") + let chooser = resultSetChooser(in: window) + XCTAssertTrue(chooser.waitToExist(timeout: 20), "A plan must arrive as a result set") + let canvas = window.scrollViews.matching(identifier: "query-plan-diagram").firstMatch + XCTAssertTrue(canvas.waitToExist(timeout: 20), "A plan opens in Diagram mode") + let zoomLevel = window.buttons["Reset Zoom"].firstMatch + XCTAssertTrue(zoomLevel.waitToExist(timeout: 10), "The plan must show its zoom level") + zoomLevel.click() + expectZoom("100%", on: zoomLevel) + let zoomOut = window.buttons["Zoom Out"].firstMatch + for level in ["75%", "67%", "50%"] { + zoomOut.click() + expectZoom(level, on: zoomLevel) + } + // Diagram mode shows a step's details in a popover, so the search starts at the app. let detail = app.descendants(matching: .any).matching(identifier: "query-plan-detail-pane").firstMatch - XCTAssertTrue(detail.waitToExist(timeout: 10), "Selecting a step must fill the detail pane") - - /// A plan keeps the status bar so it stays choosable and pinnable. It gives up the row - /// readout there and nothing else, which is what makes the bar under a plan a footer rather - /// than a claim about rows the plan does not have. - XCTAssertTrue(waitUntilHittable(chooser, timeout: 10)) - chooser.click() - - /// The pull-down opens inside the window; the menu-bar menus hang off `MenuBar`, so scoping - /// to the window isolates the one that just opened. Matching on the menu's accessibility - /// identifier instead worked here but not on the CI runner, whose macOS build exposes a - /// just-opened menu without one. - let chooserMenu = app.windows.firstMatch.menus.firstMatch + XCTAssertFalse(detail.exists, "No step is open before one is clicked") + let step = try XCTUnwrap(stepWellInside(canvas), "The plan must publish its steps as buttons") XCTAssertTrue( - chooserMenu.menuItems["Pin Result"].waitToExist(timeout: 5), - "A plan is a result set, so it must offer Pin Result" + waitUntilHittable(step, timeout: 5), + "A pointer query must find the step, or VoiceOver cannot read it under the pointer" ) - chooserMenu.menuItems["Pin Result"].click() - - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["View"].click() - let unpinItem = menuBar.menuItems["Unpin Result"] - XCTAssertTrue(unpinItem.waitToExist(timeout: 5), "A pinned plan reads as Unpin Result") - XCTAssertTrue(unpinItem.isEnabled) - app.typeKey(.escape, modifierFlags: []) - } - - /// Comparing a plan is a mode of the plan pane, not a sheet, so the editor behind it stays - /// usable and the comparison survives running the query again. The sample SQLite database makes - /// the plan change deterministic: creating an index turns a scan into a search. - func testComparingAPlanAgainstAnEarlierRun() throws { - let app = try launchWithSampleDatabase() - let subjectSQL = "SELECT * FROM Track WHERE Name = 'For Those About To Rock';" - - runExplainAction(subjectSQL, in: app) - let firstPlan = app.radioGroups["query-plan-mode-picker"].firstMatch - XCTAssertTrue(firstPlan.waitToExist(timeout: 20), "The first plan must finish before it can be a baseline") - - createPlanChangingIndex(in: app) + step.click() + XCTAssertTrue(detail.waitToExist(timeout: 5), "A click on a step in a zoomed-out diagram must open its details") - runQuery("EXPLAIN QUERY PLAN \(subjectSQL)", in: app) - let modePicker = app.radioGroups["query-plan-mode-picker"].firstMatch - XCTAssertTrue(modePicker.waitToExist(timeout: 20), "The second plan must arrive") - - let compareMode = modePicker.radioButtons["Compare"] - XCTAssertTrue( - waitUntilHittable(compareMode, timeout: 10), - "A run with an earlier plan behind it must offer Compare as a mode, not a sheet" - ) - compareMode.click() - - let baselinePicker = app.popUpButtons["query-plan-baseline-picker"].firstMatch - XCTAssertTrue( - baselinePicker.waitToExist(timeout: 15), - "Compare mode must offer the earlier run as a baseline" - ) - - let verdict = app.descendants(matching: .any) - .matching(identifier: "query-plan-comparison-verdict").firstMatch - XCTAssertTrue( - verdict.waitToExist(timeout: 15), - "The comparison must lead with what happened, not with a table of numbers" - ) - - XCTAssertTrue( - waitForPredicate(timeout: 15) { - ["added", "removed", "changed"].contains { kind in - app.descendants(matching: .any) - .matching(identifier: "query-plan-comparison-change-\(kind)").firstMatch.exists - } - }, - "Creating the index must produce a visible plan-node change" - ) - - let evidence = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) - evidence.name = "explain-plan-comparison" - evidence.lifetime = .keepAlways - add(evidence) - - /// The explicit Explain action and a hand-typed EXPLAIN have to land in one chain. This run - /// is the third of the same statement, so it must see both earlier ones. - runExplainAction(subjectSQL, in: app) - let laterPicker = app.radioGroups["query-plan-mode-picker"].firstMatch - XCTAssertTrue(laterPicker.waitToExist(timeout: 20)) - let laterCompare = laterPicker.radioButtons["Compare"] - XCTAssertTrue(waitUntilHittable(laterCompare, timeout: 10)) - laterCompare.click() - - let laterBaselines = app.popUpButtons["query-plan-baseline-picker"].firstMatch - XCTAssertTrue(laterBaselines.waitToExist(timeout: 15)) - laterBaselines.click() - XCTAssertTrue( - waitForPredicate(timeout: 10) { app.menuItems.count >= 2 }, - "A typed EXPLAIN and the Explain action must build one history, not two" - ) - app.typeKey(.escape, modifierFlags: []) - } - - /// SQLite's `EXPLAIN QUERY PLAN` reports no cost, no rows and no timing, so the tree has - /// nothing to chart. The columns that would be blank are not shown at all, and the metric - /// chooser that would have nothing to choose from is absent. - /// - /// This is the branch worth pinning here: the sample database is the only deterministic plan - /// available without a server, and it is exactly the case where a column-hiding bug would ship - /// an outline of empty columns beside a bar track that never fills. - func testAMetriclessPlanShowsNoEmptyColumnsAndNoMetricChooser() throws { - let app = try launchWithSampleDatabase() - runQuery("EXPLAIN QUERY PLAN SELECT * FROM Track;", in: app) - - let modePicker = app.radioGroups["query-plan-mode-picker"].firstMatch - XCTAssertTrue(modePicker.waitToExist(timeout: 20), "The plan must parse into a tree") - modePicker.radioButtons["Tree"].click() - - let outline = app.outlines["query-plan-outline"].firstMatch + let modes = window.radioGroups["query-plan-mode-picker"].firstMatch + modes.radioButtons["Tree"].click() + let outline = window.outlines["query-plan-outline"].firstMatch XCTAssertTrue(outline.waitToExist(timeout: 10), "Tree mode must show the plan outline") XCTAssertGreaterThan(outline.outlineRows.count, 0, "The outline must list the plan's steps") + modes.radioButtons["Diagram"].click() + XCTAssertTrue(canvas.waitToExist(timeout: 10), "Diagram mode must come back") - XCTAssertFalse( - app.popUpButtons["query-plan-metric-picker"].firstMatch.exists, - "A plan with no metric to chart must not offer a metric chooser" - ) - - // Which columns the outline drops for a metric-less plan is asserted in - // QueryPlanOutlineColumnVisibilityTests, against the coordinator and its NSTableColumns - // directly. The runner's accessibility tree does not publish this outline's headers the way - // this Mac does, so reading the column set through XCUITest tested the tree, not the rule. - } - - - func testEachPlanKeepsItsOwnZoom() throws { - let app = try launchWithSampleDatabase() - runQuery("EXPLAIN QUERY PLAN SELECT * FROM Track JOIN Album ON Track.AlbumId = Album.AlbumId;", in: app) - - let window = app.windows.firstMatch - let modePicker = window.radioGroups["query-plan-mode-picker"].firstMatch - XCTAssertTrue(modePicker.waitToExist(timeout: 20), "A parsed plan must offer its view modes") - let zoomLevel = window.buttons["Reset Zoom"].firstMatch - XCTAssertTrue(zoomLevel.waitToExist(timeout: 10), "Diagram mode must show the zoom level") + // Zoom In goes to whatever has focus. Exactly one step from 100% proves the press made from the + // editor never reached the plan. zoomLevel.click() - XCTAssertTrue(waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "100%" }) - window.buttons["Zoom Out"].firstMatch.click() - XCTAssertTrue(waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "75%" }) - - modePicker.radioButtons["Tree"].click() - XCTAssertTrue(window.outlines["query-plan-outline"].firstMatch.waitToExist(timeout: 10)) - modePicker.radioButtons["Diagram"].click() - - XCTAssertTrue(zoomLevel.waitToExist(timeout: 10), "Diagram mode must come back with its zoom level") - XCTAssertTrue( - waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "75%" }, - "Leaving Diagram mode and coming back must keep the zoom the plan was left on" - ) - - app.typeKey("t", modifierFlags: .command) - XCTAssertTrue(editorTextView(in: app).waitToExist(timeout: 10), "A new query tab must open") - app.typeKey("[", modifierFlags: [.command, .shift]) - XCTAssertTrue(zoomLevel.waitToExist(timeout: 10), "Show Previous Tab must return to the plan") + expectZoom("100%", on: zoomLevel) + editorTextView(in: window).click() + app.typeShortcut(.zoomIn) + canvas.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).click() + app.typeShortcut(.zoomIn) + expectZoom("150%", on: zoomLevel) + + // A re-run replaces every result that is not pinned. + app.typeShortcut(.pinResult) + editorTextView(in: window).click() + app.typeShortcut(.explainQuery) XCTAssertTrue( - waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == "75%" }, - "Returning to the editor tab must keep the plan's zoom" + waitForPredicate(timeout: 20) { chooser.title.contains("of 2") || chooser.title.hasSuffix("/2") }, + "A pinned plan must survive a second Explain beside the new one, got \(chooser.title)" ) + } - editorTextView(in: app).click() - app.typeKey(.return, modifierFlags: [.command, .shift]) + private func expectZoom(_ level: String, on zoomLevel: XCUIElement) { XCTAssertTrue( - waitForPredicate(timeout: 20) { (zoomLevel.value as? String) == "100%" }, - "Running the statement again makes a new plan, which must not inherit the old plan's zoom" - ) - - let compareMode = modePicker.radioButtons["Compare"] - XCTAssertTrue(waitUntilHittable(compareMode, timeout: 10), "A re-run plan must offer Compare") - compareMode.click() - XCTAssertTrue(waitForPredicate(timeout: 5) { (compareMode.value as? Int) == 1 }) - editorTextView(in: app).click() - app.typeKey(.return, modifierFlags: [.command, .shift]) - XCTAssertFalse( - waitForPredicate(timeout: 5) { (modePicker.radioButtons["Diagram"].value as? Int) == 1 }, - "Compare follows a statement that is run again, so a re-run must not drop the pane back to Diagram" + waitForPredicate(timeout: 5) { (zoomLevel.value as? String) == level }, + "The plan must sit at \(level), got \(zoomLevel.value as? String ?? "nothing")" ) - XCTAssertEqual(compareMode.value as? Int, 1) - } - - // MARK: - Helpers - - private func runQuery(_ sql: String, in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - typeQuery(sql, in: app) - app.typeKey(.return, modifierFlags: .command) - } - - private func runExplainAction(_ sql: String, in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - typeQuery(sql, in: app) - - let explainButton = app.buttons["Explain"].firstMatch - XCTAssertTrue(waitUntilHittable(explainButton, timeout: 10)) - explainButton.click() } - private func createPlanChangingIndex(in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText( - "CREATE INDEX plan_history_track_name ON Track(Name);\n" - + "SELECT name FROM sqlite_master WHERE name = 'plan_history_track_name';" - ) - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["Query"].click() - menuBar.menuItems["Execute All Statements"].click() - - let verificationResult = app.staticTexts["result-status-readout"].firstMatch - XCTAssertTrue( - verificationResult.waitToExist(timeout: 20), - "The test index must exist before the second plan runs" - ) - XCTAssertTrue( - app.staticTexts["plan_history_track_name"].firstMatch.waitToExist(timeout: 10), - "The verification query must find the test index" - ) + // A step near the edge can start the canvas's edge auto-pan under the pointer. + private func stepWellInside(_ canvas: XCUIElement) -> XCUIElement? { + let inner = canvas.frame.insetBy(dx: 60, dy: 60) + return canvas.buttons.allElementsBoundByIndex.first { inner.contains($0.frame) } } } diff --git a/TableProUITests/QueryRunUITests.swift b/TableProUITests/QueryRunUITests.swift index d51ef990cd..6c8b1fffe8 100644 --- a/TableProUITests/QueryRunUITests.swift +++ b/TableProUITests/QueryRunUITests.swift @@ -1,313 +1,71 @@ -// -// QueryRunUITests.swift -// TableProUITests -// -// Covers #2230: running every statement in the tab must be reachable from a control, not just a -// menu-bar command and a chord. The control is the Run split button in the query tab's own command -// bar, which is where it has to live: an NSToolbar belongs to the window, and a window here hosts -// table, structure and diagram tabs that have nothing to run. -// - import XCTest final class QueryRunUITests: UITestCase { - func testRunMenuOffersRunAllStatements() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText("SELECT 1;\nSELECT 2;") - - let menu = openRunMenu(in: app) - XCTAssertTrue( - menu.menuItems["Run All Statements"].waitToExist(timeout: 5), - "The Run item's menu must offer Run All Statements" - ) - XCTAssertTrue( - menu.menuItems["Run Without Limit"].exists, - "Run Without Limit must survive alongside it" - ) - XCTAssertTrue( - menu.menuItems["Clear Query"].exists, - "Clear Query lost its own button and rides this menu instead" - ) - app.typeKey(.escape, modifierFlags: []) - } - - func testRunOptionsMenuIsNamed() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - let runMenu = app.windows.firstMatch.descendants(matching: .any) - .matching(identifier: "query-run-menu") - .firstMatch - XCTAssertTrue(runMenu.waitToExist(timeout: 10), "The query tab's command bar must carry the Run menu") - XCTAssertTrue( - runMenu.label == "Run Options" || runMenu.title == "Run Options", - "The Run menu must be named Run Options: got label '\(runMenu.label)', title '\(runMenu.title)'" - ) - } - - /// Asserting the item exists would still pass if it were wired to the wrong command or to - /// nothing at all. Running it and counting the results is what covers the wiring. - func testRunAllStatementsRunsEveryStatementInTheTab() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText("SELECT 1;\nSELECT 2;") - - openRunMenu(in: app).menuItems["Run All Statements"].click() - - let chooser = app.windows.firstMatch.descendants(matching: .any) - .matching(identifier: "result-set-menu") - .firstMatch - XCTAssertTrue( - chooser.waitToExist(timeout: 30), - "Two statements must produce two results, which the status bar's chooser reports" - ) - /// `title`, not `label`. A SwiftUI `Menu` named by its own label content publishes that name - /// as `AXTitle`, which XCUITest exposes as `title`; `label` is `AXDescription` and is what - /// `.accessibilityLabel` would have filled, had it not wiped the name instead. - XCTAssertTrue( - waitForPredicate(timeout: 10) { chooser.title.contains("2") }, - "The chooser's title counts the results: expected it to name two, got \(chooser.title)" - ) - } - - func testRunAllRunsAScriptThatOpensItsOwnTransaction() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - typeQuery("BEGIN; SELECT 1 AS answer; COMMIT;", in: app) - - openRunMenu(in: app).menuItems["Run All Statements"].click() - - let window = app.windows.firstMatch - let chooser = window.descendants(matching: .any) - .matching(identifier: "result-set-menu") - .firstMatch - let banner = window.staticTexts["query-error-message"].firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 30) { chooser.exists || banner.exists }, - "Running the script must end in results or an error" - ) - XCTAssertFalse( - banner.exists, - "A script that opens its own transaction must not collide with one the app opened around it" - ) - XCTAssertTrue( - waitForPredicate(timeout: 10) { chooser.title.contains("3") }, - "All three statements must run and report a result: got \(chooser.title)" - ) - } - - func testRunAllRollsBackTheTransactionAFailedScriptLeftOpen() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - typeQuery( - "BEGIN; CREATE TABLE run_all_rollback_probe (id INTEGER); SELECT * FROM run_all_missing; COMMIT;", - in: app - ) - openRunMenu(in: app).menuItems["Run All Statements"].click() - - let banner = app.windows.firstMatch.staticTexts["query-error-message"].firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 30) { bannerText(banner).contains("Statement 3/4 failed") }, - "The script must stop at its third statement: got \(bannerText(banner))" - ) - - app.typeKey("t", modifierFlags: .command) - typeQuery("SELECT * FROM run_all_rollback_probe;", in: app) - app.typeKey(.return, modifierFlags: .command) - - XCTAssertTrue( - waitForPredicate(timeout: 30) { bannerText(banner).contains("no such table: run_all_rollback_probe") }, - "The table the failed script created inside its own transaction must be rolled back: got \(bannerText(banner))" - ) - } + private let query = "SELECT title FROM album;" - /// SQLite applies `PRAGMA foreign_keys` only outside a transaction, and applies it silently: - /// inside one it changes nothing, raises nothing, and reads back `0` after the commit. So the - /// proof that the script ran without a wrap is the constraint the fourth statement then breaks. - func testRunAllAppliesAForeignKeyPragmaInTheSameScript() throws { - let app = try launchWithSampleDatabase() + // No table stands behind these results, so each one has to be named after its statement. + private let script = """ + -- monthly totals + SELECT 1; + SELECT 2; + """ - app.typeKey("t", modifierFlags: .command) - typeQuery( - "PRAGMA foreign_keys = ON; " - + "CREATE TABLE run_all_fk_parent (id INTEGER PRIMARY KEY); " - + "CREATE TABLE run_all_fk_child (parent INTEGER REFERENCES run_all_fk_parent(id)); " - + "INSERT INTO run_all_fk_child VALUES (42);", - in: app - ) - openRunMenu(in: app).menuItems["Run All Statements"].click() + func testRunningAStatementAndAScript() throws { + let launch = try launchWithFixture() + let app = launch.app + let window = launch.window - let banner = app.windows.firstMatch.staticTexts["query-error-message"].firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 30) { bannerText(banner).contains("Statement 4/4 failed") }, - "The pragma must reach the session, so the insert breaks the constraint: got \(bannerText(banner))" - ) - XCTAssertTrue( - bannerText(banner).contains("FOREIGN KEY constraint failed"), - "The server's own reason must survive into the banner: got \(bannerText(banner))" - ) - } + app.typeShortcut(.newTab) + let editor = editorTextView(in: window) + XCTAssertTrue(editor.waitToExist(timeout: 10), "Cmd+T must open a query tab") + XCTAssertTrue(waitForEditorText(editor) { $0.isEmpty }, "A new tab starts with an empty editor") - /// `VACUUM` inside a transaction answers "cannot VACUUM from within a transaction", so a script - /// holding one must run without the app's wrap and report all three results. - func testRunAllRunsVacuumOutsideATransaction() throws { - let app = try launchWithSampleDatabase() + typeQuery(query, in: window) + app.typeShortcut(.runQuery) + let grid = dataGrid(in: window) + XCTAssertTrue(waitForClickableRows(in: grid), "Cmd+Return must run the statement into a grid") + XCTAssertTrue(gridCell(showing: "North", in: grid).waitToExist(timeout: 10), "The grid must hold album's titles") - app.typeKey("t", modifierFlags: .command) - typeQuery("CREATE TABLE run_all_vacuum_probe (id INTEGER); VACUUM; SELECT 1 AS answer;", in: app) - openRunMenu(in: app).menuItems["Run All Statements"].click() - - let window = app.windows.firstMatch - let chooser = window.descendants(matching: .any) - .matching(identifier: "result-set-menu") - .firstMatch - let banner = window.staticTexts["query-error-message"].firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 30) { chooser.exists || banner.exists }, - "Running the script must end in results or an error" - ) - XCTAssertFalse( - banner.exists, - "VACUUM must not be wrapped in a transaction: got \(bannerText(banner))" - ) + app.typeKey(.delete, modifierFlags: .command) XCTAssertTrue( - waitForPredicate(timeout: 10) { chooser.title.contains("3") }, - "All three statements must run and report a result: got \(chooser.title)" + waitForEditorText(editor) { $0.isEmpty }, + "After a run, Cmd+Delete must delete the editor line; editor holds '\(editorText(editor))'" ) - } - /// A batch runs on the connection's shared session, so a `BEGIN` the user ran a moment earlier - /// is still in force. The app must send no transaction of its own over it: SQLite refuses the - /// nested `BEGIN` outright, and the engines that accept one commit or discard the user's work. - func testRunAllJoinsTheTransactionTheUserOpened() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - app.typeKey("t", modifierFlags: .command) - typeQuery("BEGIN;", in: app) - app.typeKey(.return, modifierFlags: .command) + typeQuery(query, in: window) + let firstRow = grid.tableRows.element(boundBy: 0) + gridPoint(in: grid, of: window, dy: firstRow.frame.midY - grid.frame.minY).click() + XCTAssertTrue(waitForPredicate(timeout: 10) { firstRow.isSelected }, "Clicking a result row must select it") + editor.click() + app.typeKey(.rightArrow, modifierFlags: .command) + app.typeKey(.delete, modifierFlags: .command) XCTAssertTrue( - window.staticTexts["Query executed successfully"].waitToExist(timeout: 30), - "The user's own BEGIN must run before the batch does" - ) - - typeQuery( - "CREATE TABLE run_all_session_probe (id INTEGER); INSERT INTO run_all_session_probe VALUES (1);", - in: app + waitForEditorText(editor) { $0.isEmpty }, + "A selected result row must not take Cmd+Delete from the editor; editor holds '\(editorText(editor))'" ) - openRunMenu(in: app).menuItems["Run All Statements"].click() - let chooser = window.descendants(matching: .any) - .matching(identifier: "result-set-menu") - .firstMatch - let banner = window.staticTexts["query-error-message"].firstMatch + typeQuery(script, in: window) + app.typeShortcut(.runAllStatements) + let chooser = resultSetChooser(in: window) + XCTAssertTrue(chooser.waitToExist(timeout: 20), "Cmd+Shift+Return must report a result per statement") + // A SwiftUI Menu publishes its label content as AXTitle; `label` stays empty. XCTAssertTrue( - waitForPredicate(timeout: 30) { chooser.exists || banner.exists }, - "Running the batch must end in results or an error" - ) - XCTAssertFalse( - banner.exists, - "The batch must join the open transaction rather than open one: got \(bannerText(banner))" - ) - XCTAssertTrue( - waitForPredicate(timeout: 10) { chooser.title.contains("2") }, - "Both statements must run inside the user's transaction: got \(chooser.title)" + waitForPredicate(timeout: 15) { chooser.title.contains("of 2") || chooser.title.hasSuffix("/2") }, + "Both statements must run, got \(chooser.title)" ) - typeQuery("ROLLBACK; SELECT * FROM run_all_session_probe;", in: app) - openRunMenu(in: app).menuItems["Run All Statements"].click() + XCTAssertTrue(waitUntilHittable(chooser, timeout: 10)) + chooser.click() + // A just-opened pull-down has no identifier on the runner's macOS build, so it is scoped to the window. + let items = window.menus.firstMatch.menuItems + XCTAssertTrue(items.firstMatch.waitToExist(timeout: 5), "The chooser must list its results") + let names = items.allElementsBoundByIndex.map { $0.title } XCTAssertTrue( - waitForPredicate(timeout: 30) { - bannerText(banner).contains("no such table: run_all_session_probe") - }, - "Nothing the batch ran may be committed: the user's rollback must take it: got \(bannerText(banner))" + names.contains { $0.contains("monthly totals") }, + "A statement with a leading comment is named after it, got \(names)" ) - } - - /// A failure inside the user's transaction leaves it open, and says so. Rolling it back here - /// would discard whatever they had already done inside it, which is not the batch's to take. - func testRunAllLeavesTheUserTransactionOpenAfterAFailure() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - app.typeKey("t", modifierFlags: .command) - typeQuery("BEGIN;", in: app) - app.typeKey(.return, modifierFlags: .command) - XCTAssertTrue( - window.staticTexts["Query executed successfully"].waitToExist(timeout: 30), - "The user's own BEGIN must run before the batch does" - ) - - typeQuery( - "CREATE TABLE run_all_kept_probe (id INTEGER); " - + "INSERT INTO run_all_kept_probe VALUES (1); " - + "SELECT * FROM run_all_missing;", - in: app - ) - openRunMenu(in: app).menuItems["Run All Statements"].click() - - let banner = window.staticTexts["query-error-message"].firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 30) { bannerText(banner).contains("Statement 3/3 failed") }, - "The batch must stop at the statement that failed: got \(bannerText(banner))" - ) - XCTAssertTrue( - bannerText(banner).contains("still open"), - "The banner must say the user's transaction is still theirs to end: got \(bannerText(banner))" - ) - - typeQuery("COMMIT; SELECT * FROM run_all_kept_probe;", in: app) - openRunMenu(in: app).menuItems["Run All Statements"].click() - - /// The failed batch leaves its banner up and "Result 3 of 3" in the chooser until this one - /// lands, so only two results with no banner over them say the commit and the read both ran. - let chooser = window.descendants(matching: .any) - .matching(identifier: "result-set-menu") - .firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 30) { - !banner.exists && chooser.exists && chooser.title.contains("2") - }, - "The transaction must still be open to commit, and its table must have survived the failure: " - + "got \(chooser.exists ? chooser.title : "") \(bannerText(banner))" - ) - } - - private func bannerText(_ banner: XCUIElement) -> String { - guard banner.exists else { return "" } - return (banner.value as? String) ?? banner.label - } - - /// The control is a split button: its body runs the query and only its trailing half opens the - /// menu, so the two halves are addressed separately. The menu half carries its own identifier, - /// which is what this resolves. Reaching it as a fraction of the Run half's width used to work - /// and no longer can: the menu half is the part whose width the label decides, and it went from - /// 115pt to 47pt when the duplicate chevron came out of that label. - /// - /// The opened menu is scoped to the window rather than matched by identifier, because the CI - /// runner's macOS build exposes a just-opened menu without one. - @discardableResult - private func openRunMenu(in app: XCUIApplication) -> XCUIElement { - let window = app.windows.firstMatch - let runMenu = window.descendants(matching: .any) - .matching(identifier: "query-run-menu") - .firstMatch - XCTAssertTrue( - waitUntilHittable(runMenu, timeout: 10), - "The query tab's command bar must expose the Run split button's menu half" - ) - runMenu.click() - return window.menus.firstMatch + XCTAssertTrue(names.contains { $0.hasPrefix("SELECT 2") }, "A bare statement is named after its text, got \(names)") + XCTAssertFalse(names.contains { $0.hasPrefix("Result ") }, "No result may fall back to its position, got \(names)") + app.typeKey(.escape, modifierFlags: []) } } diff --git a/TableProUITests/QueryStatementNavigationUITests.swift b/TableProUITests/QueryStatementNavigationUITests.swift deleted file mode 100644 index 313c341802..0000000000 --- a/TableProUITests/QueryStatementNavigationUITests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import XCTest - -/// The three commands driven the way a user reaches them, through the Query menu rather than the key equivalent, -/// because the shortcuts are rebindable and the menu item is the contract. -final class QueryStatementNavigationUITests: UITestCase { - private let script = "SELECT 1;\nSELECT 2;\nSELECT 3;" - - func testTheQueryMenuOffersAllThreeStatementCommands() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText(script) - XCTAssertTrue(waitForEditorText(containing: "SELECT 3", in: editor)) - - let queryMenu = app.menuBars.menuBarItems["Query"] - XCTAssertTrue(queryMenu.waitToExist(timeout: 10)) - queryMenu.click() - - for title in ["Previous Statement", "Next Statement", "Run Statement and Advance"] { - let item = app.menuBars.menuItems[title] - XCTAssertTrue(item.waitToExist(timeout: 10), "Query > \(title) must exist") - XCTAssertTrue(item.isEnabled, "Query > \(title) must be enabled on a query tab") - } - - app.typeKey(.escape, modifierFlags: []) - } - - func testNextStatementMovesTheCaretWithoutChangingTheDocument() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText(script) - XCTAssertTrue(waitForEditorText(containing: "SELECT 3", in: editor)) - - clickQueryMenuItem("Previous Statement", in: app) - clickQueryMenuItem("Previous Statement", in: app) - - XCTAssertTrue( - waitForEditorText(containing: script, in: editor), - "Moving the caret must never change the document text" - ) - } - - /// The caret lands on the statement, so typing goes into it. That is the observable difference between a caret - /// move and a no-op, and it is what the command exists for. - func testTheCaretActuallyLandsOnTheTargetStatement() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText(script) - XCTAssertTrue(waitForEditorText(containing: "SELECT 3", in: editor)) - - clickQueryMenuItem("Previous Statement", in: app) - clickQueryMenuItem("Previous Statement", in: app) - app.typeText("X") - - XCTAssertTrue( - waitForEditorText(containing: "XSELECT 2;", in: editor), - "the caret should have landed at the start of the second statement" - ) - } - - // MARK: - Helpers - - /// Resolved and clicked in one step, with no click on the parent menu first. macOS exposes an - /// unopened menu's items in the accessibility tree, so opening the parent buys nothing and - /// costs XCUITest a second traversal of the menu bar, which measures at 4 to 6 seconds on the - /// CI runner once a connection window is loaded. `launchWithSampleDatabase` has always relied - /// on this. - private func clickQueryMenuItem(_ title: String, in app: XCUIApplication) { - let item = app.menuBars.menuItems[title] - XCTAssertTrue(item.waitToExist(timeout: 10), "Query > \(title) must exist") - item.click() - } - - private func openQueryTab(in app: XCUIApplication) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - return editor - } - - private func waitForEditorText( - containing needle: String, - in editor: XCUIElement, - timeout: TimeInterval = 10 - ) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if (editor.value as? String)?.contains(needle) == true { - return true - } - usleep(200_000) - } - return false - } -} diff --git a/TableProUITests/QueryStatementRunUITests.swift b/TableProUITests/QueryStatementRunUITests.swift deleted file mode 100644 index 221990084d..0000000000 --- a/TableProUITests/QueryStatementRunUITests.swift +++ /dev/null @@ -1,117 +0,0 @@ -import XCTest - -/// The gutter's run controls are drawn rather than hosted as a view each, so the only thing assistive technology and -/// this test can reach is the accessibility element the ribbon publishes for each one. That element is also what a -/// VoiceOver user presses, so driving it here tests the same path a real user takes. -final class QueryStatementRunUITests: UITestCase { - private let script = "SELECT 1;\nSELECT 2;\nSELECT 3;" - - /// One launch, one query tab, four scripts. - /// - /// Each of these used to launch the app and open the sample database to type a different script - /// into an empty editor. The editor is the only thing they need, and selecting all and retyping - /// empties it just as well as a new app does. - /// - /// The text is replaced rather than typed into a new tab each time, so a control left behind by - /// a previous script cannot be counted by the next one: `runControls` matches on the window, - /// and every count assertion here would be wrong if an earlier script's gutter survived. - /// - /// `continueAfterFailure` is on because the phases are independent. - func testTheGutterPublishesOneRunControlPerStatement() throws { - continueAfterFailure = true - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - replace(editor, in: app, with: script, settleOn: "SELECT 3") - let controls = runControls(in: app) - XCTAssertTrue( - controls.firstMatch.waitToExist(timeout: 10), - "Every statement must publish a run control" - ) - XCTAssertEqual(controls.count, 3, "Three statements must produce three run controls") - - /// The control has to sit beside the statement it runs, so its label names the line the statement starts on. - /// Pressing it is covered by `StatementRunControllerTests`, because XCUITest resolves a click through a - /// positional accessibility lookup and cannot map a drawn element inside the gutter, which floats over the - /// text view. The existing fold tests drive the Query menu for the same reason. - for line in 1 ... 3 { - XCTAssertTrue( - runControl(onLine: line, in: app).waitToExist(timeout: 10), - "The statement on line \(line) must have its own run control" - ) - } - XCTAssertFalse( - runControl(onLine: 4, in: app).exists, - "A line with no statement on it must have no run control" - ) - - /// A routine body is one statement, so it gets one control on its opening line rather than one per statement - /// inside it. Splitting there is what used to send a fragment to the driver. - replace( - editor, - in: app, - with: "CREATE TRIGGER t AFTER INSERT ON a BEGIN\nUPDATE b SET x = 1;\nDELETE FROM c;\nEND;\n", - settleOn: "DELETE FROM c" - ) - XCTAssertTrue(runControls(in: app).firstMatch.waitToExist(timeout: 10)) - XCTAssertEqual(runControls(in: app).count, 1, "The whole BEGIN ... END body is one statement") - XCTAssertTrue( - runControl(onLine: 1, in: app).exists, - "The one control belongs on the line the routine opens" - ) - - replace(editor, in: app, with: "SELECT 1;\n-- just a note\n", settleOn: "just a note") - XCTAssertTrue(runControls(in: app).firstMatch.waitToExist(timeout: 10)) - XCTAssertEqual( - runControls(in: app).count, - 1, - "A comment carries no statement, so it gets no run control" - ) - } - - // MARK: - Helpers - - private func runControls(in app: XCUIApplication) -> XCUIElementQuery { - app.windows.firstMatch.buttons.matching( - NSPredicate(format: "label BEGINSWITH %@", "Run the statement starting on line") - ) - } - - private func runControl(onLine line: Int, in app: XCUIApplication) -> XCUIElement { - app.windows.firstMatch.buttons["Run the statement starting on line \(line)"] - } - - private func openQueryTab(in app: XCUIApplication) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - return editor - } - - /// Select all and type over it. The editor keeps its identity, so the gutter republishes its - /// controls for the new text instead of adding to the old ones. - private func replace(_ editor: XCUIElement, in app: XCUIApplication, with text: String, settleOn needle: String) { - editor.click() - app.typeKey("a", modifierFlags: .command) - app.typeText(text) - XCTAssertTrue( - waitForEditorText(containing: needle, in: editor), - "The editor must hold the script before its run controls mean anything" - ) - } - - private func waitForEditorText( - containing needle: String, - in editor: XCUIElement, - timeout: TimeInterval = 10 - ) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if (editor.value as? String)?.contains(needle) == true { - return true - } - usleep(200_000) - } - return false - } -} diff --git a/TableProUITests/QueryTabDeleteLineUITests.swift b/TableProUITests/QueryTabDeleteLineUITests.swift deleted file mode 100644 index 5fee28cf98..0000000000 --- a/TableProUITests/QueryTabDeleteLineUITests.swift +++ /dev/null @@ -1,76 +0,0 @@ -import XCTest - -final class QueryTabDeleteLineUITests: UITestCase { - private let query = "SELECT * FROM Genre;" - - func testCommandDeleteDeletesTheEditorLineAfterRunningAQuery() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText(query) - XCTAssertTrue(waitForValue(query, in: editor, timeout: 5)) - executeQuery(in: app) - - app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: .command) - - XCTAssertTrue( - waitForValue("", in: editor, timeout: 5), - "Cmd+Delete must delete to the start of the line; got '\(editor.value as? String ?? "nil")'" - ) - } - - func testCommandDeleteDeletesTheEditorLineAfterSelectingAResultRow() throws { - let app = try launchWithSampleDatabase() - let editor = openQueryTab(in: app) - - app.typeText(query) - XCTAssertTrue(waitForValue(query, in: editor, timeout: 5)) - executeQuery(in: app) - - let grid = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 10)) - XCTAssertTrue(grid.tableRows.element(boundBy: 0).waitToExist(timeout: 10)) - /// A point in the grid rather than the row element. A table publishes its columns as - /// siblings of its rows, each one as tall as every row it spans and later in the tree, so - /// XCUITest reads every row and every cell as obscured and refuses to click either. The - /// offset lands in the first row's first data column, past the header and the row numbers. - grid.coordinate(withNormalizedOffset: .zero) - .withOffset(CGVector(dx: 60, dy: 52)) - .click() - - editor.click() - app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: .command) - app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: .command) - - XCTAssertTrue( - waitForValue("", in: editor, timeout: 5), - "A selected result row must not steal Cmd+Delete from the editor; got " - + "'\(editor.value as? String ?? "nil")'" - ) - } - - private func openQueryTab(in app: XCUIApplication) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue(waitForValue("", in: editor, timeout: 5), "A new tab starts with an empty editor") - return editor - } - - private func executeQuery(in app: XCUIApplication) { - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: .command) - let results = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(results.waitToExist(timeout: 15), "The query must produce a result grid") - } - - private func waitForValue(_ expected: String, in element: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date(timeIntervalSinceNow: timeout) - while Date() < deadline { - if (element.value as? String) == expected { - return true - } - RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) - } - return (element.value as? String) == expected - } -} diff --git a/TableProUITests/QuickSwitcherCrossConnectionUITests.swift b/TableProUITests/QuickSwitcherCrossConnectionUITests.swift deleted file mode 100644 index dd365c4d8e..0000000000 --- a/TableProUITests/QuickSwitcherCrossConnectionUITests.swift +++ /dev/null @@ -1,135 +0,0 @@ -import XCTest - -final class QuickSwitcherCrossConnectionUITests: UITestCase { - func testConnectionsScopeSearchesTheOpenSampleDatabase() throws { - let app = try launchWithSampleDatabase() - /// The sample opens a table tab, and the switcher shortcut is sent to an app that is still - /// connecting and loading if the test does not wait for that to finish. The grid appearing - /// is the signal, and waiting for the window alone is not: the window exists immediately. - /// This test passed locally and lost the keystroke on the slower CI runner without it. - XCTAssertTrue( - app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - .waitToExist(timeout: 30) - ) - - /// The switcher is a floating panel, and on the CI runner the shortcut produced nothing - /// while the grid was loaded and focused. Activating first makes the app frontmost before - /// the key goes out, which is the one difference between here and a developer's machine. - app.activate() - app.typeKey("o", modifierFlags: [.command, .shift]) - let panel = switcherPanel(in: app) - let searchField = panel.textFields["quick-switcher-search-field"] - XCTAssertTrue(searchField.waitToExist(timeout: 15)) - - /// The scope chips are present in every scope now, so a chip's existence no longer says the - /// key press landed. The grouped connection header below is what proves the scope changed. - app.typeKey("5", modifierFlags: .command) - XCTAssertTrue(panel.staticTexts["Chinook (Sample)"].waitToExist(timeout: 15)) - XCTAssertTrue(panel.buttons["Track"].waitToExist(timeout: 5)) - - searchField.typeText("track") - XCTAssertTrue(panel.buttons["Track"].waitToExist(timeout: 5)) - - searchField.typeKey("a", modifierFlags: .command) - searchField.typeText("missing-object-name") - XCTAssertTrue(panel.staticTexts["No results for \"missing-object-name\""].waitToExist(timeout: 5)) - - searchField.typeKey("a", modifierFlags: .command) - searchField.typeText("track") - searchField.typeKey(.return, modifierFlags: []) - XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) - /// Every open connection lives in one window, so opening an object adds a tab rather than a - /// window, and the window retitles to the tab it selected. The strip stays hidden while the - /// connection holds a single tab, so the title is all there is to assert on here. - let trackWindow = app.children(matching: .window) - .matching(NSPredicate(format: "title BEGINSWITH %@", "Track")).firstMatch - XCTAssertTrue(trackWindow.waitToExist(timeout: 10)) - XCTAssertEqual(app.children(matching: .window).count, 1) - - app.typeKey("o", modifierFlags: [.command, .shift]) - XCTAssertTrue(searchField.waitToExist(timeout: 5)) - app.typeKey("5", modifierFlags: .command) - searchField.typeText("invoice") - /// The panel has to have ranked Invoice before Return is sent. A new query used to leave the - /// previously highlighted row selected whenever it still matched, and in this scope every - /// subtitle carries the connection path, so Return committed the row typed before it. - XCTAssertTrue(panel.buttons["Invoice"].waitToExist(timeout: 10)) - searchField.typeKey(.return, modifierFlags: .option) - XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) - /// Option+Return opens beside the tab you were on instead of replacing it, which is the one - /// thing that separates it from a plain Return. The second tab is also what brings the strip - /// out of hiding, so both tabs are assertable from here and neither was before. - XCTAssertTrue(tab(in: app, named: "Invoice").waitToExist(timeout: 10)) - XCTAssertTrue(tab(in: app, named: "Track").exists) - XCTAssertEqual(app.children(matching: .window).count, 1) - - app.typeKey("o", modifierFlags: [.command, .shift]) - XCTAssertTrue(searchField.waitToExist(timeout: 5)) - searchField.typeText("dismiss-me") - searchField.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(searchField.waitToExist(timeout: 5), "The first Escape clears the query, it does not dismiss") - XCTAssertEqual(searchField.value as? String, "") - searchField.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) - } - - func testQueriesScopeSearchesHistoryAndOpensANewTab() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - let query = "SELECT 42 AS cross_connection_probe;" - queryEditor.click() - app.typeText(query) - app.typeKey(.return, modifierFlags: .command) - XCTAssertTrue( - app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - .waitToExist(timeout: 15) - ) - queryEditor.click() - queryEditor.typeKey("a", modifierFlags: .command) - queryEditor.typeText("SELECT 0;") - - app.typeKey("o", modifierFlags: [.command, .shift]) - let panel = switcherPanel(in: app) - let searchField = panel.textFields["quick-switcher-search-field"] - XCTAssertTrue(searchField.waitToExist(timeout: 10)) - /// The history result below is what proves Cmd+4 landed; the chip is always present now. - app.typeKey("4", modifierFlags: .command) - - searchField.typeText("cross_connection_probe") - let historyResult = panel.buttons.matching( - NSPredicate(format: "label CONTAINS %@", "cross_connection_probe") - ).firstMatch - XCTAssertTrue(historyResult.waitToExist(timeout: 10)) - - searchField.typeKey(.return, modifierFlags: .option) - XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) - /// The editor's accessibility identifier is set on the SwiftUI wrapper and does not reach - /// the text view itself, so the query it carries is what identifies the tab that opened. - let openedEditor = app.textViews - .matching(NSPredicate(format: "value CONTAINS %@", "cross_connection_probe")) - .firstMatch - XCTAssertTrue(openedEditor.waitToExist(timeout: 10)) - } - - /// Every lookup inside the switcher goes through here. Rooted at the application the same - /// queries walk the whole accessibility tree, and with a data grid loaded in the main window - /// one existence check overran XCTest's own four second evaluation watchdog and had to be - /// retried: this test alone spent seven minutes of the job's budget on that. - /// - /// `children` rather than `descendants` keeps resolving the panel shallow, because the panel is - /// a direct child of the application element and searching the descendants for it costs the - /// walk all over again. The type stays `.any` because AppKit gives a floating panel the - /// `AXDialog` subrole, which XCUITest reports as `Dialog` and not as `Window`. - private func switcherPanel(in app: XCUIApplication) -> XCUIElement { - app.children(matching: .any).matching(identifier: "quick-switcher-panel").firstMatch - } - - private func tab(in app: XCUIApplication, named name: String) -> XCUIElement { - app.buttons.matching( - NSPredicate(format: "identifier == %@ AND label BEGINSWITH %@", "editor-tab", name) - ).firstMatch - } -} diff --git a/TableProUITests/RecentTabSwitchingUITests.swift b/TableProUITests/RecentTabSwitchingUITests.swift index f3aad5258a..9e5e0c19f4 100644 --- a/TableProUITests/RecentTabSwitchingUITests.swift +++ b/TableProUITests/RecentTabSwitchingUITests.swift @@ -1,91 +1,32 @@ -// -// RecentTabSwitchingUITests.swift -// TableProUITests -// -// Control-Tab walks the window's tabs in the order they were used, not the order the strip draws -// them. (#2524) -// - -import AppKit import XCTest +// Control-Tab walks the window's tabs in the order they were used, not the order the strip draws them (#2524). final class RecentTabSwitchingUITests: UITestCase { - /// After opening Album, Artist, Customer and Employee, then selecting Album and Employee, the - /// order is Employee, Album, Customer, Artist. A tap goes back one tab and a second tap comes - /// back. Holding Control is covered by RecentTabSwitcherControllerTests. + // Using track and then artist makes track the last tab before artist, and neither of artist's neighbours. + // Holding Control is covered by RecentTabSwitcherControllerTests. func testControlTabWalksTabsInTheOrderTheyWereUsed() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) + let tables = ["album", "artist", "genre", "track"] + let launch = try launchWithFixture([ConnectionSeed(tabs: tables.map { SeededTab.table($0) })]) + let app = launch.app + let window = launch.window - openTables(["Album", "Artist", "Customer", "Employee"], in: window) XCTAssertTrue( - waitForPredicate(timeout: 20) { - Set(self.tabLabels(in: window)).isSuperset(of: ["Album", "Artist", "Customer", "Employee"]) - }, - "The strip must show a tab per opened table, got \(tabLabels(in: window))" + waitForPredicate(timeout: 20) { self.editorTabTitles(in: window) == tables }, + "The strip must restore a tab per table, got \(editorTabTitles(in: window))" ) + selectEditorTab(titled: "track", in: window) + selectEditorTab(titled: "artist", in: window) - select("Album", in: window) - select("Employee", in: window) - - app.typeKey(.tab, modifierFlags: .control) + app.typeShortcut(.recentTab) XCTAssertTrue( - waitForSelection("Album", in: window), - "A tap must go back to Album, the tab used before Employee, not its strip neighbour. Got " - + (selectedTabLabel(in: window) ?? "none") + waitForPredicate(timeout: 10) { self.selectedEditorTabTitle(in: window) == "track" }, + "A tap must go back to track, the tab used before artist. Got \(selectedEditorTabTitle(in: window) ?? "none")" ) - app.typeKey(.tab, modifierFlags: .control) - XCTAssertTrue(waitForSelection("Employee", in: window), "A second tap must come back to Employee") - } - - // MARK: - Helpers - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) + app.typeShortcut(.recentTab) XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" + waitForPredicate(timeout: 10) { self.selectedEditorTabTitle(in: window) == "artist" }, + "A second tap must come back to artist. Got \(selectedEditorTabTitle(in: window) ?? "none")" ) - return window - } - - /// Double-clicked, because a single click opens a preview tab that the next table takes over. - private func openTables(_ names: [String], in window: XCUIElement) { - for name in names { - let row = objectBrowserRow(name, in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(name)") - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - } - } - - private func tabElements(in window: XCUIElement) -> [XCUIElement] { - window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .allElementsBoundByIndex - } - - private func tabLabels(in window: XCUIElement) -> [String] { - tabElements(in: window).map { $0.label } - } - - private func selectedTabLabel(in window: XCUIElement) -> String? { - tabElements(in: window).first { $0.isSelected }?.label - } - - private func waitForSelection(_ name: String, in window: XCUIElement) -> Bool { - waitForPredicate(timeout: 10) { self.selectedTabLabel(in: window) == name } - } - - private func select(_ name: String, in window: XCUIElement) { - let tab = window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .matching(NSPredicate(format: "label == %@", name)) - .firstMatch - XCTAssertTrue(waitUntilHittable(tab, timeout: 20), "The \(name) tab must be on screen") - tab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() - XCTAssertTrue(waitForSelection(name, in: window), "Clicking the \(name) tab must select it") } } diff --git a/TableProUITests/RedisConnectionModeUITests.swift b/TableProUITests/RedisConnectionModeUITests.swift deleted file mode 100644 index f5af5ac035..0000000000 --- a/TableProUITests/RedisConnectionModeUITests.swift +++ /dev/null @@ -1,150 +0,0 @@ -import XCTest - -/// The Redis connection form swaps whole groups of fields when the mode changes, and the fields it -/// swaps live in two different panes. Nothing below the view models proves the swap actually -/// reaches the screen, so this drives the real form. -/// -/// Every element here is reached by accessibility identifier. The form renders plugin fields as -/// hosted SwiftUI controls, which arrive with their text in `value` and no label of their own, so -/// a query by visible title matches nothing at all. -final class RedisConnectionModeUITests: UITestCase { - private let modePicker = "connection-field-redisMode" - private let sentinelNodes = "connection-field-redisSentinelHosts" - private let sentinelGroupName = "connection-field-redisSentinelMasterName" - private let clusterNodes = "connection-field-redisClusterHosts" - private let databaseIndex = "connection-field-redisDatabase" - - func testSwitchingConnectionModeShowsOnlyThatModesFields() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let window = try openRedisConnectionForm(in: app) - let picker = window.popUpButtons[modePicker] - XCTAssertTrue( - picker.waitToExist(timeout: 10), - "The Redis form should offer a Connection Mode picker" - ) - - XCTAssertTrue(window.textFields["connection-form-host"].exists, "Standalone shows Host and Port") - XCTAssertFalse(hasField(sentinelNodes, in: window)) - XCTAssertFalse(hasField(clusterNodes, in: window)) - - select(option: "Sentinel", in: picker) - XCTAssertTrue( - waitForPredicate(timeout: 5) { hasField(sentinelNodes, in: window) }, - "Sentinel mode replaces Host and Port with the Sentinel node list" - ) - XCTAssertTrue(window.textFields[sentinelGroupName].waitToExist(timeout: 5)) - XCTAssertFalse(hasField(clusterNodes, in: window)) - XCTAssertFalse(window.textFields["connection-form-host"].exists) - - select(option: "Cluster", in: picker) - XCTAssertTrue( - waitForPredicate(timeout: 5) { hasField(clusterNodes, in: window) }, - "Cluster mode shows its own seed node list" - ) - XCTAssertFalse(hasField(sentinelNodes, in: window)) - XCTAssertFalse(window.textFields[sentinelGroupName].exists) - - select(option: "Standalone", in: picker) - XCTAssertTrue( - window.textFields["connection-form-host"].waitToExist(timeout: 5), - "Going back to Standalone restores Host and Port" - ) - XCTAssertFalse(hasField(sentinelNodes, in: window)) - XCTAssertFalse(hasField(clusterNodes, in: window)) - } - - /// A server can hold far more than the 16 databases Redis starts with, so the index is typed - /// as well as stepped, and nothing typed can leave the range a server can be configured for. - func testDatabaseIndexTakesATypedIndexAndStepsFromIt() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let window = try openRedisConnectionForm(in: app) - selectConnectionFormTab("options", in: window) - - let control = window.descendants(matching: .any).matching(identifier: databaseIndex).firstMatch - XCTAssertTrue(control.waitToExist(timeout: 10), "The Options tab should offer a Database Index field") - let field = control.textFields.firstMatch - XCTAssertTrue(field.waitToExist(timeout: 5), "The index should be typeable") - XCTAssertTrue(waitUntilHittable(field, timeout: 10)) - XCTAssertEqual(field.value as? String, "0") - - replaceText(in: field, with: "20a") - XCTAssertTrue( - waitForPredicate(timeout: 5) { (field.value as? String) == "20" }, - "A typed index past 15 should stay, with anything that is not a digit dropped" - ) - - let stepper = control.steppers.firstMatch - XCTAssertTrue(stepper.waitToExist(timeout: 5), "The field should be paired with a stepper") - let increment = stepper.incrementArrows.firstMatch - XCTAssertTrue(waitUntilHittable(increment, timeout: 5)) - increment.click() - XCTAssertTrue( - waitForPredicate(timeout: 5) { (field.value as? String) == "21" }, - "The stepper should step from the typed index" - ) - - replaceText(in: field, with: "99999999999") - XCTAssertTrue( - waitForPredicate(timeout: 5) { (field.value as? String) == "2147483646" }, - "An index no server can hold should be capped at the highest one a server can" - ) - } - - private func replaceText(in field: XCUIElement, with text: String) { - field.click() - field.typeKey("a", modifierFlags: .command) - field.typeText(text) - } - - /// A host list is a whole subtree rather than one control, so its identifier lands on every - /// element inside it and any one of them proves the list is on screen. - private func hasField(_ identifier: String, in window: XCUIElement) -> Bool { - window.descendants(matching: .any).matching(identifier: identifier).count > 0 - } - - private func openRedisConnectionForm(in app: XCUIApplication) throws -> XCUIElement { - let newConnection = app.menuBars.menuItems["New Connection…"] - XCTAssertTrue(newConnection.waitToExist(timeout: 10)) - newConnection.click() - - /// Scoped to the sheet, not the window: the chooser is a `.sheet` on the welcome window, so - /// a window-scoped `searchFields.firstMatch` sees the welcome list's own filter first, and - /// both carry the identifier `sidebar-filter`. The driver name then goes into the - /// connection filter and the chooser list is never filtered. - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") - - let search = sheet.searchFields.firstMatch - XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") - XCTAssertTrue(waitUntilHittable(search, timeout: 10)) - search.click() - search.typeText("Redis") - XCTAssertTrue( - waitForPredicate(timeout: 10) { (search.value as? String) == "Redis" }, - "Typing should reach the chooser's search field" - ) - - let redis = sheet.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Redis")) - .firstMatch - XCTAssertTrue(redis.waitToExist(timeout: 10), "The chooser should list Redis") - XCTAssertTrue(waitUntilHittable(redis, timeout: 10)) - redis.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let form = app.windows["connection-form"] - XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing Redis should open the connection form") - return form - } - - private func select(option: String, in picker: XCUIElement) { - XCTAssertTrue(waitUntilHittable(picker, timeout: 10)) - picker.click() - let item = picker.menuItems[option] - XCTAssertTrue(item.waitToExist(timeout: 5), "The mode picker should offer \(option)") - item.click() - } -} diff --git a/TableProUITests/ReopenClosedTabUITests.swift b/TableProUITests/ReopenClosedTabUITests.swift index a0b030f711..123e49859d 100644 --- a/TableProUITests/ReopenClosedTabUITests.swift +++ b/TableProUITests/ReopenClosedTabUITests.swift @@ -1,62 +1,42 @@ -import AppKit import XCTest -/// Reopen Closed Tab took the closed tab out of its history and opened nothing whenever the -/// connection's window still had other tabs, which is the ordinary case: close one of several -/// tabs, then press Command-Shift-T. final class ReopenClosedTabUITests: UITestCase { - func testReopeningAClosedTableTabBringsItBackSelected() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - doubleClick(row("Album", in: window)) - doubleClick(row("Artist", in: window)) - let artist = tab(named: "Artist", in: window) + private static let tables = ["album", "artist", "genre"] + + // Nothing is clicked before Cmd+W: the window's first responder used to be the connection rail, + // which answers Close itself, so the keystroke closed a connection instead of a tab. Reopen then + // took the tab out of its history and opened nothing while the window still had other tabs. + func testCommandWClosesATabAndShiftCommandTBringsItBack() throws { + let tabs = Self.tables.map { SeededTab.table($0) } + let launch = try launchWithFixture([ + ConnectionSeed(name: "Fixture A", tabs: tabs), + ConnectionSeed(name: "Fixture B", tabs: tabs), + ]) + let app = launch.app + let window = launch.window + + let rail = window.tables.matching(identifier: "workspace-rail").firstMatch XCTAssertTrue( - waitForPredicate(timeout: 20) { self.tab(named: "Album", in: window).exists && artist.isSelected }, - "Album and Artist must both be open, with Artist selected, before one is closed" + waitForPredicate(timeout: 30) { rail.exists && rail.tableRows.count == 2 }, + "Both restored connections must be listed in the rail" ) - - app.typeKey("w", modifierFlags: .command) XCTAssertTrue( - waitForPredicate(timeout: 20) { !artist.exists }, - "Close Tab must take the selected Artist tab out of the strip" + waitForPredicate(timeout: 30) { self.editorTabTitles(in: window) == Self.tables }, + "The connection on screen must restore its tabs, got \(editorTabTitles(in: window))" ) + let closed = try XCTUnwrap(selectedEditorTabTitle(in: window), "A restored tab must be selected") - app.typeKey("t", modifierFlags: [.command, .shift]) - + app.typeShortcut(.closeTab) XCTAssertTrue( - waitForPredicate(timeout: 20) { artist.exists && artist.isSelected }, - "Reopen Closed Tab must bring the Artist tab back and select it" + waitForPredicate(timeout: 20) { self.editorTabTitles(in: window) == Self.tables.filter { $0 != closed } }, + "Cmd+W must close the selected tab, got \(editorTabTitles(in: window))" ) - } + XCTAssertEqual(rail.tableRows.count, 2, "Neither connection may close with the tab") - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) + app.typeShortcut(.reopenClosedTab) XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" + waitForPredicate(timeout: 20) { self.selectedEditorTabTitle(in: window) == closed }, + "Cmd+Shift+T must bring \(closed) back selected, got \(editorTabTitles(in: window))" ) - return window - } - - private func row(_ name: String, in window: XCUIElement) -> XCUIElement { - let match = objectBrowserRow(name, in: window) - XCTAssertTrue(match.waitToExist(timeout: 20), "The object browser must list \(name)") - return match - } - - private func tab(named name: String, in window: XCUIElement) -> XCUIElement { - window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .matching(NSPredicate(format: "label == %@", name)) - .firstMatch - } - - /// Waited out after the double-click, so the next click is not coalesced into it. - private func doubleClick(_ element: XCUIElement) { - element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) } } diff --git a/TableProUITests/ResultChartUITests.swift b/TableProUITests/ResultChartUITests.swift deleted file mode 100644 index 033687789f..0000000000 --- a/TableProUITests/ResultChartUITests.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// ResultChartUITests.swift -// TableProUITests -// - -import XCTest - -final class ResultChartUITests: UITestCase { - func testAnUnlicensedChartBuildsNoResultData() throws { - let app = try launchWithSampleDatabase() - runQuery(in: app) - - let window = app.windows.firstMatch - let modePicker = window.radioGroups["results-view-mode-picker"].firstMatch - XCTAssertTrue(modePicker.waitToExist(timeout: 10), "The result must expose its view modes") - - let chart = modePicker.radioButtons["Chart"].firstMatch - XCTAssertTrue( - waitUntilHittable(chart, timeout: 10), - "Column-bearing results must offer an interactive Chart mode" - ) - chart.click() - - let gate = window.staticTexts["pro-feature-gate-resultCharts"].firstMatch - XCTAssertTrue(gate.waitToExist(timeout: 10), "An unlicensed chart must show its license gate") - XCTAssertFalse(window.descendants(matching: .any).matching(identifier: "result-chart").firstMatch.exists) - XCTAssertFalse( - window.descendants(matching: .any).matching(identifier: "result-chart-type-picker").firstMatch.exists, - "Locked chart controls must not expose result metadata behind the license gate" - ) - } - - private func runQuery(in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - editor.click() - app.typeText("SELECT Name, Milliseconds FROM Track ORDER BY TrackId LIMIT 12;") - app.typeKey(.return, modifierFlags: .command) - - let grid = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 15), "The query must produce a result grid") - } -} diff --git a/TableProUITests/ResultMapModeUITests.swift b/TableProUITests/ResultMapModeUITests.swift deleted file mode 100644 index e0aa8acc56..0000000000 --- a/TableProUITests/ResultMapModeUITests.swift +++ /dev/null @@ -1,174 +0,0 @@ -// -// ResultMapModeUITests.swift -// TableProUITests -// - -import AppKit -import XCTest - -/// Map is the only result mode gated on what the result holds rather than on the kind of tab, and -/// clicking a shape to select its row is the half of #2532 that no unit test can prove end to end. -/// -/// SQLite reports `sqlite3_column_decltype` verbatim, so a column declared `POLYGON` classifies as -/// spatial with no extension and no server. That is what keeps this deterministic. -final class ResultMapModeUITests: UITestCase { - /// One polygon, deliberately large. After Fit to Result it covers nearly the whole pane, so a - /// click at the centre of the map is inside it whatever camera MapKit settles on. - private static let spatialSetup = """ - CREATE TABLE IF NOT EXISTS map_probe (name TEXT, shape POLYGON); - DELETE FROM map_probe; - INSERT INTO map_probe VALUES ('block', 'SRID=4326;POLYGON((-122.52 37.70,-122.35 37.70,-122.35 37.83,-122.52 37.83,-122.52 37.70))'); - SELECT name, shape FROM map_probe; - """ - - private static let plainQuery = "SELECT 1 AS n;" - - func testMapAppearsOnlyWhileTheResultHoldsAGeometryColumn() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - runSpatialSetup(in: app) - - let modePicker = window.radioGroups["results-view-mode-picker"].firstMatch - XCTAssertTrue(modePicker.waitToExist(timeout: 30), "The result must expose its view modes") - XCTAssertTrue( - waitForPredicate(timeout: 30, { modePicker.radioButtons["Map"].firstMatch.exists }), - "A geometry column must put Map in the switcher" - ) - - runPlainQuery(in: app) - - XCTAssertTrue( - waitForPredicate(timeout: 30, { !modePicker.radioButtons["Map"].firstMatch.exists }), - "A result with no geometry column must take the Map segment away again" - ) - XCTAssertTrue(modePicker.radioButtons["Data"].firstMatch.exists, "The other modes stay") - } - - /// Leaving Map with no way back is the defect this feature would otherwise inherit: a mode that - /// leaves the available set takes its switcher segment and every View menu item with it, and - /// those items carry no key equivalent. - func testATabLeftOnMapLandsOnDataWhenTheNextResultHasNoGeometry() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - runSpatialSetup(in: app) - let modePicker = window.radioGroups["results-view-mode-picker"].firstMatch - XCTAssertTrue(modePicker.waitToExist(timeout: 30)) - guard chooseMap(in: modePicker) else { - throw XCTSkip("The Map segment never appeared, so there is nothing to leave") - } - - runPlainQuery(in: app) - - XCTAssertTrue( - waitForPredicate(timeout: 30, { !modePicker.radioButtons["Map"].firstMatch.exists }), - "Map must leave the switcher once the result has no geometry" - ) - let data = modePicker.radioButtons["Data"].firstMatch - XCTAssertTrue(data.waitToExist(timeout: 10)) - XCTAssertEqual( - data.value as? Int, 1, - "The tab must land on Data rather than stay on a mode it can no longer offer" - ) - } - - /// The click half of the request. Driven through XCUITest because it is the only thing here - /// that posts real events: an `osascript` click does not reach the map, and neither does it - /// reach the data grid, so it can prove nothing either way. - func testClickingAShapeSelectsItsRow() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - runSpatialSetup(in: app) - let modePicker = window.radioGroups["results-view-mode-picker"].firstMatch - XCTAssertTrue(modePicker.waitToExist(timeout: 30)) - guard chooseMap(in: modePicker) else { - throw XCTSkip("The Map segment never appeared") - } - - let map = window.descendants(matching: .any)["result-map"].firstMatch - XCTAssertTrue(map.waitToExist(timeout: 30), "Choosing Map must show the map pane") - /// MapKit needs a moment to place the camera before a coordinate under the pointer means - /// anything. - _ = waitForPredicate(timeout: 10, { map.frame.width > 100 }) - - /// A point offset from the element rather than a child element: the map publishes no - /// per-shape accessibility children, so there is nothing to address by identifier. - map.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() - - /// Read the status bar while still in Map mode. Switching to Data remounts the grid, which - /// restores the tab's own stored selection over the live one, so that route cannot observe - /// a selection the map just made. - let readout = window.staticTexts["result-status-readout"].firstMatch - XCTAssertTrue(readout.waitToExist(timeout: 20), "Map mode still reports what the result holds") - - let selected = waitForPredicate(timeout: 20) { - let text = (readout.value as? String) ?? readout.label - return text.contains("selected") - } - XCTAssertTrue( - selected, - "Clicking a shape must select its row; readout said \((readout.value as? String) ?? readout.label)" - ) - } - - // MARK: - Helpers - - private func chooseMap(in modePicker: XCUIElement) -> Bool { - let map = modePicker.radioButtons["Map"].firstMatch - guard waitForPredicate(timeout: 30, { map.exists && map.isHittable }) else { return false } - map.click() - return true - } - - /// Creating a table and inserting a row is a destructive run, so the execution gate puts a - /// sheet in front of it, and the statements only run once it is confirmed. - private func runSpatialSetup(in app: XCUIApplication) { - app.typeKey("t", modifierFlags: .command) - replaceEditorText(with: Self.spatialSetup, in: app) - openRunMenu(in: app).menuItems["Run All Statements"].click() - let confirm = app.windows.firstMatch.sheets.firstMatch.buttons["Execute"] - if confirm.waitToExist(timeout: 15) { confirm.click() } - } - - /// Runs in the tab the spatial query already used, deliberately. A fresh tab starts on Data, so - /// opening one would pass the reconciliation test whether or not the mapped tab was ever - /// reconciled: the assertion would be reading a mode that was never Map. - private func runPlainQuery(in app: XCUIApplication) { - replaceEditorText(with: Self.plainQuery, in: app) - app.typeKey(.return, modifierFlags: .command) - } - - private func replaceEditorText(with text: String, in app: XCUIApplication) { - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 15)) - queryEditor.click() - app.typeKey("a", modifierFlags: .command) - paste(text, into: app) - } - - /// The editor's own bracket and quote completion rewrites parentheses as they are typed, and a - /// WKT polygon is nothing but parentheses. The pasteboard reaches the buffer intact. - private func paste(_ text: String, into app: XCUIApplication) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - app.typeKey("v", modifierFlags: .command) - } - - /// The control is a split button: its leading half runs the query and only its trailing chevron - /// opens the menu, so a plain `click()` would execute instead of opening. Cmd+Return runs the - /// statement under the cursor alone, which is not enough for a setup that creates a table. - private func openRunMenu(in app: XCUIApplication) -> XCUIElement { - let window = app.windows.firstMatch - let executeMenu = window.descendants(matching: .any) - .matching(identifier: "query-run-menu") - .firstMatch - XCTAssertTrue( - waitUntilHittable(executeMenu, timeout: 15), - "The editor toolbar must expose the Run split button" - ) - executeMenu.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).click() - return window.menus.firstMatch - } -} diff --git a/TableProUITests/ResultSetPinUITests.swift b/TableProUITests/ResultSetPinUITests.swift deleted file mode 100644 index b61f1e6530..0000000000 --- a/TableProUITests/ResultSetPinUITests.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// ResultSetPinUITests.swift -// TableProUITests -// -// Covers #1982: pinning a query result must be reachable from a control, not the View menu alone. -// That control used to be the result strip's own tab; it is the status bar's result-set chooser -// now, which is why the chooser is shown even for a single result. -// - -import XCTest - -final class ResultSetPinUITests: UITestCase { - func testResultSetChooserOffersPinForASingleResult() throws { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText("SELECT 1;") - app.typeKey(.return, modifierFlags: .command) - - let chooser = app.windows.firstMatch.descendants(matching: .any) - .matching(identifier: "result-set-menu") - .firstMatch - XCTAssertTrue( - chooser.waitToExist(timeout: 20), - "A single result still offers the chooser, because Pin lives in its menu" - ) - XCTAssertTrue(waitUntilHittable(chooser, timeout: 10)) - chooser.click() - - /// The pull-down opens inside the window; the menu-bar menus hang off `MenuBar`, so scoping - /// to the window isolates the one that just opened. Matching on the menu's accessibility - /// identifier instead worked locally but not on the CI runner, whose macOS build exposes a - /// just-opened menu without one. - let menu = app.windows.firstMatch.menus.firstMatch - XCTAssertTrue( - menu.menuItems["Pin Result"].waitToExist(timeout: 5), - "The chooser's menu must offer Pin Result" - ) - XCTAssertTrue( - menu.menuItems["Close Other Results"].exists, - "Close Others survives the move off the strip" - ) - menu.menuItems["Pin Result"].click() - - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["View"].click() - let unpinItem = menuBar.menuItems["Unpin Result"] - XCTAssertTrue(unpinItem.waitToExist(timeout: 5), "A pinned result reads as Unpin Result") - XCTAssertTrue(unpinItem.isEnabled) - app.typeKey(.escape, modifierFlags: []) - } -} diff --git a/TableProUITests/ResultStatementLinkUITests.swift b/TableProUITests/ResultStatementLinkUITests.swift deleted file mode 100644 index 7e0b733bc5..0000000000 --- a/TableProUITests/ResultStatementLinkUITests.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// ResultStatementLinkUITests.swift -// TableProUITests -// -// Covers #2280: a result set says which statement produced it. -// -// Only the naming half is asserted here. The other half moves the editor's caret, and a text view's selected range -// is not something XCUITest can read: `value` is the whole document, which is what the editor suites use it for. -// Exposing the caret purely so a test could see it would be a hook with no product behind it, so the caret move is -// covered by `StatementAnchorTests` instead, down at the resolution that decides where it lands. -// - -import XCTest - -final class ResultStatementLinkUITests: UITestCase { - /// Three statements, none of them naming a single table, so every result has to fall back to naming itself. Before - /// this change the strip read "Result 1", "Result 2", "Result 3". - private let script = """ - -- monthly totals - SELECT 1; - SELECT 2; - SELECT 3; - """ - - func testResultsAreNamedAfterTheirStatements() throws { - let app = try runScript() - - let menu = openChooser(in: app) - let items = menu.menuItems - /// `title`, not `label`, for the same reason the chooser itself is read that way: a name - /// that comes from the item's own content reaches XCUITest as `AXTitle`. - let names = (0 ..< items.count).map { items.element(boundBy: $0).title } - let descriptions = (0 ..< items.count).map { items.element(boundBy: $0).label } - - XCTAssertTrue( - names.contains { $0.contains("monthly totals") }, - "A statement with a leading comment is named after it, got titles \(names) labels \(descriptions)" - ) - XCTAssertNotEqual( - names.filter { $0.hasPrefix("Result ") }.count, - 3, - "Results must not all fall back to the positional name, got \(names)" - ) - app.typeKey(.escape, modifierFlags: []) - } - - /// Selecting a result must stay survivable: it switches the grid, moves the caret, and leaves the query intact. - /// The caret itself is unreadable from here, but the query text is not, and a jump that corrupted the document - /// would show up as a difference. - func testSelectingResultsLeavesTheQueryIntact() throws { - let app = try runScript() - - let editor = editorTextView(in: app) - let before = (editor.value as? String) ?? "" - XCTAssertFalse(before.isEmpty, "The editor must hold the script that was run") - - for index in [2, 0, 0] { - let menu = openChooser(in: app) - let item = menu.menuItems.element(boundBy: index) - XCTAssertTrue(waitUntilHittable(item, timeout: 10)) - item.click() - } - - XCTAssertEqual((editor.value as? String) ?? "", before, "Selecting results must not change the query") - } - - /// Opens the status bar's result-set chooser and returns the pull-down it presents. The menu is - /// scoped to the window because the menu-bar menus hang off `MenuBar`, and a just-opened menu - /// has no accessibility identifier on the CI runner's macOS build. - private func openChooser(in app: XCUIApplication) -> XCUIElement { - let chooser = app.windows.firstMatch.descendants(matching: .any) - .matching(identifier: "result-set-menu") - .firstMatch - XCTAssertTrue(waitUntilHittable(chooser, timeout: 15), "The status bar must offer the result chooser") - chooser.click() - return app.windows.firstMatch.menus.firstMatch - } - - // MARK: - Harness - - private func runScript() throws -> XCUIApplication { - let app = try launchWithSampleDatabase() - - app.typeKey("t", modifierFlags: .command) - let queryEditor = editorTextView(in: app) - XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) - queryEditor.click() - app.typeText(script) - - /// Execute All, so every statement produces its own result rather than only the one under the caret. - app.menuBars.firstMatch.menuBarItems["Query"].click() - app.menuBars.firstMatch.menuItems["Execute All Statements"].click() - - let chooser = app.windows.firstMatch.descendants(matching: .any) - .matching(identifier: "result-set-menu") - .firstMatch - XCTAssertTrue(chooser.waitToExist(timeout: 20), "The script must produce a result per statement") - /// `title`, not `label`: the chooser is named by its own label content, which reaches - /// XCUITest as `AXTitle`. `label` is `AXDescription` and stays empty for such a control. - XCTAssertTrue( - waitForPredicate(timeout: 15) { chooser.title.contains("3") }, - "Three statements must produce three results, got \(chooser.title)" - ) - return app - } -} diff --git a/TableProUITests/ResultStatusBarUITests.swift b/TableProUITests/ResultStatusBarUITests.swift index 06f8986767..250164172a 100644 --- a/TableProUITests/ResultStatusBarUITests.swift +++ b/TableProUITests/ResultStatusBarUITests.swift @@ -1,161 +1,43 @@ -// -// ResultStatusBarUITests.swift -// TableProUITests -// - -import AppKit import XCTest final class ResultStatusBarUITests: UITestCase { - func testTheModeSwitcherAndTheReadoutShareTheBottomBar() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let grid = runQuery(in: app) - - let modePicker = window.radioGroups["results-view-mode-picker"].firstMatch - XCTAssertTrue(modePicker.waitToExist(timeout: 10), "The result must expose its view modes") - - let readout = window.staticTexts["result-status-readout"].firstMatch - XCTAssertTrue(readout.waitToExist(timeout: 10), "The status bar must report what the result holds") - - /// Automatic grammar agreement is resolved from the String Catalog. A key that never made it - /// into the catalog renders its markup verbatim, which ships "^[12 rows](inflect: true)". - let text = (readout.value as? String) ?? readout.label - XCTAssertFalse(text.isEmpty, "The readout must say something") - XCTAssertFalse( - text.contains("^["), - "Inflection markup leaked into the UI, so the key is missing from the String Catalog: \(text)" - ) - - XCTAssertGreaterThanOrEqual( - readout.frame.minY, grid.frame.maxY, - "The status bar belongs below the result it describes" - ) - XCTAssertGreaterThanOrEqual( - modePicker.frame.minY, grid.frame.maxY, - "The view switcher leads that same bar rather than taking a band of its own" - ) - XCTAssertLessThan( - modePicker.frame.maxX, readout.frame.minX, - "The switcher comes before the readout on the bar" - ) - } - - /// The readout is anchored to the leading edge rather than centred between two clusters, so it - /// starts near the pane's left edge whatever the controls on the right happen to be. - func testTheReadoutIsAnchoredToTheLeadingEdge() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let grid = runQuery(in: app) - - let readout = window.staticTexts["result-status-readout"].firstMatch - XCTAssertTrue(readout.waitToExist(timeout: 10)) + // Milliseconds first, so it is the column `gridPoint` lands on. The first three run 343719, 342562 and 230619. + private static let query = "SELECT milliseconds, name FROM track ORDER BY id;" + + func testMouseSelectionFeedsTheSelectionSum() throws { + let launch = try launchWithFixture([ConnectionSeed(tabs: [.query(Self.query)])]) + let app = launch.app + let window = launch.window + + let editor = editorTextView(in: window) + XCTAssertTrue(waitForEditorText(editor) { $0 == Self.query }, "The restored tab must hold its query") + app.typeShortcut(.runQuery) + let grid = dataGrid(in: window) + XCTAssertTrue(waitForClickableRows(in: grid), "The query must have its rows in before one can be clicked") + XCTAssertTrue(grid.tableRows.element(boundBy: 2).waitToExist(timeout: 10), "The result must lay out three rows") + let summary = window.buttons["result-status-selection-summary"].firstMatch - let modePicker = window.radioGroups["results-view-mode-picker"].firstMatch - XCTAssertTrue(modePicker.waitToExist(timeout: 10)) - let offsetIntoPane = modePicker.frame.minX - grid.frame.minX - XCTAssertLessThan( - offsetIntoPane, 40, - "The leading cluster must start at the pane's edge, not drift with the width of the controls" + // A plain click selects a row, not a cell, so Shift-click has to take its anchor from the focused cell. + millisecondsCell(row: 0, in: grid, of: window).click() + XCTAssertTrue( + waitForPredicate(timeout: 10) { grid.tableRows.element(boundBy: 0).isSelected }, + "A click must select its row" ) - } - - /// Opening one table after another used to strip the bar back to the mode switcher and refill it - /// in stages. The transient frames are not observable from here, because the clear and reload - /// outrun XCUITest's polling, so this asserts the part that is: every control the bar owns is - /// still there once each table has settled, on a tab that is being reused rather than replaced. - /// - /// Runs at the default window width, where the bar draws every control inline. Rows-per-page - /// folds into the page indicator once the pane is narrow, which - /// `testEveryControlIsReachableOnceTheBarHasToFold` covers instead. - func testTheBarKeepsItsControlsAcrossTableSwitches() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - for table in ["Album", "Artist", "Track"] { - let row = objectBrowserRow(table, in: window) - guard row.waitToExist(timeout: 15) else { - XCTFail("The object browser must list \(table)") - return - } - clickAtCenter(row) - - let readout = window.staticTexts["result-status-readout"].firstMatch - XCTAssertTrue(readout.waitToExist(timeout: 15), "\(table): the readout must survive the switch") - - for identifier in ["result-status-columns", "result-status-filters", "pagination-page-size"] { - XCTAssertTrue( - window.descendants(matching: .any)[identifier].firstMatch.waitToExist(timeout: 15), - "\(table): \(identifier) left the bar" - ) - } - - XCTAssertTrue( - window.descendants(matching: .any)["pagination-page-indicator"].firstMatch.exists, - "\(table): the page indicator left the bar" - ) - } - } - - /// A window narrow enough that the bar has to fold, which is the state it used to break in: its - /// clusters were pinned at full width, so the whole tab content column laid out at 766pt and - /// SwiftUI centred it inside the pane, leaving the edges unreachable. Measured at the window's - /// own 720pt minimum that cost 163pt on each side, taking the grid's row numbers, its whole - /// first column and the entire pagination cluster; at 1000pt it still cost 23pt a side. - /// - /// `existence` is not the assertion. Every one of those controls existed throughout; they were - /// simply outside the window. `isHittable` is what tells the two apart. - func testEveryControlIsReachableOnceTheBarHasToFold() throws { - try skipUnlessTheScreenFitsThePinnedWindow() - let app = try launchWithSampleDatabase(environment: pinnedEnvironment) - let window = app.windows.firstMatch - - let row = objectBrowserRow("Track", in: window) - guard row.waitToExist(timeout: 15) else { - XCTFail("The object browser must list Track") - return - } - clickAtCenter(row) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 15), "Opening a table must produce a result grid") + let third = millisecondsCell(row: 2, in: grid, of: window) + XCUIElement.perform(withKeyModifiers: .shift) { third.click() } + expectSummary(summary, count: 3, digits: "916900", after: "Shift-click from row 1 to row 3") - /// The pin is what puts the bar in its folded state, so a launch that came up at some other - /// width would assert nothing. Checked rather than assumed. - try XCTSkipUnless( - abs(window.frame.width - Self.pinnedWindowSize.width) <= 1, - "The window came up \(window.frame.width)pt wide instead of \(Self.pinnedWindowSize.width)pt, so the bar is not in its folded state" + millisecondsCell(row: 0, in: grid, of: window).click() + XCTAssertTrue( + waitForPredicate(timeout: 10) { !self.summary(summary, reads: 3, digits: "916900") }, + "A plain click must replace the Shift-click selection" ) - - /// The folded switcher is a pull-down rather than a segmented control, which is the cheapest - /// proof from here that the bar really did give up chrome instead of overflowing. - let modePicker = window.popUpButtons["results-view-mode-picker"].firstMatch - XCTAssertTrue(modePicker.waitToExist(timeout: 10), "A folded bar carries the modes in a pull-down") - XCTAssertTrue(modePicker.isHittable, "The mode pull-down must be reachable, not merely present") - - /// Columns and the page indicator have no menu-bar equivalent, so the bar keeps both at - /// every width. Rows-per-page moves inside the page indicator's menu rather than leaving. - let controls = ["result-status-columns", "result-status-highlight", "result-status-filters", "pagination-page-indicator"] - for identifier in controls { - let control = window.descendants(matching: .any)[identifier].firstMatch - XCTAssertTrue(control.waitToExist(timeout: 10), "\(identifier) left the bar") - XCTAssertTrue(control.isHittable, "\(identifier) is present but out of reach") - expectInsideTheWindow(control, window, named: identifier) - } - - expectInsideTheWindow(grid, window, named: "the grid") - } - - /// The sample's first three Track rows run 343719, 342562 and 230619 milliseconds. - func testDraggingDownANumberColumnSumsTheCellsItSwept() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let grid = runMillisecondsQuery(in: app) + XCUIElement.perform(withKeyModifiers: .command) { third.click() } + expectSummary(summary, count: 2, digits: "574338", after: "Command-click on row 3 after a click on row 1") millisecondsCell(row: 0, in: grid, of: window) .click(forDuration: 0.3, thenDragTo: millisecondsCell(row: 2, in: grid, of: window)) - - let summary = expectSummary(in: window, count: 3, digits: "916900", after: "a drag over three cells") + expectSummary(summary, count: 3, digits: "916900", after: "A drag over three cells") summary.click() let popover = window.popovers.firstMatch @@ -165,118 +47,23 @@ final class ResultStatusBarUITests: UITestCase { XCTAssertTrue(popover.buttons["Copy Average"].firstMatch.exists, "Every figure in the detail must be copyable") } - /// A plain click selects a row, not a cell, so Shift-click has to take its anchor from the - /// focused cell. Anchored on nothing, it selected the one cell it landed on. - func testShiftClickAfterAClickSelectsTheCellsBetween() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let grid = runMillisecondsQuery(in: app) - - millisecondsCell(row: 0, in: grid, of: window).click() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - let third = millisecondsCell(row: 2, in: grid, of: window) - XCUIElement.perform(withKeyModifiers: .shift) { - third.click() - } - - expectSummary(in: window, count: 3, digits: "916900", after: "Shift-click from row 1 to row 3") - } - - func testCommandClickAfterAClickAddsOneCell() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let grid = runMillisecondsQuery(in: app) - - millisecondsCell(row: 0, in: grid, of: window).click() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - let third = millisecondsCell(row: 2, in: grid, of: window) - XCUIElement.perform(withKeyModifiers: .command) { - third.click() - } - - expectSummary(in: window, count: 2, digits: "574338", after: "Command-click on row 3 after a click on row 1") - } - - private func runMillisecondsQuery(in app: XCUIApplication) -> XCUIElement { - let grid = runQuery("SELECT Milliseconds, Name FROM Track ORDER BY TrackId LIMIT 12;", in: app) - XCTAssertTrue(waitForClickableRows(in: grid), "The query must have its rows in before one can be clicked") - XCTAssertTrue(grid.tableRows.element(boundBy: 2).waitToExist(timeout: 10), "The result must lay out three rows") - return grid - } - - /// Milliseconds is the first data column, which is where `gridPoint` lands. private func millisecondsCell(row: Int, in grid: XCUIElement, of window: XCUIElement) -> XCUICoordinate { let frame = grid.tableRows.element(boundBy: row).frame return gridPoint(in: grid, of: window, dy: frame.midY - grid.frame.minY) } - @discardableResult - private func expectSummary(in window: XCUIElement, count: Int, digits: String, after gesture: String) -> XCUIElement { - let summary = window.buttons["result-status-selection-summary"].firstMatch + private func expectSummary(_ summary: XCUIElement, count: Int, digits: String, after gesture: String) { XCTAssertTrue(summary.waitToExist(timeout: 15), "\(gesture) must put a summary on the bar") XCTAssertTrue( - waitForPredicate(timeout: 10) { - summary.label.hasSuffix("Count \(count)") && Self.withoutGrouping(summary.label).contains(digits) - }, - "\(gesture) must sum \(count) cells to \(digits), got \(summary.label)" - ) - return summary - } - - /// The separator follows the locale the runner happens to use, so only the digits are compared. - private static func withoutGrouping(_ text: String) -> String { - text.filter { !$0.isPunctuation && !$0.isWhitespace } - } - - /// The defect this guards was 163pt of overhang. `XCUIElement.frame` rounds, and a split - /// divider can leave a control a point over the edge on a window whose origin is not on a whole - /// point, so a point of slack keeps the assertion about the defect rather than about rounding. - /// The exact geometry is asserted to the point in `ResultStatusBarLayoutTests`, which measures - /// real view frames instead. - private func expectInsideTheWindow(_ element: XCUIElement, _ window: XCUIElement, named name: String) { - XCTAssertGreaterThanOrEqual( - element.frame.minX, window.frame.minX - Self.edgeTolerance, - "\(name) starts before the window's leading edge" + waitForPredicate(timeout: 10) { self.summary(summary, reads: count, digits: digits) }, + "\(gesture) must sum \(count) cells to \(digits), got \(summary.exists ? summary.label : "no summary")" ) - XCTAssertLessThanOrEqual( - element.frame.maxX, window.frame.maxX + Self.edgeTolerance, - "\(name) ends past the window's trailing edge" - ) - } - - /// Wide enough that the sidebar still leaves a usable pane, narrow enough that the bar folds. - /// Measured: the pull-down replaces the segmented switcher at or below this width. - private static let pinnedWindowSize = CGSize(width: 1_000, height: 820) - private static let edgeTolerance: CGFloat = 2 - - private var pinnedEnvironment: [String: String] { - ["TABLEPRO_SCREENSHOT_FRAME": "\(Int(Self.pinnedWindowSize.width))x\(Int(Self.pinnedWindowSize.height))"] } - /// A screen that cannot hold the pinned window makes the launch come up at some other size, and - /// the tier under test is then not the one on screen. - private func skipUnlessTheScreenFitsThePinnedWindow() throws { - let width = NSScreen.main?.frame.width ?? 0 - try XCTSkipUnless( - width >= Self.pinnedWindowSize.width, - "Needs a screen at least \(Int(Self.pinnedWindowSize.width))pt wide to hold the pinned window; this one is \(Int(width))pt" - ) - } - - @discardableResult - private func runQuery( - _ sql: String = "SELECT Name, Milliseconds FROM Track ORDER BY TrackId LIMIT 12;", - in app: XCUIApplication - ) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - editor.click() - app.typeText(sql) - app.typeKey(.return, modifierFlags: .command) - - let grid = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 15), "The query must produce a result grid") - return grid + // The grouping separator follows the runner's locale, so only the digits are compared. + private func summary(_ summary: XCUIElement, reads count: Int, digits: String) -> Bool { + guard summary.exists else { return false } + let label = summary.label + return label.hasSuffix("Count \(count)") && label.filter { $0.isNumber }.contains(digits) } } diff --git a/TableProUITests/RowImportMappingMemoryUITests.swift b/TableProUITests/RowImportMappingMemoryUITests.swift deleted file mode 100644 index 437fea63a6..0000000000 --- a/TableProUITests/RowImportMappingMemoryUITests.swift +++ /dev/null @@ -1,67 +0,0 @@ -import XCTest - -/// A CSV whose headers do not all match the table's columns is mapped by hand once, imported, and -/// the next import into the same table opens on that mapping. -/// -/// Every query is rooted at a window or its sheet rather than at the application, because the -/// sample database's grid publishes thousands of elements and an application-wide search walks -/// all of them. -final class RowImportMappingMemoryUITests: UITestCase { - private let fileName = "genres.csv" - private let genres = Data("GenreId,Title\n9001,Imported genre\n".utf8) - - func testTheMappingPickedForATableComesBackOnTheNextImport() throws { - let app = try launchWithSampleDatabase(andDataFile: fileName, contents: genres) - let windows = rowImportWindows(in: app, dataFileTitle: fileName) - - let first = openSheetIntoGenre(windows, in: app) - let title = element(first.popUpButtons, labelEndingWith: "Column for Title") - XCTAssertTrue(title.waitToExist(timeout: 20), "The sheet must list the Title field") - XCTAssertEqual(title.value as? String, "Skip", "Title matches no Genre column by name") - element(first.checkBoxes, labelEndingWith: "Import Title").click() - choose("Name", from: title) - first.buttons["Import"].firstMatch.click() - - let done = windows.connection.sheets.buttons["Done"].firstMatch - XCTAssertTrue(done.waitToExist(timeout: 30), "The import must finish and report success") - done.click() - XCTAssertTrue( - waitForPredicate(timeout: 10) { !windows.connection.sheets.firstMatch.exists }, - "The import sheet must close after the import" - ) - - let second = openSheetIntoGenre(windows, in: app) - let restored = element(second.popUpButtons, labelEndingWith: "Column for Title") - XCTAssertTrue(restored.waitToExist(timeout: 20), "The sheet must list the Title field again") - XCTAssertEqual(restored.value as? String, "Name", "Title must come back mapped to Name") - let caption = second.staticTexts - .matching(NSPredicate(format: "value == %@", "Restored the mapping saved for Genre.")) - .firstMatch - XCTAssertTrue(caption.waitToExist(timeout: 5), "The sheet must say the mapping was restored") - second.buttons["Cancel"].firstMatch.click() - } - - private func openSheetIntoGenre( - _ windows: (dataFile: XCUIElement, connection: XCUIElement), - in app: XCUIApplication - ) -> XCUIElement { - let sheet = openRowImportSheet( - from: windows.dataFile, - titled: fileName, - into: windows.connection, - in: app - ) - let destination = element(sheet.popUpButtons, labelEndingWith: "Import into") - XCTAssertTrue(waitUntilHittable(destination, timeout: 15), "The sheet must offer the destination tables") - choose("Genre", from: destination) - return sheet - } - - private func choose(_ title: String, from popUp: XCUIElement) { - XCTAssertTrue(waitUntilHittable(popUp, timeout: 10), "\(popUp) must be clickable") - popUp.click() - let item = popUp.menuItems[title].firstMatch - XCTAssertTrue(item.waitToExist(timeout: 10), "The menu must offer \(title)") - item.click() - } -} diff --git a/TableProUITests/RowImportNewTableEditsUITests.swift b/TableProUITests/RowImportNewTableEditsUITests.swift deleted file mode 100644 index 67669e6ab8..0000000000 --- a/TableProUITests/RowImportNewTableEditsUITests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import XCTest - -/// A new table's column edits outlive a parsing option change, while what the user left alone follows -/// the new read. -/// -/// `GenreId` holds ` 9001 `, which reads as text until **Trim leading and trailing spaces** is on and -/// as a number after it, so the type menu of the untouched column is what shows the file was read -/// again. The key set on `Title` is the edit the read used to throw away. The file reaches the sheet -/// through the data file window's Import into Table, the one route that needs no open panel, and every -/// query is rooted at a window or its sheet because the sample database's grid publishes thousands of -/// elements. -final class RowImportNewTableEditsUITests: UITestCase { - private let fileName = "genres.csv" - private let genres = Data("GenreId,Title\n 9001 ,Imported genre\n".utf8) - - func testAColumnEditSurvivesAParsingOptionChange() throws { - let app = try launchWithSampleDatabase(andDataFile: fileName, contents: genres) - let windows = rowImportWindows(in: app, dataFileTitle: fileName) - let sheet = openRowImportSheet( - from: windows.dataFile, - titled: fileName, - into: windows.connection, - in: app - ) - let newTable = sheet.radioButtons["New table"].firstMatch - XCTAssertTrue(waitUntilHittable(newTable, timeout: 10), "The sheet must offer a new table") - newTable.click() - - let titleKey = element(sheet.checkBoxes, labelEndingWith: "Title is a primary key") - XCTAssertTrue(waitUntilHittable(titleKey, timeout: 20), "The new table must list the Title column") - titleKey.click() - XCTAssertTrue( - waitForPredicate(timeout: 5) { (titleKey.value as? Int) == 1 }, - "Title must become the primary key" - ) - - let idType = element(sheet.popUpButtons, labelEndingWith: "Type of GenreId") - XCTAssertTrue(idType.waitToExist(timeout: 10), "The new table must list the GenreId column") - XCTAssertEqual(typeName(of: idType), "TEXT", "Untrimmed, ' 9001 ' reads as text") - - let trim = element(sheet.checkBoxes, labelEndingWith: "Trim leading and trailing spaces") - XCTAssertTrue(waitUntilHittable(trim, timeout: 10), "The CSV options must be in the sheet") - trim.click() - - XCTAssertTrue( - waitForPredicate(timeout: 20) { typeName(of: idType) == "INTEGER" }, - "Trimming must read the file again and propose a number for the untouched GenreId column" - ) - XCTAssertEqual(titleKey.value as? Int, 1, "The key set on Title must survive the new read") - - sheet.buttons["Cancel"].firstMatch.click() - } - - private func typeName(of popUp: XCUIElement) -> String? { - (popUp.value as? String)?.uppercased() - } -} diff --git a/TableProUITests/RowImportSourceNameUITests.swift b/TableProUITests/RowImportSourceNameUITests.swift index a79112ec07..a2aebb990c 100644 --- a/TableProUITests/RowImportSourceNameUITests.swift +++ b/TableProUITests/RowImportSourceNameUITests.swift @@ -1,24 +1,17 @@ import XCTest -/// Import into Table hands the rows over through a snapshot the data file window writes under a -/// generated name. The sheet and the table name it proposes have to come from the file the user -/// opened, not from that snapshot. -/// -/// Every query is rooted at a window or its sheet, because the sample database's grid publishes -/// thousands of elements. +// Import into Table reads the rows from a snapshot the data file window writes under a generated name. +// The sheet, the proposed table and the table the import creates have to follow the file the user opened. final class RowImportSourceNameUITests: UITestCase { private let fileName = "genres.csv" private let genres = Data("GenreId,Title\n9001,Imported genre\n".utf8) - func testTheSheetNamesTheDataFileAndProposesATableAfterIt() throws { - let app = try launchWithSampleDatabase(andDataFile: fileName, contents: genres) - let windows = rowImportWindows(in: app, dataFileTitle: fileName) - let sheet = openRowImportSheet( - from: windows.dataFile, - titled: fileName, - into: windows.connection, - in: app - ) + func testImportingADataFileAddsItsRowsToANewTable() throws { + let launch = try launchWithFixture(openingDataFile: fileName, contents: genres) + let app = launch.app + let dataWindow = dataFileWindow(in: app) + XCTAssertTrue(dataWindow.waitToExist(timeout: 30), "\(fileName) produced no data file window") + let sheet = openRowImportSheet(from: dataWindow, titled: fileName, into: launch.window, in: app) let title = sheet.staticTexts.matching(NSPredicate(format: "value == %@", fileName)).firstMatch XCTAssertTrue(title.waitToExist(timeout: 10), "The sheet must be titled after \(fileName)") @@ -34,6 +27,19 @@ final class RowImportSourceNameUITests: UITestCase { "The proposed table must be named after \(fileName), got \(name.value ?? "nothing")" ) - sheet.buttons["Cancel"].firstMatch.click() + let importButton = sheet.buttons["Import"].firstMatch + XCTAssertTrue( + waitForPredicate(timeout: 15) { importButton.exists && importButton.isEnabled }, + "A named new table must be ready to import" + ) + importButton.click() + + XCTAssertTrue( + waitForPredicate(timeout: 30) { + FixtureFile.rows("SELECT * FROM genres", in: launch.database) == [["9001", "Imported genre"]] + }, + "The import must create genres holding the file's row, the file holds " + + "\(FixtureFile.rows("SELECT * FROM genres", in: launch.database) ?? [])" + ) } } diff --git a/TableProUITests/RowInspector/InspectorJsonFieldEditUITests.swift b/TableProUITests/RowInspector/InspectorJsonFieldEditUITests.swift deleted file mode 100644 index e3a6789860..0000000000 --- a/TableProUITests/RowInspector/InspectorJsonFieldEditUITests.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// InspectorJsonFieldEditUITests.swift -// TableProUITests -// -// The row inspector's JSON field kept two mirrors of one value and reconciled them from an -// `onChange` action that re-read a context a render out of date. Typing one character made the -// editor put the previous value back and push it into the store again, at about 75 rounds a -// second: one keystroke produced 6,944 body evaluations in 30 seconds at 100% CPU, and the -// character was thrown away (#3051). -// -// So the assertion is not "the app is responsive", which is hard to state and easy to pass by -// accident. It is that the character is still there afterwards, which the loop could not manage. -// - -import XCTest - -final class InspectorJsonFieldEditUITests: UITestCase { - /// Chinook has no JSON column and no text value that parses as a JSON object, so the app seeds - /// one when this variable is set. Both sides spell the name out because a UI test target cannot - /// import the app. - private let jsonFixtureVariable = "TABLEPRO_UI_TEST_SEED_JSON_TABLE" - private let fixtureTable = "json_fixture" - private let typedMarker = "ZZTOP" - - func testTypingInTheJsonFieldKeepsWhatWasTyped() throws { - let app = try launchWithSampleDatabase(environment: [jsonFixtureVariable: "1"]) - let window = app.windows.firstMatch - - openFixtureTable(in: app) - showInspector(in: app) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The fixture table must produce a result grid") - XCTAssertTrue(waitForClickableRows(in: grid), "The fixture table must return a row") - /// Past the header, which is 28pt with no column comments, and inside the first row at - /// every row height the setting offers. - gridPoint(in: grid, of: window, dy: 40).click() - - let field = window.textViews.matching(identifier: "inspector-json-field").firstMatch - XCTAssertTrue(field.waitToExist(timeout: 20), "The JSON column must open the JSON editor") - - let before = field.value as? String ?? "" - XCTAssertTrue(before.contains("\"name\""), "The fixture document must be in the editor, got '\(before)'") - - field.click() - app.typeText(typedMarker) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { (field.value as? String)?.contains(self.typedMarker) ?? false }, - "Every typed character must survive; the editor holds '\(field.value as? String ?? "nil")'" - ) - - /// The loop put the pre-edit text back a moment after the keystroke, so one check could - /// pass on timing alone. Letting the run loop turn and asking again is what separates - /// "typed" from "kept". - _ = waitForPredicate(timeout: 3) { false } - XCTAssertTrue( - (field.value as? String)?.contains(typedMarker) ?? false, - "The typed text must still be there a moment later; the editor holds " - + "'\(field.value as? String ?? "nil")'" - ) - } - - // MARK: - Helpers - - /// The object browser draws its rows as hosted cells, so the name arrives as the static text's - /// `value` behind the object kind, and the row takes a coordinate click rather than an - /// element one. `objectBrowserRow` carries both facts. - private func openFixtureTable(in app: XCUIApplication) { - let window = app.windows.firstMatch - XCTAssertTrue( - window.outlines.firstMatch.waitToExist(timeout: 30), - "The object browser must list the sample's tables" - ) - - let row = objectBrowserRow(fixtureTable, in: window) - XCTAssertTrue( - row.waitToExist(timeout: 30), - "The seeded fixture table must appear in the object browser" - ) - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - } - - /// The inspector remembers whether it was open, so the starting state is whatever the previous - /// launch left. The View menu item reads Hide Inspector once it is showing, which is the only - /// handle on that state. - private func showInspector(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - - let show = menuBar.menuItems["Show Inspector"] - if show.waitToExist(timeout: 5) { - show.click() - return - } - app.typeKey(.escape, modifierFlags: []) - } -} diff --git a/TableProUITests/SQLiteFirstTableLoadUITests.swift b/TableProUITests/SQLiteFirstTableLoadUITests.swift index 63f09717d7..17ef47d1ff 100644 --- a/TableProUITests/SQLiteFirstTableLoadUITests.swift +++ b/TableProUITests/SQLiteFirstTableLoadUITests.swift @@ -1,71 +1,41 @@ import XCTest -/// Issue #2342. The first table clicked after opening a SQLite database showed "Executing…" -/// forever. Cancelling it and clicking any table afterwards, including the same one, loaded -/// immediately, and an empty table stalled exactly like a large one, so the stall was never the -/// query itself. +// #2342: the first table opened after connecting stalled on "Executing…", and so did the first one +// opened in a window with no tabs left, which takes the add-first-tab path instead. final class SQLiteFirstTableLoadUITests: UITestCase { - func testTheFirstTableClickAfterOpeningADatabaseLoadsItsRows() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) + func testTheFirstTableLoadsWithAndWithoutATab() throws { + let launch = try launchWithFixture() + let window = launch.window + let grid = dataGrid(in: window) + let editor = editorTextView(in: window) + XCTAssertTrue(editor.waitToExist(timeout: 20), "The connection must restore its query tab") + + _ = openFromObjectBrowser("album", in: launch) XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" + waitForPredicate(timeout: 15) { !window.staticTexts["Executing…"].exists }, + "The first table opened after connecting must finish loading" ) - clickAtCenter(try tableRow(named: "Album", in: window)) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The first table click must open the data grid") - XCTAssertTrue( - waitForPredicate(timeout: 30) { !grid.tableRows.allElementsBoundByIndex.isEmpty }, - "The first table click must load its rows rather than stall" - ) + // The strip lists its tabs only while there are two or more. + launch.app.typeShortcut(.closeTab) XCTAssertTrue( - waitForPredicate(timeout: 15) { !window.staticTexts["Executing…"].exists }, - "The executing indicator must clear once the rows are in" + waitForPredicate(timeout: 20) { self.editorTabCount(in: window) == 0 && editor.exists }, + "Command W must close the table tab and leave the query tab in front" ) - } - - /// The reported path, which the case above cannot reach. `launchWithSampleDatabase` opens a - /// table for you, so every test built on it clicks a table on a connection that already has a - /// tab, which retargets it. Closing every tab leaves the window open with none, so the next - /// click is the window's first tab and takes `addFirstTableTab` instead. That path scheduled two - /// loads for one click, and the first took the second's rows down with it (#2342). - func testTheFirstTableInAWindowWithNoTabsLoadsItsRows() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) + launch.app.typeShortcut(.closeTab) XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" + waitForPredicate(timeout: 20) { !editor.exists && !grid.exists }, + "Command W must close the last tab" ) - - let closeAllTabs = app.menuBars.menuItems["Close All Tabs"] - XCTAssertTrue(closeAllTabs.waitToExist(timeout: 15), "File > Close All Tabs must be available") - closeAllTabs.click() - - let grid = window.tables.matching(identifier: "data-grid").firstMatch XCTAssertTrue( - waitForPredicate(timeout: 20) { !grid.exists }, - "Closing every tab must leave the window with no result grid" + objectBrowserRow("album", in: window).exists, + "Closing the last tab must leave the connection open" ) - clickAtCenter(try tableRow(named: "Album", in: window)) - - XCTAssertTrue(grid.waitToExist(timeout: 30), "The first table click must open the data grid") + clickAtCenter(objectBrowserRow("album", in: window)) XCTAssertTrue( - waitForPredicate(timeout: 30) { !grid.tableRows.allElementsBoundByIndex.isEmpty }, - "The first table opened in a window with no tabs must show its rows" + waitForClickableRows(in: grid), + "The first table opened in a window with no tabs must load its rows" ) } - - private func tableRow(named name: String, in window: XCUIElement) throws -> XCUIElement { - let match = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: \(name)")) - .firstMatch - XCTAssertTrue(match.waitToExist(timeout: 20), "The object browser must list \(name)") - return match - } } diff --git a/TableProUITests/SavedQueryHistoryUITests.swift b/TableProUITests/SavedQueryHistoryUITests.swift deleted file mode 100644 index d8e4699a7b..0000000000 --- a/TableProUITests/SavedQueryHistoryUITests.swift +++ /dev/null @@ -1,78 +0,0 @@ -import AppKit -import XCTest - -/// Issue #2505. Editing a saved query keeps the SQL it replaced, Show History lists it, and -/// Restore This Version puts it back while keeping the text it replaced as another version. -final class SavedQueryHistoryUITests: UITestCase { - func testAnEditedSavedQueryCanBeRestoredFromItsHistory() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) - showFavorites(in: app) - - let newFavorite = window.buttons["New Favorite…"] - XCTAssertTrue(newFavorite.waitToExist(timeout: 15), "The empty Favorites tab offers New Favorite…") - newFavorite.click() - fillFavoriteSheet(in: app, name: "Revenue", query: "SELECT 1", confirm: "Add") - - let row = window.outlines.staticTexts["Revenue"] - XCTAssertTrue(row.waitToExist(timeout: 15), "The new saved query appears in the sidebar") - row.rightClick() - let edit = contextMenuItem("Edit…", in: app) - XCTAssertTrue(edit.waitToExist(timeout: 5)) - edit.click() - fillFavoriteSheet(in: app, name: nil, query: "SELECT 2", confirm: "Save") - - XCTAssertTrue(row.waitToExist(timeout: 10)) - row.rightClick() - let showHistory = contextMenuItem("Show History", in: app) - XCTAssertTrue(showHistory.waitToExist(timeout: 5), "A saved query's menu offers Show History") - showHistory.click() - - let versions = window.tables["version-history-list"] - XCTAssertTrue(versions.waitToExist(timeout: 15), "Show History opens the history tab") - XCTAssertTrue( - waitForPredicate(timeout: 10) { versions.tableRows.count == 2 }, - "The current version and the SQL the edit replaced" - ) - - versions.tableRows.element(boundBy: 1).click() - let restore = window.buttons["version-history-restore"] - XCTAssertTrue(waitForPredicate(timeout: 10) { restore.isEnabled }, "An earlier version can be restored") - restore.click() - - XCTAssertTrue( - waitForPredicate(timeout: 10) { versions.tableRows.count == 3 }, - "Restoring keeps the SQL it replaced as another version" - ) - } - - private func fillFavoriteSheet(in app: XCUIApplication, name: String?, query: String, confirm: String) { - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "The saved query sheet opens") - if let name { - let nameField = sheet.textFields.firstMatch - XCTAssertTrue(nameField.waitToExist(timeout: 5)) - nameField.click() - nameField.typeText(name) - } - let queryEditor = sheet.textViews.firstMatch - XCTAssertTrue(queryEditor.waitToExist(timeout: 5)) - queryEditor.click() - queryEditor.typeKey("a", modifierFlags: .command) - queryEditor.typeText(query) - let confirmButton = sheet.buttons[confirm] - XCTAssertTrue(waitForPredicate(timeout: 5) { confirmButton.isEnabled }) - confirmButton.click() - XCTAssertTrue(waitForPredicate(timeout: 10) { !sheet.exists }, "The sheet closes after \(confirm)") - } - - private func showFavorites(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - let showFavorites = menuBar.menuItems["Show Favorites"] - XCTAssertTrue(showFavorites.waitToExist(timeout: 5), "View > Show Favorites must be reachable") - showFavorites.click() - } -} diff --git a/TableProUITests/SchemaManagementMenuUITests.swift b/TableProUITests/SchemaManagementMenuUITests.swift deleted file mode 100644 index 240314dfc2..0000000000 --- a/TableProUITests/SchemaManagementMenuUITests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// SchemaManagementMenuUITests.swift -// TableProUITests -// -// New Schema… and Edit Schema… are gated on a driver capability, not on a DatabaseType. The -// failure this guards is the one Drop Schema already shipped: an item the menu offers and the -// driver then refuses, which reaches the user as an error alert for something the app promised. -// -// The sample database is SQLite, which has no schemas at all, so this is the negative half of the -// contract and it runs without a server. The positive half needs a live PostgreSQL and is covered -// by SchemaEditEligibilityTests and PostgreSQLSchemaStatementPlannerTests instead. -// - -import XCTest - -final class SchemaManagementMenuUITests: UITestCase { - func testASchemalessEngineOffersNoSchemaManagementItems() throws { - let app = try launchWithSampleDatabase() - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 20), "The menu bar never appeared") - - menuBar.menuBarItems["Database"].click() - - let schema = menuBar.menuItems["Schema"] - XCTAssertTrue(schema.waitToExist(timeout: 10), "Database > Schema must be reachable") - schema.click() - - XCTAssertFalse( - menuBar.menuItems["New Schema…"].waitToExist(timeout: 3), - "SQLite has no schemas, so New Schema… must not be offered. An item the driver then " - + "refuses reaches the user as an error alert for something the menu promised." - ) - XCTAssertFalse( - menuBar.menuItems["Edit Schema…"].waitToExist(timeout: 1), - "SQLite has no schema to edit, so Edit Schema… must not be offered" - ) - - app.typeKey(.escape, modifierFlags: []) - app.typeKey(.escape, modifierFlags: []) - } -} diff --git a/TableProUITests/SettingsDeeplinkUITests.swift b/TableProUITests/SettingsDeeplinkUITests.swift index 7bdb833223..af4e9814b3 100644 --- a/TableProUITests/SettingsDeeplinkUITests.swift +++ b/TableProUITests/SettingsDeeplinkUITests.swift @@ -1,43 +1,14 @@ import XCTest final class SettingsDeeplinkUITests: UITestCase { - // Pane titles are localized. - private let englishArguments = ["-AppleLanguages", "(en)"] - func testASettingsLinkOpensItsPane() throws { - try assertLink("tablepro://settings/license", opensPaneTitled: "License") - } - - func testTheMCPLinkOpensTheMCPPane() throws { - try assertLink("tablepro://settings/mcp", opensPaneTitled: "MCP") - } - - func testAnUnknownPaneOpensTheLastUsedPane() throws { - let app = try launchOpeningURL("tablepro://settings/not-a-pane", arguments: englishArguments) - - let settingsWindow = app.windows["settings"] - XCTAssertTrue(settingsWindow.waitToExist(timeout: 10)) - XCTAssertTrue( - waitForPredicate(timeout: 5) { settingsWindow.title == "General" }, - "A fresh sandbox has no last-used pane, so Settings opens on General, not \(settingsWindow.title)" - ) - } - - private func assertLink( - _ link: String, - opensPaneTitled title: String, - file: StaticString = #filePath, - line: UInt = #line - ) throws { - let app = try launchOpeningURL(link, arguments: englishArguments) + let app = try launchOpeningURL("tablepro://settings/license") - let settingsWindow = app.windows["settings"] - XCTAssertTrue(settingsWindow.waitToExist(timeout: 10), file: file, line: line) + let settings = window("settings", in: app) + XCTAssertTrue(settings.waitToExist(timeout: 10), "A settings link must open the settings window") XCTAssertTrue( - waitForPredicate(timeout: 5) { settingsWindow.title == title }, - "\(link) has to open the \(title) pane, the window is titled \(settingsWindow.title)", - file: file, - line: line + waitForPredicate(timeout: 5) { settings.title == "License" }, + "The link must open the License pane, the window is titled \(settings.title)" ) } } diff --git a/TableProUITests/SettingsWindowTitleUITests.swift b/TableProUITests/SettingsWindowTitleUITests.swift deleted file mode 100644 index 27b893be9c..0000000000 --- a/TableProUITests/SettingsWindowTitleUITests.swift +++ /dev/null @@ -1,28 +0,0 @@ -import XCTest - -final class SettingsWindowTitleUITests: UITestCase { - /// The window is found by its accessibility identifier, never by its title: the title is the - /// thing under test, so matching on it would pass without the window ever being retitled. - func testTheSettingsWindowTitleFollowsTheSelectedPane() throws { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let settingsMenuItem = app.menuBars.menuItems["Settings…"] - XCTAssertTrue(settingsMenuItem.waitToExist(timeout: 10)) - settingsMenuItem.click() - - let settingsWindow = app.windows["settings"] - XCTAssertTrue(settingsWindow.waitToExist(timeout: 10)) - - for pane in ["Keyboard", "Appearance", "MCP", "Plugins", "Editor", "General"] { - let paneButton = app.toolbars.buttons[pane] - XCTAssertTrue(paneButton.waitToExist(timeout: 10)) - paneButton.click() - - XCTAssertTrue( - waitForPredicate(timeout: 5) { settingsWindow.title == pane }, - "The settings window has to be titled \(pane), it is titled \(settingsWindow.title)" - ) - } - } -} diff --git a/TableProUITests/SidebarAddMenuUITests.swift b/TableProUITests/SidebarAddMenuUITests.swift index d8cc720c5b..7da53901d0 100644 --- a/TableProUITests/SidebarAddMenuUITests.swift +++ b/TableProUITests/SidebarAddMenuUITests.swift @@ -1,61 +1,66 @@ -// -// SidebarAddMenuUITests.swift -// TableProUITests -// - import XCTest -// Item queries are scoped to the button: the Database menu carries the same titles. - final class SidebarAddMenuUITests: UITestCase { - func testTheAddButtonOpensANewTable() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) + func testCreatingATableFromTheSidebar() throws { + let launch = try launchWithFixture() + let app = launch.app + let window = launch.window + // Scoped to the button: the Database menu carries the same titles. let add = window.descendants(matching: .any).matching(identifier: "sidebar-add").firstMatch - XCTAssertTrue(add.waitToExist(timeout: 30), "The Tables tab carries an add button at its foot") + XCTAssertTrue(waitUntilHittable(add, timeout: 30), "The Tables tab carries an add button at its foot") add.click() - let newTable = add.menuItems["New Table…"].firstMatch XCTAssertTrue(newTable.waitToExist(timeout: 10), "The add button offers New Table…") - XCTAssertTrue(newTable.isEnabled, "A writable SQLite connection can create a table") - XCTAssertTrue(add.menuItems["New Folder"].firstMatch.exists, "The flat list offers New Folder") newTable.click() + // By identifier: the editor tab named Create Table also matches a "Create Table" button query. + let commit = window.buttons["create-table-commit"].firstMatch + XCTAssertTrue(commit.waitToExist(timeout: 30), "New Table… from the add button opens the Create Table tab") XCTAssertTrue( - window.buttons["create-table-commit"].firstMatch.waitToExist(timeout: 30), - "New Table… from the add button opens the Create Table tab" + window.staticTexts.matching(identifier: "create-table-validation").firstMatch.waitToExist(timeout: 10), + "A draft that cannot run yet must say what is missing" ) - } + XCTAssertFalse(commit.isEnabled, "An empty draft has no name and no column, so Create Table stays dimmed") - func testTheFavoritesAddButtonOffersTheFavoritesCommands() throws { - let app = try launchWithSampleDatabase() - let window = try mainWindow(of: app) - showFavorites(in: app) + let name = window.textFields["create-table-name"] + XCTAssertTrue(waitUntilHittable(name, timeout: 10)) + name.click() + name.typeText("playlist") - let add = window.descendants(matching: .any).matching(identifier: "sidebar-favorites-add").firstMatch - XCTAssertTrue(add.waitToExist(timeout: 30), "The Favorites tab carries its own add button") - add.click() - - for title in ["New Query", "New Favorite…", "New Folder", "Add Linked SQL Folder…"] { - XCTAssertTrue(add.menuItems[title].firstMatch.waitToExist(timeout: 10), "Favorites add offers \(title)") - } - app.typeKey(.escape, modifierFlags: []) - } + fillFirstColumn(named: "title", type: "TEXT", in: window, app: app) - private func showFavorites(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() + XCTAssertTrue( + waitForPredicate(timeout: 10) { commit.isEnabled }, + "A named table with a named, typed column can be created" + ) + commit.click() - let showFavorites = menuBar.menuItems["Show Favorites"] - XCTAssertTrue(showFavorites.waitToExist(timeout: 5), "View > Show Favorites must be reachable") - showFavorites.click() + XCTAssertTrue( + objectBrowserRow("playlist", in: window).waitToExist(timeout: 30), + "The new table must be listed in the sidebar" + ) + XCTAssertEqual( + sqliteStrings("SELECT name FROM pragma_table_info('playlist')", in: launch.database), + ["title"], + "The table must be in the file with the column the draft named" + ) } - private func mainWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - return window + // Cells are drawn, not mounted, so the row is reached through a point and edited from the keyboard: + // Left until the cursor stops on the first column, Return opens its editor, Tab commits and opens the next. + private func fillFirstColumn(named column: String, type: String, in window: XCUIElement, app: XCUIApplication) { + let grid = dataGrid(in: window) + XCTAssertTrue(waitForClickableRows(in: grid), "The draft starts with one empty column row") + let firstRow = grid.tableRows.firstMatch + gridPoint(in: grid, of: window, dy: firstRow.frame.midY - grid.frame.minY).click() + for _ in 0 ..< 12 { + app.typeKey(.leftArrow, modifierFlags: []) + } + app.typeKey(.return, modifierFlags: []) + app.typeText(column) + app.typeKey(.tab, modifierFlags: []) + app.typeText(type) + app.typeKey(.return, modifierFlags: []) } } diff --git a/TableProUITests/SidebarFavoritesFirstEntryUITests.swift b/TableProUITests/SidebarFavoritesFirstEntryUITests.swift deleted file mode 100644 index affc17af4c..0000000000 --- a/TableProUITests/SidebarFavoritesFirstEntryUITests.swift +++ /dev/null @@ -1,40 +0,0 @@ -import AppKit -import XCTest - -/// Issue #3016. The Favorites tab loads its queries after its first render, and nothing told SwiftUI -/// when that load landed, so the tab kept whatever it was built with. The reported symptom was a -/// missing Queries section beside the favorite tables; on a connection with no favorites at all the -/// same gap left the spinner up for good, which is what this drives because it needs no seeding. -final class SidebarFavoritesFirstEntryUITests: UITestCase { - func testTheFavoritesTabSettlesOnItsFirstEntry() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) - - showFavorites(in: app) - - let noFavorites = window.staticTexts["No Favorites"] - XCTAssertTrue( - noFavorites.waitToExist(timeout: 15), - """ - One switch to Favorites has to settle on a finished state. The sample database has no \ - favorites, so that state is the onboarding view; a tab still showing its spinner means \ - the load finished with nothing listening. - """ - ) - } - - /// `View > Show Favorites` rather than the Favorites segment of the sidebar's scope control. The - /// menu item is the route that does not depend on the sidebar being laid out, and this test is - /// about what the tab shows on its very first entry, not about how it was reached; the scope - /// control has its own case in `ConnectionWindowChromeUITests`. - private func showFavorites(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - - let showFavorites = menuBar.menuItems["Show Favorites"] - XCTAssertTrue(showFavorites.waitToExist(timeout: 5), "View > Show Favorites must be reachable") - showFavorites.click() - } -} diff --git a/TableProUITests/SidebarSchemaFilterMenuUITests.swift b/TableProUITests/SidebarSchemaFilterMenuUITests.swift deleted file mode 100644 index 594d61458d..0000000000 --- a/TableProUITests/SidebarSchemaFilterMenuUITests.swift +++ /dev/null @@ -1,28 +0,0 @@ -import AppKit -import XCTest - -/// SQLite lists no schemas, so the schema filter is on the View menu and dimmed there. The positive -/// flow needs a schema-grouped server, which no bundled engine reaches without one. -final class SidebarSchemaFilterMenuUITests: UITestCase { - func testFilterSchemasIsListedAndDimmedForAnEngineWithoutSchemas() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - - let filterSchemas = menuBar.menuItems["Filter Schemas…"] - XCTAssertTrue(filterSchemas.waitToExist(timeout: 5), "View must list Filter Schemas…") - XCTAssertFalse(filterSchemas.isEnabled, "An engine with no schemas has nothing to filter") - let showAll = menuBar.menuItems["Show All Schemas"] - XCTAssertTrue(showAll.exists, "View must list Show All Schemas") - XCTAssertFalse(showAll.isEnabled) - app.typeKey(.escape, modifierFlags: []) - } -} diff --git a/TableProUITests/SidebarSystemContainersUITests.swift b/TableProUITests/SidebarSystemContainersUITests.swift deleted file mode 100644 index e2116fd335..0000000000 --- a/TableProUITests/SidebarSystemContainersUITests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import XCTest - -/// #2832. The sidebar tree lists system databases and schemas only on request, so the request has to -/// be reachable from the sidebar's own View Options and has to be the same setting Settings shows. -final class SidebarSystemContainersUITests: UITestCase { - func testViewOptionsTurnsOnTheSettingShownInSettings() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - let viewOptions = window.descendants(matching: .any) - .matching(identifier: "sidebar-view-options").firstMatch - XCTAssertTrue(viewOptions.waitToExist(timeout: 10)) - viewOptions.click() - - let entry = app.menuItems["System Databases and Schemas"].firstMatch - XCTAssertTrue(entry.waitToExist(timeout: 10), "View Options offers System Databases and Schemas") - entry.click() - - let settingsMenuItem = app.menuBars.menuItems["Settings…"] - XCTAssertTrue(settingsMenuItem.waitToExist(timeout: 10)) - settingsMenuItem.click() - - let generalPaneButton = app.toolbars.buttons["General"] - XCTAssertTrue(generalPaneButton.waitToExist(timeout: 10)) - generalPaneButton.click() - - let toggle = app.switches["show-system-containers-toggle"].firstMatch - XCTAssertTrue(toggle.waitToExist(timeout: 10)) - XCTAssertTrue( - waitForPredicate(timeout: 5) { isOn(toggle) }, - "Choosing the View Options entry turns on the setting Settings shows" - ) - } -} diff --git a/TableProUITests/SidebarTableTabUITests.swift b/TableProUITests/SidebarTableTabUITests.swift index f2aeac9642..c366aee68a 100644 --- a/TableProUITests/SidebarTableTabUITests.swift +++ b/TableProUITests/SidebarTableTabUITests.swift @@ -1,92 +1,81 @@ -import AppKit import XCTest -/// Issue #2235. Clicking a table opens it in a preview tab that the next click reuses, so browsing -/// several tables used to leave them all fighting over one tab. Double-clicking one keeps it. +// #2235 and #2436: a single click opens a table in a preview tab the next click reuses; a double-click +// on the sidebar row or on the tab keeps it. The restored query tab keeps the tab strip on screen. +// Consecutive clicks never land on the same spot, so none can be coalesced into a double-click. final class SidebarTableTabUITests: UITestCase { - func testDoubleClickingATableKeepsItsTabSoTheNextTableGetsItsOwn() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) + func testAPreviewTabIsReusedUntilItIsKept() throws { + let launch = try launchWithFixture() + let window = launch.window - /// Establishes the state the issue describes: a preview tab showing Album. Whether the - /// sample database left a tab of its own open does not matter, because the assertion is - /// about the change in the count rather than its value. - click(row("Album", in: window)) - let beforeKeeping = tabCount(in: window) - - doubleClick(row("Album", in: window)) - click(row("Artist", in: window)) + clickAtCenter(sidebarRow("album", in: window)) + XCTAssertTrue(waitForTabs(["album"], in: window), "A click must open the table in a tab") + let previewing = tabCount(in: window) + clickAtCenter(sidebarRow("artist", in: window)) XCTAssertTrue( - waitForPredicate(timeout: 20) { tabCount(in: window) > beforeKeeping }, - """ - A double-clicked table keeps its tab, so clicking a second table must open a second tab \ - rather than replacing the first. Tabs before: \(beforeKeeping), after: \ - \(tabCount(in: window)). - """ + waitForTabs(["artist"], without: ["album"], in: window), + "A second click must reuse the preview tab rather than open another" ) - } - - func testClickingASecondTableStillReusesThePreviewTab() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - click(row("Album", in: window)) - let beforeSecondClick = tabCount(in: window) + XCTAssertEqual(tabCount(in: window), previewing) - click(row("Artist", in: window)) + doubleClickAtCenter(sidebarRow("genre", in: window)) + XCTAssertTrue(waitForTabs(["genre"], without: ["artist"], in: window), "A double-click opens in the preview tab") + clickAtCenter(sidebarRow("track", in: window)) + XCTAssertTrue( + waitForTabs(["genre", "track"], in: window), + "A double-clicked table keeps its tab, so the next table must get a tab of its own" + ) + XCTAssertEqual(tabCount(in: window), previewing + 1) + clickAtCenter(tab("track", in: window)) + clickAtCenter(sidebarRow("note", in: window)) XCTAssertTrue( - waitForPredicate(timeout: 10) { tabCount(in: window) == beforeSecondClick }, - """ - A single click still opens into the preview tab, which the next click reuses. \ - Tabs before: \(beforeSecondClick), after: \(tabCount(in: window)). - """ + waitForTabs(["note"], without: ["track"], in: window), + "Selecting a preview tab must not keep it, so the next table still reuses it" ) - } + XCTAssertEqual(tabCount(in: window), previewing + 1) - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) + doubleClickAtCenter(tab("note", in: window)) + clickAtCenter(sidebarRow("document", in: window)) XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" + waitForTabs(["note", "document"], in: window), + "Double-clicking a preview tab keeps it, so the next table must get a tab of its own" ) - return window + XCTAssertEqual(tabCount(in: window), previewing + 2) } - /// The tree draws its rows as hosted cells, so the name arrives as the static text's `value` - /// rather than as a label or an identifier, and AppKit reports the rows themselves as disabled. - /// Matching on `value` is what finds them; clicking through a coordinate is what reaches them. - private func row(_ name: String, in window: XCUIElement) -> XCUIElement { - let match = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: \(name)")) - .firstMatch - XCTAssertTrue(match.waitToExist(timeout: 20), "The object browser must list \(name)") + private func sidebarRow(_ name: String, in window: XCUIElement) -> XCUIElement { + let row = objectBrowserRow(name, in: window) + XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(name)") + return row + } + + private func tab(_ title: String, in window: XCUIElement) -> XCUIElement { + let match = tabs(in: window).matching(NSPredicate(format: "label == %@", title)).firstMatch + XCTAssertTrue(match.waitToExist(timeout: 20), "The tab strip must show \(title)") return match } - private func click(_ element: XCUIElement) { - clickAtCenter(element) - settleBetweenClicks() + private func tabs(in window: XCUIElement) -> XCUIElementQuery { + window.descendants(matching: .any).matching(identifier: "editor-tab") } - private func doubleClick(_ element: XCUIElement) { - element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - settleBetweenClicks() + private func tabCount(in window: XCUIElement) -> Int { + tabs(in: window).count } - /// A genuine delay, not a poll: there is no state to observe between two clicks, and the only - /// thing being waited out is the window in which macOS would coalesce the next click into this - /// one. `waitForPredicate(timeout:) { false }` was three seconds of the same thing written as a - /// condition that cannot succeed. - private func settleBetweenClicks() { - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) + private func hasTab(_ title: String, in window: XCUIElement) -> Bool { + tabs(in: window).matching(NSPredicate(format: "label == %@", title)).firstMatch.exists } - /// The strip is drawn only once a connection holds more than one tab, so one tab reads as zero - /// here. The tests compare counts rather than read a total, which holds either way. - private func tabCount(in window: XCUIElement) -> Int { - window.descendants(matching: .any).matching(identifier: "editor-tab").count + private func waitForTabs(_ present: [String], without absent: [String] = [], in window: XCUIElement) -> Bool { + waitForPredicate(timeout: 20) { + present.allSatisfy { self.hasTab($0, in: window) } && !absent.contains { self.hasTab($0, in: window) } + } + } + + private func doubleClickAtCenter(_ element: XCUIElement) { + element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() } } diff --git a/TableProUITests/SingleWindowMenuContractUITests.swift b/TableProUITests/SingleWindowMenuContractUITests.swift deleted file mode 100644 index befcf86f04..0000000000 --- a/TableProUITests/SingleWindowMenuContractUITests.swift +++ /dev/null @@ -1,139 +0,0 @@ -import XCTest - -/// The single-window model moved the editor tab commands off AppKit's own window-tab selectors -/// onto the app's tab list, and added the Window menu item the HIG requires once app windows -/// share a tabbing identifier. These are the parts of that contract a UI test can assert without -/// a live database. -final class SingleWindowMenuContractUITests: UITestCase { - private func launchAppShowingAWindow() throws -> XCUIApplication { - let app = try launchApp() - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - return app - } - - /// Every app window now shares one tabbing identifier, so Merge All Windows is the documented - /// route back to a single window. It was the one standard tab item the Window menu omitted. - func testWindowMenuOffersMergeAllWindows() throws { - let app = try launchAppShowingAWindow() - - let mergeAll = app.menuBars.menuItems["Merge All Windows"] - XCTAssertTrue( - mergeAll.waitToExist(timeout: 5), - "Window menu must offer Merge All Windows once app windows share a tabbing identifier" - ) - } - - func testWindowMenuKeepsTheStandardTabCommands() throws { - let app = try launchAppShowingAWindow() - - for title in ["Show Previous Tab", "Show Next Tab", "Move Tab to New Window"] { - XCTAssertTrue( - app.menuBars.menuItems[title].waitToExist(timeout: 5), - "Window menu must keep the standard tab command \(title)" - ) - } - } - - func testFileMenuOffersTheEditorTabCommands() throws { - let app = try launchAppShowingAWindow() - - XCTAssertTrue( - app.menuBars.menuItems["New Tab"].waitToExist(timeout: 5), - "File menu must offer New Tab, which now acts on the editor tab list" - ) - } - - /// Close is one item named after the window in front, the way Xcode, Terminal and Finder all - /// ship it. Launch shows the welcome window, which has no tabs, so it reads Close Window. - func testCloseIsNamedForTheKeyWindow() throws { - let app = try launchAppShowingAWindow() - - XCTAssertTrue( - app.menuBars.menuItems["Close Window"].waitToExist(timeout: 5), - "A window with no tabs must name the close command Close Window" - ) - } - - /// The retitle itself. "Close Window" is the title the menu is built with, so asserting only - /// that would pass even if the title were never resolved from the key window. - func testCloseIsNamedCloseTabOnceATabIsOpen() throws { - let app = try launchWithSampleDatabase() - app.typeKey("t", modifierFlags: .command) - - XCTAssertTrue( - app.menuBars.menuItems["Close Tab"].waitToExist(timeout: 15), - "A window with a tab open must name the close command Close Tab" - ) - XCTAssertFalse( - app.menuBars.menuItems["Close Window"].exists, - "The close command is one item, so the built title must be gone once it resolves" - ) - } - - /// The only test that reaches the sample database the way a person does. Every other suite asks - /// for it at launch, because the menu route costs XCUITest a ten second failed traversal on - /// every call, so the menu item itself would otherwise lose its coverage entirely. - func testHelpMenuOpensTheSampleDatabase() throws { - let app = try launchAndOpenSampleDatabaseFromHelpMenu() - - XCTAssertTrue( - waitForSampleDatabaseWindow(in: app), - "Help > Open Sample Database must open a connected window" - ) - } - - /// The connections strip offers Close on a row, and the HIG requires every context-menu command - /// to be reachable from the menu bar too. - func testFileMenuOffersCloseConnection() throws { - let app = try launchAppShowingAWindow() - - XCTAssertTrue( - app.menuBars.menuItems["Close Connection"].waitToExist(timeout: 5), - "File menu must mirror the connections strip's Close command" - ) - } - - /// The rail is named for what it lists. "Workspace" was a term the product invented for a - /// window-like thing, which the HIG's Windows guidance rules out. - /// Either verb, because the item toggles with the strip's visibility and this is about the noun. - /// Asserting only on "Show" made the test depend on how many connections happened to be - /// restored at launch, which another suite could change out from under it. - func testViewMenuNamesTheConnectionsStrip() throws { - let app = try launchAppShowingAWindow() - let menuItems = app.menuBars.menuItems - - XCTAssertTrue( - menuItems["Show Connections"].waitToExist(timeout: 5) - || menuItems["Hide Connections"].exists, - "View menu must name the strip after what it lists" - ) - XCTAssertFalse(menuItems["Show Workspace Rail"].exists, "The invented noun must be gone") - XCTAssertFalse(menuItems["Hide Workspace Rail"].exists, "The invented noun must be gone") - } - - /// Edit > Data acts only on a data file window, so a connection window leaves it out. - /// `DataFileWindowUITests` opens the same submenu from a data file window. - func testConnectionWindowLeavesOutEditData() throws { - let app = try launchWithSampleDatabase() - let menuItems = app.menuBars.menuItems - - XCTAssertTrue( - menuItems["Restore Previous Values…"].waitToExist(timeout: 5), - "The Edit menu must be listed for the checks below to mean anything" - ) - XCTAssertFalse(menuItems["Column Statistics…"].exists, "A connection window must not list Edit > Data") - XCTAssertFalse(menuItems["Import into Table…"].exists, "A connection window must not list Edit > Data") - } - - /// Launching shows the welcome window and nothing else. A second main window appearing here - /// is the shape of the bug the single-window model exists to prevent. - func testLaunchOpensExactlyOneWindow() throws { - let app = try launchAppShowingAWindow() - - XCTAssertEqual( - app.windows.count, - 1, - "Launch must open exactly one window, not one per restored connection" - ) - } -} diff --git a/TableProUITests/SoftwareUpdateSettingsUITests.swift b/TableProUITests/SoftwareUpdateSettingsUITests.swift deleted file mode 100644 index 0b1bb30a2e..0000000000 --- a/TableProUITests/SoftwareUpdateSettingsUITests.swift +++ /dev/null @@ -1,99 +0,0 @@ -import XCTest - -/// Updates check and install on their own, so the two toggles that reverse that are the whole of -/// the user's consent and the pane has to offer both. The frequency picker that used to sit between -/// them is gone: it reversed nothing, it only slowed a security fix down, and Sparkle documents -/// `SUScheduledCheckInterval` in Info.plist as where that value belongs. -/// -/// Nothing here writes an update preference. Sparkle reads and writes them through `SUHost`, which -/// uses the standard user defaults for the main bundle, so a click on one of these controls lands -/// in the real `com.TablePro` domain rather than the UI-test suite, and the runner is sandboxed -/// away from clearing it again. A case that flipped a toggle would change the update behaviour of -/// the machine it ran on and leak into every later case. A launch argument such as -/// `-SUEnableAutomaticChecks YES` lands in the argument domain instead, which is read first and -/// never written, so that is how a case pins the state a control depends on. -final class SoftwareUpdateSettingsUITests: UITestCase { - func testSoftwareUpdateControlsAreOffered() throws { - let app = try launchApp() - openGeneralSettings(in: app) - - XCTAssertTrue(app.switches["automatic-update-check-toggle"].firstMatch.waitToExist(timeout: 10)) - XCTAssertTrue(app.switches["automatic-update-install-toggle"].firstMatch.waitToExist(timeout: 10)) - XCTAssertTrue(app.buttons["check-for-updates-button"].firstMatch.waitToExist(timeout: 10)) - } - - /// The pane used to reach the changelog in a browser while Help and the welcome window both - /// opened the in-app window, so one label did two different things by entry point. - /// - /// The assertion has to be the window, not the element's role: `.buttonStyle(.link)` publishes - /// the control as a Link whatever its action does, which is also how the welcome window's copy - /// reads. Only clicking it tells the two apart. - func testWhatsNewOpensTheInAppWindow() throws { - let app = try launchApp() - openGeneralSettings(in: app) - - let whatsNew = app.links["whats-new-link"].firstMatch - XCTAssertTrue(whatsNew.waitToExist(timeout: 10)) - whatsNew.click() - - XCTAssertTrue( - app.windows["What's New"].waitToExist(timeout: 10), - "Settings must open the same window as Help > What's New, not a browser tab" - ) - } - - /// The row every comparable Mac app has and this pane did not: without it the only answer to - /// "am I current" is to press the button and wait. - func testLastCheckedRowIsShown() throws { - let app = try launchApp(arguments: ["-SUEnableAutomaticChecks", "NO"]) - openGeneralSettings(in: app) - - let lastChecked = app.staticTexts["last-update-check-label"].firstMatch - XCTAssertTrue(lastChecked.waitToExist(timeout: 10)) - - // A SwiftUI Text in a Form row publishes its string as the element's value, not its label, - // the way every other static text in this pane does. Both are read because the runner's - // accessibility tree differs from this machine's. - let shown = (lastChecked.value as? String) ?? lastChecked.label - XCTAssertTrue(shown.contains("Last checked"), "Expected a last-checked line, got \(shown)") - } - - /// The install toggle follows `allowsAutomaticUpdates`, which Sparkle derives from - /// `automaticallyChecksForUpdates` when `SUAllowsAutomaticUpdates` is unset. With checks pinned - /// off it is not merely dimmed: `setAutomaticallyDownloadsUpdates` returns without writing. - func testInstallToggleFollowsTheCheckToggle() throws { - let app = try launchApp(arguments: ["-SUEnableAutomaticChecks", "NO"]) - openGeneralSettings(in: app) - - let installToggle = app.switches["automatic-update-install-toggle"].firstMatch - XCTAssertTrue(installToggle.waitToExist(timeout: 10)) - XCTAssertFalse(installToggle.isEnabled, "Automatic installs cannot be on while checks are off") - } - - /// A started updater in a test launch asks for permission on the runner's second launch, and - /// that prompt took the key window from thirteen tests in eleven unrelated suites. The updater - /// now starts from `AppDelegate.runPostLaunchActivationIfNeeded()`, which returns early for an - /// isolated launch, so Check for Updates stays disabled. Automatic checks are pinned off so a - /// started updater would neither prompt nor begin a background check, either of which would - /// disable the button for a while and let this pass without proving anything. - func testATestLaunchNeverStartsTheUpdater() throws { - let app = try launchApp(arguments: ["-SUEnableAutomaticChecks", "NO"]) - openGeneralSettings(in: app) - - let checkNow = app.windows["settings"].buttons["check-for-updates-button"].firstMatch - XCTAssertTrue(checkNow.waitToExist(timeout: 10)) - XCTAssertFalse(checkNow.isEnabled, "A UI test launch must not start Sparkle") - } - - private func openGeneralSettings(in app: XCUIApplication) { - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - - let settingsMenuItem = app.menuBars.menuItems["Settings…"] - XCTAssertTrue(settingsMenuItem.waitToExist(timeout: 10)) - settingsMenuItem.click() - - let generalPaneButton = app.toolbars.buttons["General"] - XCTAssertTrue(generalPaneButton.waitToExist(timeout: 10)) - generalPaneButton.click() - } -} diff --git a/TableProUITests/StructureColumnMoveUITests.swift b/TableProUITests/StructureColumnMoveUITests.swift deleted file mode 100644 index 71064650d4..0000000000 --- a/TableProUITests/StructureColumnMoveUITests.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// StructureColumnMoveUITests.swift -// TableProUITests -// - -import XCTest - -/// Issue #2479 shipped column reorder as a drag and nothing else, so the only way to move a column -/// needed a pointer: no menu command, no keyboard, and nothing VoiceOver could perform. The tab -/// strip had already closed the same gap with Move Tab Left and Move Tab Right beside its drag. -/// -/// The sample database is SQLite, which reorders by rebuilding the table, so both commands are -/// offered and neither is run here: confirming the rebuild is a different flow. -final class StructureColumnMoveUITests: UITestCase { - func testAColumnRowsContextualMenuOffersBothMoveCommands() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - let row = objectBrowserRow("Album", in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(row) - - showStructure(in: app, window: window) - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The structure editor must draw its column grid") - XCTAssertTrue( - waitForPredicate(timeout: 30) { grid.tableRows.count > 1 }, - "Album has more than one column, so both directions have somewhere to go" - ) - /// Existing is not laid out. A coordinate taken off a grid whose frame is still empty - /// resolves to `(inf, inf)`, which `rightClick` then posts at no display at all and the - /// runner dies rather than failing an assertion. - XCTAssertTrue( - waitForPredicate(timeout: 30) { grid.frame.width > 0 && grid.frame.height > 0 }, - "The grid must be laid out before a coordinate is taken off it" - ) - - let target = gridPoint(in: grid, of: window, dy: 40) - - /// Right-clicking an unselected row falls through to the row view's own `menu(for:)`. - target.rightClick() - assertMoveCommandsOffered(in: app, path: "an unselected column row") - app.typeKey(.escape, modifierFlags: []) - - /// Selecting first is the ordinary path, and it is a different one in AppKit: - /// `KeyHandlingTableView.rightMouseDown` intercepts a click inside the selection and - /// answers from `DataGridRowView.contextMenu(for:)`, which never sees the row view's - /// override. Items added only there were missing from exactly the path most users take. - target.click() - target.rightClick() - assertMoveCommandsOffered(in: app, path: "a selected column row") - app.typeKey(.escape, modifierFlags: []) - } - - private func assertMoveCommandsOffered(in app: XCUIApplication, path: String) { - let up = app.menuItems["Move Column Up"].firstMatch - let down = app.menuItems["Move Column Down"].firstMatch - XCTAssertTrue(up.waitToExist(timeout: 15), "\(path) must offer Move Column Up") - XCTAssertTrue(down.exists, "\(path) must offer Move Column Down") - /// Which of the two is live depends on where in the run the click landed; a first or last - /// column has one direction with nowhere to go. `ColumnMoveTests` pins that arithmetic. - XCTAssertTrue( - up.isEnabled || down.isEnabled, - "SQLite reorders by rebuilding, so a column has at least one direction it can move" - ) - } -} diff --git a/TableProUITests/StructureConstraintsTabUITests.swift b/TableProUITests/StructureConstraintsTabUITests.swift deleted file mode 100644 index 69e9178883..0000000000 --- a/TableProUITests/StructureConstraintsTabUITests.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// StructureConstraintsTabUITests.swift -// TableProUITests -// - -import XCTest - -final class StructureConstraintsTabUITests: UITestCase { - /// The Constraints tab is gated on a per-engine capability, so the failure it guards against is - /// a tab that never appears at all. SQLite declares check constraints, so the sample database - /// is enough to prove the tab reaches the picker and renders when selected. - func testConstraintsTabIsOfferedAndOpens() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - let row = objectBrowserRow("Album", in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(row) - - showStructure(in: app, window: window) - - let constraints = subTab(named: "Constraints", in: window) - XCTAssertTrue( - constraints.waitToExist(timeout: 20), - "SQLite declares check constraints, so the structure editor must offer Constraints" - ) - constraints.click() - - XCTAssertTrue( - waitForPredicate(timeout: 10) { isSelected(constraints) }, - "Selecting Constraints moves the picker to it" - ) - } - - /// A radio button in a hosted picker reports `AXSelected` as nil and answers with `AXValue` - /// instead, so `isSelected` reads as false however the picker is set. - private func isSelected(_ segment: XCUIElement) -> Bool { - (segment.value as? NSNumber)?.intValue == 1 - } - - /// The sub-tab labels carry item counts, so they are matched by prefix rather than exactly. - private func subTab(named name: String, in window: XCUIElement) -> XCUIElement { - window.radioGroups["structure-tab-picker"].firstMatch - .radioButtons - .matching(NSPredicate(format: "label BEGINSWITH %@", name)) - .firstMatch - } -} diff --git a/TableProUITests/StructureForeignKeyEditUITests.swift b/TableProUITests/StructureForeignKeyEditUITests.swift deleted file mode 100644 index 25678cdc89..0000000000 --- a/TableProUITests/StructureForeignKeyEditUITests.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// StructureForeignKeyEditUITests.swift -// TableProUITests -// - -import XCTest - -/// The reported defect end to end. Adding a foreign key on SQLite used to reach the DDL generator -/// and come back as "Unsupported schema operation: Add foreign key ''": the "+" was offered on -/// every engine that *has* foreign keys rather than every engine that can edit them, and Save never -/// consulted the validation that would have caught the blank row. -final class StructureForeignKeyEditUITests: UITestCase { - /// SQLite is curated as rebuilding the table for a foreign key change, so the pair under the - /// list has to be live. A regression in that curated value would silently take the whole - /// feature away without failing a unit test that never reads the registry. - func testAddForeignKeyIsOfferedOnSQLite() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - try openForeignKeysTab(app: app, window: window) - - let add = window.buttons["structure-footer-add"].firstMatch - XCTAssertTrue(add.waitToExist(timeout: 20), "The Foreign Keys tab must offer an add button") - XCTAssertTrue( - waitForPredicate(timeout: 10) { add.isEnabled }, - "SQLite rebuilds the table to change a foreign key, so adding one must be offered" - ) - } - - /// The "+" stages a blank row immediately. Saving it used to build - /// `ADD CONSTRAINT "" FOREIGN KEY () REFERENCES "" ()`; now the save is refused and says which - /// row is incomplete, with the edit left staged. - func testSavingABlankForeignKeyIsRefusedWithAReason() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - try openForeignKeysTab(app: app, window: window) - - let add = window.buttons["structure-footer-add"].firstMatch - XCTAssertTrue(add.waitToExist(timeout: 20)) - XCTAssertTrue(waitForPredicate(timeout: 10) { add.isEnabled }) - add.click() - - app.typeKey("s", modifierFlags: .command) - - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 20), "An incomplete foreign key must stop the save") - - /// `value`, not `label`. An `NSAlert`'s messageText and informativeText reach XCUITest as the - /// static text's value and leave its label empty, so reading the label alone found the right - /// sheet and reported it as blank. - let text = sheet.staticTexts.allElementsBoundByIndex - .map { ($0.value as? String) ?? $0.label } - .joined(separator: " ") - XCTAssertFalse( - text.contains("Unsupported schema operation"), - "The blank row must be refused by validation, not by the DDL generator" - ) - XCTAssertTrue( - text.contains("Foreign key"), - "The refusal must name the row that is incomplete, got: \(text)" - ) - } - - private func openForeignKeysTab(app: XCUIApplication, window: XCUIElement) throws { - let row = objectBrowserRow("Album", in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(row) - - showStructure(in: app, window: window) - - /// The sub-tab labels carry item counts, so they are matched by prefix rather than exactly. - let foreignKeys = window.radioGroups["structure-tab-picker"].firstMatch - .radioButtons - .matching(NSPredicate(format: "label BEGINSWITH %@", "Foreign Keys")) - .firstMatch - XCTAssertTrue(foreignKeys.waitToExist(timeout: 20), "SQLite has foreign keys, so the tab must be offered") - foreignKeys.click() - } -} diff --git a/TableProUITests/StructureIndexNameReuseUITests.swift b/TableProUITests/StructureIndexNameReuseUITests.swift deleted file mode 100644 index 34a00f1f53..0000000000 --- a/TableProUITests/StructureIndexNameReuseUITests.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// StructureIndexNameReuseUITests.swift -// TableProUITests -// - -import XCTest - -/// Deleting an index and giving another index its name in the same save was refused as "Duplicate -/// index name", counting the row being deleted, although the save drops the old index before -/// anything takes its name. -/// -/// The two indexes cover different columns, so the file tells a save that ran from one that was -/// refused: afterwards the deleted index's name has to be on the other index's column. -final class StructureIndexNameReuseUITests: UITestCase { - private let indexedColumns = ["idx_body": "body", "idx_tag": "tag"] - - func testRenamingAnIndexOntoADeletedIndexsNameSaves() throws { - let databases = try seedSQLiteSession( - connectionNames: ["Indexes"], - databaseSQL: """ - CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT, tag TEXT); - CREATE INDEX idx_body ON notes (body); - CREATE INDEX idx_tag ON notes (tag); - """ - ) - let database = try XCTUnwrap(databases.first) - let app = try launchApp() - let window = app.windows.firstMatch - let grid = try openIndexesGrid(app: app, window: window) - - /// Up from whichever row the click took puts the selection on the first row, so the delete - /// and the rename land on different rows without reading the order the catalog lists them in. - gridPoint(in: grid, of: window, dy: 60).click() - app.typeKey(.upArrow, modifierFlags: []) - XCTAssertTrue( - waitForPredicate(timeout: 10) { grid.tableRows.element(boundBy: 0).isSelected }, - "The first index row must be selected" - ) - app.typeKey(.delete, modifierFlags: []) - app.typeKey(.downArrow, modifierFlags: []) - XCTAssertTrue( - waitForPredicate(timeout: 10) { grid.tableRows.element(boundBy: 1).isSelected }, - "The deleted row stays listed until the save, so Down must reach the second index" - ) - - showInspector(in: app) - let nameField = window.textFields - .matching(NSPredicate(format: "value IN %@", Array(indexedColumns.keys))) - .firstMatch - XCTAssertTrue(nameField.waitToExist(timeout: 30), "The inspector must show the second index's name") - let renamed = try XCTUnwrap(nameField.value as? String) - let deleted = try XCTUnwrap(indexedColumns.keys.first { $0 != renamed }) - let renamedColumn = try XCTUnwrap(indexedColumns[renamed]) - - nameField.click() - app.typeKey("a", modifierFlags: .command) - app.typeText(deleted) - app.typeKey(.return, modifierFlags: []) - - app.typeKey("s", modifierFlags: .command) - - /// `DROP INDEX` is a destructive statement, so the save is reviewed before it runs. Before - /// the fix the save stopped earlier, on "Some Changes Are Incomplete". - let sheet = window.sheets.firstMatch - let execute = sheet.buttons["sql-review-execute"].firstMatch - XCTAssertTrue( - execute.waitToExist(timeout: 20), - "The save must reach the review of its statements, got: \(text(of: sheet))" - ) - execute.click() - - XCTAssertTrue( - waitForPredicate(timeout: 30) { grid.tableRows.count == 1 }, - "The save must run and the grid reload with the one index the table kept" - ) - XCTAssertEqual( - sqliteStrings("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'notes'", in: database), - [deleted], - "Only the renamed index may be left, under the deleted index's name" - ) - let definition = sqliteStrings("SELECT sql FROM sqlite_master WHERE name = '\(deleted)'", in: database) - .joined(separator: "\n") - XCTAssertTrue( - definition.contains(renamedColumn), - "\(deleted) must now be the index on \(renamedColumn), got: \(definition)" - ) - } - - private func openIndexesGrid(app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { - let table = objectBrowserRow("notes", in: window) - XCTAssertTrue(table.waitToExist(timeout: 60), "The restored connection must list notes") - clickAtCenter(table) - - showStructure(in: app, window: window) - let indexes = window.radioGroups["structure-tab-picker"].firstMatch - .radioButtons - .matching(NSPredicate(format: "label BEGINSWITH %@", "Indexes")) - .firstMatch - XCTAssertTrue(indexes.waitToExist(timeout: 20), "SQLite has indexes, so the tab must be offered") - indexes.click() - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The structure editor must draw its index grid") - XCTAssertTrue( - waitForPredicate(timeout: 30) { - grid.frame.width > 0 && grid.frame.height > 0 && grid.tableRows.count == 2 - }, - "notes has two indexes, so the grid must list two rows" - ) - return grid - } - - /// `value`, not `label`: an alert's text reaches XCUITest as the static text's value. - private func text(of sheet: XCUIElement) -> String { - sheet.staticTexts.allElementsBoundByIndex - .map { ($0.value as? String) ?? $0.label } - .joined(separator: " ") - } - - /// The inspector remembers whether it was open, so the View menu item's title is the only - /// handle on its state. - private func showInspector(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - - let show = menuBar.menuItems["Show Inspector"] - if show.waitToExist(timeout: 5) { - show.click() - return - } - app.typeKey(.escape, modifierFlags: []) - } -} diff --git a/TableProUITests/StructurePreviewSQLUITests.swift b/TableProUITests/StructurePreviewSQLUITests.swift deleted file mode 100644 index c05c3dd754..0000000000 --- a/TableProUITests/StructurePreviewSQLUITests.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// StructurePreviewSQLUITests.swift -// TableProUITests -// - -import XCTest - -final class StructurePreviewSQLUITests: UITestCase { - func testPreviewingAForeignKeyDropOnSQLiteShowsTheRebuildSaveWouldReview() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - let grid = try openForeignKeysGrid(app: app, window: window) - gridPoint(in: grid, of: window, dy: 40).click() - XCTAssertTrue( - waitForPredicate(timeout: 10) { grid.tableRows.allElementsBoundByIndex.contains { $0.isSelected } }, - "The click must select Album's foreign key row" - ) - - let remove = window.buttons["structure-footer-remove"].firstMatch - XCTAssertTrue(remove.waitToExist(timeout: 20), "The Foreign Keys tab must offer a remove button") - XCTAssertTrue( - waitForPredicate(timeout: 10) { remove.isEnabled }, - "SQLite rebuilds the table to drop a foreign key, so removing one must be offered" - ) - remove.click() - - app.typeKey("p", modifierFlags: [.command, .shift]) - - let sheet = window.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 30), "Preview SQL must open a sheet") - XCTAssertTrue( - sheet.buttons["Open in Query Editor"].firstMatch.waitToExist(timeout: 30), - "A foreign key change on SQLite is a table rebuild, and Preview must show that script" - ) - XCTAssertFalse( - sheet.buttons["sql-review-execute"].firstMatch.exists, - "Preview shows the rebuild and runs nothing" - ) - let text = sheet.staticTexts.allElementsBoundByIndex - .map { ($0.value as? String) ?? $0.label } - .joined(separator: " ") - XCTAssertFalse( - text.contains("Unsupported schema operation"), - "Preview must not refuse a change Save knows how to make, got: \(text)" - ) - } - - private func openForeignKeysGrid(app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { - let row = objectBrowserRow("Album", in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(row) - - showStructure(in: app, window: window) - - let foreignKeys = window.radioGroups["structure-tab-picker"].firstMatch - .radioButtons - .matching(NSPredicate(format: "label BEGINSWITH %@", "Foreign Keys")) - .firstMatch - XCTAssertTrue(foreignKeys.waitToExist(timeout: 20), "SQLite has foreign keys, so the tab must be offered") - foreignKeys.click() - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The structure editor must draw its foreign key grid") - XCTAssertTrue( - waitForPredicate(timeout: 30) { - grid.frame.width > 0 && grid.frame.height > 0 && grid.tableRows.count == 1 - }, - "Album references Artist and nothing else, so the grid must list exactly one foreign key" - ) - return grid - } -} diff --git a/TableProUITests/StructureRowMenuParityUITests.swift b/TableProUITests/StructureRowMenuParityUITests.swift deleted file mode 100644 index 1c2b2ee736..0000000000 --- a/TableProUITests/StructureRowMenuParityUITests.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// StructureRowMenuParityUITests.swift -// TableProUITests -// - -import XCTest - -/// A column row raises its own menu whichever way it was clicked. -/// -/// AppKit takes two routes to a row's menu. `KeyHandlingTableView.rightMouseDown` intercepts a -/// click that lands inside the selection and answers from `DataGridRowView.contextMenu(for:)`; a -/// click outside it falls through to `super`, which reaches the row view's own `menu(for:)`. The -/// Structure tab overrode only the second, so selecting a column and right-clicking it produced -/// the data grid's row commands over a schema row. -/// -/// Only the positive half is assertable here. `Copy Name` exists in no menu bar menu, so finding -/// it proves the contextual menu carried it; the absence of the data grid's commands cannot be -/// asserted through XCUITest at all, because their titles do sit in the menu bar. That half is -/// `StructureRowMenuRouteTests`, which builds both menus and reads their items. -final class StructureRowMenuParityUITests: UITestCase { - private let structureOnlyItem = "Copy Name" - - func testAColumnRowRaisesTheStructureMenuWhetherOrNotItIsSelected() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - let row = objectBrowserRow("Album", in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(row) - - showStructure(in: app, window: window) - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The structure editor must draw its column grid") - XCTAssertTrue( - waitForPredicate(timeout: 30) { grid.tableRows.count > 1 }, - "Album must report its columns" - ) - /// Existing is not laid out. A coordinate taken off a grid whose frame is still empty - /// resolves to `(inf, inf)`, which `rightClick` then posts at no display at all and the - /// runner dies rather than failing an assertion. - XCTAssertTrue( - waitForPredicate(timeout: 30) { grid.frame.width > 0 && grid.frame.height > 0 }, - "The grid must be laid out before a coordinate is taken off it" - ) - - let target = gridPoint(in: grid, of: window, dy: 40) - - target.rightClick() - assertStructureMenu(in: app, path: "an unselected column row") - app.typeKey(.escape, modifierFlags: []) - - /// Selecting first is the route that was broken, and the one most people take. - target.click() - XCTAssertTrue( - waitForPredicate(timeout: 10) { grid.tableRows.allElementsBoundByIndex.contains { $0.isSelected } }, - "The click must select a column row, or this asserts the same route twice" - ) - target.rightClick() - assertStructureMenu(in: app, path: "a selected column row") - app.typeKey(.escape, modifierFlags: []) - } - - private func assertStructureMenu(in app: XCUIApplication, path: String) { - XCTAssertTrue( - contextMenuItem(structureOnlyItem, in: app).waitToExist(timeout: 15), - "\(path) must offer \(structureOnlyItem), which only the structure menu builds" - ) - } -} diff --git a/TableProUITests/StructureSaveConfirmationUITests.swift b/TableProUITests/StructureSaveConfirmationUITests.swift index 4ebeaedc31..ebc25bd1c6 100644 --- a/TableProUITests/StructureSaveConfirmationUITests.swift +++ b/TableProUITests/StructureSaveConfirmationUITests.swift @@ -1,49 +1,35 @@ -// -// StructureSaveConfirmationUITests.swift -// TableProUITests -// - import XCTest -/// A Structure save asks once. An `ALTER` save is confirmed by the execution gate's sheet, which -/// shows the statements it runs; a table rebuild is confirmed by its review sheet, which shows the -/// script. The editor used to ask first with an alert of its own and the gate then asked again, and -/// a rebuild's review was followed by the gate's sheet stacked over it, so one decision took two -/// answers, and three with Touch ID. -/// -/// SQLite runs every case here: it drops a column with `ALTER`, and it changes a foreign key or a -/// column's position by rebuilding the table. +// A Structure save asks once, at the execution gate's sheet. The editor used to ask with an alert of its +// own first, so one decision took two answers. final class StructureSaveConfirmationUITests: UITestCase { - func testDroppingAColumnAtAlertAsksOnce() throws { - let (app, database) = try openNotesStructure(connection: "Alerted", safeModeLevel: "alert") - let window = app.windows.firstMatch - - stageColumnDrop(in: app, window: window) - app.typeKey("s", modifierFlags: .command) + private let columnsQuery = "SELECT name FROM pragma_table_xinfo('note')" - let execute = gateExecuteButton(in: window) - XCTAssertTrue(execute.waitToExist(timeout: 20), "The first sheet must be the gate's, with the statement") - XCTAssertFalse(window.sheets.buttons["Apply Changes"].exists, "The editor must not ask before the gate does") - execute.click() + func testDroppingAColumnAsksOnceAndCancelKeepsItStaged() throws { + let (launch, _) = try launchWithFixtureTable("note", safeModeLevel: "alert") + let window = launch.window + let app = launch.app + showStructure(in: app, window: window) + let grid = fixtureGrid(in: window, app: app) XCTAssertTrue( - waitForPredicate(timeout: 30) { - sqliteStrings("SELECT name FROM pragma_table_xinfo('notes')", in: database) == ["body"] - }, - "One answer must be enough for the drop to reach the file" + waitForPredicate(timeout: 30) { grid.value(row: 2, column: 1) == "tag" }, + "The Columns tab must list note's columns" ) - XCTAssertFalse(window.sheets.firstMatch.exists, "Nothing is left to answer") - } - - func testCancellingTheGateKeepsTheDropStagedAndShowsNothingElse() throws { - let (app, database) = try openNotesStructure(connection: "Cancelled", safeModeLevel: "silent") - let window = app.windows.firstMatch - - stageColumnDrop(in: app, window: window) - app.typeKey("s", modifierFlags: .command) + grid.clickCell(row: 2, column: 1) + XCTAssertTrue( + waitForPredicate(timeout: 10) { grid.element.tableRows.element(boundBy: 1).isSelected }, + "The click must select the tag row" + ) + let remove = window.buttons["structure-footer-remove"].firstMatch + XCTAssertTrue(remove.waitToExist(timeout: 20), "The Columns tab must offer a remove button") + XCTAssertTrue(waitForPredicate(timeout: 10) { remove.isEnabled }, "Removing the selected column must be offered") + remove.click() + app.typeShortcut(.save) let execute = gateExecuteButton(in: window) - XCTAssertTrue(execute.waitToExist(timeout: 20), "Dropping a column must ask once, at the gate") + XCTAssertTrue(execute.waitToExist(timeout: 20), "The first sheet must be the gate's, with the statement") + XCTAssertFalse(window.sheets.buttons["Apply Changes"].exists, "The editor must not ask before the gate does") let cancel = window.sheets.buttons["Cancel"].firstMatch XCTAssertTrue(cancel.waitToExist(timeout: 5), "The gate's sheet must offer Cancel") cancel.click() @@ -52,166 +38,37 @@ final class StructureSaveConfirmationUITests: UITestCase { waitForPredicate(timeout: 10) { !window.sheets.firstMatch.exists }, "Cancel must close the gate's sheet" ) - XCTAssertFalse( - waitForPredicate(timeout: 3) { window.sheets.firstMatch.exists }, - "A Cancel is the user's own choice and must not be answered with an error sheet" - ) XCTAssertEqual( - sqliteStrings("SELECT name FROM pragma_table_xinfo('notes')", in: database), - ["tag", "body"], + FixtureFile.rows(columnsQuery, in: launch.database), + [["id"], ["tag"], ["body"]], "Nothing may run after a Cancel" ) - app.typeKey("s", modifierFlags: .command) + // Asking again proves the drop is still staged and that nothing, an error included, answered the Cancel. + app.typeShortcut(.save) XCTAssertTrue( gateExecuteButton(in: window).waitToExist(timeout: 20), - "The drop must still be staged, so saving again asks again" - ) - window.sheets.buttons["Cancel"].firstMatch.click() - } - - func testRemovingAForeignKeyRunsFromItsReviewWithoutAskingAgain() throws { - let databases = try seedSQLiteSession( - connectionNames: ["Rebuild"], - databaseSQL: """ - CREATE TABLE artist (id INTEGER PRIMARY KEY); - CREATE TABLE album (id INTEGER PRIMARY KEY, artist_id INTEGER REFERENCES artist (id)); - """ - ) - let database = try XCTUnwrap(databases.first) - let app = try launchApp() - let window = app.windows.firstMatch - - let table = objectBrowserRow("album", in: window) - XCTAssertTrue(table.waitToExist(timeout: 60), "The restored connection must list album") - clickAtCenter(table) - showStructure(in: app, window: window) - - let foreignKeys = window.radioGroups["structure-tab-picker"].firstMatch - .radioButtons - .matching(NSPredicate(format: "label BEGINSWITH %@", "Foreign Keys")) - .firstMatch - XCTAssertTrue(foreignKeys.waitToExist(timeout: 20), "SQLite has foreign keys, so the tab must be offered") - foreignKeys.click() - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The Foreign Keys tab must draw its grid") - XCTAssertTrue( - waitForPredicate(timeout: 30) { - grid.frame.width > 0 && grid.frame.height > 0 && grid.tableRows.count == 1 - }, - "album has one foreign key, so the grid must list one row" - ) - gridPoint(in: grid, of: window, dy: 40).click() - - let remove = window.buttons["structure-footer-remove"].firstMatch - XCTAssertTrue(remove.waitToExist(timeout: 20), "The Foreign Keys tab must offer a remove button") - XCTAssertTrue(waitForPredicate(timeout: 10) { remove.isEnabled }, "Removing the selected key must be offered") - remove.click() - - app.typeKey("s", modifierFlags: .command) - - let apply = window.sheets.buttons["sql-review-execute"].firstMatch - XCTAssertTrue(apply.waitToExist(timeout: 30), "Changing a SQLite foreign key must show the rebuild first") - XCTAssertEqual(apply.label, "Apply and Rebuild") - let sheetText = texts(in: window.sheets.firstMatch) - XCTAssertTrue( - sheetText.contains("Runs on 'Rebuild'"), - "The review is the confirmation, so it must name the connection, got: \(sheetText)" - ) - apply.click() - - XCTAssertTrue( - waitForPredicate(timeout: 30) { - sqliteStrings("SELECT \"table\" FROM pragma_foreign_key_list('album')", in: database).isEmpty - }, - "The review's own button must run the rebuild, with no second sheet to answer" + "The drop must still be staged, so saving again must ask again" ) - XCTAssertFalse(gateExecuteButton(in: window).exists, "The gate must not ask again after the review") - } - - func testMovingAColumnRunsFromItsReviewWithoutAskingAgain() throws { - let (app, database) = try openNotesStructure(connection: "Reorder", safeModeLevel: "silent") - let window = app.windows.firstMatch - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - let target = gridPoint(in: grid, of: window, dy: 40) - target.click() - target.rightClick() - let moveDown = app.menuItems["Move Column Down"].firstMatch - XCTAssertTrue(moveDown.waitToExist(timeout: 15), "The first column row must offer Move Column Down") - moveDown.click() - - let rebuild = window.sheets.buttons["sql-review-execute"].firstMatch - XCTAssertTrue(rebuild.waitToExist(timeout: 30), "SQLite moves a column by rebuilding, so it shows the script") - XCTAssertEqual(rebuild.label, "Rebuild Table") - rebuild.click() + XCTAssertEqual(window.sheets.count, 1, "The gate's sheet must be the only one") + gateExecuteButton(in: window).click() XCTAssertTrue( waitForPredicate(timeout: 30) { - sqliteStrings("SELECT name FROM pragma_table_xinfo('notes')", in: database) == ["body", "tag"] + FixtureFile.rows(self.columnsQuery, in: launch.database) == [["id"], ["body"]] }, - "The review's own button must run the rebuild, with no second sheet to answer" - ) - XCTAssertFalse(gateExecuteButton(in: window).exists, "The gate must not ask again after the review") - } - - // MARK: - Helpers - - /// Seeds `notes (tag TEXT, body TEXT)` at `safeModeLevel`, launches, and opens its Columns tab. - private func openNotesStructure( - connection: String, - safeModeLevel: String - ) throws -> (XCUIApplication, URL) { - let databases = try seedSQLiteSession( - connectionNames: [connection], - databaseSQL: "CREATE TABLE notes (tag TEXT, body TEXT); INSERT INTO notes VALUES ('a', 'b');", - safeModeLevel: safeModeLevel + "One answer must be enough for the drop to reach the file" ) - let database = try XCTUnwrap(databases.first) - let app = try launchApp() - let window = app.windows.firstMatch - - let table = objectBrowserRow("notes", in: window) - XCTAssertTrue(table.waitToExist(timeout: 60), "The restored connection must list notes") - clickAtCenter(table) - - showStructure(in: app, window: window) - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The structure editor must draw its column grid") XCTAssertTrue( - waitForPredicate(timeout: 30) { - grid.frame.width > 0 && grid.frame.height > 0 && grid.tableRows.count == 2 - }, - "notes has two columns, so the grid must list two rows" - ) - return (app, database) - } - - private func stageColumnDrop(in app: XCUIApplication, window: XCUIElement) { - let grid = window.tables.matching(identifier: "data-grid").firstMatch - gridPoint(in: grid, of: window, dy: 40).click() - XCTAssertTrue( - waitForPredicate(timeout: 10) { grid.tableRows.allElementsBoundByIndex.contains { $0.isSelected } }, - "The click must select a column row" + waitForPredicate(timeout: 10) { !window.sheets.firstMatch.exists }, + "Nothing is left to answer" ) - let remove = window.buttons["structure-footer-remove"].firstMatch - XCTAssertTrue(remove.waitToExist(timeout: 20), "The Columns tab must offer a remove button") - XCTAssertTrue(waitForPredicate(timeout: 10) { remove.isEnabled }, "Removing the selected column must be offered") - remove.click() } - /// The gate's confirming button. The rebuild review's shares its identifier, so the label is - /// what tells the two apart. + // The rebuild review shares the gate's identifier, so the label tells the two apart. private func gateExecuteButton(in window: XCUIElement) -> XCUIElement { window.sheets.buttons .matching(NSPredicate(format: "identifier == %@ AND label == %@", "sql-review-execute", "Execute")) .firstMatch } - - private func texts(in element: XCUIElement) -> String { - element.staticTexts.allElementsBoundByIndex - .flatMap { [$0.label, ($0.value as? String) ?? ""] } - .joined(separator: " ") - } } diff --git a/TableProUITests/StructureTabIdentityUITests.swift b/TableProUITests/StructureTabIdentityUITests.swift deleted file mode 100644 index 0fc3732c6d..0000000000 --- a/TableProUITests/StructureTabIdentityUITests.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// StructureTabIdentityUITests.swift -// TableProUITests -// - -import XCTest - -final class StructureTabIdentityUITests: UITestCase { - /// Two tabs on one table used to share a single structure editor, because the view took its - /// SwiftUI identity from the table rather than from the tab. Where the user was in the editor is - /// the cheapest observable proof of the sharing: moving the first tab to Indexes moved the - /// second one with it. - func testTwoTabsOnOneTableAreSeparateStructureEditors() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - let row = objectBrowserRow("Album", in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") - clickAtCenter(row) - - showStructure(in: app, window: window) - let indexes = subTab(named: "Indexes", in: window) - XCTAssertTrue(indexes.waitToExist(timeout: 20), "The structure editor must offer Indexes") - indexes.click() - XCTAssertTrue( - waitForPredicate(timeout: 10) { isSelected(indexes) }, - "The first tab is on Indexes" - ) - - row.rightClick() - let openInNewTab = app.menuItems["Open in New Tab"].firstMatch - XCTAssertTrue(openInNewTab.waitToExist(timeout: 15), "The sidebar must offer Open in New Tab") - openInNewTab.click() - - showStructure(in: app, window: window) - let columns = subTab(named: "Columns", in: window) - XCTAssertTrue(columns.waitToExist(timeout: 20), "The second tab must have its own structure editor") - XCTAssertTrue( - waitForPredicate(timeout: 10) { isSelected(columns) }, - "The second tab opens on Columns rather than inheriting the first tab's Indexes" - ) - } - - /// A radio button in a hosted picker reports `AXSelected` as nil and answers with `AXValue` - /// instead, so `isSelected` reads as false however the picker is set. - private func isSelected(_ segment: XCUIElement) -> Bool { - (segment.value as? NSNumber)?.intValue == 1 - } - - /// The sub-tab labels carry item counts, so they are matched by prefix rather than exactly. - private func subTab(named name: String, in window: XCUIElement) -> XCUIElement { - window.radioGroups["structure-tab-picker"].firstMatch - .radioButtons - .matching(NSPredicate(format: "label BEGINSWITH %@", name)) - .firstMatch - } -} diff --git a/TableProUITests/StructureTypelessColumnUITests.swift b/TableProUITests/StructureTypelessColumnUITests.swift deleted file mode 100644 index c42c00993e..0000000000 --- a/TableProUITests/StructureTypelessColumnUITests.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// StructureTypelessColumnUITests.swift -// TableProUITests -// - -import XCTest - -/// SQLite reads a column declared without a type, `body` in `CREATE TABLE notes (tag TEXT, body)`, -/// with an empty type. Save held every column in the table to having one, the untouched ones and -/// the one being dropped included, so any change to such a table was refused as "Some Changes Are -/// Incomplete" before it ran. -/// -/// Either row of the grid can take the click. Dropping `tag` leaves the typeless `body` untouched, -/// and dropping `body` is the typeless column itself on its way out; both were refused. -final class StructureTypelessColumnUITests: UITestCase { - func testDroppingAColumnFromATableWithATypelessColumnSaves() throws { - let databases = try seedSQLiteSession( - connectionNames: ["Typeless"], - databaseSQL: "CREATE TABLE notes (tag TEXT, body); INSERT INTO notes VALUES ('a', 1);" - ) - let database = try XCTUnwrap(databases.first) - let app = try launchApp() - let window = app.windows.firstMatch - - let table = objectBrowserRow("notes", in: window) - XCTAssertTrue(table.waitToExist(timeout: 60), "The restored connection must list notes") - clickAtCenter(table) - - showStructure(in: app, window: window) - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The structure editor must draw its column grid") - XCTAssertTrue( - waitForPredicate(timeout: 30) { - grid.frame.width > 0 && grid.frame.height > 0 && grid.tableRows.count == 2 - }, - "notes has two columns, so the grid must list two rows" - ) - - gridPoint(in: grid, of: window, dy: 40).click() - XCTAssertTrue( - waitForPredicate(timeout: 10) { grid.tableRows.allElementsBoundByIndex.contains { $0.isSelected } }, - "The click must select a column row" - ) - - let remove = window.buttons["structure-footer-remove"].firstMatch - XCTAssertTrue(remove.waitToExist(timeout: 20), "The Columns tab must offer a remove button") - XCTAssertTrue(waitForPredicate(timeout: 10) { remove.isEnabled }, "Removing the selected column must be offered") - remove.click() - - app.typeKey("s", modifierFlags: .command) - - let sheet = app.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 20), "Dropping a column must ask before it runs") - let text = sheet.staticTexts.allElementsBoundByIndex - .map { ($0.value as? String) ?? $0.label } - .joined(separator: " ") - XCTAssertFalse( - text.contains("must have a name and a data type"), - "A column with no type must not stop the save, got: \(text)" - ) - - /// The first sheet is the execution gate's, and it is the only one. The editor used to ask - /// first with an alert of its own, so the same decision took two answers. - let execute = window.sheets.buttons["sql-review-execute"].firstMatch - XCTAssertTrue( - execute.waitToExist(timeout: 20), - "The first sheet must be the gate's, showing the statement it runs, got: \(text)" - ) - XCTAssertEqual(execute.label, "Execute") - XCTAssertFalse(window.sheets.buttons["Apply Changes"].exists, "The editor must not ask before the gate does") - let statement = (window.sheets.textViews.firstMatch.value as? String) ?? "" - XCTAssertTrue(statement.contains("DROP COLUMN"), "The gate must be showing the drop, got: \(statement)") - execute.click() - - XCTAssertTrue( - waitForPredicate(timeout: 30) { - sqliteStrings("SELECT name FROM pragma_table_xinfo('notes')", in: database).count == 1 - }, - "The drop must reach the file" - ) - XCTAssertTrue( - waitForPredicate(timeout: 30) { grid.tableRows.count == 1 }, - "The grid must list the one column the save kept" - ) - XCTAssertFalse(window.sheets.firstMatch.exists, "One answer is the whole confirmation") - } -} diff --git a/TableProUITests/Support/AppShortcut.swift b/TableProUITests/Support/AppShortcut.swift new file mode 100644 index 0000000000..c397463811 --- /dev/null +++ b/TableProUITests/Support/AppShortcut.swift @@ -0,0 +1,70 @@ +import XCTest + +// The app's default key equivalents (KeyboardShortcutModels.defaultShortcuts). A key press reaches the +// command directly; a menu click pays XCUITest's menu traversal, which can stall ten seconds. +internal enum AppShortcut { + case newConnection + case settings + case openQuickly + case switchConnection + case newTab + case closeTab + case reopenClosedTab + case recentTab + case runQuery + case runAllStatements + case explainQuery + case pinResult + case save + case undo + case selectAll + case addRow + case deleteRow + case refresh + case export + case toggleFilters + case toggleHistory + case toggleInspector + case toggleAgentMode + case focusObjectList + case focusSidebarFilter + case zoomIn + + fileprivate var chord: (key: String, modifiers: XCUIElement.KeyModifierFlags) { + switch self { + case .newConnection: ("n", .command) + case .settings: (",", .command) + case .openQuickly: ("o", [.command, .shift]) + case .switchConnection: ("c", [.command, .control]) + case .newTab: ("t", .command) + case .closeTab: ("w", .command) + case .reopenClosedTab: ("t", [.command, .shift]) + case .recentTab: (XCUIKeyboardKey.tab.rawValue, .control) + case .runQuery: (XCUIKeyboardKey.return.rawValue, .command) + case .runAllStatements: (XCUIKeyboardKey.return.rawValue, [.command, .shift]) + case .explainQuery: ("e", [.command, .option]) + case .pinResult: ("p", [.command, .option]) + case .save: ("s", .command) + case .undo: ("z", .command) + case .selectAll: ("a", .command) + case .addRow: ("n", [.command, .shift]) + case .deleteRow: (XCUIKeyboardKey.delete.rawValue, .command) + case .refresh: ("r", .command) + case .export: ("e", [.command, .shift]) + case .toggleFilters: ("f", [.command, .shift]) + case .toggleHistory: ("y", .command) + case .toggleInspector: ("i", [.command, .option]) + case .toggleAgentMode: ("a", [.command, .shift, .option]) + case .focusObjectList: ("l", [.command, .option, .control]) + case .focusSidebarFilter: ("f", [.command, .option, .control]) + case .zoomIn: ("=", .command) + } + } +} + +internal extension XCUIElement { + /// Sent to the key window, so call it on the app unless the command belongs to a focused element. + func typeShortcut(_ shortcut: AppShortcut) { + typeKey(shortcut.chord.key, modifierFlags: shortcut.chord.modifiers) + } +} diff --git a/TableProUITests/Support/SeededSQLiteSession.swift b/TableProUITests/Support/SeededSQLiteSession.swift index b39c869ece..df843e289d 100644 --- a/TableProUITests/Support/SeededSQLiteSession.swift +++ b/TableProUITests/Support/SeededSQLiteSession.swift @@ -6,53 +6,75 @@ import SQLite3 import XCTest -/// The files the app reads to reopen the last session: the connections, which of them were open, -/// and each one's tabs. Written before launch, they make the app restore a session of SQLite -/// connections the test built, the way it does after a relaunch. +internal enum SeededTab { + case query(String) + case table(String) +} + +internal struct ConnectionSeed { + internal var name: String + internal var databaseSQL: String + internal var safeModeLevel: String + internal var tabs: [SeededTab] + internal var isOpen: Bool + + /// `isOpen: false` saves the connection without restoring it, so it is reachable only from a list or the switcher. + internal init( + name: String = UITestFixture.connectionName, + databaseSQL: String = UITestFixture.sql, + safeModeLevel: String = "silent", + tabs: [SeededTab] = [.query("SELECT 1;")], + isOpen: Bool = true + ) { + self.name = name + self.databaseSQL = databaseSQL + self.safeModeLevel = safeModeLevel + self.tabs = tabs + self.isOpen = isOpen + } +} + +internal struct SeededConnection { + internal let id: UUID + internal let name: String + internal let database: URL +} + +/// Writes the files the app reads to reopen its last session, so a launch restores these connections already connected. internal extension UITestCase { - /// Each connection gets its own database file built from `databaseSQL`, and `tabsEach` query - /// tabs, so nothing restored depends on a table's rows loading. Returns the database files in - /// connection order. Every connection runs at `safeModeLevel`, Silent unless a test asks. @discardableResult - func seedSQLiteSession( - connectionNames: [String], - databaseSQL: String, - tabsEach: Int = 1, - safeModeLevel: String = "silent" - ) throws -> [URL] { + func seedSQLiteSession(_ seeds: [ConnectionSeed]) throws -> [SeededConnection] { let root = try XCTUnwrap(sandboxRoot, "setUpWithError did not prepare a sandbox") let supportDirectory = root.appendingPathComponent("TablePro", isDirectory: true) let tabStateDirectory = supportDirectory.appendingPathComponent("TabState", isDirectory: true) try FileManager.default.createDirectory(at: tabStateDirectory, withIntermediateDirectories: true) - var connections: [[String: Any]] = [] - var connectionIds: [String] = [] - var databaseURLs: [URL] = [] - for (index, name) in connectionNames.enumerated() { - let id = UUID().uuidString - let databaseURL = root.appendingPathComponent("restored-\(index).sqlite") - makeDatabase(at: databaseURL, sql: databaseSQL) - connections.append(connectionPayload( - id: id, - name: name, - databasePath: databaseURL.path, - sortOrder: index, - safeModeLevel: safeModeLevel - )) - connectionIds.append(id) - databaseURLs.append(databaseURL) + var payloads: [[String: Any]] = [] + var seeded: [SeededConnection] = [] + var openIds: [String] = [] + for (index, seed) in seeds.enumerated() { + let connection = SeededConnection( + id: UUID(), + name: seed.name, + database: root.appendingPathComponent("seeded-\(index).sqlite") + ) + makeDatabase(at: connection.database, sql: seed.databaseSQL) + payloads.append(connectionPayload(connection, sortOrder: index, safeModeLevel: seed.safeModeLevel)) try writeJSON( - tabState(tabCount: tabsEach), - to: tabStateDirectory.appendingPathComponent("\(id).json") + tabState(seed.tabs), + to: tabStateDirectory.appendingPathComponent("\(connection.id.uuidString).json") ) + seeded.append(connection) + if seed.isOpen { + openIds.append(connection.id.uuidString) + } } - try writeJSON(connections, to: supportDirectory.appendingPathComponent("connections.json")) - try writeJSON(connectionIds, to: supportDirectory.appendingPathComponent("LastOpenConnections.json")) - return databaseURLs + try writeJSON(payloads, to: supportDirectory.appendingPathComponent("connections.json")) + try writeJSON(openIds, to: supportDirectory.appendingPathComponent("LastOpenConnections.json")) + return seeded } - /// The first column of every row `sql` returns, read straight from the file rather than through - /// the app, so an assertion sees what the database holds and not what the grid shows. + /// Reads the file, not the grid, so an assertion sees what the database holds. func sqliteStrings(_ sql: String, in databaseURL: URL) -> [String] { var handle: OpaquePointer? defer { sqlite3_close(handle) } @@ -75,18 +97,16 @@ internal extension UITestCase { } private func connectionPayload( - id: String, - name: String, - databasePath: String, + _ connection: SeededConnection, sortOrder: Int, safeModeLevel: String ) -> [String: Any] { [ - "id": id, - "name": name, + "id": connection.id.uuidString, + "name": connection.name, "host": "", "port": 0, - "database": databasePath, + "database": connection.database.path, "username": "", "type": "SQLite", "sshEnabled": false, @@ -99,19 +119,29 @@ internal extension UITestCase { ] } - private func tabState(tabCount: Int) -> [String: Any] { - let tabs: [[String: Any]] = (1 ... tabCount).map { number in - [ - "id": UUID().uuidString, - "title": "Query \(number)", - "query": "SELECT \(number);", - "tabType": ["query": [String: Any]()], - "tableName": NSNull(), - "databaseName": "", - "isView": false, - ] + private func tabState(_ tabs: [SeededTab]) -> [String: Any] { + let persisted: [[String: Any]] = tabs.enumerated().map { index, tab in + switch tab { + case let .query(sql): + return persistedTab(title: "Query \(index + 1)", query: sql, type: "query", tableName: NSNull()) + case let .table(name): + return persistedTab(title: name, query: "SELECT * FROM \"\(name)\";", type: "table", tableName: name) + } } - return ["tabs": tabs, "selectedTabId": tabs[0]["id"] ?? ""] + guard let selected = persisted.first?["id"] else { return ["tabs": persisted] } + return ["tabs": persisted, "selectedTabId": selected] + } + + private func persistedTab(title: String, query: String, type: String, tableName: Any) -> [String: Any] { + [ + "id": UUID().uuidString, + "title": title, + "query": query, + "tabType": [type: [String: Any]()], + "tableName": tableName, + "databaseName": "", + "isView": false, + ] } private func writeJSON(_ object: Any, to url: URL) throws { diff --git a/TableProUITests/Support/UITestCase+EditorTabs.swift b/TableProUITests/Support/UITestCase+EditorTabs.swift new file mode 100644 index 0000000000..81179bf730 --- /dev/null +++ b/TableProUITests/Support/UITestCase+EditorTabs.swift @@ -0,0 +1,62 @@ +import XCTest + +extension UITestCase { + // MARK: - Editor tabs + + // The strip lays tabs out by position, which is not the order the tree lists them in. + internal func editorTabs(in window: XCUIElement) -> [XCUIElement] { + window.descendants(matching: .any) + .matching(identifier: "editor-tab") + .allElementsBoundByIndex + .sorted { $0.frame.minX < $1.frame.minX } + } + + internal func editorTabTitles(in window: XCUIElement) -> [String] { + editorTabs(in: window).map { $0.label } + } + + internal func editorTab(titled title: String, in window: XCUIElement) -> XCUIElement { + window.descendants(matching: .any) + .matching(identifier: "editor-tab") + .matching(NSPredicate(format: "label == %@", title)) + .firstMatch + } + + internal func selectedEditorTabTitle(in window: XCUIElement) -> String? { + editorTabs(in: window).first { $0.isSelected }?.label + } + + internal func selectEditorTab(titled title: String, in window: XCUIElement) { + let tab = editorTab(titled: title, in: window) + XCTAssertTrue(waitUntilHittable(tab, timeout: 20), "The \(title) tab must be on screen") + clickAtCenter(tab) + XCTAssertTrue( + waitForPredicate(timeout: 10) { self.selectedEditorTabTitle(in: window) == title }, + "Clicking the \(title) tab must select it" + ) + } + + // MARK: - Results + + internal func resultSetChooser(in window: XCUIElement) -> XCUIElement { + window.descendants(matching: .any).matching(identifier: "result-set-menu").firstMatch + } + + internal func gridCell(showing text: String, in grid: XCUIElement) -> XCUIElement { + grid.staticTexts.matching(NSPredicate(format: "value == %@", text)).firstMatch + } + + // MARK: - Editor text + + internal func editorText(_ editor: XCUIElement) -> String { + (editor.value as? String) ?? "" + } + + internal func waitForEditorText( + _ editor: XCUIElement, + timeout: TimeInterval = 10, + _ matches: (String) -> Bool + ) -> Bool { + waitForPredicate(timeout: timeout) { matches(self.editorText(editor)) } + } +} diff --git a/TableProUITests/Support/UITestCase+FixtureGrid.swift b/TableProUITests/Support/UITestCase+FixtureGrid.swift new file mode 100644 index 0000000000..922d0a0d9c --- /dev/null +++ b/TableProUITests/Support/UITestCase+FixtureGrid.swift @@ -0,0 +1,146 @@ +import SQLite3 +import XCTest + +// A plain cell publishes as static text and a cell with a picker as a combo box, so a cell is found by +// the position its label opens with, whatever its type. +internal struct FixtureGrid { + internal let element: XCUIElement + internal let app: XCUIApplication + internal let objectBrowser: XCUIElement + + internal func cell(row: Int, column: Int) -> XCUIElement { + element.descendants(matching: .any) + .matching(NSPredicate(format: "label BEGINSWITH %@", "Row \(row), column \(column): ")) + .firstMatch + } + + internal func value(row: Int, column: Int) -> String? { + let cell = cell(row: row, column: column) + guard cell.exists else { return nil } + return cell.value as? String + } + + internal func waitForValue(_ expected: String, row: Int, column: Int, timeout: TimeInterval = 20) -> Bool { + UITestPoll.until(timeout: timeout) { value(row: row, column: column) == expected } + } + + internal func header(_ column: String) -> XCUIElement { + element.buttons + .matching(NSPredicate( + format: "label == %@ OR label BEGINSWITH %@", + "Column: \(column)", + "Column: \(column), " + )) + .firstMatch + } + + // XCUITest reads every row, cell and header as obscured, so a click is a point taken off the grid. + // On the 1024pt runner the object browser overlaps the grid's leading edge, so the point stays right of it. + internal func point(inside frame: CGRect) -> XCUICoordinate { + let clearOfBrowser = objectBrowser.exists ? objectBrowser.frame.maxX + 8 : frame.minX + let x = frame.midX >= clearOfBrowser ? frame.midX : min(frame.maxX - 4, clearOfBrowser) + let origin = element.frame.origin + return element.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: x - origin.x, dy: frame.midY - origin.y)) + } + + internal func cellPoint(row: Int, column: Int) -> XCUICoordinate { + let cell = cell(row: row, column: column) + XCTAssertTrue(cell.waitToExist(timeout: 20), "Row \(row) must publish column \(column)") + return point(inside: cell.frame) + } + + internal func clickCell(row: Int, column: Int) { + cellPoint(row: row, column: column).click() + } + + internal func editCell(row: Int, column: Int, to text: String) { + clickCell(row: row, column: column) + app.typeKey(.return, modifierFlags: []) + app.typeKey("a", modifierFlags: .command) + app.typeText(text) + app.typeKey(.return, modifierFlags: []) + } + + internal func clickHeader(_ column: String, rightClick: Bool = false) { + let header = header(column) + XCTAssertTrue(header.waitToExist(timeout: 20), "The grid must publish a \(column) header") + let target = point(inside: header.frame) + if rightClick { + target.rightClick() + } else { + target.click() + } + } +} + +internal enum FixtureFile { + // Nil until the statement can run, so a caller can poll for a table the app has not created yet. + internal static func rows(_ sql: String, in databaseURL: URL) -> [[String]]? { + var handle: OpaquePointer? + defer { sqlite3_close(handle) } + guard sqlite3_open_v2(databaseURL.path, &handle, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { return nil } + sqlite3_busy_timeout(handle, 5_000) + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK else { return nil } + var rows: [[String]] = [] + while sqlite3_step(statement) == SQLITE_ROW { + let columns = sqlite3_column_count(statement) + rows.append((0 ..< columns).map { index in + sqlite3_column_text(statement, index).map { String(cString: $0) } ?? "NULL" + }) + } + return rows + } + + internal static func execute(_ sql: String, in databaseURL: URL) -> Bool { + var handle: OpaquePointer? + defer { sqlite3_close(handle) } + guard sqlite3_open_v2(databaseURL.path, &handle, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK else { return false } + sqlite3_busy_timeout(handle, 5_000) + return sqlite3_exec(handle, sql, nil, nil, nil) == SQLITE_OK + } +} + +extension UITestCase { + internal func fixtureGrid(in window: XCUIElement, app: XCUIApplication) -> FixtureGrid { + FixtureGrid(element: dataGrid(in: window), app: app, objectBrowser: objectBrowser(in: window)) + } + + internal func launchWithFixtureTable( + _ table: String, + safeModeLevel: String = "silent" + ) throws -> (launch: FixtureLaunch, grid: FixtureGrid) { + let launch = try launchWithFixture([ConnectionSeed(safeModeLevel: safeModeLevel, tabs: [.table(table)])]) + let grid = fixtureGrid(in: launch.window, app: launch.app) + XCTAssertTrue(waitForClickableRows(in: grid.element), "The restored \(table) tab must load its rows") + return (launch, grid) + } + + internal func openFromObjectBrowser(_ table: String, in launch: FixtureLaunch) -> FixtureGrid { + let row = objectBrowserRow(table, in: launch.window) + XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(table)") + clickAtCenter(row) + let grid = fixtureGrid(in: launch.window, app: launch.app) + XCTAssertTrue(waitForClickableRows(in: grid.element), "\(table) must load its rows") + return grid + } + + // Hosted rows report themselves disabled and never become hittable, so the menu is raised at a point. + internal func rightClickInObjectBrowser(_ name: String, in window: XCUIElement) { + let row = objectBrowserRow(name, in: window) + XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(name)") + row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).rightClick() + } + + internal func tabsTitled(_ title: String, in window: XCUIElement) -> XCUIElementQuery { + window.descendants(matching: .any) + .matching(identifier: "editor-tab") + .matching(NSPredicate(format: "label == %@", title)) + } + + internal func editorTabCount(in window: XCUIElement) -> Int { + window.descendants(matching: .any).matching(identifier: "editor-tab").count + } +} diff --git a/TableProUITests/Support/UITestCase+RowImport.swift b/TableProUITests/Support/UITestCase+RowImport.swift index 1cd14057a6..3ed2475709 100644 --- a/TableProUITests/Support/UITestCase+RowImport.swift +++ b/TableProUITests/Support/UITestCase+RowImport.swift @@ -1,48 +1,35 @@ import XCTest -/// Reaches the row import sheet through the data file window's Import into Table, the one route to -/// the sheet that needs no open panel. -/// -/// On a 1024pt runner screen the data file window and the connection window open on the same frame, -/// so a click aimed at the data file's grid lands on whichever window is in front, which after an -/// import is the connection window. The data file window is brought forward through the Window menu -/// instead, by its title. extension UITestCase { - internal func launchWithSampleDatabase(andDataFile name: String, contents: Data) throws -> XCUIApplication { - let root = try XCTUnwrap(sandboxRoot, "setUpWithError did not prepare a sandbox") - let fileURL = root.appendingPathComponent(name) - try contents.write(to: fileURL) - return try launchApp(environment: [ - "TABLEPRO_UI_TEST_OPEN_SAMPLE": "1", - "TABLEPRO_UI_TEST_OPEN_FILE": fileURL.path + /// The fixture connection and a data file window, both open at launch. A launch intent skips the + /// session restore, so the connection opens through its `tablepro://connect/` link instead. + @discardableResult + internal func launchWithFixture(openingDataFile name: String, contents: Data) throws -> FixtureLaunch { + let connections = try seedSQLiteSession([ConnectionSeed()]) + let connection = try XCTUnwrap(connections.first) + let fileURL = try writeIntoSandbox(name, contents: contents) + let app = try launchApp(environment: [ + "TABLEPRO_UI_TEST_OPEN_FILE": fileURL.path, + "TABLEPRO_UI_TEST_OPEN_URL": "tablepro://connect/\(connection.id.uuidString)", ]) + let window = mainWindow(in: app) + XCTAssertTrue(waitForObjectBrowser(in: window), "The fixture connection never listed its tables") + return FixtureLaunch(app: app, window: window, connections: connections) } - internal func rowImportWindows( - in app: XCUIApplication, - dataFileTitle: String - ) -> (dataFile: XCUIElement, connection: XCUIElement) { - let dataWindow = app.windows.matching(identifier: "main-data-file").firstMatch - XCTAssertTrue(dataWindow.waitToExist(timeout: 30), "\(dataFileTitle) produced no data file window") - let connectionWindow = app.windows - .matching(NSPredicate(format: "identifier != %@", "main-data-file")) - .firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 30) { - objectBrowser(in: connectionWindow).descendants(matching: .staticText).firstMatch.exists - }, - "The sample database never finished opening" - ) - return (dataWindow, connectionWindow) + internal func dataFileWindow(in app: XCUIApplication) -> XCUIElement { + window("main-data-file", in: app) } + // The one route to the sheet without an open panel. The data file and connection windows share a frame + // on the 1024pt runner, so the data file window is brought forward from the Window menu, by title. internal func openRowImportSheet( from dataWindow: XCUIElement, titled dataFileTitle: String, into connectionWindow: XCUIElement, in app: XCUIApplication ) -> XCUIElement { - let grid = dataWindow.tables.matching(identifier: "data-grid").firstMatch + let grid = dataGrid(in: dataWindow) XCTAssertTrue(grid.waitToExist(timeout: 30), "The data file window has no grid") XCTAssertTrue(waitForClickableRows(in: grid), "The data file must load rows") @@ -72,13 +59,8 @@ extension UITestCase { return sheet } - /// A SwiftUI control with `labelsHidden()` and its own accessibility label publishes both, - /// joined, as `"Title, Import Title"` on the runner, so a subscript by the accessibility label - /// alone finds nothing there. - internal func element( - _ query: XCUIElementQuery, - labelEndingWith label: String - ) -> XCUIElement { + /// A SwiftUI control with `labelsHidden()` publishes both labels joined ("Title, Import Title") on the runner. + internal func element(_ query: XCUIElementQuery, labelEndingWith label: String) -> XCUIElement { query.matching(NSPredicate(format: "label ENDSWITH %@", label)).firstMatch } } diff --git a/TableProUITests/Support/UITestCase+Welcome.swift b/TableProUITests/Support/UITestCase+Welcome.swift new file mode 100644 index 0000000000..10d5802f50 --- /dev/null +++ b/TableProUITests/Support/UITestCase+Welcome.swift @@ -0,0 +1,51 @@ +import AppKit +import XCTest + +extension UITestCase { + internal var builtProductsDirectory: URL { + Bundle(for: UITestCase.self).bundleURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + // Matched by bundle path: the developer's own TablePro may be running, and the bundle identifier is per developer. + internal func appUnderTest() -> NSRunningApplication? { + let products = builtProductsDirectory + return NSWorkspace.shared.runningApplications.first { running in + guard let bundleURL = running.bundleURL else { return false } + return bundleURL.lastPathComponent == "TablePro.app" + && bundleURL.deletingLastPathComponent() == products + } + } + + // LaunchServices' policy is what the Dock and the app switcher read; a window query cannot tell + // "no Dock icon" from "not in front". + internal func waitForActivationPolicy( + _ expected: NSApplication.ActivationPolicy, + timeout: TimeInterval = 15 + ) -> Bool { + waitForPredicate(timeout: timeout) { self.appUnderTest()?.activationPolicy == expected } + } + + internal func welcomeConnectionList(in welcome: XCUIElement) -> XCUIElement { + welcome.outlines["welcome-connection-list"] + } + + // A row publishes its name either as its label or as a static text's value, depending on the cell. + internal func welcomeConnectionRows(named name: String, in list: XCUIElement) -> XCUIElementQuery { + list.outlineRows.containing( + NSPredicate( + format: "label BEGINSWITH %@ OR (elementType == %lu AND value BEGINSWITH %@)", + name, + XCUIElement.ElementType.staticText.rawValue, + name + ) + ) + } + + internal func welcomeConnectionRow(named name: String, in list: XCUIElement) -> XCUIElement { + welcomeConnectionRows(named: name, in: list).firstMatch + } +} diff --git a/TableProUITests/Support/UITestCase.swift b/TableProUITests/Support/UITestCase.swift index fc0a789c96..2417340912 100644 --- a/TableProUITests/Support/UITestCase.swift +++ b/TableProUITests/Support/UITestCase.swift @@ -1,38 +1,37 @@ -import TableProPluginKit import XCTest -/// The base every UI test builds on, and the only supported way to get a running app. -/// -/// A UI test drives the real app against real storage unless something redirects it, and this -/// project has already paid for that: a suite left stray connections in the developer's own store, -/// and because `startupBehavior` defaults to reopening the last session, launching the app under -/// test restored and connected to a production database over an SSH tunnel. -/// -/// `launchApp()` hands the app a throwaway directory that lives for one test. The app resolves its -/// Application Support root, its defaults domain and its keychain from it, so nothing a test does -/// can reach the real ones, and nothing it leaves behind outlives the run. +/// The only way a UI test gets a running app: every launch names a sandbox, so the app never touches the real store. internal class UITestCase: XCTestCase { internal private(set) var sandboxRoot: URL? private var launchedApps: [XCUIApplication] = [] private var privacyAlertMonitor: (any NSObjectProtocol)? + // One path per checkout: the app names its defaults suite after the path and empties it when the + // directory is new, so each test starts clean and a worktree never shares a suite with another. + private static var sandboxLocation: URL { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in Bundle(for: UITestCase.self).bundlePath.utf8 { + hash = (hash ^ UInt64(byte)) &* 0x0000_0100_0000_01b3 + } + return FileManager.default.temporaryDirectory + .appendingPathComponent("TableProUITests", isDirectory: true) + .appendingPathComponent(String(hash, radix: 16), isDirectory: true) + } + override internal func setUpWithError() throws { try super.setUpWithError() continueAfterFailure = false privacyAlertMonitor = addPrivacyAlertMonitor() - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("TableProUITests", isDirectory: true) - .appendingPathComponent(UUID().uuidString, isDirectory: true) + let root = Self.sandboxLocation + if FileManager.default.fileExists(atPath: root.path) { + try FileManager.default.removeItem(at: root) + } try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) sandboxRoot = root - /// Every run shares one defaults domain, so emptying it here is what keeps one test from - /// reading what the last one wrote. - UserDefaults.standard.removePersistentDomain(forName: PluginHostStorage.sandboxSuiteName) } - /// Terminating before removing the directory matters: the app writes on the way down, and a - /// directory deleted underneath it would let those writes fail into somewhere unexamined. + // Terminate first: the app writes on the way down, and those writes must land inside the sandbox. override internal func tearDownWithError() throws { attachElementTreeIfFailed() for app in launchedApps where app.state != .notRunning { @@ -51,12 +50,8 @@ internal class UITestCase: XCTestCase { try super.tearDownWithError() } - /// macOS raises its local-network privacy alert over the app under test, and XCTest's built-in - /// handler misses it: that matcher keys on the wording "would like to find", which macOS 26 - /// rewrote to "Allow ... to find devices on local networks?". Nothing dismissed it, so every - /// element lookup for the rest of the test paid a full interruption sweep, which cost one suite - /// ten minutes of a forty minute job. Matching on the buttons rather than the title keeps this - /// working through the next rewording. Nothing under test needs the local network. + // XCTest's own handler misses macOS 26's local-network alert ("Allow ... to find devices"), and an + // undismissed alert makes every later query pay an interruption sweep. Matching buttons survives rewording. private func addPrivacyAlertMonitor() -> any NSObjectProtocol { addUIInterruptionMonitor(withDescription: "System privacy alert") { alert in for label in ["Don't Allow", "Deny", "Allow"] where alert.buttons[label].exists { @@ -67,10 +62,7 @@ internal class UITestCase: XCTestCase { } } - /// A UI test that fails only on CI is undiagnosable from a log line: the assertion says what - /// was not found, never what was there instead. The tree is captured here so the result bundle - /// the workflow already uploads carries it, which is the difference between reading a runner - /// failure and guessing at it. + // An assertion says what was missing, never what was there; the tree in the result bundle does. private func attachElementTreeIfFailed() { guard let run = testRun, run.failureCount + run.unexpectedExceptionCount > 0 else { return } for (index, app) in launchedApps.enumerated() { @@ -81,10 +73,9 @@ internal class UITestCase: XCTestCase { } } - /// `arguments` is separate from `environment` because the two are not interchangeable. A - /// defaults override such as `-AppleLanguages` only takes effect as a launch argument: passed - /// in the environment it is an ordinary variable nothing reads, so the pin silently does - /// nothing and the test passes only on a machine that was already in that language. + // MARK: - Launching + + /// A defaults override such as `-AppleLanguages` only works as a launch argument, never in `environment`. internal func launchApp( environment: [String: String] = [:], arguments: [String] = [] @@ -93,135 +84,135 @@ internal class UITestCase: XCTestCase { let app = XCUIApplication() app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" app.launchEnvironment["TABLEPRO_UI_TEST_SANDBOX"] = root.path - for (key, value) in environment { - app.launchEnvironment[key] = value - } - app.launchArguments.append(contentsOf: arguments) + app.launchEnvironment.merge(environment) { _, new in new } + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + arguments app.launch() launchedApps.append(app) return app } - /// Asks for the sample database at launch instead of clicking `Help > Open Sample Database`. - /// - /// The menu route cost ten seconds on every call. XCUITest resolves a menu item by opening its - /// parent, and a probe that resolved the item first left the menu open, so the click's own - /// traversal failed with "open menu during menu traversal", waited out a ten second watchdog, - /// snapshotted the whole accessibility hierarchy and only then retried. Every one of the 66 - /// tests that opened the sample paid it: 11 minutes of a 39 minute suite. None of those tests - /// is about the Help menu, so they no longer go through it. + /// Restores `seeds` as the last session, so the window opens already connected. /// - /// `SingleWindowMenuContractUITests.testHelpMenuOpensTheSampleDatabase` still drives the menu - /// item the way a person does, so the route keeps its coverage. - /// - /// The variable is read by `UITestLaunchEnvironment` in the app, which turns it into an - /// ordinary `LaunchIntent`. Both sides spell it out because a UI test target cannot import the - /// app, the same as `TABLEPRO_UI_TESTING` below. + /// An intent variable (`TABLEPRO_UI_TEST_OPEN_*`) in `environment` skips the session restore; + /// use `launchWithFixture(openingDataFile:contents:)` to pair the fixture with a data file. @discardableResult - internal func launchWithSampleDatabase( + internal func launchWithFixture( + _ seeds: [ConnectionSeed] = [ConnectionSeed()], environment: [String: String] = [:], arguments: [String] = [] - ) throws -> XCUIApplication { - var launchEnvironment = environment - launchEnvironment["TABLEPRO_UI_TEST_OPEN_SAMPLE"] = "1" - let app = try launchApp(environment: launchEnvironment, arguments: arguments) - XCTAssertTrue( - waitForSampleDatabaseWindow(in: app), - "The sample database never finished opening" - ) - return app + ) throws -> FixtureLaunch { + let connections = try seedSQLiteSession(seeds) + let app = try launchApp(environment: environment, arguments: arguments) + let window = mainWindow(in: app) + XCTAssertTrue(waitForObjectBrowser(in: window), "The fixture connection never listed its tables") + return FixtureLaunch(app: app, window: window, connections: connections) } - /// Writes `contents` into this test's sandbox and launches with it open in a data file window. - /// - /// `TABLEPRO_UI_TEST_OPEN_FILE` is read by `UITestLaunchEnvironment` in the app and delivered as - /// an ordinary open-file intent, so the window arrives the way a Finder double-click brings it. + /// Opens a data file window the way a Finder double-click does. internal func launchWithDataFile(named name: String, contents: Data) throws -> XCUIApplication { - let root = try XCTUnwrap(sandboxRoot, "setUpWithError did not prepare a sandbox") - let fileURL = root.appendingPathComponent(name) - try contents.write(to: fileURL) + let fileURL = try writeIntoSandbox(name, contents: contents) return try launchApp(environment: ["TABLEPRO_UI_TEST_OPEN_FILE": fileURL.path]) } - /// Arrives as the open intent a Finder double-click on a `.tablepro` file sends. + /// Arrives as the intent a Finder double-click on a `.tablepro` file sends. internal func launchWithConnectionShare(named name: String, contents: Data) throws -> XCUIApplication { - let root = try XCTUnwrap(sandboxRoot, "setUpWithError did not prepare a sandbox") - let fileURL = root.appendingPathComponent(name) - try contents.write(to: fileURL) + let fileURL = try writeIntoSandbox(name, contents: contents) return try launchApp(environment: ["TABLEPRO_UI_TEST_OPEN_CONNECTION_SHARE": fileURL.path]) } - /// `TABLEPRO_UI_TEST_OPEN_URL` reaches the app's own URL handling, never another installed copy. + /// Reaches the app's own URL handling, never another installed copy registered for the scheme. internal func launchOpeningURL(_ url: String, arguments: [String] = []) throws -> XCUIApplication { try launchApp(environment: ["TABLEPRO_UI_TEST_OPEN_URL": url], arguments: arguments) } - /// Returning as soon as the launch was requested is what used to leave fourteen suites poking - /// at a window that had no connection yet, and every one of those misses cost an XCUITest - /// retry. The object browser having rows is the cheapest proof the connection is live. - /// - /// The query is built once, asks only whether a first match exists, and never leaves the - /// sidebar. `objectBrowser(in:)` says why the last part matters: the sample opens `Track`, and - /// its 1,000 rows usually reach the grid before the table list reaches the sidebar. A search - /// for the outline that starts at the window walks the whole grid while the sidebar is still a - /// spinner, three to six seconds on the app's main thread per check, and the table list it was - /// waiting for could not load under that. The runs that reported this as "never finished - /// opening" had the sidebar spinning and the grid full, a different test each time. Locally the - /// wait settles in about two seconds. - internal func waitForSampleDatabaseWindow(in app: XCUIApplication, timeout: TimeInterval = 30) -> Bool { - let firstObject = objectBrowser(in: app.children(matching: .window).firstMatch) - .descendants(matching: .staticText).firstMatch - return waitForPredicate(timeout: timeout) { firstObject.exists } + internal func writeIntoSandbox(_ name: String, contents: Data) throws -> URL { + let root = try XCTUnwrap(sandboxRoot, "setUpWithError did not prepare a sandbox") + let fileURL = root.appendingPathComponent(name) + try contents.write(to: fileURL) + return fileURL } - /// The connection window's object browser, found without searching the rest of the window. - /// - /// The sidebar is the first group directly under the window's split group, ahead of the - /// splitter and the detail pane: `SplitGroup > Group > ScrollView > Outline` once the tables - /// have loaded and `SplitGroup > Group > ActivityIndicator` before. A descendants search from - /// the window reaches the outline first when it exists, but when it does not yet exist the - /// search goes on into the data grid, which publishes about 12,000 elements for `Track`. - /// Stepping through direct children keeps a miss as cheap as a hit. + // MARK: - Finding things + + /// A window is a direct child of the application; `app.windows` walks the whole tree to find one. + internal func window(_ identifier: String, in app: XCUIApplication) -> XCUIElement { + app.children(matching: .window).matching(identifier: identifier).firstMatch + } + + internal func mainWindow(in app: XCUIApplication) -> XCUIElement { + window("main", in: app) + } + + internal func welcomeWindow(in app: XCUIApplication) -> XCUIElement { + window("welcome", in: app) + } + + // A descendants search from the window reaches the data grid while the sidebar is still a spinner, + // and walking a loaded grid costs seconds on the app's main thread. Direct children keep a miss cheap. internal func objectBrowser(in window: XCUIElement) -> XCUIElement { window.children(matching: .splitGroup).firstMatch .children(matching: .group).firstMatch .descendants(matching: .outline).firstMatch } - /// Opens the sample database the way a person does. Only the menu contract suite needs this; - /// everything else takes the launch route above. - @discardableResult - internal func launchAndOpenSampleDatabaseFromHelpMenu() throws -> XCUIApplication { - let app = try launchApp() - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuItems["Open Sample Database"].click() - return app + /// The cheapest proof a connection is live: its object browser lists something. + internal func waitForObjectBrowser(in window: XCUIElement, timeout: TimeInterval = 30) -> Bool { + let firstObject = objectBrowser(in: window).descendants(matching: .staticText).firstMatch + return waitForPredicate(timeout: timeout) { firstObject.exists } } - internal func waitForPredicate(timeout: TimeInterval, _ condition: () -> Bool) -> Bool { - UITestPoll.until(timeout: timeout, condition) + /// Hosted cells publish the row's name as the static text's `value`, prefixed with its kind. + internal func objectBrowserRow(_ name: String, kind: String = "Table", in window: XCUIElement) -> XCUIElement { + objectBrowser(in: window).staticTexts + .matching(NSPredicate(format: "value == %@", "\(kind): \(name)")) + .firstMatch } - internal func isOn(_ toggle: XCUIElement) -> Bool { - if let number = toggle.value as? Int { return number == 1 } - return (toggle.value as? String) == "1" + internal func dataGrid(in window: XCUIElement) -> XCUIElement { + window.tables.matching(identifier: "data-grid").firstMatch + } + + // The identifier sits on the SwiftUI representable, not on the NSTextView, so it never resolves. + internal func editorTextView(in window: XCUIElement) -> XCUIElement { + window.textViews.firstMatch + } + + /// Keystrokes typed before the editor installs its key monitor are lost, so this retypes until the text arrives. + internal func typeQuery(_ sql: String, in window: XCUIElement, attempts: Int = 3) { + let editor = editorTextView(in: window) + XCTAssertTrue(editor.waitToExist(timeout: 10), "The query tab must hold an editor to type into") + for _ in 0 ..< attempts { + editor.click() + editor.typeKey("a", modifierFlags: .command) + editor.typeText(sql) + let arrived = waitForPredicate(timeout: 3) { + ((editor.value as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == sql + } + if arrived { return } + } + XCTFail("The editor never received the query typed into it") } - /// The precondition a click actually has. `waitForExistence` only says the element is in the - /// tree, which a row inside a pane that is still animating open already is; the click then - /// lands on a moving target, the app hit-tests the point to nothing, and the event goes - /// nowhere with no failure of its own. `isHittable` is the question AppKit can answer: does - /// this point come back to this element. Nothing in the app can defend against the early - /// click, because animating a pane into place is what AppKit does. + /// An item of the contextual menu a right-click just raised; the menu bar's closed copy of the + /// same title is never hittable. Assert an absent contextual item in a unit test instead. + internal func contextMenuItem(_ title: String, in app: XCUIApplication) -> XCUIElement { + let matches = app.menuItems.matching(NSPredicate(format: "title == %@", title)) + return matches.allElementsBoundByIndex.first { $0.isHittable } ?? matches.firstMatch + } + + // MARK: - Waiting + + internal func waitForPredicate(timeout: TimeInterval, _ condition: () -> Bool) -> Bool { + UITestPoll.until(timeout: timeout, condition) + } + + /// Existence is not enough to click: a row in a pane still animating open hit-tests to nothing. internal func waitUntilHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { waitForPredicate(timeout: timeout) { element.exists && element.isHittable } } - /// macOS clicks an element where it is and never scrolls it into view first, so a row below - /// the fold of a scrolling pane is not hittable until the pane has been scrolled down to it. - /// The scroll goes to a point inside the content. Measured: scrolling a window element posts - /// the event at its top-left corner, over the title bar, and the pane does not move. + // macOS clicks where an element is and never scrolls it into view. Scrolling a window posts at its + // top-left corner, over the title bar, so the scroll goes to a point inside the content. internal func scrollUntilHittable(_ element: XCUIElement, in container: XCUIElement, timeout: TimeInterval) -> Bool { let content = container.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) let deadline = Date(timeIntervalSinceNow: timeout) @@ -232,19 +223,24 @@ internal class UITestCase: XCTestCase { return true } - /// Whether the keyboard is on this element. - /// - /// `hasFocus` is declared on `XCUIElementAttributes` in ObjC - /// (`XCUIAutomation.framework/Headers/XCUIElementAttributes.h:69`) and does not reach Swift: - /// it appears in no `XCUIAutomation.swiftinterface` for this toolchain, and `hasKeyboardFocus` - /// is the iOS spelling. Key-value coding is not the way round it either. Measured: it raises - /// `NSInternalInconsistencyException: Calling hasFocus on element is not supported on a macOS.`, - /// which took every test in `WindowFocusUITests` with it. - /// - /// What macOS does publish is the snapshot XCUITest prints for itself. Its first line holds the - /// element's own attributes and carries `Keyboard Focused` when that element has the keyboard, - /// so that line is what this reads. Only the first: every `NSTableView` cell under a focused - /// list carries the same word, and the subtree below is not this element's answer. + // A grid enters the tree before its query returns, and a coordinate taken off an empty frame posts + // at (inf, inf). `firstMatch` stops the walk; asking for every row mounts a cell for each. + internal func waitForClickableRows(in grid: XCUIElement, timeout: TimeInterval = 30) -> Bool { + let firstRow = grid.tableRows.firstMatch + return waitForPredicate(timeout: timeout) { + grid.frame.width > 0 && grid.frame.height > 0 && firstRow.exists + } + } + + // MARK: - Acting + + internal func isOn(_ toggle: XCUIElement) -> Bool { + if let number = toggle.value as? Int { return number == 1 } + return (toggle.value as? String) == "1" + } + + // `hasFocus` does not reach Swift on macOS and raises through KVC. The first line of the element's + // snapshot carries "Keyboard Focused"; later lines belong to its children. internal func holdsKeyboardFocus(_ element: XCUIElement) -> Bool { guard element.exists else { return false } let ownAttributes = element.debugDescription @@ -254,194 +250,52 @@ internal class UITestCase: XCTestCase { return ownAttributes.contains("Keyboard Focused") } - /// Switches the result to its Structure editor, through **View > Result View > Structure** - /// rather than the `Structure` segment of the results status bar. - /// - /// The segment cannot be clicked on the runner. Its screen is 1024x768, and a window that - /// wide cannot hold the sidebar, the detail pane at its minimum width and the row inspector - /// at once: the detail pane keeps its minimum and is drawn under the sidebar, taking the - /// leading half of the status bar with it. XCUITest still reports the segment as existing and - /// hittable, because its accessibility frame is where the layout says it is, so the click is - /// posted at (314, 691) and lands on the sidebar. Nothing fails there. The result stays on - /// Data, and the suite's next assertion reads the data grid as though it were the structure - /// grid, or waits out its timeout for a structure tab picker that was never going to appear. - /// (Run 33734073855, where the element tree captured the mode picker still reporting - /// `Data` selected after the click.) - /// - /// The menu item carries no geometry, so it is reachable whatever the window is doing. - /// Nothing probes for it first: XCUITest resolves a menu item by opening its parent, and a - /// probe that resolves it leaves that menu open, so the click's own traversal then fails with - /// "open menu during menu traversal" and waits out a ten second watchdog. Waiting on the menu - /// bar costs nothing and waiting for the tab picker afterwards is what makes the switch - /// observed rather than assumed. - internal func showStructure(in app: XCUIApplication, window: XCUIElement) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 20), "The app must publish its menu bar") - menuBar.menuItems["Structure"].click() - XCTAssertTrue( - window.radioGroups["structure-tab-picker"].firstMatch.waitToExist(timeout: 30), - "The structure editor must open on the Structure result view" - ) - } - - /// A point inside the data grid that an overlapping pane cannot steal. - /// - /// A coordinate is the only way to click a row at all: the grid's columns are siblings of its - /// rows and later in the tree, so XCUITest reads every row and every cell as obscured and - /// refuses to click either. The grid's leading edge is not safe to measure from, though. On - /// the runner the detail pane is drawn under the sidebar, so a point 80pt in from that edge - /// lands on the object browser and a right-click raises its menu rather than the grid's. - /// Starting from whichever edge is further right keeps the point on the grid at any width. + // Rows and cells read as obscured by their sibling columns, so a click goes through a coordinate. + // On the 1024pt runner the sidebar overlaps the grid's leading edge, so start right of whichever is further. internal func gridPoint(in grid: XCUIElement, of window: XCUIElement, dy: CGFloat) -> XCUICoordinate { - let clearOfBrowser = window.outlines.firstMatch.frame.maxX + 40 - grid.frame.minX + let clearOfBrowser = objectBrowser(in: window).frame.maxX + 40 - grid.frame.minX return grid.coordinate(withNormalizedOffset: .zero) .withOffset(CGVector(dx: max(80, clearOfBrowser), dy: dy)) } - /// The preconditions a click taken off the grid actually has, which existence does not give. - /// - /// The grid enters the tree when its table view is mounted, which is before the query behind it - /// has returned. A click posted then lands on empty grid and selects nothing, and nothing fails - /// there: the suite goes on to wait out its own timeout for whatever the selection was supposed - /// to produce, and reports that as the missing thing. A grid that exists is also not laid out - /// yet, and a coordinate taken off an empty frame resolves to `(inf, inf)`, which posts at no - /// display at all and takes the runner down instead of failing. - /// - /// The question has to stop at the first row. `allElementsBoundByIndex` resolves the whole set, - /// and asking the grid for its rows is what activates `DataGridCellAccessibilityView`, so the - /// table view then prepares every row of the page and mounts a cell view for each: fine on - /// Album's 347 rows, and past XCUITest's own query budget on the `Track` table the sample opens - /// by default, where it fails the suite with "Timed out while evaluating UI query" rather than - /// with an assertion. `firstMatch` is what stops the traversal early. - internal func waitForClickableRows(in grid: XCUIElement, timeout: TimeInterval = 30) -> Bool { - let firstRow = grid.tableRows.firstMatch - return waitForPredicate(timeout: timeout) { - grid.frame.width > 0 && grid.frame.height > 0 && firstRow.exists - } - } - - /// The object browser draws its rows as hosted cells, so a row's name arrives as the static - /// text's `value`, carrying the object kind the row reads out to VoiceOver, rather than as a - /// label or an identifier. Matching on `value` is what finds them. - internal func objectBrowserRow( - _ name: String, - kind: String = "Table", - in window: XCUIElement - ) -> XCUIElement { - window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "\(kind): \(name)")) - .firstMatch - } - - /// The query editor's text view. Eighteen suites carried a byte-identical private copy of this - /// before it moved here. - /// - /// The identifier branch is the specific query and the `firstMatch` fallback is what actually - /// resolves today, because the identifier is applied to the SwiftUI representable rather than - /// to the `NSTextView` underneath it. Both are kept: the fallback is what works, and the - /// identified lookup is what should start working the day the identifier reaches the text view. - internal func editorTextView(in app: XCUIApplication) -> XCUIElement { - let window = app.windows.firstMatch - let identified = window.textViews.matching(identifier: "sql-editor-textview").firstMatch - if identified.exists { - return identified - } - return window.textViews.firstMatch - } - - /// Puts `sql` in the query editor and confirms it arrived, retyping it if it did not. - /// - /// A click focuses the editor, but the keystrokes that follow it can outrun the focus: the - /// editor installs its coordinators and its key monitor on a later run-loop turn, and anything - /// typed before that lands nowhere. Measured on this suite, `EXPLAIN QUERY PLAN SELECT …` - /// reached the editor as `IN QUERY PLAN SELECT …` and the query came back - /// `near "IN": syntax error`, which reads in the report as a broken query plan rather than as - /// five lost keystrokes. Select-all before each attempt, so a partial first attempt is replaced - /// rather than prepended to. - internal func typeQuery(_ sql: String, in app: XCUIApplication, attempts: Int = 3) { - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10), "The query tab must hold an editor to type into") - for _ in 0 ..< attempts { - editor.click() - app.typeKey("a", modifierFlags: .command) - app.typeText(sql) - let arrived = waitForPredicate(timeout: 3) { - ((editor.value as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == sql - } - if arrived { return } - } - XCTFail("The editor never received the query typed into it") - } - - /// AppKit reports those rows as disabled, so they never become hittable and a plain `click()` - /// waits for a state that cannot arrive. Clicking through a coordinate reaches them. + /// AppKit reports some rows as disabled, so they never become hittable; a coordinate click reaches them. internal func clickAtCenter(_ element: XCUIElement) { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() } - /// An item of the contextual menu a right-click just raised, picked out from the menu bar's - /// copy of the same title. - /// - /// A closed menu bar submenu is still in the accessibility tree, so an app-rooted - /// `menuItems[title]` matches **Database > Copy To…** as readily as the menu under the pointer - /// and then refuses to click either. Hittability is what separates them: only the open menu's - /// items can be clicked. - /// - /// Scoping by container does not work here, measured: `app.children(matching: .menu)` is empty - /// while a contextual menu is up, so a query built on it silently answers no. That is why this - /// takes a title the menu bar also has and narrows it, rather than asking a container what it - /// holds. **A negative assertion cannot be written this way at all**: an absent contextual - /// item is indistinguishable from a present-but-unhittable menu bar one. Assert the absence in - /// a unit test over the menu-building code instead. - internal func contextMenuItem(_ title: String, in app: XCUIApplication) -> XCUIElement { - let matches = app.menuItems.matching(NSPredicate(format: "title == %@", title)) - return matches.allElementsBoundByIndex.first { $0.isHittable } ?? matches.firstMatch + /// For a command with no key equivalent. Click it without probing: resolving the item opens its + /// menu, and the click's own traversal then fails with "open menu during menu traversal". + internal func menuItem(_ title: String, under menu: String, in app: XCUIApplication) -> XCUIElement { + app.menuBars.firstMatch.menuBarItems[menu].menuItems[title].firstMatch } - /// Reached by the row's own identifier, because a section row's label is nested and does not - /// answer a subscript by title. - /// - /// Not finding the row fails the test rather than skipping it: a section list the accessibility - /// tree cannot see is a section list VoiceOver cannot drive. - internal func selectConnectionFormTab(_ tab: String, in form: XCUIElement) { - let row = form.descendants(matching: .any) - .matching(identifier: "connection-form-section-\(tab)") - .firstMatch - XCTAssertTrue( - row.waitToExist(timeout: 10), - "No section row identified connection-form-section-\(tab)" - ) - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.click() + // The status-bar segment lands under the sidebar on the 1024x768 runner and the click silently + // hits the sidebar, so the result view is switched from View > Result View. + internal func showResultView(_ title: String, in app: XCUIApplication) { + XCTAssertTrue(app.menuBars.firstMatch.waitToExist(timeout: 20), "The app must publish its menu bar") + menuItem(title, under: "View", in: app).click() } - /// The app removes its own defaults domain as it terminates, which is the only point that - /// reliably comes after `cfprefsd` has written it. This sweep is the backstop for a run that - /// crashed or was killed before it got there, and it runs before the class's tests so a - /// previous session's leftovers go too. - override internal class func setUp() { - super.setUp() - sweepLeftoverSuites() + internal func showStructure(in app: XCUIApplication, window: XCUIElement) { + showResultView("Structure", in: app) + XCTAssertTrue( + window.radioGroups["structure-tab-picker"].firstMatch.waitToExist(timeout: 30), + "The structure editor must open on the Structure result view" + ) } - override internal class func tearDown() { - sweepLeftoverSuites() - super.tearDown() + internal func showData(in app: XCUIApplication, window: XCUIElement) { + showResultView("Data", in: app) + XCTAssertTrue(dataGrid(in: window).waitToExist(timeout: 30), "The Data result view must show a grid") } +} - private static func sweepLeftoverSuites() { - let preferences = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Preferences", isDirectory: true) - let names = (try? FileManager.default.contentsOfDirectory(atPath: preferences.path)) ?? [] - for name in names where name.hasPrefix("com.TablePro.uitest") && name.hasSuffix(".plist") { - removeSuite(named: String(name.dropLast(".plist".count))) - } - } +internal struct FixtureLaunch { + internal let app: XCUIApplication + internal let window: XCUIElement + internal let connections: [SeededConnection] - private static func removeSuite(named suiteName: String) { - UserDefaults.standard.removePersistentDomain(forName: suiteName) - let plist = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Preferences/\(suiteName).plist") - try? FileManager.default.removeItem(at: plist) + internal var database: URL { + connections[0].database } } diff --git a/TableProUITests/Support/UITestFixture.swift b/TableProUITests/Support/UITestFixture.swift new file mode 100644 index 0000000000..465538aef8 --- /dev/null +++ b/TableProUITests/Support/UITestFixture.swift @@ -0,0 +1,29 @@ +// Small on purpose: Chinook's 1,000-row Track grid published about 12,800 accessibility elements, +// and every query against its window cost seconds. +internal enum UITestFixture { + internal static let connectionName = "Fixture" + + internal static let sql = """ + CREATE TABLE artist (id INTEGER PRIMARY KEY, name TEXT NOT NULL); + CREATE TABLE album (id INTEGER PRIMARY KEY, title TEXT NOT NULL, + artist_id INTEGER NOT NULL REFERENCES artist(id)); + CREATE TABLE genre (id INTEGER PRIMARY KEY, name TEXT NOT NULL); + CREATE TABLE track (id INTEGER PRIMARY KEY, name TEXT NOT NULL, + album_id INTEGER REFERENCES album(id), milliseconds INTEGER NOT NULL); + CREATE INDEX track_album ON track(album_id); + CREATE TABLE document (id INTEGER PRIMARY KEY, label TEXT NOT NULL, payload TEXT NOT NULL); + CREATE TABLE note (id INTEGER PRIMARY KEY, tag TEXT, body TEXT NOT NULL); + INSERT INTO artist VALUES (1, 'Ada'), (2, 'Brook'), (3, 'Cole'), (4, 'Dana'), (5, 'Eli'); + INSERT INTO album VALUES (1, 'North', 1), (2, 'South', 2), (3, 'East', 2), (4, 'West', 3), + (5, 'Up', 4), (6, 'Down', 5); + INSERT INTO genre VALUES (1, 'Rock'), (2, 'Jazz'), (3, 'Metal'); + INSERT INTO track VALUES (1, 'One', 1, 343719), (2, 'Two', 1, 342562), (3, 'Three', 2, 230619), + (4, 'Four', 2, 252051), (5, 'Five', 3, 375418), (6, 'Six', 3, 205662), + (7, 'Seven', 4, 233926), (8, 'Eight', 4, 210834), (9, 'Nine', 5, 203102), + (10, 'Ten', 6, 263497), (11, 'Eleven', 6, 199836), (12, 'Twelve', 6, 215196); + INSERT INTO document VALUES + (1, 'First', '{"id": 1, "name": "Acme", "active": true, "tags": ["alpha", "beta"], "limits": {"seats": 25}}'), + (2, 'Second', '{"id": 2, "name": "Beta", "active": false, "tags": [], "limits": {"seats": 5}}'); + INSERT INTO note VALUES (1, 'todo', 'Buy milk'), (2, 'idea', 'Ship it'), (3, NULL, 'Plain'); + """ +} diff --git a/TableProUITests/SupportWindowUITests.swift b/TableProUITests/SupportWindowUITests.swift deleted file mode 100644 index 607b9d2768..0000000000 --- a/TableProUITests/SupportWindowUITests.swift +++ /dev/null @@ -1,89 +0,0 @@ -import XCTest - -/// The support screen is discoverable, never delivered. A launch that shows it, or any surface -/// that offers it before the user asks, is the defect this suite exists to catch: TablePro's free -/// tier is the whole app and nothing about it counts down. -/// -/// The window is found by its accessibility identifier rather than its title, the way every other -/// auxiliary window is. -final class SupportWindowUITests: UITestCase { - private func launchShowingWelcome() throws -> XCUIApplication { - let app = try launchApp() - XCTAssertTrue(app.windows["welcome"].waitToExist(timeout: 10)) - return app - } - - private func openSupportWindow(in app: XCUIApplication) -> XCUIElement { - let item = app.menuBars.menuItems["Support TablePro"] - XCTAssertTrue(item.waitToExist(timeout: 10), "Support TablePro must be in the Help menu") - item.click() - - let window = app.windows["support"] - XCTAssertTrue(window.waitToExist(timeout: 10), "Support TablePro must open the support window") - return window - } - - func testLaunchingUnlicensedShowsNothingUntilTheUserAsks() throws { - let app = try launchShowingWelcome() - - XCTAssertFalse( - app.windows["support"].exists, - "Launching must never present the support screen on its own" - ) - } - - /// Both actions are asserted present and never clicked: clicking either hands the default - /// browser a URL, which is not this suite's business. - func testHelpMenuOpensTheSupportWindowWithBothActions() throws { - let app = try launchShowingWelcome() - let window = openSupportWindow(in: app) - - XCTAssertTrue( - window.descendants(matching: .any)["support-buy-license"].waitToExist(timeout: 5), - "An unlicensed user must be offered a license" - ) - XCTAssertTrue( - window.descendants(matching: .any)["support-sponsor"].exists, - "The sponsor action must always be offered" - ) - } - - /// The standing link is the one surface that is present without being asked for, so it is also - /// the one that has to stay a line of text: no alert, no sheet, nothing over the window. - func testTheWelcomeWindowCarriesTheStandingLink() throws { - let app = try launchShowingWelcome() - let link = app.windows["welcome"].descendants(matching: .any)["support-prompt-link"] - - XCTAssertTrue(link.waitToExist(timeout: 10), "An unlicensed welcome window must offer the link") - XCTAssertTrue(waitUntilHittable(link, timeout: 5)) - link.click() - - XCTAssertTrue( - app.windows["support"].waitToExist(timeout: 10), - "The standing link must open the support window" - ) - } - - func testAConnectionWindowCarriesTheStandingLink() throws { - let app = try launchWithSampleDatabase() - let window = app.children(matching: .window).firstMatch - - XCTAssertTrue( - window.descendants(matching: .any)["support-prompt-link"].waitToExist(timeout: 15), - "An unlicensed connection window must offer the link in the sidebar footer" - ) - } - - func testCommandWClosesTheSupportWindow() throws { - let app = try launchShowingWelcome() - let window = openSupportWindow(in: app) - - app.typeKey("w", modifierFlags: .command) - - XCTAssertTrue( - waitForPredicate(timeout: 5) { !window.exists }, - "Command W must close the support window" - ) - XCTAssertTrue(app.windows["welcome"].exists, "Command W must not reach past the front window") - } -} diff --git a/TableProUITests/SwitchConnectionAfterDisconnectUITests.swift b/TableProUITests/SwitchConnectionAfterDisconnectUITests.swift deleted file mode 100644 index 735d162432..0000000000 --- a/TableProUITests/SwitchConnectionAfterDisconnectUITests.swift +++ /dev/null @@ -1,59 +0,0 @@ -import XCTest - -/// Switch Connection is the command that leaves the connection in front of you, and it used to be -/// disabled by that connection going away. It validated on `isConnected` and ran through the -/// selected connection's command actions, both of which die with the session, so the Database menu -/// greyed it out and `Ctrl+Cmd+C` did nothing over the pane telling the user to reconnect or pick -/// another connection. The connections strip and the View menu survived; the switcher did not, and -/// it is the only route to a connection that is not open yet. -/// -/// One connection is enough to pin this. The strip needs a second connection to appear and the -/// sample database is a single-file SQLite connection, the same reason `ConnectionCloseUITests` -/// drives its command from the menu bar. What this asserts is the command's own enablement, which -/// is what the bug was. -final class SwitchConnectionAfterDisconnectUITests: UITestCase { - func testSwitchConnectionStaysEnabledAfterDisconnecting() throws { - let app = try launchWithSampleDatabase() - - XCTAssertTrue( - waitForDatabaseItem(named: "Switch Connection…", in: app, toBeEnabled: true, timeout: 30), - "Opening the sample database must leave the switcher reachable" - ) - - databaseItem(named: "Disconnect", in: app).click() - - XCTAssertTrue( - waitForDatabaseItem(named: "Reconnect", in: app, toBeEnabled: true, timeout: 20), - "Disconnect must leave the window offering a reconnect, which is how we know it landed" - ) - XCTAssertTrue( - waitForDatabaseItem(named: "Switch Connection…", in: app, toBeEnabled: true, timeout: 20), - "Switch Connection must survive the connection it switches away from" - ) - } - - /// A menu item only answers `isEnabled` while its menu is up, so each poll opens the menu and - /// closes it again, leaving the next one starting from the same state. - private func waitForDatabaseItem( - named title: String, - in app: XCUIApplication, - toBeEnabled expected: Bool, - timeout: TimeInterval - ) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let item = databaseItem(named: title, in: app) - guard item.waitToExist(timeout: 5) else { continue } - let isEnabled = item.isEnabled - app.typeKey(.escape, modifierFlags: []) - if isEnabled == expected { return true } - } - return false - } - - private func databaseItem(named title: String, in app: XCUIApplication) -> XCUIElement { - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["Database"].click() - return menuBar.menuItems[title] - } -} diff --git a/TableProUITests/SwitcherEscapeUITests.swift b/TableProUITests/SwitcherEscapeUITests.swift deleted file mode 100644 index 4ee67813e6..0000000000 --- a/TableProUITests/SwitcherEscapeUITests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import XCTest - -/// Escape has to dismiss the switcher surfaces, not just the Open Quickly panel, and it has to do -/// it through a real key press: the unit tests call the delegate hook directly, which is below the -/// menu-equivalent and responder-chain stages where a dismissal actually gets lost. -final class SwitcherEscapeUITests: UITestCase { - func testEscapeDismissesTheConnectionSwitcher() throws { - let app = try launchWithSampleDatabase() - try waitForGrid(in: app) - - app.typeKey("c", modifierFlags: [.command, .control]) - let field = connectionSearchField(in: app) - XCTAssertTrue(field.waitToExist(timeout: 15)) - - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue( - field.waitForNonExistence(timeout: 5), - "Escape on an empty filter must dismiss the connection switcher" - ) - } - - /// Reopening is the half a stuck `isPresented` binding breaks: AppKit can close the popover - /// window while SwiftUI still believes it is showing, and the toolbar command then toggles the - /// binding off instead of presenting again. - func testTheConnectionSwitcherReopensAfterEscape() throws { - let app = try launchWithSampleDatabase() - try waitForGrid(in: app) - - app.typeKey("c", modifierFlags: [.command, .control]) - let field = connectionSearchField(in: app) - XCTAssertTrue(field.waitToExist(timeout: 15)) - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(field.waitForNonExistence(timeout: 5)) - - app.typeKey("c", modifierFlags: [.command, .control]) - XCTAssertTrue( - field.waitToExist(timeout: 10), - "The switcher must reopen after an Escape dismissal" - ) - } - - private func waitForGrid(in app: XCUIApplication) throws { - XCTAssertTrue( - app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - .waitToExist(timeout: 30) - ) - app.activate() - } - - /// Keyed on the placeholder because every `NativeSearchField` publishes the same default - /// identifier, so an identifier match cannot tell the switcher popovers apart. - private func connectionSearchField(in app: XCUIApplication) -> XCUIElement { - app.searchFields.matching( - NSPredicate(format: "placeholderValue BEGINSWITH[c] %@", "Search connections") - ).firstMatch - } -} diff --git a/TableProUITests/SwitcherWithoutToolbarAnchorUITests.swift b/TableProUITests/SwitcherWithoutToolbarAnchorUITests.swift deleted file mode 100644 index 959fcb2db2..0000000000 --- a/TableProUITests/SwitcherWithoutToolbarAnchorUITests.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// SwitcherWithoutToolbarAnchorUITests.swift -// TableProUITests -// -// Covers the defect class where a command that has a menu item and a keyboard shortcut is the -// only thing that can present a chooser, but the chooser is declared inside a toolbar item's own -// view. AppKit clips a toolbar item into the overflow menu on a narrow window and lets the user -// delete it outright in Customize Toolbar, and in both states that view is not on screen, so the -// command set its flag and nothing appeared: no popover, no error, no feedback. -// - -import XCTest - -/// Hiding the toolbar is the deterministic way to reach the no-anchor state from a test. -/// -/// The state CI actually failed in is an overflowed item on a 1024pt screen, which cannot be -/// reproduced on a developer machine: `recomputeWindowMinSize()` puts the window's minimum width -/// near 1364pt with a table tab open, so a pinned frame is clamped back up and the toolbar never -/// runs out of room. Hiding the toolbar removes the anchor the same way, through a route any -/// machine can take, and it exercises the same branch. -final class SwitcherWithoutToolbarAnchorUITests: UITestCase { - func testSwitchConnectionOpensWithTheToolbarHidden() throws { - let app = try launchWithSampleDatabase() - try waitForGrid(in: app) - - let toolbar = app.windows.firstMatch.toolbars.firstMatch - /// Normalised rather than asserted. `MainWindowToolbar` sets `autosavesConfiguration`, and - /// AppKit persists toolbar visibility through its own defaults rather than the sandbox - /// `UITestCase` hands the app, so this suite inherits whatever the last run left behind, - /// including its own. An earlier version of this test hid the toolbar without restoring it - /// and every later run then failed on a precondition instead of on the behaviour. - setToolbar(shown: true, toolbar: toolbar, in: app) - setToolbar(shown: false, toolbar: toolbar, in: app) - /// Restored even when an assertion below fails. `MainWindowToolbar` sets - /// `autosavesConfiguration`, and AppKit persists that visibility through its own defaults - /// rather than the sandbox `UITestCase` hands the app, so leaving the toolbar hidden would - /// hide it for every later launch on the machine and fail suites that have nothing to do - /// with this one. - defer { setToolbar(shown: true, toolbar: toolbar, in: app) } - - app.typeKey("c", modifierFlags: [.command, .control]) - - XCTAssertTrue( - connectionSearchField(in: app).waitToExist(timeout: 15), - "Switch Connection must present with no toolbar item to anchor to" - ) - app.typeKey(.escape, modifierFlags: []) - } - - // MARK: - Helpers - - /// Driven by the key equivalent rather than by walking the View menu, which - /// `UITestCase.launchWithSampleDatabase` documents as the slow and flake-prone route. The menu - /// item is built with a fixed "Show Toolbar" title in both states, so its label is no signal. - private func setToolbar(shown: Bool, toolbar: XCUIElement, in app: XCUIApplication) { - _ = toolbar.waitToExist(timeout: 3) - guard toolbar.exists != shown else { return } - app.typeKey("t", modifierFlags: [.command, .option]) - XCTAssertTrue( - waitForPredicate(timeout: 10) { toolbar.exists == shown }, - "Command Option T must \(shown ? "show" : "hide") the toolbar" - ) - } - - private func waitForGrid(in app: XCUIApplication) throws { - XCTAssertTrue( - app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch - .waitToExist(timeout: 30) - ) - app.activate() - } - - /// Keyed on the placeholder because every `NativeSearchField` publishes the same default - /// identifier, so an identifier match cannot tell the switcher surfaces apart. It is also what - /// makes this assertion independent of which surface presented: popover or panel, the field is - /// the same one. - private func connectionSearchField(in app: XCUIApplication) -> XCUIElement { - app.searchFields.matching( - NSPredicate(format: "placeholderValue BEGINSWITH[c] %@", "Search connections") - ).firstMatch - } -} diff --git a/TableProUITests/TableChangeReloadUITests.swift b/TableProUITests/TableChangeReloadUITests.swift index efe95b9188..ed44870dde 100644 --- a/TableProUITests/TableChangeReloadUITests.swift +++ b/TableProUITests/TableChangeReloadUITests.swift @@ -1,405 +1,96 @@ -// -// TableChangeReloadUITests.swift -// TableProUITests -// -// A change to a table used to reach only whichever tab each window had in front. A save announced -// nothing at all, so a second tab on the same table went on showing the rows from before it, and a -// structure save reloaded nothing behind the Structure view, so switching back to Data showed the -// old columns. Neither tab reloaded when shown: each already held rows and had run its query. -// - -import AppKit import XCTest +// A save used to announce nothing, so a second tab on the same table kept its old rows, and a structure +// save reloaded nothing behind the Structure view. Neither tab reloaded when shown again. final class TableChangeReloadUITests: UITestCase { - /// Chinook has no small table a test can write to without leaving the edit for the next case, so - /// the app seeds this one afresh at every launch. Both sides spell the name out because a UI test - /// target cannot import the app. - private let fixtureVariable = "TABLEPRO_UI_TEST_SEED_JSON_TABLE" - private let fixtureTable = "json_fixture" - private let labelColumnPosition = 2 private let savedValue = "Saved Elsewhere" - private let heldValue = "Held Here" private let addedColumn = "added_col" - private let otherTable = "MediaType" - /// The first tab is sorted by `id` descending and the second is not, so the two show the edited - /// row at different positions. Row 2 of the first tab reads the saved value only once that tab - /// has fetched again; the second tab's row 2 never holds it, so a grid still painting the second - /// tab for a moment after the switch cannot pass this. + // The first tab sorts id descending and the second does not, so the edited row sits at a different + // position in each, and a grid still painting the second tab cannot pass for the first. func testASaveInOneTabReachesAnotherTabOnTheSameTable() throws { - let app = try launchWithSampleDatabase(environment: [fixtureVariable: "1"]) - let window = app.windows.firstMatch - let grid = window.tables.matching(identifier: "data-grid").firstMatch - let tableRow = openFixtureTable(in: window, grid: grid) - - try clickHeader("id", in: grid) - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.label(row: 1, in: grid) == "First" }, - "The first click sorts ascending" - ) - try clickHeader("id", in: grid) - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.label(row: 1, in: grid) == "Second" }, - "The second click sorts descending, which puts the row this test edits second" - ) - - tableRow.rightClick() - let openInNewTab = app.menuItems["Open in New Tab"].firstMatch - XCTAssertTrue(openInNewTab.waitToExist(timeout: 15), "The sidebar must offer Open in New Tab") + let (launch, grid) = try launchWithFixtureTable("document") + let window = launch.window + let app = launch.app + + grid.clickHeader("id", rightClick: true) + let sortDescending = contextMenuItem("Sort Descending", in: app) + XCTAssertTrue(sortDescending.waitToExist(timeout: 10), "The header menu must offer Sort Descending") + sortDescending.click() + XCTAssertTrue(grid.waitForValue("Second", row: 1, column: 2), "Sorting id descending must put Second first") + + rightClickInObjectBrowser("document", in: window) + let openInNewTab = contextMenuItem("Open in New Tab", in: app) + XCTAssertTrue(openInNewTab.waitToExist(timeout: 15), "The object browser must offer Open in New Tab") openInNewTab.click() XCTAssertTrue( - waitForPredicate(timeout: 20) { self.fixtureTabs(in: window).count == 2 }, + waitForPredicate(timeout: 20) { self.tabsTitled("document", in: window).count == 2 }, "Open in New Tab must add a second tab on the same table" ) XCTAssertTrue( waitForPredicate(timeout: 30) { - self.label(row: 1, in: grid) == "First" && self.label(row: 2, in: grid) == "Second" + grid.value(row: 1, column: 2) == "First" && grid.value(row: 2, column: 2) == "Second" }, - "The second tab opens unsorted" + "The second tab must open unsorted" ) - editCell(row: 1, column: labelColumnPosition, in: grid, app: app, to: savedValue) + grid.editCell(row: 1, column: 2, to: savedValue) + XCTAssertTrue(grid.waitForValue(savedValue, row: 1, column: 2), "The edit must land in the second tab") + app.typeShortcut(.save) XCTAssertTrue( - waitForPredicate(timeout: 10) { self.label(row: 1, in: grid) == self.savedValue }, - "The edit must land in the second tab" + waitForPredicate(timeout: 30) { + FixtureFile.rows("SELECT label FROM document WHERE id = 1", in: launch.database) == [[self.savedValue]] + }, + "Save must write the edit to the file" ) - app.typeKey("s", modifierFlags: .command) - /// The save reloads the tab that made it, so its edited cell settles on the stored value. - _ = waitForPredicate(timeout: 5) { false } - let firstTab = fixtureTabs(in: window).element(boundBy: 0) + let firstTab = tabsTitled("document", in: window).element(boundBy: 0) XCTAssertTrue(firstTab.waitToExist(timeout: 10), "The first tab must still be in the strip") clickAtCenter(firstTab) - XCTAssertTrue( - waitForPredicate(timeout: 30) { self.label(row: 2, in: grid) == self.savedValue }, + grid.waitForValue(savedValue, row: 2, column: 2, timeout: 30), "The first tab must show the saved value without a refresh, it shows " - + "'\(label(row: 2, in: grid) ?? "nil")'" + + "'\(grid.value(row: 2, column: 2) ?? "nil")'" ) - XCTAssertEqual(label(row: 1, in: grid), "Second", "The first tab must keep its own sort across the reload") + XCTAssertEqual(grid.value(row: 1, column: 2), "Second", "The first tab must keep its own sort across the reload") } func testAColumnAddedInTheStructureViewShowsInTheDataView() throws { - let app = try launchWithSampleDatabase(environment: [fixtureVariable: "1"]) - let window = app.windows.firstMatch - let grid = window.tables.matching(identifier: "data-grid").firstMatch - _ = openFixtureTable(in: window, grid: grid) - XCTAssertTrue(header("label", in: grid).waitToExist(timeout: 30), "The Data view must show the fixture's columns") - - showStructure(in: app, window: window) - addColumnAndSave(in: window, grid: grid, app: app) - - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["View"].menuItems["Data"].click() - - XCTAssertTrue( - header(addedColumn, in: grid).waitToExist(timeout: 30), - "Back on Data, the grid must show the column the Structure view added, without a refresh" - ) - } - - /// The first tab is left on its structure's DDL, which is not one of the sub-tabs a mount - /// fetches to baseline the editor. Showing it again does not change the selection either, so - /// nothing fetched the DDL after the other tab's save and it went on showing the old table. - func testAColumnAddedInAnotherTabShowsInTheDDLThisTabLeftOpen() throws { - let app = try launchWithSampleDatabase(environment: [fixtureVariable: "1"]) - let window = app.windows.firstMatch - let grid = window.tables.matching(identifier: "data-grid").firstMatch - let tableRow = openFixtureTable(in: window, grid: grid) - - showStructure(in: app, window: window) - let ddlTab = structureSubTab(named: "DDL", in: window) - XCTAssertTrue(ddlTab.waitToExist(timeout: 20), "The structure editor must offer a DDL sub-tab") - ddlTab.click() - XCTAssertTrue( - ddlText(containing: fixtureTable, in: window).waitToExist(timeout: 30), - "The DDL sub-tab must show the fixture's CREATE TABLE" - ) - XCTAssertFalse(ddlText(containing: addedColumn, in: window).exists, "The fixture starts without the column") - - tableRow.rightClick() - let openInNewTab = app.menuItems["Open in New Tab"].firstMatch - XCTAssertTrue(openInNewTab.waitToExist(timeout: 15), "The sidebar must offer Open in New Tab") - openInNewTab.click() - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.fixtureTabs(in: window).count == 2 }, - "Open in New Tab must add a second tab on the same table" - ) - XCTAssertTrue(waitForClickableRows(in: grid), "The second tab must load its rows") - - showStructure(in: app, window: window) - addColumnAndSave(in: window, grid: grid, app: app) - - let firstTab = fixtureTabs(in: window).element(boundBy: 0) - XCTAssertTrue(firstTab.waitToExist(timeout: 10), "The first tab must still be in the strip") - clickAtCenter(firstTab) - - XCTAssertTrue( - ddlText(containing: addedColumn, in: window).waitToExist(timeout: 30), - "Back on the first tab, its DDL must show the column the other tab added, without a refresh" - ) - } - - /// The first tab holds a staged column in its Structure view while the second adds another and - /// saves. Fetching that change would throw the staged column away, so it has to wait until the - /// column is gone, and it used to be dropped instead: the first tab went back to the columns it - /// had before the save and staged its next edit against them. The staged column is removed with - /// the footer's remove button rather than undone, because switching tabs resets the window's - /// undo stack, so Cmd+Z after coming back reaches nothing. - func testAColumnAddedInAnotherTabShowsOnceThisTabRemovesItsStagedColumn() throws { - let app = try launchWithSampleDatabase(environment: [fixtureVariable: "1"]) - let window = app.windows.firstMatch - let grid = window.tables.matching(identifier: "data-grid").firstMatch - let tableRow = openFixtureTable(in: window, grid: grid) + let (launch, grid) = try launchWithFixtureTable("document") + let window = launch.window + let app = launch.app + XCTAssertTrue(grid.header("label").waitToExist(timeout: 30), "The Data view must show document's columns") showStructure(in: app, window: window) let add = window.buttons["structure-footer-add"].firstMatch XCTAssertTrue(add.waitToExist(timeout: 20), "The Columns tab must offer an add button") XCTAssertTrue(waitForPredicate(timeout: 10) { add.isEnabled }, "SQLite adds a column with ALTER TABLE") add.click() - let stagedRow = 4 - XCTAssertTrue( - cellElement(row: stagedRow, column: 1, in: grid).waitToExist(timeout: 10), - "Adding a column stages a fourth row under the fixture's three" - ) - - openSecondFixtureTab(from: tableRow, in: window, grid: grid, app: app) - showStructure(in: app, window: window) - addColumnAndSave(in: window, grid: grid, app: app) - - let firstTab = fixtureTabs(in: window).element(boundBy: 0) - XCTAssertTrue(firstTab.waitToExist(timeout: 10), "The first tab must still be in the strip") - clickAtCenter(firstTab) - XCTAssertTrue( - cellElement(row: stagedRow, column: 1, in: grid).waitToExist(timeout: 20), - "The first tab keeps its staged column across the other tab's save" - ) - XCTAssertNotEqual(value(row: stagedRow, column: 1, in: grid), addedColumn, "Nothing is fetched over a staged edit") - - point(at: cellElement(row: stagedRow, column: 1, in: grid).frame, in: grid).click() - let remove = window.buttons["structure-footer-remove"].firstMatch - XCTAssertTrue(remove.waitToExist(timeout: 10), "The Columns tab must offer a remove button") - XCTAssertTrue(waitForPredicate(timeout: 10) { remove.isEnabled }, "Removing the staged column must be offered") - remove.click() - - XCTAssertTrue( - waitForPredicate(timeout: 30) { self.value(row: stagedRow, column: 1, in: grid) == self.addedColumn }, - "Once its staged column is removed, the first tab must show the column the other tab added, it shows " - + "'\(value(row: stagedRow, column: 1, in: grid) ?? "nil")'" - ) - } - - /// An undo only reaches the tab it was made in while that tab stays in front, since a switch - /// resets the window's undo stack, so the save comes from a second window. The tab holding the - /// edit keeps its rows through the save, and used to keep them after the edit was undone too: - /// only Discard resumed the reload the edit had put off. - func testUndoingTheLastEditReloadsRowsSavedInAnotherWindow() throws { - let app = try launchWithSampleDatabase(environment: [fixtureVariable: "1"]) - let firstWindow = app.windows.firstMatch - let firstGrid = firstWindow.tables.matching(identifier: "data-grid").firstMatch - let tableRow = openFixtureTable(in: firstWindow, grid: firstGrid) - openSecondFixtureTab(from: tableRow, in: firstWindow, grid: firstGrid, app: app) - - /// A third table in front of the first window gives the two windows different titles, which - /// is how the Window menu tells them apart. - let otherRow = objectBrowserRow(otherTable, in: firstWindow) - XCTAssertTrue(otherRow.waitToExist(timeout: 20), "The object browser must list \(otherTable)") - otherRow.rightClick() - let openOther = app.menuItems["Open in New Tab"].firstMatch - XCTAssertTrue(openOther.waitToExist(timeout: 15), "The sidebar must offer Open in New Tab") - openOther.click() - XCTAssertTrue(waitForClickableRows(in: firstGrid), "\(otherTable) must load its rows") - - let editingTab = fixtureTabs(in: firstWindow).element(boundBy: 0) - XCTAssertTrue(waitUntilHittable(editingTab, timeout: 20), "The first fixture tab must be hittable") - editingTab.rightClick() - let moveItem = app.menuItems.matching(identifier: "Move Tab to New Window").firstMatch - XCTAssertTrue(moveItem.waitToExist(timeout: 5), "The tab menu must offer Move Tab to New Window") - moveItem.click() - XCTAssertTrue(waitForPredicate(timeout: 20) { app.windows.count >= 2 }, "The move must open a second window") - - let editingWindow = app.windows.matching(NSPredicate(format: "title CONTAINS %@", fixtureTable)).firstMatch - let editingGrid = editingWindow.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(waitForClickableRows(in: editingGrid), "The moved tab must load its rows in its own window") - editCell(row: 1, column: labelColumnPosition, in: editingGrid, app: app, to: heldValue) - XCTAssertTrue( - waitForPredicate(timeout: 10) { self.label(row: 1, in: editingGrid) == self.heldValue }, - "The edit must land in the moved tab" - ) - - bringToFront(windowTitled: otherTable, in: app) - let savingWindow = app.windows.containing(.any, identifier: "editor-tab").firstMatch - let savingGrid = savingWindow.tables.matching(identifier: "data-grid").firstMatch - let savingTab = fixtureTabs(in: savingWindow).firstMatch - XCTAssertTrue(waitUntilHittable(savingTab, timeout: 20), "The first window must keep the second fixture tab") - clickAtCenter(savingTab) - XCTAssertTrue( - waitForPredicate(timeout: 30) { self.label(row: 2, in: savingGrid) == "Second" }, - "The second fixture tab opens unsorted" - ) - editCell(row: 2, column: labelColumnPosition, in: savingGrid, app: app, to: savedValue) - app.typeKey("s", modifierFlags: .command) XCTAssertTrue( - waitForPredicate(timeout: 30) { self.label(row: 2, in: savingGrid) == self.savedValue }, - "The save must land in the second fixture tab" + grid.cell(row: 4, column: 1).waitToExist(timeout: 10), + "Adding a column must stage a fourth row under document's three" ) - let otherTab = savingWindow.descendants(matching: .any) - .matching(identifier: "editor-tab") - .matching(NSPredicate(format: "label == %@", otherTable)) - .firstMatch - clickAtCenter(otherTab) - - bringToFront(windowTitled: fixtureTable, in: app) + grid.editCell(row: 4, column: 1, to: addedColumn) + grid.editCell(row: 4, column: 2, to: "TEXT") XCTAssertTrue( - waitForPredicate(timeout: 10) { self.label(row: 1, in: editingGrid) == self.heldValue }, - "The moved tab keeps its edit across the other window's save" + grid.waitForValue("TEXT", row: 4, column: 2, timeout: 10), + "The new column must carry a type, it has '\(grid.value(row: 4, column: 2) ?? "nil")'" ) - XCTAssertEqual(label(row: 2, in: editingGrid), "Second", "Nothing reloads over an unsaved edit") - - app.typeKey("z", modifierFlags: .command) - - XCTAssertTrue( - waitForPredicate(timeout: 30) { self.label(row: 2, in: editingGrid) == self.savedValue }, - "Once its edit is undone, the moved tab must show the other window's save, it shows " - + "'\(label(row: 2, in: editingGrid) ?? "nil")'" - ) - XCTAssertEqual(label(row: 1, in: editingGrid), "First", "The undo must put the edited cell back") - } - - // MARK: - Helpers - - private func openSecondFixtureTab( - from tableRow: XCUIElement, - in window: XCUIElement, - grid: XCUIElement, - app: XCUIApplication - ) { - tableRow.rightClick() - let openInNewTab = app.menuItems["Open in New Tab"].firstMatch - XCTAssertTrue(openInNewTab.waitToExist(timeout: 15), "The sidebar must offer Open in New Tab") - openInNewTab.click() - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.fixtureTabs(in: window).count == 2 }, - "Open in New Tab must add a second tab on the same table" - ) - XCTAssertTrue(waitForClickableRows(in: grid), "The second tab must load its rows") - } - - /// The Window menu lists every open window by title and is reachable however the windows - /// overlap, which a click on a window behind another is not. - private func bringToFront(windowTitled title: String, in app: XCUIApplication) { - let windowMenu = app.menuBars.firstMatch.menuBarItems["Window"] - XCTAssertTrue(windowMenu.waitToExist(timeout: 20), "The app must publish its Window menu") - windowMenu.click() - let item = windowMenu.menuItems.matching(NSPredicate(format: "title CONTAINS %@", title)).firstMatch - XCTAssertTrue(item.waitToExist(timeout: 10), "The Window menu must list the window showing \(title)") - item.click() - } - - private func addColumnAndSave(in window: XCUIElement, grid: XCUIElement, app: XCUIApplication) { - let add = window.buttons["structure-footer-add"].firstMatch - XCTAssertTrue(add.waitToExist(timeout: 20), "The Columns tab must offer an add button") - XCTAssertTrue(waitForPredicate(timeout: 10) { add.isEnabled }, "SQLite adds a column with ALTER TABLE") - add.click() - let newRow = 4 + app.typeShortcut(.save) XCTAssertTrue( - cellElement(row: newRow, column: 1, in: grid).waitToExist(timeout: 10), - "Adding a column stages a fourth row under the fixture's three" - ) - editCell(row: newRow, column: 1, in: grid, app: app, to: addedColumn) - editCell(row: newRow, column: 2, in: grid, app: app, to: "TEXT") - XCTAssertTrue( - waitForPredicate(timeout: 10) { self.value(row: newRow, column: 2, in: grid) == "TEXT" }, - "The new column must carry a type, it has '\(value(row: newRow, column: 2, in: grid) ?? "nil")'" + waitForPredicate(timeout: 30) { + FixtureFile.rows("SELECT name FROM pragma_table_xinfo('document')", in: launch.database)? + .contains([self.addedColumn]) == true + }, + "Save must add the column to the file without asking" ) + XCTAssertFalse(window.sheets.firstMatch.exists, "Adding a column at Silent must not raise a sheet") - app.typeKey("s", modifierFlags: .command) + showData(in: app, window: window) XCTAssertTrue( - waitForPredicate(timeout: 30) { !app.sheets.firstMatch.exists && !window.sheets.firstMatch.exists } - && waitForPredicate(timeout: 30) { !(self.value(row: newRow, column: 1, in: grid) ?? "").isEmpty }, - "The save must go through without a sheet" + grid.header(addedColumn).waitToExist(timeout: 30), + "Back on Data, the grid must show the column the Structure view added, without a refresh" ) - _ = waitForPredicate(timeout: 3) { false } - } - - /// The sub-tab labels carry item counts, so they are matched by prefix rather than exactly. - private func structureSubTab(named name: String, in window: XCUIElement) -> XCUIElement { - window.radioGroups["structure-tab-picker"].firstMatch - .radioButtons - .matching(NSPredicate(format: "label BEGINSWITH %@", name)) - .firstMatch - } - - private func ddlText(containing text: String, in window: XCUIElement) -> XCUIElement { - window.textViews.matching(NSPredicate(format: "value CONTAINS %@", text)).firstMatch - } - - private func openFixtureTable(in window: XCUIElement, grid: XCUIElement) -> XCUIElement { - let tableRow = objectBrowserRow(fixtureTable, in: window) - XCTAssertTrue(tableRow.waitToExist(timeout: 30), "The seeded fixture table must appear in the object browser") - tableRow.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - XCTAssertTrue(waitForClickableRows(in: grid), "The fixture table must load its rows") - return tableRow - } - - /// The sample opens `Track` in a tab of its own, so the fixture's tabs are picked out by title. - private func fixtureTabs(in window: XCUIElement) -> XCUIElementQuery { - window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .matching(NSPredicate(format: "label == %@", fixtureTable)) - } - - private func header(_ column: String, in grid: XCUIElement) -> XCUIElement { - grid.buttons - .matching(NSPredicate(format: "label BEGINSWITH %@", "Column: \(column)")) - .firstMatch - } - - /// A header is clicked through a coordinate taken off the grid. XCUITest reports the header - /// element as never hittable however long it waits, and clicks the same point fine. - private func clickHeader(_ column: String, in grid: XCUIElement) throws { - let header = header(column, in: grid) - XCTAssertTrue(header.waitToExist(timeout: 30), "The grid must publish an \(column) header") - let frame = header.frame - XCTAssertTrue(frame.width > 0, "The \(column) header must be laid out") - point(at: frame, in: grid).click() - } - - private func editCell(row: Int, column: Int, in grid: XCUIElement, app: XCUIApplication, to value: String) { - let cell = cellElement(row: row, column: column, in: grid) - XCTAssertTrue(cell.waitToExist(timeout: 10), "Row \(row) must publish its cells") - point(at: cell.frame, in: grid).click() - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - app.typeKey("a", modifierFlags: .command) - app.typeText(value) - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - } - - private func point(at frame: CGRect, in grid: XCUIElement) -> XCUICoordinate { - let origin = grid.frame.origin - return grid.coordinate(withNormalizedOffset: .zero) - .withOffset(CGVector(dx: frame.midX - origin.x, dy: frame.midY - origin.y)) - } - - /// A cell with a picker publishes as a combo box and a plain one as static text, so both are - /// found by the identifier they share. - private func cellElement(row: Int, column: Int, in grid: XCUIElement) -> XCUIElement { - grid.descendants(matching: .any) - .matching(identifier: "DataGridCellAccessibilityView") - .matching(NSPredicate(format: "label BEGINSWITH %@", "Row \(row), column \(column): ")) - .firstMatch - } - - private func value(row: Int, column: Int, in grid: XCUIElement) -> String? { - let cell = cellElement(row: row, column: column, in: grid) - guard cell.exists else { return nil } - return cell.value as? String - } - - private func label(row: Int, in grid: XCUIElement) -> String? { - value(row: row, column: labelColumnPosition, in: grid) } } diff --git a/TableProUITests/TableFolderUITests.swift b/TableProUITests/TableFolderUITests.swift deleted file mode 100644 index 177b5211b2..0000000000 --- a/TableProUITests/TableFolderUITests.swift +++ /dev/null @@ -1,106 +0,0 @@ -import XCTest - -/// Issue #3167. Tables and views can be filed into folders from the object browser's own menu, a -/// new folder is named in its row, and deleting a folder puts its tables back and can be undone. -/// -/// The sample database is SQLite in the flat layout, so the tree layout and drag and drop are not -/// exercised here. Their rules are asserted without a window in `TableFolderPlannerTests`, -/// `TableFolderDropResolverTests` and `DatabaseTreeMenuSpecTests`. -final class TableFolderUITests: UITestCase { - func testMovingATableIntoANewFolderNamesTheFolderInItsRow() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - - fileIntoNewFolder("Album", named: "Music", in: window, of: app) - - XCTAssertTrue( - folderRow("Music", in: window).waitToExist(timeout: 20), - "The folder must appear under the name typed into its row" - ) - XCTAssertTrue( - objectBrowserRow("Album", in: window).waitToExist(timeout: 10), - "The table must still be listed, now inside the folder" - ) - } - - func testDeletingAFolderKeepsItsTablesAndUndoBringsItBack() throws { - let app = try launchWithSampleDatabase() - let window = try readyWindow(of: app) - fileIntoNewFolder("Album", named: "Music", in: window, of: app) - let folder = folderRow("Music", in: window) - XCTAssertTrue(folder.waitToExist(timeout: 20)) - - folder.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).rightClick() - let delete = window.menus.menuItems["Delete Folder"].firstMatch - XCTAssertTrue(delete.waitToExist(timeout: 15), "A folder row must offer Delete Folder") - XCTAssertTrue(waitUntilHittable(delete, timeout: 10)) - delete.click() - - XCTAssertTrue( - waitForPredicate(timeout: 20) { !folder.exists }, - "Delete Folder must remove the folder without asking, since nothing leaves the database" - ) - XCTAssertTrue( - objectBrowserRow("Album", in: window).waitToExist(timeout: 10), - "The table the folder held must go back to its section" - ) - - app.typeKey("z", modifierFlags: .command) - - XCTAssertTrue( - folderRow("Music", in: window).waitToExist(timeout: 20), - "Undo must bring the deleted folder back" - ) - } - - // MARK: - Helpers - - private func fileIntoNewFolder( - _ table: String, - named name: String, - in window: XCUIElement, - of app: XCUIApplication - ) { - let row = objectBrowserRow(table, in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(table)") - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).rightClick() - - let moveTo = window.menus.menuItems["Move to"].firstMatch - XCTAssertTrue(moveTo.waitToExist(timeout: 15), "A table row must offer Move to") - moveTo.hover() - - let newFolderItems = app.menuItems.matching(NSPredicate(format: "title == %@", "New Folder")) - var newFolder: XCUIElement? - XCTAssertTrue( - waitForPredicate(timeout: 10) { - newFolder = newFolderItems.allElementsBoundByIndex.first { $0.isHittable } - return newFolder != nil - }, - "Move to must offer New Folder" - ) - newFolder?.click() - - let field = window.textFields.matching(identifier: "database-tree-rename-field").firstMatch - XCTAssertTrue(field.waitToExist(timeout: 20), "A new folder must open with its name field") - app.typeKey("a", modifierFlags: .command) - app.typeText(name) - app.typeKey(.return, modifierFlags: []) - } - - /// Matched on the start of the value, because the row reads its item count after the name. - private func folderRow(_ name: String, in window: XCUIElement) -> XCUIElement { - window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value BEGINSWITH %@", "Folder: \(name),")) - .firstMatch - } - - private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) - XCTAssertTrue( - waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, - "The object browser must list the sample database's tables" - ) - return window - } -} diff --git a/TableProUITests/TableInfoTriggerUITests.swift b/TableProUITests/TableInfoTriggerUITests.swift deleted file mode 100644 index 2b21207f9c..0000000000 --- a/TableProUITests/TableInfoTriggerUITests.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// TableInfoTriggerUITests.swift -// TableProUITests -// - -import XCTest - -final class TableInfoTriggerUITests: UITestCase { - private let englishArguments = ["-AppleLanguages", "(en)"] - - /// Table Info reads its numbers only while the inspector is on screen, so the read that a table - /// opened behind a closed inspector skipped has to happen when the inspector opens. - func testOpeningTheInspectorAfterTheTableLoadsShowsTableInfo() throws { - let app = try launchWithSampleDatabase(arguments: englishArguments) - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The sample database produced no data grid") - XCTAssertTrue(waitForClickableRows(in: grid), "The sample table must load rows first") - - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - let show = menuBar.menuItems["Show Inspector"] - XCTAssertTrue(show.waitToExist(timeout: 10), "A fresh window starts with the inspector closed") - show.click() - - let tableInfo = window.descendants(matching: .any)["table-info"].firstMatch - XCTAssertTrue( - tableInfo.waitToExist(timeout: 20), - "Opening the inspector with no row selected must read and show the table's info" - ) - } -} diff --git a/TableProUITests/TableLoadKeepsGridUITests.swift b/TableProUITests/TableLoadKeepsGridUITests.swift deleted file mode 100644 index 6d4f9e60af..0000000000 --- a/TableProUITests/TableLoadKeepsGridUITests.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// TableLoadKeepsGridUITests.swift -// TableProUITests -// - -import XCTest - -/// Opening a table into the preview tab empties the tab's rows before the new table's fetch runs. -/// For the whole of that fetch the results pane drew nothing at all, no grid and no headers, so a -/// slow table read as a blank page that never loaded. -final class TableLoadKeepsGridUITests: UITestCase { - /// A view that takes seconds to answer, so the fetch is still running when the test looks. - private let slowView = "slow_rows" - - func testATableOpenedIntoThePreviewTabKeepsItsGridWhileItLoads() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The sample database opens a table") - - createSlowView(in: app, window: window) - - let album = objectBrowserRow("Album", in: window) - XCTAssertTrue(album.waitToExist(timeout: 15), "The object browser must list Album") - XCTAssertTrue(waitUntilHittable(album, timeout: 15), "Album's row must settle before it is clicked") - clickAtCenter(album) - XCTAssertTrue( - header("Title", in: grid).waitToExist(timeout: 30), - "Album opens in a preview tab of its own, beside the query tab" - ) - - openQuickly(slowView, in: app) - - XCTAssertTrue( - executionIndicator(in: window).waitToExist(timeout: 15), - "The view's first page must still be loading when the pane is checked" - ) - XCTAssertTrue(grid.exists, "The grid must stay on screen while the view's rows load") - - XCTAssertTrue( - header("id", in: grid).waitToExist(timeout: 90), - "The view's rows must land in the same grid" - ) - } - - /// A query tab has no grid to keep before its first result, so the pane shows the run in progress - /// instead of a blank page, and the status bar stays where it was. - func testAQueryWithNoResultYetShowsProgressInThePane() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - app.typeKey("t", modifierFlags: .command) - typeQuery( - "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < 20000000) SELECT count(*) FROM c;", - in: app - ) - app.typeKey(.return, modifierFlags: .command) - - let progress = window.activityIndicators["results-loading"].firstMatch - XCTAssertTrue(progress.waitToExist(timeout: 15), "The pane must show the run once it outlasts the grace") - let indicator = executionIndicator(in: window) - XCTAssertTrue(indicator.exists, "The status bar must still report the run") - XCTAssertGreaterThan( - indicator.frame.midY, - window.frame.maxY - 60, - "The status bar must stay at the bottom of the pane" - ) - } - - private func createSlowView(in app: XCUIApplication, window: XCUIElement) { - app.typeKey("t", modifierFlags: .command) - typeQuery( - """ - CREATE VIEW IF NOT EXISTS \(slowView) AS WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n \ - WHERE x < 40000000) SELECT x AS id FROM n WHERE x % 40000 = 0; - """, - in: app - ) - app.typeKey(.return, modifierFlags: .command) - XCTAssertTrue( - waitForPredicate(timeout: 30) { !self.executionIndicator(in: window).exists }, - "CREATE VIEW must finish" - ) - } - - /// The query tab in front holds work, so this cannot take it over; the view goes into the - /// preview tab only because Album's is the selected one. - private func openQuickly(_ name: String, in app: XCUIApplication) { - let openQuickly = app.menuBars.menuItems["Open Quickly…"] - XCTAssertTrue(openQuickly.waitToExist(timeout: 5)) - openQuickly.click() - let searchField = app.textFields["quick-switcher-search-field"] - XCTAssertTrue(searchField.waitToExist(timeout: 15), "Open Quickly must show its search field") - searchField.typeText(name) - let result = app.buttons[name].firstMatch - XCTAssertTrue(result.waitToExist(timeout: 20), "Open Quickly must list \(name)") - app.typeKey(.return, modifierFlags: []) - } - - private func executionIndicator(in window: XCUIElement) -> XCUIElement { - window.activityIndicators["execution-indicator"].firstMatch - } - - private func header(_ name: String, in grid: XCUIElement) -> XCUIElement { - grid.buttons.matching(NSPredicate(format: "label == %@", "Column: \(name)")).firstMatch - } -} diff --git a/TableProUITests/TableProLaunchUITests.swift b/TableProUITests/TableProLaunchUITests.swift deleted file mode 100644 index 4c366260d5..0000000000 --- a/TableProUITests/TableProLaunchUITests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import XCTest - -final class TableProLaunchUITests: UITestCase { - func testApplicationLaunchesMainWindow() throws { - let app = try launchApp() - - XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) - } - - func testMainWindowLaunchesAtOrAboveBaseMinimum() throws { - let app = try launchApp() - - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 10)) - - let frame = window.frame - XCTAssertGreaterThanOrEqual(frame.width, 720, "Window width must be at least the base minimum (720)") - XCTAssertGreaterThanOrEqual(frame.height, 480, "Window height must be at least the base minimum (480)") - } -} diff --git a/TableProUITests/TrailingPaneSurfaceUITests.swift b/TableProUITests/TrailingPaneSurfaceUITests.swift deleted file mode 100644 index 51a7995cb3..0000000000 --- a/TableProUITests/TrailingPaneSurfaceUITests.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// TrailingPaneSurfaceUITests.swift -// TableProUITests -// -// The trailing pane has one header on every surface: a picker between the inspector and the -// assistant, or the surface's name where there is nothing to choose, and a menu of that surface's -// commands. The View menu's two commands take their titles from the surface the pane is drawing, so -// in Agent mode the pane toggle names the result column and Show Assistant is dimmed. -// - -import XCTest - -final class TrailingPaneSurfaceUITests: UITestCase { - /// The titles are matched by their English text, so the app runs in a known language. - private let englishArguments = ["-AppleLanguages", "(en)"] - - /// Agent mode reveals its result column on the way in, so the toggle starts on Hide Result. - func testAgentModeRetitlesThePaneToggleAndDimsShowAssistant() throws { - let app = try launchWithSampleDatabase(arguments: englishArguments) - _ = try mainWindow(of: app) - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - - menuBar.menuBarItems["View"].click() - menuBar.menuItems["Mode"].click() - let agent = menuBar.menuItems["Agent"] - XCTAssertTrue(agent.waitToExist(timeout: 10), "View > Mode must offer Agent") - agent.click() - - menuBar.menuBarItems["View"].click() - let hideResult = menuBar.menuItems["Hide Result"] - XCTAssertTrue( - hideResult.waitToExist(timeout: 15), - "In Agent mode the pane toggle names the result column it closes, not an inspector nobody sees" - ) - XCTAssertFalse( - menuBar.menuItems["Hide Inspector"].exists, - "The inspector is not on screen in Agent mode, so nothing may offer to hide it" - ) - let showAssistant = menuBar.menuItems["Show Assistant"] - XCTAssertTrue(showAssistant.exists) - XCTAssertFalse( - showAssistant.isEnabled, - "The conversation is the content column in Agent mode, so there is no assistant to show" - ) - - hideResult.click() - menuBar.menuBarItems["View"].click() - XCTAssertTrue( - menuBar.menuItems["Show Result"].waitToExist(timeout: 10), - "Closing the result column must leave a command that brings it back" - ) - app.typeKey(.escape, modifierFlags: []) - } - - /// The picker writes the connection's surface and the window swaps the pane on the next turn of - /// the run loop, after the picker's own action has returned. The inspector's field search leaving - /// the window is what shows the swap happened. - func testTheHeaderPickerMovesThePaneToTheAssistant() throws { - let app = try launchWithSampleDatabase(arguments: englishArguments) - let window = try mainWindow(of: app) - selectFirstRow(in: window) - showInspector(in: app) - XCTAssertTrue( - window.searchFields["inspector-field-search"].waitToExist(timeout: 20), - "The inspector must be showing the row's fields before the pane can move off it" - ) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.surfaceSegment("Assistant", in: window).exists }, - "The inspector's header offers the assistant while AI is on, named rather than by its glyph" - ) - let assistant = surfaceSegment("Assistant", in: window) - XCTAssertTrue(waitUntilHittable(assistant, timeout: 10)) - assistant.click() - - XCTAssertTrue( - waitForPredicate(timeout: 15) { !window.searchFields["inspector-field-search"].exists }, - "Picking the assistant must take the inspector off the pane" - ) - let menu = window.descendants(matching: .any)["trailing-pane-menu"].firstMatch - XCTAssertTrue( - waitForPredicate(timeout: 10) { menu.exists && menu.label == "Assistant Options" }, - "The assistant draws the same header as the inspector, carrying its own commands" - ) - - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["View"].click() - XCTAssertTrue( - menuBar.menuItems["Hide Assistant"].waitToExist(timeout: 10), - "The View menu reads the surface the pane is drawing" - ) - app.typeKey(.escape, modifierFlags: []) - } - - /// Fields and JSON are a choice in the inspector's menu now, not a second segmented control - /// beside the surface picker. - func testTheInspectorMenuSwitchesTheRowToJSON() throws { - let app = try launchWithSampleDatabase(arguments: englishArguments) - let window = try mainWindow(of: app) - selectFirstRow(in: window) - showInspector(in: app) - - let menu = window.descendants(matching: .any)["trailing-pane-menu"].firstMatch - XCTAssertTrue(menu.waitToExist(timeout: 20), "Every surface's header carries its commands menu") - XCTAssertEqual( - menu.label, - "Inspector Options", - "The ellipsis draws no text, so its label is the only name VoiceOver has for it" - ) - XCTAssertTrue(waitUntilHittable(menu, timeout: 10)) - menu.click() - - let json = window.menuItems["JSON"].firstMatch - XCTAssertTrue(json.waitToExist(timeout: 10), "The inspector's menu offers the JSON rendering") - json.click() - - XCTAssertTrue( - window.searchFields["json-row-filter"].waitToExist(timeout: 20), - "Choosing JSON must show the row as JSON" - ) - } - - // MARK: - Helpers - - private func mainWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - return window - } - - /// A segmented control publishes its segments as radio buttons, and the CI runner has been seen - /// publishing the same controls as plain buttons, so both are asked, inside the picker first. - private func surfaceSegment(_ title: String, in window: XCUIElement) -> XCUIElement { - let radio = window.radioGroups["trailing-pane-surface"].radioButtons[title].firstMatch - return radio.exists ? radio : window.buttons.matching(identifier: title).firstMatch - } - - /// A point offset from the grid rather than a row: the grid publishes its columns as siblings of - /// its rows, so XCUITest reads every row as obscured and refuses to click one. `dy` clears the - /// 42pt header, and the rows have to be in first or the click lands on an empty grid. - private func selectFirstRow(in window: XCUIElement) { - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The sample database produced no data grid") - XCTAssertTrue(waitForClickableRows(in: grid), "The sample table must load rows before one is selected") - gridPoint(in: grid, of: window, dy: 70).click() - } - - /// The pane remembers whether it was open and on which surface, so the starting state is - /// whatever the previous launch left. Show Inspector is offered in every state but one, the - /// inspector already on screen. - private func showInspector(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 10)) - menuBar.menuBarItems["View"].click() - - let show = menuBar.menuItems["Show Inspector"] - if show.waitToExist(timeout: 5) { - show.click() - return - } - app.typeKey(.escape, modifierFlags: []) - } -} diff --git a/TableProUITests/ValueFilterEditUITests.swift b/TableProUITests/ValueFilterEditUITests.swift index 7345c2fd38..35d2655a26 100644 --- a/TableProUITests/ValueFilterEditUITests.swift +++ b/TableProUITests/ValueFilterEditUITests.swift @@ -1,238 +1,56 @@ -// -// ValueFilterEditUITests.swift -// TableProUITests -// - -import AppKit import XCTest final class ValueFilterEditUITests: UITestCase { - private static let table = "Employee" - private static let filteredColumn = "Title" - private static let keptTitle = "IT Staff" - private static let editedColumnPosition = 2 - private static let titleColumnPosition = 4 - private static let editedValue = "Edited Under Filter" - private static let savedValue = "Saved Under Filter" - - func testDiscardingAnEditMadeUnderAValueFilterRestoresTheEditedRow() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - let grid = openTable(in: window) - - let secondRowBefore = try cellValue(row: 2, in: grid) - let lastRowBefore = try cellValue(row: 8, in: grid) - - openValueFilter(on: Self.filteredColumn, in: grid, app: app) - keepOnly(Self.keptTitle, in: window) - - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.currentCellValue(row: 2, in: grid) == lastRowBefore }, - "Under the filter the table's last row must be shown second" - ) - - editCell(row: 2, in: grid, app: app, to: Self.editedValue) - XCTAssertTrue( - waitForPredicate(timeout: 10) { self.currentCellValue(row: 2, in: grid) == Self.editedValue }, - "The edit must land on the row shown second" - ) - - rightClickHeader(Self.filteredColumn, in: grid) - let clearFilter = contextMenuItem("Clear Value Filter", in: app) - XCTAssertTrue(clearFilter.waitToExist(timeout: 10), "A filtered column's menu must offer Clear Value Filter") - clearFilter.click() - - guard let discard = discardButton(in: app, of: window) else { - XCTFail("Clearing the filter over a pending edit must ask first") - return - } - discard.click() - - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.currentCellValue(row: 8, in: grid) == lastRowBefore }, - "Discard must put back the value of the row that was edited" - ) - XCTAssertEqual( - currentCellValue(row: 2, in: grid), - secondRowBefore, - "Discard must not write the edited row's value into the row that shared its position" - ) - } + private let savedValue = "Saved Under Filter" func testSavingAnEditKeepsTheValueFilter() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - let grid = openTable(in: window) - let lastRowBefore = try cellValue(row: 8, in: grid) - - openValueFilter(on: Self.filteredColumn, in: grid, app: app) - keepOnly(Self.keptTitle, in: window) - XCTAssertTrue( - waitForPredicate(timeout: 20) { self.currentCellValue(row: 2, in: grid) == lastRowBefore }, - "Under the filter the table's last row must be shown second" - ) - - editCell(row: 2, in: grid, app: app, to: Self.savedValue) - XCTAssertTrue( - waitForPredicate(timeout: 10) { self.currentCellValue(row: 2, in: grid) == Self.savedValue }, - "The edit must land on the row shown second" - ) - app.typeKey("s", modifierFlags: .command) - - XCTAssertFalse( - waitForPredicate(timeout: 8) { self.cellElement(row: 3, in: grid).exists }, - "The rows read back after Save must still be filtered to \(Self.keptTitle)" - ) - XCTAssertEqual(currentCellValue(row: 2, in: grid), Self.savedValue, "The saved value must be read back") - } - - func testReturnInTheSearchKeepsOnlyTheValuesItFound() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - let grid = openTable(in: window) - - openValueFilter(on: Self.filteredColumn, in: grid, app: app) - let popover = window.popovers.firstMatch - let search = popover.searchFields["value-filter-search"].firstMatch - XCTAssertTrue(search.waitToExist(timeout: 10), "Filter Values… must open the value filter") - let caption = popover.staticTexts - .matching(NSPredicate(format: "value == %@ OR label == %@", "Values from 8 loaded rows", "Values from 8 loaded rows")) - .firstMatch - XCTAssertTrue( - caption.waitToExist(timeout: 10), - "The caption must be resolved from the String Catalog, not print its inflection markup" - ) - - search.typeText("zzz") - let selectAllResults = popover.checkBoxes - .matching(NSPredicate(format: "title == %@ OR label == %@", "Select All Results", "Select All Results")) - .firstMatch - XCTAssertTrue(selectAllResults.waitToExist(timeout: 10), "A search must scope Select All to its results") - search.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - XCTAssertFalse( - popover.waitForNonExistence(timeout: 3), - "Return must not apply a search that found nothing" - ) + let (launch, grid) = try launchWithFixtureTable("album") + let window = launch.window + let app = launch.app - search.typeKey("a", modifierFlags: .command) - search.typeText("Manager") - search.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - XCTAssertTrue(popover.waitForNonExistence(timeout: 10), "Return must apply the search and close the value filter") - - XCTAssertTrue( - waitForPredicate(timeout: 20) { - self.currentCellValue(row: 3, column: Self.titleColumnPosition, in: grid) == "IT Manager" - }, - "The three managers must be the rows left" - ) - XCTAssertEqual(currentCellValue(row: 1, column: Self.titleColumnPosition, in: grid), "General Manager") - XCTAssertEqual(currentCellValue(row: 2, column: Self.titleColumnPosition, in: grid), "Sales Manager") - XCTAssertTrue( - waitForPredicate(timeout: 10) { - !self.cellElement(row: 4, column: Self.titleColumnPosition, in: grid).exists - }, - "No row that is not a manager may stay" - ) - } - - // MARK: - Helpers - - private func openTable(in window: XCUIElement) -> XCUIElement { - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - let row = objectBrowserRow(Self.table, in: window) - XCTAssertTrue(row.waitToExist(timeout: 30), "The object browser must list \(Self.table)") - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(waitForClickableRows(in: grid, timeout: 30), "\(Self.table) must load its rows") - return grid - } - - private func rightClickHeader(_ column: String, in grid: XCUIElement) { - let header = grid.buttons - .matching(NSPredicate(format: "label BEGINSWITH %@", "Column: \(column)")) - .firstMatch - XCTAssertTrue(header.waitToExist(timeout: 30), "The grid must publish a \(column) header") - point(at: header.frame, in: grid).rightClick() - } - - private func openValueFilter(on column: String, in grid: XCUIElement, app: XCUIApplication) { - rightClickHeader(column, in: grid) + grid.clickHeader("artist_id", rightClick: true) let filterValues = contextMenuItem("Filter Values…", in: app) XCTAssertTrue(filterValues.waitToExist(timeout: 10), "The header menu must offer Filter Values…") filterValues.click() - } - /// `value` must match one title only: Apply keeps every value the search finds. - private func keepOnly(_ value: String, in window: XCUIElement) { let popover = window.popovers.firstMatch let search = popover.searchFields["value-filter-search"].firstMatch XCTAssertTrue(search.waitToExist(timeout: 10), "Filter Values… must open the value filter") - search.typeText(value) - + search.typeText("2") let valueToggle = popover.checkBoxes.matching(identifier: "value-filter-value").firstMatch - XCTAssertTrue(waitUntilHittable(valueToggle, timeout: 10), "The search must leave \(value) in the list") - + XCTAssertTrue(waitUntilHittable(valueToggle, timeout: 10), "The search must leave 2 in the list") let apply = popover.buttons["Apply"].firstMatch XCTAssertTrue(waitUntilHittable(apply, timeout: 10), "The value filter must offer Apply") apply.click() XCTAssertTrue(popover.waitForNonExistence(timeout: 10), "Apply must close the value filter") - } - - /// Asked of the sheet and then of the app's own windows, never of `app.buttons`: the runner - /// publishes the Touch Bar's copy of a button under the application element, and XCUITest - /// refuses to click that one. - private func discardButton(in app: XCUIApplication, of window: XCUIElement) -> XCUIElement? { - let queries = [ - window.sheets.buttons["Discard"], - app.dialogs.buttons["Discard"], - app.windows.buttons["Discard"] - ] - for query in queries { - let button = query.firstMatch - if waitUntilHittable(button, timeout: 5) { return button } - } - return nil - } - - private func editCell(row: Int, in grid: XCUIElement, app: XCUIApplication, to value: String) { - let cell = cellElement(row: row, in: grid) - XCTAssertTrue(cell.waitToExist(timeout: 10), "Row \(row) must publish its cells") - point(at: cell.frame, in: grid).click() - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - app.typeKey("a", modifierFlags: .command) - app.typeText(value) - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - } - private func point(at frame: CGRect, in grid: XCUIElement) -> XCUICoordinate { - let origin = grid.frame.origin - return grid.coordinate(withNormalizedOffset: .zero) - .withOffset(CGVector(dx: frame.midX - origin.x, dy: frame.midY - origin.y)) - } - - private func cellElement( - row: Int, - column: Int = ValueFilterEditUITests.editedColumnPosition, - in grid: XCUIElement - ) -> XCUIElement { - grid.staticTexts - .matching(NSPredicate(format: "label BEGINSWITH %@", "Row \(row), column \(column): ")) - .firstMatch - } + XCTAssertTrue( + waitForPredicate(timeout: 20) { + grid.value(row: 1, column: 2) == "South" && grid.value(row: 2, column: 2) == "East" + && !grid.cell(row: 3, column: 2).exists + }, + "Keeping artist 2 must leave South and East" + ) - private func currentCellValue( - row: Int, - column: Int = ValueFilterEditUITests.editedColumnPosition, - in grid: XCUIElement - ) -> String? { - let cell = cellElement(row: row, column: column, in: grid) - guard cell.exists else { return nil } - return cell.value as? String - } + grid.editCell(row: 2, column: 2, to: savedValue) + XCTAssertTrue(grid.waitForValue(savedValue, row: 2, column: 2, timeout: 10), "The edit must land on East") - private func cellValue(row: Int, in grid: XCUIElement) throws -> String { - _ = waitForPredicate(timeout: 20) { self.currentCellValue(row: row, in: grid) != nil } - return try XCTUnwrap(currentCellValue(row: row, in: grid), "The grid must publish row \(row)'s cells") + // Renamed behind the app's back, so row 1 changes only once the save has read its rows back. + XCTAssertTrue(FixtureFile.execute("UPDATE album SET title = 'Southbound' WHERE id = 2", in: launch.database)) + app.typeShortcut(.save) + XCTAssertTrue( + grid.waitForValue("Southbound", row: 1, column: 2, timeout: 30), + "Save must read the rows back" + ) + XCTAssertFalse( + grid.cell(row: 3, column: 2).exists, + "The rows read back after Save must still be filtered to artist 2" + ) + XCTAssertEqual(grid.value(row: 2, column: 2), savedValue, "The saved value must be read back") + XCTAssertEqual( + FixtureFile.rows("SELECT title FROM album WHERE id = 3", in: launch.database), + [[savedValue]], + "Save must write the edit to the file" + ) } } diff --git a/TableProUITests/VimNormalModeChordUITests.swift b/TableProUITests/VimNormalModeChordUITests.swift index f1e13d02c1..3539f78729 100644 --- a/TableProUITests/VimNormalModeChordUITests.swift +++ b/TableProUITests/VimNormalModeChordUITests.swift @@ -1,126 +1,66 @@ import XCTest final class VimNormalModeChordUITests: UITestCase { - private var appWithVimMode: XCUIApplication? + func testVimModesKeepTheirKeys() throws { + let launch = try launchWithFixture() + let app = launch.app + enableVimMode(in: app) - override func tearDownWithError() throws { - continueAfterFailure = true - if let appWithVimMode { - setVimMode(false, in: appWithVimMode) - } - appWithVimMode = nil - try super.tearDownWithError() - } - - func testNormalModeChordsNeverTypeIntoTheQuery() throws { - let app = try launchWithSampleDatabase() - enableVimModeUntilTearDown(in: app) - let editor = openQueryTab(in: app) + app.typeShortcut(.newTab) + let editor = editorTextView(in: launch.window) + XCTAssertTrue(editor.waitToExist(timeout: 10), "Cmd+T must open a query tab") + XCTAssertTrue(waitForEditorText(editor) { $0.isEmpty }, "A new tab starts with an empty editor") app.typeText("iSELECT 1") - app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + app.typeKey(.escape, modifierFlags: []) app.typeKey("h", modifierFlags: .option) app.typeKey(" ", modifierFlags: .option) app.typeKey("d", modifierFlags: .control) app.typeText("A AS a") + expectText("SELECT 1 AS a", in: editor, "Option+H, Option+Space and Ctrl+D must not edit the query in Normal mode") - XCTAssertTrue( - waitForValue("SELECT 1 AS a", in: editor), - "Option+H, Option+Space and Ctrl+D must not edit the query in Normal mode; editor holds " - + "'\(debugValue(of: editor))'" - ) - } - - func testInsertModeControlHDeletesTheSelection() throws { - let app = try launchWithSampleDatabase() - enableVimModeUntilTearDown(in: app) - let editor = openQueryTab(in: app) - - app.typeText("i1 + 23") - app.typeKey(XCUIKeyboardKey.leftArrow, modifierFlags: .shift) - app.typeKey(XCUIKeyboardKey.leftArrow, modifierFlags: .shift) - app.typeKey("h", modifierFlags: .control) - - XCTAssertTrue( - waitForValue("1 + ", in: editor), - "Ctrl+H in Insert mode must delete the selection, as Delete does; editor holds " - + "'\(debugValue(of: editor))'" - ) - } - - /// #2914: the editor's own Escape and Vim's Escape were two event monitors claiming one key - /// with no defined order, so Escape stopped leaving Insert mode while Ctrl+[ went on working. - /// Each round appends a character and deletes it again, which nets to nothing only while every - /// Escape lands. An Escape that does not leaves the round's Normal-mode keys in the query. - func testEscapeLeavesInsertModeEveryTime() throws { - let app = try launchWithSampleDatabase() - enableVimModeUntilTearDown(in: app) - let editor = openQueryTab(in: app) - - app.typeText("iSELECT") - app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + // #2914: the editor's Escape and Vim's Escape were two monitors on one key with no defined order. + // Each round appends a character and deletes it, which nets to nothing only while every Escape lands. + app.typeKey(.escape, modifierFlags: []) for _ in 1 ... 5 { - app.typeText("A") - app.typeText("X") - app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + app.typeText("AX") + app.typeKey(.escape, modifierFlags: []) app.typeText("x") } + expectText("SELECT 1 AS a", in: editor, "Every Escape must leave Insert mode") - XCTAssertTrue( - waitForValue("SELECT", in: editor), - "Every Escape must leave Insert mode, so each round's appended character is deleted " - + "again; editor holds '\(debugValue(of: editor))'" - ) - } - - private func enableVimModeUntilTearDown(in app: XCUIApplication) { - appWithVimMode = app - setVimMode(true, in: app) + app.typeText("A + 23") + app.typeKey(.leftArrow, modifierFlags: .shift) + app.typeKey(.leftArrow, modifierFlags: .shift) + app.typeKey("h", modifierFlags: .control) + expectText("SELECT 1 AS a + ", in: editor, "Ctrl+H in Insert mode must delete the selection, as Delete does") } - private func setVimMode(_ enabled: Bool, in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 20), "The app must publish its menu bar") - menuBar.menuItems["Settings…"].click() - - let settings = app.children(matching: .window).matching(identifier: "settings").firstMatch - XCTAssertTrue(settings.waitToExist(timeout: 10), "Settings must open") + private func enableVimMode(in app: XCUIApplication) { + app.typeShortcut(.settings) + let settings = window("settings", in: app) + XCTAssertTrue(settings.waitToExist(timeout: 10), "Cmd+, must open Settings") let editorPane = settings.toolbars.buttons["Editor"] XCTAssertTrue(editorPane.waitToExist(timeout: 10)) editorPane.click() - let vimToggle = settings.descendants(matching: .any).matching(identifier: "vim-mode-toggle").firstMatch - XCTAssertTrue( - scrollUntilHittable(vimToggle, in: settings, timeout: 30), - "The Editor pane must offer Vim mode" - ) - if isOn(vimToggle) != enabled { - vimToggle.click() - } - XCTAssertTrue( - waitForPredicate(timeout: 5) { self.isOn(vimToggle) == enabled }, - "Vim mode must be \(enabled ? "on" : "off") after the click" - ) - - app.typeKey("w", modifierFlags: .command) - XCTAssertTrue(waitForPredicate(timeout: 10) { !settings.exists }, "Settings must close") - } + let toggle = settings.descendants(matching: .any).matching(identifier: "vim-mode-toggle").firstMatch + XCTAssertTrue(scrollUntilHittable(toggle, in: settings, timeout: 30), "The Editor pane must offer Vim mode") + XCTAssertFalse(isOn(toggle), "A new sandbox starts with Vim mode off") + toggle.click() + XCTAssertTrue(waitForPredicate(timeout: 5) { self.isOn(toggle) }, "Vim mode must be on after the click") - private func openQueryTab(in app: XCUIApplication) -> XCUIElement { - app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - XCTAssertTrue(waitForValue("", in: editor), "A new tab starts with an empty editor") - return editor + settings.buttons[XCUIIdentifierCloseWindow].click() + XCTAssertTrue(settings.waitForNonExistence(timeout: 10), "Settings must close") } - private func waitForValue(_ expected: String, in element: XCUIElement) -> Bool { - waitForPredicate(timeout: 10) { (element.value as? String) == expected } + // Control characters are spelled out, so a stray Option+Space shows as rather than a space. + private func expectText(_ expected: String, in editor: XCUIElement, _ message: String) { + XCTAssertTrue(waitForEditorText(editor) { $0 == expected }, "\(message); editor holds '\(visible(editorText(editor)))'") } - private func debugValue(of element: XCUIElement) -> String { - let value = (element.value as? String) ?? "nil" - return value.unicodeScalars.map { scalar in + private func visible(_ text: String) -> String { + text.unicodeScalars.map { scalar in scalar.isASCII && scalar.value >= 0x20 ? String(scalar) : String(format: "", scalar.value) }.joined() } diff --git a/TableProUITests/WarmTabSwitchUITests.swift b/TableProUITests/WarmTabSwitchUITests.swift index 64791e525b..c58d0a9733 100644 --- a/TableProUITests/WarmTabSwitchUITests.swift +++ b/TableProUITests/WarmTabSwitchUITests.swift @@ -1,89 +1,49 @@ -// -// WarmTabSwitchUITests.swift -// TableProUITests -// - -import AppKit import XCTest -/// Issue #2424. A warm switch between two loaded table tabs used to rebuild the whole grid and -/// re-format every loaded cell. The formatted text is now kept per tab and the switch no longer -/// reports a content change, so the guard here is that a tab still comes back with its own rows -/// rather than blank or holding the other tab's. +// #2424: a warm switch now keeps each tab's formatted cells, so a tab has to come back with its own +// rows rather than blank or holding the other tab's. final class WarmTabSwitchUITests: UITestCase { - func testAlternatingBetweenTwoLoadedTableTabsKeepsEachTabsOwnRows() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - XCTAssertTrue(window.waitToExist(timeout: 30)) - - openKeptTab("Album", in: window) - let albumReadout = try settledReadout(in: window, describing: "Album") - - openKeptTab("Artist", in: window) - let artistReadout = try settledReadout(in: window, describing: "Artist") - - XCTAssertNotEqual( - albumReadout, artistReadout, - "The two tables must differ in what their status bar reports, or the test cannot tell them apart" - ) + private struct LoadedTable { + let title: String + let ownCell: String + let otherCell: String + let rowCount: String + } - for _ in 0..<3 { - selectTab(titled: "Album", in: window) - XCTAssertTrue( - waitForPredicate(timeout: 15) { readout(in: window) == albumReadout }, - "Returning to Album must show Album's rows again, not a blank grid. Read: \(readout(in: window) ?? "nil")" - ) + private static let album = LoadedTable(title: "album", ownCell: "North", otherCell: "Ada", rowCount: "6 rows") + private static let artist = LoadedTable(title: "artist", ownCell: "Ada", otherCell: "North", rowCount: "5 rows") - selectTab(titled: "Artist", in: window) - XCTAssertTrue( - waitForPredicate(timeout: 15) { readout(in: window) == artistReadout }, - "Returning to Artist must show Artist's rows again. Read: \(readout(in: window) ?? "nil")" - ) + func testAlternatingBetweenTwoLoadedTableTabsKeepsEachTabsOwnRows() throws { + let launch = try launchWithFixture([ConnectionSeed(tabs: [.table("album"), .table("artist")])]) + let window = launch.window + + XCTAssertTrue(waitForRows(of: Self.album, in: window), "album must load before the switching begins") + selectEditorTab(titled: Self.artist.title, in: window) + XCTAssertTrue(waitForRows(of: Self.artist, in: window), "artist must load before the switching begins") + + for _ in 0 ..< 3 { + for table in [Self.album, Self.artist] { + selectEditorTab(titled: table.title, in: window) + XCTAssertTrue( + waitForRows(of: table, in: window), + "Returning to \(table.title) must show its own rows; the bar reads \(readout(in: window) ?? "nothing")" + ) + } } } - private func openKeptTab(_ table: String, in window: XCUIElement) { - let row = objectBrowserRow(table, in: window) - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(table)") - row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) - } - - private func selectTab(titled title: String, in window: XCUIElement) { - let tab = window.descendants(matching: .any) - .matching(identifier: "editor-tab") - .containing(NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", title, title)) - .firstMatch - XCTAssertTrue(tab.waitToExist(timeout: 15), "The editor tab strip must carry a tab for \(title)") - clickAtCenter(tab) - Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) + private func waitForRows(of table: LoadedTable, in window: XCUIElement) -> Bool { + let grid = dataGrid(in: window) + return waitForPredicate(timeout: 15) { + gridCell(showing: table.ownCell, in: grid).exists + && !gridCell(showing: table.otherCell, in: grid).exists + && (readout(in: window)?.contains(table.rowCount) ?? false) + } } private func readout(in window: XCUIElement) -> String? { let element = window.staticTexts["result-status-readout"].firstMatch guard element.exists else { return nil } - let text = (element.value as? String) ?? element.label - return text.isEmpty ? nil : text - } - - /// The readout is filled in stages as a table loads, and the polling is tight enough that an - /// intermediate value can look stable across two of them. Settling is measured in wall clock - /// instead: the same string has to survive a full second of samples. - private func settledReadout(in window: XCUIElement, describing table: String) throws -> String { - var previous: String? - var stableSince = Date() - XCTAssertTrue( - waitForPredicate(timeout: 30) { - let current = readout(in: window) - if current != previous { - previous = current - stableSince = Date() - return false - } - return current != nil && Date().timeIntervalSince(stableSince) >= 1 - }, - "\(table) must finish loading before the switch test begins" - ) - return try XCTUnwrap(readout(in: window)) + return (element.value as? String) ?? element.label } } diff --git a/TableProUITests/WelcomeConnectionListUITests.swift b/TableProUITests/WelcomeConnectionListUITests.swift index 2fb9b2d85c..7509a4fabc 100644 --- a/TableProUITests/WelcomeConnectionListUITests.swift +++ b/TableProUITests/WelcomeConnectionListUITests.swift @@ -3,191 +3,44 @@ import XCTest final class WelcomeConnectionListUITests: UITestCase { private let seededNames = ["delta-local", "alpha-staging", "charlie-prod"] - func testSeededConnectionsAreListed() throws { - let (_, list) = try launchWithSeededConnections() - + func testTheConnectionListShowsFavoritesAndCancelsARename() throws { + try seedSQLiteSession(seededNames.map { ConnectionSeed(name: $0, tabs: [], isOpen: false) }) + let app = try launchApp() + let welcome = welcomeWindow(in: app) + XCTAssertTrue(welcome.waitToExist(timeout: 15)) + let list = welcomeConnectionList(in: welcome) + XCTAssertTrue(list.waitToExist(timeout: 15), "The welcome window must show the connection list") for name in seededNames { - XCTAssertTrue(row(named: name, in: list).waitToExist(timeout: 10), "\(name) must be listed") + XCTAssertTrue(welcomeConnectionRow(named: name, in: list).waitToExist(timeout: 10), "\(name) must be listed") } - } - - func testShowRecentConnectionsSettingHidesAndRestoresTheSection() throws { - let app = try launchWithSampleDatabase() - let menuBar = app.menuBars.firstMatch - let fileMenu = menuBar.menuBarItems["File"] - fileMenu.click() - fileMenu.menus.menuItems["Manage Connections"].click() - - let welcome = app.children(matching: .window).matching(identifier: "welcome").firstMatch - XCTAssertTrue(welcome.waitToExist(timeout: 10)) - let list = welcome.outlines["welcome-connection-list"] - XCTAssertTrue(list.waitToExist(timeout: 10)) - XCTAssertTrue( - waitForPredicate(timeout: 10) { self.rows(named: "Chinook (Sample)", in: list).count == 2 }, - "The opened sample must appear in Recent and Connections" - ) - - menuBar.menuItems["Settings…"].click() - let settings = app.children(matching: .window).matching(identifier: "settings").firstMatch - XCTAssertTrue(settings.waitToExist(timeout: 10), "Settings must open") - let generalPaneButton = settings.toolbars.buttons["General"] - XCTAssertTrue(generalPaneButton.waitToExist(timeout: 10)) - generalPaneButton.click() - - let toggle = settings.descendants(matching: .any) - .matching(identifier: "show-recent-connections-toggle").firstMatch - XCTAssertTrue(toggle.waitToExist(timeout: 10), "The General pane must offer the setting") - XCTAssertTrue(isOn(toggle), "Recent connections must be shown by default") - - toggle.click() - XCTAssertTrue( - waitForPredicate(timeout: 10) { self.rows(named: "Chinook (Sample)", in: list).count == 1 }, - "Turning the setting off must remove only the Recent copy" - ) - toggle.click() + let favorite = welcomeConnectionRow(named: "charlie-prod", in: list) + XCTAssertTrue(waitUntilHittable(favorite, timeout: 10)) + favorite.rightClick() + let addToFavorites = contextMenuItem("Add to Favorites", in: app) + XCTAssertTrue(waitUntilHittable(addToFavorites, timeout: 5)) + addToFavorites.click() XCTAssertTrue( - waitForPredicate(timeout: 10) { self.rows(named: "Chinook (Sample)", in: list).count == 2 }, - "Turning the setting back on must restore the retained Recent entry" - ) - - let clear = settings.descendants(matching: .any) - .matching(identifier: "clear-recent-connections-button").firstMatch - XCTAssertTrue(clear.waitToExist(timeout: 10), "The General pane must offer Clear Recent") - clear.click() - XCTAssertTrue( - waitForPredicate(timeout: 10) { self.rows(named: "Chinook (Sample)", in: list).count == 1 }, - "Clear Recent in Settings must empty the Recent section" - ) - XCTAssertTrue(waitForPredicate(timeout: 5) { !clear.isEnabled }, "An empty history leaves nothing to clear") - } - - func testAddToFavoritesListsTheConnectionUnderFavoritesAndInPlace() throws { - let (app, list) = try launchWithSeededConnections() - let target = row(named: "charlie-prod", in: list) - XCTAssertTrue(waitUntilHittable(target, timeout: 10)) - - target.rightClick() - let favorite = contextMenuItem("Add to Favorites", in: app) - XCTAssertTrue(waitUntilHittable(favorite, timeout: 5)) - favorite.click() - - XCTAssertTrue( - waitForPredicate(timeout: 10) { rows(named: "charlie-prod", in: list).count == 2 }, + waitForPredicate(timeout: 10) { self.welcomeConnectionRows(named: "charlie-prod", in: list).count == 2 }, "A favorite must show in Favorites and still in its place" ) - } - func testEscapeCancelsARename() throws { - let (app, list) = try launchWithSeededConnections() - let target = row(named: "alpha-staging", in: list) - XCTAssertTrue(waitUntilHittable(target, timeout: 10)) - - target.rightClick() + let renamed = welcomeConnectionRow(named: "alpha-staging", in: list) + XCTAssertTrue(waitUntilHittable(renamed, timeout: 10)) + renamed.rightClick() let rename = contextMenuItem("Rename", in: app) XCTAssertTrue(waitUntilHittable(rename, timeout: 5)) rename.click() - let field = list.textFields["welcome-rename-field"] XCTAssertTrue(field.waitToExist(timeout: 5), "Rename must put the name in an editable field") app.typeText("renamed-by-test") app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(field.waitForNonExistence(timeout: 5), "Escape must end editing") - XCTAssertTrue(row(named: "alpha-staging", in: list).waitToExist(timeout: 5)) - XCTAssertFalse(row(named: "renamed-by-test", in: list).exists, "Escape must keep the old name") - } - - func testSortByNameReordersTheList() throws { - let (app, list) = try launchWithSeededConnections() - XCTAssertTrue(row(named: "charlie-prod", in: list).waitToExist(timeout: 10)) - XCTAssertEqual(verticalOrder(of: seededNames, in: list), seededNames, "Manual order must follow the ranks") - - let viewMenu = app.menuBars.menuBarItems["View"] - viewMenu.click() - let sortMenu = viewMenu.menus.menuItems["Sort Connections By"] - sortMenu.hover() - sortMenu.menus.menuItems["Name"].click() - - let sorted = seededNames.sorted() - XCTAssertTrue( - waitForPredicate(timeout: 10) { verticalOrder(of: seededNames, in: list) == sorted }, - "Sort by Name must reorder the list alphabetically" - ) - } - - func testNewGroupFromTheFileMenuOpensTheSheet() throws { - let (app, list) = try launchWithSeededConnections() - XCTAssertTrue(row(named: "delta-local", in: list).waitToExist(timeout: 10)) - - let fileMenu = app.menuBars.menuBarItems["File"] - fileMenu.click() - fileMenu.menus.menuItems["New Group…"].click() - - let sheet = app.windows["welcome"].sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "File > New Group must open the new group sheet") - XCTAssertTrue(sheet.textFields.firstMatch.waitToExist(timeout: 5), "The sheet must ask for a name") - } - - // MARK: - Fixture - - private func launchWithSeededConnections() throws -> (XCUIApplication, XCUIElement) { - try seedConnections() - let app = try launchApp() - let welcome = app.windows["welcome"] - XCTAssertTrue(welcome.waitToExist(timeout: 15)) - let list = welcome.outlines["welcome-connection-list"] - XCTAssertTrue(list.waitToExist(timeout: 15), "The welcome window must show the connection list") - return (app, list) - } - - private func rows(named name: String, in list: XCUIElement) -> XCUIElementQuery { - list.outlineRows.containing( - NSPredicate( - format: "label BEGINSWITH %@ OR (elementType == %lu AND value BEGINSWITH %@)", - name, - XCUIElement.ElementType.staticText.rawValue, - name - ) + XCTAssertTrue(waitForPredicate(timeout: 5) { !field.exists }, "Escape must end editing") + XCTAssertTrue(welcomeConnectionRow(named: "alpha-staging", in: list).waitToExist(timeout: 5)) + XCTAssertFalse( + welcomeConnectionRow(named: "renamed-by-test", in: list).exists, + "Escape must keep the old name" ) } - - private func row(named name: String, in list: XCUIElement) -> XCUIElement { - rows(named: name, in: list).firstMatch - } - - private func verticalOrder(of names: [String], in list: XCUIElement) -> [String] { - names - .map { ($0, row(named: $0, in: list).frame.minY) } - .sorted { $0.1 < $1.1 } - .map(\.0) - } - - private func seedConnections() throws { - let root = try XCTUnwrap(sandboxRoot, "setUpWithError did not prepare a sandbox") - let supportDirectory = root.appendingPathComponent("TablePro", isDirectory: true) - try FileManager.default.createDirectory(at: supportDirectory, withIntermediateDirectories: true) - - let connections = seededNames.enumerated().map { connectionPayload(name: $0.element, sortOrder: $0.offset) } - try JSONSerialization.data(withJSONObject: connections, options: [.sortedKeys]) - .write(to: supportDirectory.appendingPathComponent("connections.json"), options: .atomic) - } - - private func connectionPayload(name: String, sortOrder: Int) -> [String: Any] { - [ - "id": UUID().uuidString, - "name": name, - "host": "127.0.0.1", - "port": 3_306, - "database": "app", - "username": "root", - "type": "MySQL", - "sshEnabled": false, - "sshHost": "", - "sshUsername": "", - "sshAuthMethod": "password", - "sshPrivateKeyPath": "", - "sortOrder": sortOrder, - ] - } } diff --git a/TableProUITests/WelcomeWindowUITests.swift b/TableProUITests/WelcomeWindowUITests.swift index 0f6d7a854e..150b02336d 100644 --- a/TableProUITests/WelcomeWindowUITests.swift +++ b/TableProUITests/WelcomeWindowUITests.swift @@ -1,9 +1,9 @@ import XCTest final class WelcomeWindowUITests: UITestCase { - func testAFreshLaunchOpensOnTheWelcomeWindowWithNoTour() throws { + func testALaunchOpensOneWelcomeWindowAsAnOrdinaryApp() throws { let app = try launchApp() - let welcome = app.windows["welcome"] + let welcome = welcomeWindow(in: app) XCTAssertTrue(welcome.waitToExist(timeout: 10)) XCTAssertTrue( @@ -11,11 +11,18 @@ final class WelcomeWindowUITests: UITestCase { "An empty store must offer the sample database where the list would be" ) XCTAssertFalse(welcome.links["Skip"].exists, "The first launch must not open on a tour") + XCTAssertEqual( + app.children(matching: .window).count, + 1, + "Launch must open the welcome window alone, not one window per restored connection" + ) + XCTAssertFalse(window("support", in: app).exists, "Launching must never present the support screen") + XCTAssertTrue(waitForActivationPolicy(.regular), "A launch the person asked for is an ordinary app") } func testAFirstLaunchShowsTheWelcomeSheetUntilContinue() throws { let app = try launchApp(environment: ["TABLEPRO_UI_TEST_SHOW_WELCOME_SHEET": "1"]) - let welcome = app.windows["welcome"] + let welcome = welcomeWindow(in: app) XCTAssertTrue(welcome.waitToExist(timeout: 10)) let continueButton = welcome.descendants(matching: .button)["welcome-sheet-continue"] @@ -27,36 +34,26 @@ final class WelcomeWindowUITests: UITestCase { XCTAssertTrue(welcome.buttons["Open Sample Database"].waitToExist(timeout: 5)) } - func testHelpMenuShowsTheWelcomeSheetAgain() throws { - let app = try launchApp() - let welcome = app.windows["welcome"] - XCTAssertTrue(welcome.waitToExist(timeout: 10)) - XCTAssertFalse(welcome.descendants(matching: .button)["welcome-sheet-continue"].exists) - - let item = app.menuBars.menuItems["Getting Started"] - XCTAssertTrue(item.waitToExist(timeout: 10), "Help must offer the welcome sheet") - item.click() - - XCTAssertTrue( - welcome.descendants(matching: .button)["welcome-sheet-continue"].waitToExist(timeout: 10), - "Help > Getting Started must show the sheet again" - ) - } - + // The one flow left on the bundled Chinook sample: opening it is what is under test. func testOpenSampleDatabaseFromTheEmptyListOpensTheSample() throws { let app = try launchApp() - let open = app.windows["welcome"].buttons["Open Sample Database"] + let welcome = welcomeWindow(in: app) + let open = welcome.buttons["Open Sample Database"] XCTAssertTrue(open.waitToExist(timeout: 10)) XCTAssertTrue(waitUntilHittable(open, timeout: 5)) - open.click() - XCTAssertTrue(waitForSampleDatabaseWindow(in: app), "Open Sample Database must open the sample") + let window = mainWindow(in: app) + XCTAssertTrue(waitForObjectBrowser(in: window), "Open Sample Database must open a connected window") + XCTAssertTrue(objectBrowserRow("Track", in: window).waitToExist(timeout: 10), "The sidebar must list Chinook's tables") + XCTAssertTrue(waitForClickableRows(in: dataGrid(in: window)), "The sample must open Track with its rows") + XCTAssertTrue(waitForPredicate(timeout: 10) { window.title == "Track" }, "The window must name the open table") + XCTAssertFalse(welcome.exists, "Opening the sample takes the place of the welcome window") } func testTheVersionSettingHidesTheVersionAndTheUpdateCheckOnTheOpenWindow() throws { - let app = try launchApp(arguments: ["-AppleLanguages", "(en)"]) - let welcome = app.children(matching: .window).matching(identifier: "welcome").firstMatch + let app = try launchApp() + let welcome = welcomeWindow(in: app) XCTAssertTrue(welcome.waitToExist(timeout: 10)) let versionLine = welcome.descendants(matching: .any).matching(identifier: "welcome-version-line").firstMatch @@ -65,9 +62,9 @@ final class WelcomeWindowUITests: UITestCase { XCTAssertTrue(versionLine.waitToExist(timeout: 10), "The welcome window must show the version by default") XCTAssertTrue(updateCheck.exists, "The welcome window must offer the update check by default") - app.menuBars.menuItems["Settings…"].click() - let settings = app.children(matching: .window).matching(identifier: "settings").firstMatch - XCTAssertTrue(settings.waitToExist(timeout: 10), "Settings must open") + app.typeShortcut(.settings) + let settings = window("settings", in: app) + XCTAssertTrue(settings.waitToExist(timeout: 10), "Command-comma must open Settings") let generalPaneButton = settings.toolbars.buttons["General"] XCTAssertTrue(generalPaneButton.waitToExist(timeout: 10)) generalPaneButton.click() diff --git a/TableProUITests/WindowExecutionIndicatorUITests.swift b/TableProUITests/WindowExecutionIndicatorUITests.swift deleted file mode 100644 index a73278d881..0000000000 --- a/TableProUITests/WindowExecutionIndicatorUITests.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// WindowExecutionIndicatorUITests.swift -// TableProUITests -// - -import XCTest - -/// #2342: opening a SQLite database and clicking a table left the window on "Executing…" with a -/// live Stop control and no rows, and pressing Stop was the only way out. -final class WindowExecutionIndicatorUITests: UITestCase { - private func executionIndicator(in window: XCUIElement) -> XCUIElement { - window.activityIndicators["execution-indicator"].firstMatch - } - - private func executionStop(in window: XCUIElement) -> XCUIElement { - window.buttons["execution-stop"].firstMatch - } - - func testTheExecutingIndicatorClearsOnceEachTableHasLoaded() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - for table in ["Album", "Artist", "Genre"] { - let row = objectBrowserRow(table, in: window) - XCTAssertTrue(row.waitToExist(timeout: 15), "The object browser must list \(table)") - XCTAssertTrue(waitUntilHittable(row, timeout: 15), "\(table)'s row must settle before it is clicked") - clickAtCenter(row) - - let readout = window.staticTexts["result-status-readout"].firstMatch - XCTAssertTrue(readout.waitToExist(timeout: 20), "\(table): the result must land") - - let settled = waitForPredicate(timeout: 15) { - !executionIndicator(in: window).exists - && !executionStop(in: window).exists - } - XCTAssertTrue(settled, "\(table): the status bar still reports a query that has already finished") - } - } - - func testTheExecutingIndicatorAppearsWhileAQueryRunsAndClearsAfterIt() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - - app.typeKey("t", modifierFlags: .command) - typeQuery( - "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < 20000000) SELECT count(*) FROM c;", - in: app - ) - app.typeKey(.return, modifierFlags: .command) - - XCTAssertTrue( - executionStop(in: window).waitToExist(timeout: 15), - "A running query must offer Stop in the status bar" - ) - XCTAssertTrue( - executionIndicator(in: window).exists, - "The status bar must report a query that is running" - ) - - let settled = waitForPredicate(timeout: 90) { - !executionIndicator(in: window).exists && !executionStop(in: window).exists - } - XCTAssertTrue(settled, "The status bar must go back to idle once the query has finished") - } -} diff --git a/TableProUITests/WindowFocusUITests.swift b/TableProUITests/WindowFocusUITests.swift index c6c84f9bb5..d9faca3da6 100644 --- a/TableProUITests/WindowFocusUITests.swift +++ b/TableProUITests/WindowFocusUITests.swift @@ -1,114 +1,52 @@ import XCTest -/// A keyboard-only user has to be able to leave the sidebar. -/// -/// The window worked its key view loop out once, before SwiftUI had built the panes, so the loop -/// covered the sidebar and nothing else: the SQL editor and the data grid were never Tab stops, and -/// there was no command that named a pane either. #2904 reported the half of that a user notices. -/// -/// Every command here is taken from the menu bar rather than by key equivalent. A menu item carries -/// no geometry, so it is reachable whatever the window is doing, and it exercises the validation -/// arm at the same time. Nothing probes for an item first: XCUITest resolves a menu item by opening -/// its parent, and a probe that resolves it leaves that menu open, so the click's own traversal then -/// fails with "open menu during menu traversal". +// #2904: the key view loop was worked out before SwiftUI built the panes, so Tab never left the sidebar. final class WindowFocusUITests: UITestCase { - private func chooseFocusCommand(_ title: String, in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - XCTAssertTrue(menuBar.waitToExist(timeout: 20), "The app must publish its menu bar") - menuBar.menuItems[title].click() - } - - private func tabUntil(in app: XCUIApplication, limit: Int, _ reached: () -> Bool) -> Bool { - for _ in 0 ..< limit { - app.typeKey(XCUIKeyboardKey.tab, modifierFlags: []) - if reached() { return true } - } - return false - } - - func testFocusObjectListPutsTheKeyboardOnTheSidebarList() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let objectList = window.outlines.firstMatch - XCTAssertTrue(objectList.waitToExist(timeout: 30), "A connected window lists its objects") - - chooseFocusCommand("Focus Object List", in: app) - + func testTabWalksTheWindowAndFocusCommandsJumpBack() throws { + let launch = try launchWithFixture() + let app = launch.app + let window = launch.window + let objectList = objectBrowser(in: window) + let filterField = window.searchFields["sidebar-filter"] + + let album = objectBrowserRow("album", in: window) + XCTAssertTrue(album.waitToExist(timeout: 20), "The object browser must list album") + clickAtCenter(album) + let grid = dataGrid(in: window) + XCTAssertTrue(waitForClickableRows(in: grid), "The table has to open a data grid to tab into") + + app.typeShortcut(.focusSidebarFilter) XCTAssertTrue( - waitForPredicate(timeout: 10) { holdsKeyboardFocus(objectList) }, - "Focus Object List must put the keyboard on the list, not merely reveal it" + waitForPredicate(timeout: 10) { self.holdsKeyboardFocus(filterField) }, + "Focus Sidebar Filter must put the keyboard in the filter field" ) - } - - func testFocusSidebarFilterTakesTheKeyboardBack() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let objectList = window.outlines.firstMatch - let filterField = app.searchFields["sidebar-filter"] - XCTAssertTrue(objectList.waitToExist(timeout: 30)) - - chooseFocusCommand("Focus Object List", in: app) - XCTAssertTrue(waitForPredicate(timeout: 10) { holdsKeyboardFocus(objectList) }) - - chooseFocusCommand("Focus Sidebar Filter", in: app) - + // Not a fixed count: with Keyboard Navigation on, the view options button is a stop before the list. XCTAssertTrue( - waitForPredicate(timeout: 10) { holdsKeyboardFocus(filterField) }, - "The pair has to work in both directions or the keyboard is trapped in the list" + tabUntil(in: app, limit: 6) { self.holdsKeyboardFocus(objectList) }, + "Tab out of the filter field has to reach the list below it" ) - } - - /// The reported symptom. Tab is the macOS mechanism for moving focus inside a window, and it has - /// to reach the list without any command at all. - func testTabMovesFromTheFilterFieldIntoTheObjectList() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let objectList = window.outlines.firstMatch - let filterField = app.searchFields["sidebar-filter"] - XCTAssertTrue(objectList.waitToExist(timeout: 30)) - - chooseFocusCommand("Focus Sidebar Filter", in: app) - XCTAssertTrue(waitForPredicate(timeout: 10) { holdsKeyboardFocus(filterField) }) - - /// Not one Tab. With the system's Keyboard Navigation on, the sidebar's view options button - /// is a legitimate stop between the field and the list, so a fixed count asserts the - /// reader's System Settings rather than the key view loop. XCTAssertTrue( - tabUntil(in: app, limit: 6) { holdsKeyboardFocus(objectList) }, - "Tab out of the filter field has to reach the list below it" + tabUntil(in: app, limit: 12) { self.holdsKeyboardFocus(grid) }, + "Tab never left the sidebar, so the detail panes are not in the key view loop" ) - } - - /// The part of the loop that was missing entirely: nothing outside the sidebar was ever a Tab - /// stop, so this fails on a window whose loop is worked out once and never refreshed. - /// - /// It names the grid rather than excluding the sidebar's own controls. An exclusion passes the - /// moment focus reaches any control the test did not think to list, and the sidebar has one more - /// than it looks: the view options button beside the filter field. - func testTabReachesTheDataGridOutsideTheSidebar() throws { - let app = try launchWithSampleDatabase() - let window = app.windows.firstMatch - let objectList = window.outlines.firstMatch - let filterField = app.searchFields["sidebar-filter"] - XCTAssertTrue(objectList.waitToExist(timeout: 30)) - - openFirstTable(in: app, window: window, objectList: objectList) - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The table has to open a data grid to tab into") - - chooseFocusCommand("Focus Sidebar Filter", in: app) - XCTAssertTrue(waitForPredicate(timeout: 10) { holdsKeyboardFocus(filterField) }) + app.typeShortcut(.focusObjectList) XCTAssertTrue( - tabUntil(in: app, limit: 12) { holdsKeyboardFocus(grid) }, - "Tab never left the sidebar, so the detail panes are not in the key view loop" + waitForPredicate(timeout: 10) { self.holdsKeyboardFocus(objectList) }, + "Focus Object List must put the keyboard on the list, not merely reveal it" + ) + app.typeShortcut(.focusSidebarFilter) + XCTAssertTrue( + waitForPredicate(timeout: 10) { self.holdsKeyboardFocus(filterField) }, + "The pair has to work in both directions or the keyboard is trapped in the list" ) } - private func openFirstTable(in app: XCUIApplication, window: XCUIElement, objectList: XCUIElement) { - chooseFocusCommand("Focus Object List", in: app) - XCTAssertTrue(waitForPredicate(timeout: 10) { holdsKeyboardFocus(objectList) }) - app.typeKey(XCUIKeyboardKey.downArrow, modifierFlags: []) - app.typeKey(XCUIKeyboardKey.return, modifierFlags: []) + private func tabUntil(in app: XCUIApplication, limit: Int, _ reached: () -> Bool) -> Bool { + for _ in 0 ..< limit { + app.typeKey(.tab, modifierFlags: []) + if reached() { return true } + } + return false } } diff --git a/project.yml b/project.yml index eb806f0ff1..ac55a6f41e 100644 --- a/project.yml +++ b/project.yml @@ -439,28 +439,6 @@ targets: - Plugins/Shared/PluginURLSessionTaskBox.swift # Plugin logic under test. Plugin bundles load at runtime, so the test bundle # compiles the pure-logic files its suites cover. - - Plugins/BeancountDriverPlugin/BeancountIncludeResolver.swift - - Plugins/BeancountDriverPlugin/BeancountDirectiveDetails.swift - - Plugins/BeancountDriverPlugin/BeancountDirectiveProjection.swift - - Plugins/BeancountDriverPlugin/BeancountSourceScanner.swift - - Plugins/BeancountDriverPlugin/BeancountConnectRuntime.swift - - Plugins/BeancountDriverPlugin/BeancountPluginDriver+Backend.swift - - Plugins/BeancountDriverPlugin/BeancountPluginDriver+Projection.swift - - Plugins/BeancountDriverPlugin/BeancountPluginDriver+PythonScript.swift - - Plugins/BeancountDriverPlugin/BeancountPluginDriver+RledgerOutput.swift - - Plugins/BeancountDriverPlugin/BeancountPluginDriver.swift - - Plugins/BigQueryDriverPlugin/BigQueryConnection.swift - - Plugins/BigQueryDriverPlugin/BigQueryCredentialFactory.swift - - Plugins/BigQueryDriverPlugin/BigQueryError.swift - - Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Execution.swift - - Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+QueryBuilding.swift - - Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Rename.swift - - Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Routines.swift - - Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift - - Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift - - Plugins/BigQueryDriverPlugin/BigQueryQueryParameters.swift - - Plugins/BigQueryDriverPlugin/BigQueryStatementGenerator.swift - - Plugins/BigQueryDriverPlugin/BigQueryTypeMapper.swift - Plugins/CSVExportPlugin/CSVEncodingReport.swift - Plugins/CSVExportPlugin/CSVExportModels.swift - Plugins/CSVExportPlugin/CSVExportOptionsView.swift @@ -481,113 +459,16 @@ targets: - Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift - Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+TableOperations.swift - Plugins/ClickHouseDriverPlugin/ClickHouseTableOperations.swift - - Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLMetadata.swift - - Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Query.swift - - Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver+Schema.swift - - Plugins/CloudflareR2SQLDriverPlugin/CloudflareR2SQLPluginDriver.swift - - Plugins/CassandraDriverPlugin/CassandraBoundValue.swift - - Plugins/CassandraDriverPlugin/CassandraBrowseStatement.swift - - Plugins/CassandraDriverPlugin/CassandraCellText.swift - - Plugins/CassandraDriverPlugin/CassandraConnectTimeout.swift - - Plugins/CassandraDriverPlugin/CassandraIndexStatements.swift - - Plugins/CassandraDriverPlugin/CassandraObjectQueries.swift - - Plugins/CassandraDriverPlugin/CassandraResumePoints.swift - - Plugins/CassandraDriverPlugin/CassandraRowWriter.swift - - Plugins/CassandraDriverPlugin/CassandraVarint.swift - - Plugins/DamengDriverPlugin/DamengConnectTimeout.swift - - Plugins/DamengDriverPlugin/DamengParameterBinder.swift - - Plugins/DamengDriverPlugin/DamengIndexStatements.swift - - Plugins/OracleDriverPlugin/OracleBindPlaceholders.swift - - Plugins/OracleDriverPlugin/OracleBrowseSQL.swift - - Plugins/OracleDriverPlugin/OracleColumnStatements.swift - - Plugins/OracleDriverPlugin/OracleIndexStatements.swift - - Plugins/OracleDriverPlugin/OracleNationalText.swift - - Plugins/OracleDriverPlugin/OraclePartitionMapping.swift - - Plugins/OracleDriverPlugin/OracleRowWriter.swift - - Plugins/OracleDriverPlugin/OracleTableDDL.swift - - Plugins/OracleDriverPlugin/OracleTypeCatalog.swift - - Plugins/DamengDriverPlugin/DamengStatementClassifier.swift - - Plugins/DamengDriverPlugin/DamengSystemSchemas.swift - - Plugins/DuckDBDriverPlugin/DuckDBAccessMode.swift - - Plugins/DuckDBDriverPlugin/DuckDBConnectAttempt.swift - - Plugins/DuckDBDriverPlugin/DuckDBFileKinds.swift - - Plugins/DuckDBDriverPlugin/DuckDBIndexClauses.swift - - Plugins/DuckDBDriverPlugin/DuckDBIdleRelease.swift - - Plugins/DuckDBDriverPlugin/DuckDBLexicalFeatures.swift - - Plugins/DuckDBDriverPlugin/DuckDBLockConflict.swift - - Plugins/DuckDBDriverPlugin/DuckDBPositionParser.swift - - Plugins/DuckDBDriverPlugin/DuckDBSchemaQueries.swift - - Plugins/DuckDBDriverPlugin/DuckDBTransactionProbe.swift - - Plugins/DuckDBDriverPlugin/DuckDBTypeRendering.swift - - Plugins/DuckDBDriverPlugin/DuckDBViewDefinition.swift - - Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBAccessPlanner.swift - Plugins/DynamoDBDriverPlugin/DynamoDBAttributeValue.swift - Plugins/DynamoDBDriverPlugin/DynamoDBBrowseRequest.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBCatalog.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBCellCodec.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBClient.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBEndpoint.swift - Plugins/DynamoDBDriverPlugin/DynamoDBError.swift - Plugins/DynamoDBDriverPlugin/DynamoDBExpression.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBFilterTranslator.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBItemTable.swift - Plugins/DynamoDBDriverPlugin/DynamoDBJSON.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBNumber.swift - Plugins/DynamoDBDriverPlugin/DynamoDBPartiQL.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBPlugin.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+API.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Execution.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Reading.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Schema.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+TableManagement.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Writes.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBRetryPolicy.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBSigner.swift - Plugins/DynamoDBDriverPlugin/DynamoDBStatement.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBTableDefinition.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBTableSchema.swift - - Plugins/DynamoDBDriverPlugin/DynamoDBWriteStatements.swift - - Plugins/ElasticsearchDriverPlugin/ElasticsearchConnection.swift - - Plugins/ElasticsearchDriverPlugin/ElasticsearchConsoleParser.swift - - Plugins/ElasticsearchDriverPlugin/ElasticsearchMappingFlattener.swift - - Plugins/ElasticsearchDriverPlugin/ElasticsearchNodes.swift - - Plugins/ElasticsearchDriverPlugin/ElasticsearchOperations.swift - - Plugins/ElasticsearchDriverPlugin/ElasticsearchQueryBuilder.swift - - Plugins/ElasticsearchDriverPlugin/ElasticsearchStatementGenerator.swift - - Plugins/EtcdDriverPlugin/EtcdCommandArgument.swift - Plugins/EtcdDriverPlugin/EtcdCommandParser.swift - - Plugins/EtcdDriverPlugin/EtcdGatewayRoute.swift - - Plugins/EtcdDriverPlugin/EtcdKeyspace.swift - - Plugins/EtcdDriverPlugin/EtcdLeaseID.swift - - Plugins/EtcdDriverPlugin/EtcdQueryBuilder.swift - - Plugins/EtcdDriverPlugin/EtcdRequestRecovery.swift - - Plugins/EtcdDriverPlugin/EtcdRowFilter.swift - - Plugins/EtcdDriverPlugin/EtcdServerFault.swift - - Plugins/EtcdDriverPlugin/EtcdServerTrust.swift - - Plugins/EtcdDriverPlugin/EtcdStatementGenerator.swift - - Plugins/FirebaseDriverPlugin/FirebaseCredentialFactory.swift - - Plugins/FirebaseDriverPlugin/FirebasePlugin.swift - - Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Editing.swift - - Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Execution.swift - - Plugins/FirebaseDriverPlugin/FirebasePluginDriver+Metadata.swift - - Plugins/FirebaseDriverPlugin/FirebasePluginDriver.swift - - Plugins/TrinoDriverPlugin/TrinoConnectionFailure.swift - - Plugins/TrinoDriverPlugin/TrinoSSLMapping.swift - - Plugins/TrinoDriverPlugin/TrinoRowEditValues.swift - - Plugins/TypesenseDriverPlugin/TypesenseApiKeys.swift - - Plugins/TypesenseDriverPlugin/TypesenseConsoleParser.swift - - Plugins/TypesenseDriverPlugin/TypesenseFilterBuilder.swift - - Plugins/TypesenseDriverPlugin/TypesenseOperations.swift - - Plugins/TypesenseDriverPlugin/TypesenseQueryBuilder.swift - - Plugins/TypesenseDriverPlugin/TypesenseSchema.swift - - Plugins/TypesenseDriverPlugin/TypesenseStatementGenerator.swift - Plugins/JSONExportPlugin/JSONExportModels.swift - Plugins/MarkdownExportPlugin/MarkdownExportModels.swift - - Plugins/ParquetExportPlugin/ParquetExportModels.swift - - Plugins/ParquetExportPlugin/ParquetTableFiles.swift - - Plugins/ParquetExportPlugin/ParquetTypeMapper.swift - Plugins/JSONImportPlugin/JSONFieldDetectionCache.swift - Plugins/JSONImportPlugin/JSONFieldSurvey.swift - Plugins/JSONImportPlugin/JSONImportOptions.swift @@ -596,14 +477,7 @@ targets: - Plugins/JSONImportPlugin/JSONImportPlugin.swift - Plugins/JSONImportPlugin/JSONLineBatches.swift - Plugins/JSONImportPlugin/JSONLineReader.swift - - Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift - - Plugins/MSSQLDriverPlugin/MSSQLTypeQueries.swift - - Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift - - Plugins/MSSQLDriverPlugin/MSSQLEntraConnectSession.swift - Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift - - Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift - - Plugins/MSSQLDriverPlugin/MSSQLSessionTransaction.swift - - Plugins/MSSQLDriverPlugin/MSSQLStatementGenerator.swift - Plugins/MQLExportPlugin/MQLCollectionDefinition.swift - Plugins/MQLExportPlugin/MQLExportHelpers.swift - Plugins/MQLExportPlugin/MQLExportModels.swift @@ -611,71 +485,6 @@ targets: - Plugins/MQLExportPlugin/MQLExportPlugin.swift - Plugins/MQLExportPlugin/MQLScriptLexer.swift - Plugins/MQLExportPlugin/MQLScriptValue.swift - - Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift - - Plugins/MongoDBDriverPlugin/MongoBsonAssembly.swift - - Plugins/MongoDBDriverPlugin/MongoDBDecimal128.swift - - Plugins/MongoDBDriverPlugin/MongoDBAuthSourceResolver.swift - - Plugins/MongoDBDriverPlugin/MongoDBBinarySubtypes.swift - - Plugins/MongoDBDriverPlugin/MongoDBCapabilities.swift - - Plugins/MongoDBDriverPlugin/MongoDBCollectionDDL.swift - - Plugins/MongoDBDriverPlugin/MongoDBCollectionSchema.swift - - Plugins/MongoDBDriverPlugin/MongoDBCreateDatabasePlan.swift - - Plugins/MongoDBDriverPlugin/MongoDBFieldKinds.swift - - Plugins/MongoDBDriverPlugin/MongoDBFilterClause.swift - - Plugins/MongoDBDriverPlugin/MongoDBFilterValue.swift - - Plugins/MongoDBDriverPlugin/MongoDBFindLimitPolicy.swift - - Plugins/MongoDBDriverPlugin/MongoDBIndexEntry.swift - - Plugins/MongoDBDriverPlugin/MongoDBJsonLayout.swift - - Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift - - Plugins/MongoDBDriverPlugin/MongoDBNameValidator.swift - - Plugins/MongoDBDriverPlugin/MongoDBNamespaceEntry.swift - - Plugins/MongoDBDriverPlugin/MongoDBObjectStatements.swift - - Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift - - Plugins/MongoDBDriverPlugin/MongoDBRawFilterNormalizer.swift - - Plugins/MongoDBDriverPlugin/MongoDBSSLMapping.swift - - Plugins/MongoDBDriverPlugin/MongoDBShellLiteral.swift - - Plugins/MongoDBDriverPlugin/MongoDBShellText.swift - - Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift - - Plugins/MongoDBDriverPlugin/MongoDBUpdateDocument.swift - - Plugins/MongoDBDriverPlugin/MongoDBWriteRefusal.swift - - Plugins/MongoDBDriverPlugin/MongoDocumentText.swift - - Plugins/MongoDBDriverPlugin/MongoExtendedJsonForm.swift - - Plugins/MongoDBDriverPlugin/MongoNestedValueDiff.swift - - Plugins/MongoDBDriverPlugin/MongoDocumentWritePlan.swift - - Plugins/MongoDBDriverPlugin/MongoInsertOptions.swift - - Plugins/MongoDBDriverPlugin/MongoDBDocumentEditingError.swift - - Plugins/MongoDBDriverPlugin/MongoDocumentGuard.swift - - Plugins/MongoDBDriverPlugin/MongoDocumentIdentity.swift - - Plugins/MongoDBDriverPlugin/MongoDocumentPresentation.swift - - Plugins/MongoDBDriverPlugin/MongoDocumentReplacement.swift - - Plugins/MongoDBDriverPlugin/MongoEditableDocument.swift - - Plugins/MongoDBDriverPlugin/MongoExtendedJsonType.swift - - Plugins/MongoDBDriverPlugin/PluginQueryResult+MongoResult.swift - - Plugins/MongoDBDriverPlugin/MongoDBStructureEditing.swift - - Plugins/MongoDBDriverPlugin/MongoDBTimeoutPolicy.swift - - Plugins/MongoDBDriverPlugin/MongoCollectionCatalog.swift - - Plugins/MongoDBDriverPlugin/MongoFieldChange.swift - - Plugins/MongoDBDriverPlugin/MongoFieldChangeAssessment.swift - - Plugins/MongoDBDriverPlugin/MongoFieldDataProbe.swift - - Plugins/MongoDBDriverPlugin/MongoFieldDependent.swift - - Plugins/MongoDBDriverPlugin/MongoFieldReferences.swift - - Plugins/MongoDBDriverPlugin/MongoWriteConcern.swift - - Plugins/MongoDBDriverPlugin/MongoScriptAccessPolicy.swift - - Plugins/MongoDBDriverPlugin/MongoScriptCommandBuilder.swift - - Plugins/MongoDBDriverPlugin/MongoShellCommandLine.swift - - Plugins/MongoDBDriverPlugin/MongoScriptContext.swift - - Plugins/MongoDBDriverPlugin/MongoScriptCursorOptions.swift - - Plugins/MongoDBDriverPlugin/MongoScriptError.swift - - Plugins/MongoDBDriverPlugin/MongoScriptJson.swift - - Plugins/MongoDBDriverPlugin/MongoScriptObjectId.swift - - Plugins/MongoDBDriverPlugin/MongoScriptPrelude.swift - - Plugins/MongoDBDriverPlugin/MongoScriptText.swift - - Plugins/MongoDBDriverPlugin/MongoWriteFailure.swift - - Plugins/MongoDBDriverPlugin/MongoWriteLedger.swift - - Plugins/MongoDBDriverPlugin/MongoStreamProjection.swift - - Plugins/MongoDBDriverPlugin/MongoFieldCensus.swift - - Plugins/MongoDBDriverPlugin/MongoScriptCursorPlan.swift - - Plugins/OracleDriverPlugin/OracleObjectQueries.swift - Plugins/MySQLDriverPlugin/GeometryWKBParser.swift - Plugins/MySQLDriverPlugin/MariaDBFieldClassifier.swift - Plugins/MySQLDriverPlugin/MariaDBFieldMetadata.swift @@ -735,18 +544,6 @@ targets: # The rest of TableProSQLiteCore needs `sqlite3_load_extension`, which macOS's SQLite, the one # this bundle links, does not export. - Packages/TableProCore/Sources/TableProSQLiteCore/SQLiteResultColumns.swift - - Plugins/LibSQLDriverPlugin/LibSQLBusyTimeout.swift - - Plugins/LibSQLDriverPlugin/LibSQLError.swift - - Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Rename.swift - - Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Triggers.swift - - Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift - - Plugins/LibSQLDriverPlugin/SQLiteLocalBackend.swift - - Plugins/LibSQLDriverPlugin/LibSQLDefaultValue.swift - - Plugins/LibSQLDriverPlugin/HranaHttpClient.swift - - Plugins/CloudflareD1DriverPlugin/CloudflareD1DefaultValue.swift - - Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift - - Plugins/MSSQLDriverPlugin/MSSQLCheckConstraintDefinition.swift - - Plugins/MSSQLDriverPlugin/MSSQLTableDefinitionSQL.swift - Plugins/PostgreSQLDriverPlugin/CockroachRelationSQL.swift - Plugins/PostgreSQLDriverPlugin/ColumnQueryShape.swift - Plugins/PostgreSQLDriverPlugin/LibPQByteaDecoder.swift @@ -796,40 +593,6 @@ targets: - Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift - Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift - Plugins/PostgreSQLDriverPlugin/RedshiftTableCatalog.swift - - Plugins/KafkaDriverPlugin/KafkaApiKey.swift - - Plugins/KafkaDriverPlugin/KafkaBrowseEngine.swift - - Plugins/KafkaDriverPlugin/KafkaCluster.swift - - Plugins/KafkaDriverPlugin/KafkaCluster+Routing.swift - - Plugins/KafkaDriverPlugin/KafkaConnection.swift - - Plugins/KafkaDriverPlugin/KafkaDeleteTopicsRequest.swift - - Plugins/KafkaDriverPlugin/KafkaFetchRequest.swift - - Plugins/KafkaDriverPlugin/KafkaFindCoordinatorRequest.swift - - Plugins/KafkaDriverPlugin/KafkaGroupsRequest.swift - - Plugins/KafkaDriverPlugin/KafkaMetadataRequest.swift - - Plugins/KafkaDriverPlugin/KafkaOffsetsRequest.swift - - Plugins/KafkaDriverPlugin/KafkaPlugin.swift - - Plugins/KafkaDriverPlugin/KafkaPluginDriver.swift - - Plugins/KafkaDriverPlugin/KafkaProduceRequest.swift - - Plugins/KafkaDriverPlugin/KafkaSASL.swift - - Plugins/KafkaDriverPlugin/KafkaCRC32C.swift - - Plugins/KafkaDriverPlugin/KafkaCompression.swift - - Plugins/KafkaDriverPlugin/KafkaClusterTypes.swift - - Plugins/KafkaDriverPlugin/KafkaConnectionField.swift - - Plugins/KafkaDriverPlugin/KafkaCredentials.swift - - Plugins/KafkaDriverPlugin/KafkaError.swift - - Plugins/KafkaDriverPlugin/KafkaMessageFlattener.swift - - Plugins/KafkaDriverPlugin/KafkaPartitioner.swift - - Plugins/KafkaDriverPlugin/KafkaProtocolReader.swift - - Plugins/KafkaDriverPlugin/KafkaProtocolWriter.swift - - Plugins/KafkaDriverPlugin/KafkaQL.swift - - Plugins/KafkaDriverPlugin/KafkaRecordBatch.swift - - Plugins/KafkaDriverPlugin/KafkaRecordOrdering.swift - - Plugins/KafkaDriverPlugin/KafkaRequest.swift - - Plugins/KafkaDriverPlugin/KafkaScram.swift - - Plugins/KafkaDriverPlugin/SnappyDecoder.swift - - Plugins/TeradataDriverPlugin/TeradataRowEditSQL.swift - - Plugins/TeradataDriverPlugin/TeradataSSLMapping.swift - - Plugins/TeradataDriverPlugin/TeradataVersionProbe.swift - Plugins/RedisDriverPlugin/RedisArgumentCodec.swift - Plugins/RedisDriverPlugin/RedisAuthCommand.swift - Plugins/RedisDriverPlugin/RedisClusterAggregator.swift @@ -888,52 +651,10 @@ targets: - Plugins/XLSXExportPlugin/XLSXWriter.swift - Plugins/XLSXImportPlugin/XLSXImportOptions.swift - Plugins/XLSXImportPlugin/XLSXSheetParser.swift - - Plugins/SnowflakeDriverPlugin/PluginCellValueBox.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeBindingEncoder.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeDDLGenerator.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeError.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeHTTPRetry.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeHeartbeat.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeMFATokenStore.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeObjectQueries.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeSchemaQueries.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeSessionKey.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeSQL.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeStatementGenerator.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeStatementType.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeTypeMapper.swift - - Plugins/SnowflakeDriverPlugin/SnowflakeValueDecoder.swift - - Plugins/SurrealDBDriverPlugin/SurrealCBOR.swift - - Plugins/SurrealDBDriverPlugin/SurrealCellCoder.swift - - Plugins/SurrealDBDriverPlugin/SurrealDBConnectionConfig.swift - - Plugins/SurrealDBDriverPlugin/SurrealDBConnectionFields.swift - - Plugins/SurrealDBDriverPlugin/SurrealDBError.swift - - Plugins/SurrealDBDriverPlugin/SurrealFieldKind.swift - - Plugins/SurrealDBDriverPlugin/SurrealInfoParser.swift - - Plugins/SurrealDBDriverPlugin/SurrealQL.swift - - Plugins/SurrealDBDriverPlugin/SurrealQueryBuilder.swift - - Plugins/SurrealDBDriverPlugin/SurrealRawCondition.swift - - Plugins/SurrealDBDriverPlugin/SurrealRowFlattener.swift - - Plugins/SurrealDBDriverPlugin/SurrealStatementGenerator.swift - - Plugins/SurrealDBDriverPlugin/SurrealValue+Display.swift - - Plugins/SurrealDBDriverPlugin/SurrealValue.swift dependencies: - target: TablePro - package: TableProCore - products: [CSQLite, TableProConnectionLibrary, TableProDocumentPath, TableProFirebaseCore, TableProGeometry, TableProJavaScriptText, TableProLogRedaction, TableProMSSQLCore, TableProNumberFormatting, TableProR2SQLCore, TableProSQLGrammar, TableProSSHTransport, TableProTabular, TableProTabularIO, TableProTLSClientIdentity, TableProTrinoCore, TableProWeaviateCore] - # The Kafka integration suite drives the real driver, so the test target links what - # the plugin target links: zstd for decompression and NIO for the transport. - - package: zstd - product: libzstd - - package: swift-nio - product: NIOCore - - package: swift-nio - product: NIOPosix - - package: swift-nio-ssl - product: NIOSSL - - package: TableProCore - product: TableProTeradataCore + products: [CSQLite, TableProConnectionLibrary, TableProGeometry, TableProJavaScriptText, TableProMSSQLCore, TableProNumberFormatting, TableProSQLGrammar, TableProSSHTransport, TableProTabular, TableProTabularIO, TableProTLSClientIdentity] settings: base: CURRENT_PROJECT_VERSION: 1 @@ -1378,60 +1099,12 @@ targets: - -liconv SWIFT_INCLUDE_PATHS: $(SRCROOT)/Plugins/DamengDriverPlugin/CDameng - DamengDriverTests: - type: bundle.unit-test - platform: macOS - sources: - - path: Plugins/DamengDriverPlugin - excludes: - - Info.plist - - README.md - - Plugins/Shared/PluginQueryTimeout.swift - - Plugins/DamengDriverPluginTests - dependencies: - - target: TableProPluginKit - settings: - base: - GENERATE_INFOPLIST_FILE: YES - HEADER_SEARCH_PATHS: - - $(inherited) - - $(SRCROOT)/Plugins/DamengDriverPlugin/CDameng - LIBRARY_SEARCH_PATHS: - - $(inherited) - - $(SRCROOT)/Native/DamengBridge/lib - OTHER_LDFLAGS: - - $(inherited) - - -force_load - - $(SRCROOT)/Native/DamengBridge/lib/libdameng_bridge.a - - -framework - - Security - - -framework - - CoreFoundation - - -liconv - SWIFT_INCLUDE_PATHS: $(SRCROOT)/Plugins/DamengDriverPlugin/CDameng - HanaDriver: templates: [DriverPlugin, HanaHelperEmbedding] templateAttributes: folder: HanaDriverPlugin principalClass: HanaPlugin - HanaDriverTests: - type: bundle.unit-test - platform: macOS - templates: [HanaHelperEmbedding] - sources: - - path: Plugins/HanaDriverPlugin - excludes: - - Info.plist - - README.md - - Plugins/HanaDriverPluginTests - dependencies: - - target: TableProPluginKit - settings: - base: - GENERATE_INFOPLIST_FILE: YES - ElasticsearchDriverPlugin: templates: [DriverPlugin] templateAttributes: @@ -1684,26 +1357,6 @@ aggregateTargets: scheme: {} schemes: - DamengDriverTests: - build: - targets: - DamengDriverTests: test - buildImplicitDependencies: true - test: - config: Debug - targets: - - name: DamengDriverTests - - HanaDriverTests: - build: - targets: - HanaDriverTests: test - buildImplicitDependencies: true - test: - config: Debug - targets: - - name: HanaDriverTests - TablePro: build: targets: diff --git a/scripts/check-mongodb-filter-shapes.sh b/scripts/check-mongodb-filter-shapes.sh deleted file mode 100755 index 23cc301d41..0000000000 --- a/scripts/check-mongodb-filter-shapes.sh +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env bash -# -# Every filter document MongoDBQueryBuilder can emit, built into BSON by the MongoDB driver's own -# MongoBsonBuilder against the libbson we actually link. -# -# The builder assembles Extended JSON by hand. MongoBsonBuilder hands the text to -# bson_new_from_json whole, or builds it member by member when it holds a $type, $regex or -# $options operator document, which libbson would otherwise read as a binary or regular -# expression value. A shape that stops building fails at run time with an unhelpful error, and one -# built as the wrong BSON type matches something else without failing, while the Swift tests only -# compare strings. So this compiles the plugin's planner and builder with swiftc, builds every -# shape, and checks the BSON type wherever libbson's own reading and the query's meaning part. -# Run it after bumping libbson, or after adding an operator arm that emits a new shape. -# -# Usage: scripts/check-mongodb-filter-shapes.sh [arm64|x86_64] - -set -uo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -ARCH="${1:-$(uname -m)}" -PLUGIN="$ROOT/Plugins/MongoDBDriverPlugin" -LIB="$ROOT/Libs/libbson_${ARCH}.a" -INCLUDE="$PLUGIN/CLibMongoc/include" -WORK="$(mktemp -d)" -trap 'rm -rf "$WORK"' EXIT - -if [ ! -f "$LIB" ]; then - echo "missing $LIB (run scripts/download-libs.sh)" >&2 - exit 2 -fi - -cat > "$WORK/main.swift" <<'PROBE' -import CLibMongoc -import Foundation - -/// What libbson stored a value as, read back from its canonical Extended JSON, which spells every -/// type but a document, an array, a string, a boolean and null as a one-key wrapper. -enum Kind: String { - case document, array, string, bool, null, regex, binary, date, objectId, decimal, other - - init(_ value: Any) { - switch value { - case is String: self = .string - case is NSNull: self = .null - case is [Any]: self = .array - case let number as NSNumber where CFGetTypeID(number) == CFBooleanGetTypeID(): self = .bool - case let object as [String: Any]: - let wrappers: [String: Kind] = [ - "$regularExpression": .regex, "$binary": .binary, "$date": .date, "$oid": .objectId, - "$numberDecimal": .decimal, "$numberInt": .other, "$numberLong": .other, "$numberDouble": .other, - "$timestamp": .other, "$code": .other, "$symbol": .other, "$minKey": .other, "$maxKey": .other, - "$undefined": .other, "$dbPointer": .other - ] - guard object.count == 1, let key = object.keys.first, let kind = wrappers[key] else { - self = .document - return - } - self = kind - default: self = .other - } - } -} - -struct ShapeCheck { - private(set) var failures = 0 - - mutating func check(_ label: String, _ json: String, kinds: KeyValuePairs = [:]) { - var reason = "libbson refused it" - guard let document = MongoBsonBuilder.document(from: json, onParseFailure: { reason = $0 }) else { - report(label, "does not build: \(reason)", json) - return - } - defer { bson_destroy(document) } - guard let text = bson_as_canonical_extended_json(document, nil) else { - report(label, "has no canonical Extended JSON", json) - return - } - defer { bson_free(text) } - let built = try? JSONSerialization.jsonObject(with: Data(String(cString: text).utf8)) - for (path, expected) in kinds { - guard let value = Self.value(at: path, in: built) else { - report(label, "has no \(path)", json) - return - } - let actual = Kind(value) - guard actual == expected else { - report(label, "holds \(path) as \(actual.rawValue), not \(expected.rawValue)", json) - return - } - } - print("ok \(label)") - } - - private static func value(at path: String, in root: Any?) -> Any? { - path.split(separator: ".").reduce(root) { node, key in - if let object = node as? [String: Any] { return object[String(key)] } - if let array = node as? [Any], let index = Int(key), array.indices.contains(index) { return array[index] } - return nil - } - } - - private mutating func report(_ label: String, _ problem: String, _ json: String) { - print("FAIL \(label): \(problem)\n \(json)") - failures += 1 - } -} - -print("libbson \(String(cString: bson_get_version()))") -var shapes = ShapeCheck() - -shapes.check("dotted equality", #"{"customer.country": "US"}"#) -shapes.check("dotted comparison", #"{"customer.age": {"$gte": 18}}"#) -shapes.check("array dot notation", #"{"items.sku": "A100"}"#) -shapes.check("elemMatch multi", #"{"items": {"$elemMatch": {"price": {"$gt": 500}, "name": "Laptop"}}}"#) -shapes.check("elemMatch single", #"{"items": {"$elemMatch": {"sku": "A100"}}}"#) -shapes.check("elemMatch dotted inner", #"{"orders": {"$elemMatch": {"customer.country": "US"}}}"#) -shapes.check( - "elemMatch colliding key", - #"{"items": {"$elemMatch": {"$and": [{"price": {"$gt": 10}}, {"price": {"$lt": 90}}]}}}"# -) -shapes.check( - "elemMatch match-any", - #"{"items": {"$elemMatch": {"$or": [{"price": {"$gt": 500}}, {"name": "Laptop"}]}}}"# -) -shapes.check("elemMatch non-BMP key", #"{"🎁items": {"$elemMatch": {"sku": "A100"}}}"#) -shapes.check("string field quoted", #"{"customer.zip": "12345"}"#) -shapes.check( - "elemMatch regex", - #"{"items": {"$elemMatch": {"name": {"$regex": "^Lap", "$options": "i"}}}}"#, - kinds: ["items.$elemMatch.name": .document, "items.$elemMatch.name.$regex": .string] -) -shapes.check( - "elemMatch not regex", - #"{"items": {"$elemMatch": {"name": {"$not": {"$regularExpression": {"pattern": "^Lap", "options": ""}}}}}}"#, - kinds: ["items.$elemMatch.name.$not": .regex] -) -shapes.check( - "not regex ignoring case", - #"{"name": {"$not": {"$regularExpression": {"pattern": "^a$", "options": "i"}}}}"#, - kinds: ["name.$not": .regex] -) -shapes.check("and of clauses", #"{"$and": [{"items.sku": "A100"}, {"customer.country": "US"}]}"#) -shapes.check("or of clauses", #"{"$or": [{"items": {"$elemMatch": {"price": {"$gt": 500}}}}, {"a": 1}]}"#) -shapes.check("raw wrapped", #"{"$and": [{"customer.country": "US"}]}"#) -shapes.check("raw match all", #"{"$and": [{}]}"#) -shapes.check("impossible filter", #"{"_id": {"$in": []}}"#) -shapes.check( - "date coercion", - #"{"createdAt": {"$gte": {"$date": {"$numberLong": "1704067200000"}}}}"#, - kinds: ["createdAt.$gte": .date] -) -shapes.check( - "objectId coercion", - #"{"_id": {"$gt": {"$oid": "507f1f77bcf86cd799439011"}}}"#, - kinds: ["_id.$gt": .objectId] -) -shapes.check( - "decimal coercion", - #"{"price": {"$gte": {"$numberDecimal": "19.99"}}}"#, - kinds: ["price.$gte": .decimal] -) -shapes.check( - "objectId both ways", - #"{"$or": [{"ref": {"$oid": "507f1f77bcf86cd799439011"}}, {"ref": "507f1f77bcf86cd799439011"}]}"#, - kinds: ["$or.0.ref": .objectId, "$or.1.ref": .string] -) -shapes.check( - "binary wrapper", - #"{"id": {"$binary": {"base64": "TGVnYWN5AAAAAAAAAAAAAA==", "subType": "03"}}}"#, - kinds: ["id": .binary] -) -shapes.check( - "regex no options", - #"{"name": {"$regex": "^A"}}"#, - kinds: ["name": .document, "name.$regex": .string] -) -shapes.check("ne null", #"{"name": {"$ne": null}}"#, kinds: ["name.$ne": .null]) -shapes.check( - "nor list", - #"{"$nor": [{"a": {"$regex": "^x$", "$options": "i"}}]}"#, - kinds: ["$nor": .array, "$nor.0.a": .document, "$nor.0.a.$options": .string] -) -shapes.check("between", #"{"name": {"$gte": "Smith, John", "$lte": "Zed"}}"#) -shapes.check("getField escape hatch", #"{"$expr": {"$gt": [{"$getField": "price.usd"}, 40]}}"#) -shapes.check( - "raw type operator", - #"{"sig": {"$type": "binData"}}"#, - kinds: ["sig": .document, "sig.$type": .string] -) -shapes.check( - "raw regex beside another operator", - #"{"f": {"$regex": "^a", "$exists": true}}"#, - kinds: ["f": .document, "f.$exists": .bool] -) - -guard shapes.failures == 0 else { - print("\n\(shapes.failures) shape(s) failed") - exit(1) -} -print("\nevery shape builds as the query means it") -PROBE - -xcrun --sdk macosx swiftc -swift-version 6 -Onone -module-name FilterShapes \ - -target "${ARCH}-apple-macos14.0" \ - -I "$PLUGIN/CLibMongoc" \ - -Xcc -I"$INCLUDE" -Xcc -I"$INCLUDE/libbson-1.0" -Xcc -I"$INCLUDE/libmongoc-1.0" \ - "$PLUGIN/MongoScriptJson.swift" "$PLUGIN/MongoBsonAssembly.swift" "$PLUGIN/MongoBsonBuilder.swift" \ - "$WORK/main.swift" "$LIB" -lresolv -framework CoreFoundation -framework Security \ - -o "$WORK/probe" || exit 2 - -"$WORK/probe" diff --git a/scripts/ci/check-entitlements.py b/scripts/ci/check-entitlements.py new file mode 100755 index 0000000000..b4064bb42d --- /dev/null +++ b/scripts/ci/check-entitlements.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Check both apps pin the Production CloudKit environment. + +A signed build without the key talks to Development, which the shipped schema never reaches. +""" + +from __future__ import annotations + +import plistlib +import sys +from pathlib import Path + +KEY = "com.apple.developer.icloud-container-environment" +ENTITLEMENTS = ( + "TablePro/TablePro.entitlements", + "TableProMobile/TableProMobile/TableProMobileRelease.entitlements", +) + + +def main() -> int: + root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parents[2] + status = 0 + for relative in ENTITLEMENTS: + value = plistlib.loads((root / relative).read_bytes()).get(KEY) + if value != "Production": + print(f"{relative}: {KEY} is {value!r}, expected 'Production'") + status = 1 + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/check-license-inventory.py b/scripts/ci/check-license-inventory.py new file mode 100755 index 0000000000..82ce81ce59 --- /dev/null +++ b/scripts/ci/check-license-inventory.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Check licenses.yml against the libraries the build actually ships. + +Every version is checked against its pin in a build script or a Package.resolved, because a bump +can change a license (OpenSSL at 3.0, Redis at 7.4) and that needs a person to re-read it. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import yaml + +INVENTORY = "TablePro/Resources/ThirdPartyLicenses/licenses.yml" +RESOLVED = ( + "TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + "Packages/TableProOracle/Package.resolved", + "TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", +) +ACKNOWLEDGEMENTS = "TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json" +PROJECTED = ("id", "name", "version", "spdx", "copyrights", "homepageURL", "textFile") +PIN = re.compile(r'^([A-Z0-9_]+_VERSION)="(?:\$\{[^:}]+:-)*(v?[0-9][^"$}]*)\}*"', re.MULTILINE) + + +def bare(version: str) -> str: + return version[1:] if version.startswith("v") else version + + +def shell_pins(root: Path) -> dict[str, set[str]]: + scripts = root / "scripts" + files = sorted(scripts.glob("build-*.sh")) + sorted((scripts / "lib").glob("*.sh")) + sorted((scripts / "ios").glob("*.sh")) + pins: dict[str, set[str]] = {} + for path in files: + for name, value in PIN.findall(path.read_text(encoding="utf-8")): + pins.setdefault(name, set()).add(value) + return pins + + +def package_pins(root: Path) -> dict[str, set[str]]: + pins: dict[str, set[str]] = {} + for relative in RESOLVED: + path = root / relative + if not path.exists(): + continue + for entry in json.loads(path.read_text(encoding="utf-8")).get("pins", []): + state = entry.get("state", {}) + version = state.get("version") or (f"rev:{state['revision']}" if "revision" in state else None) + if version: + pins.setdefault(entry["identity"], set()).add(version) + return pins + + +def problems(root: Path) -> list[str]: + inventory = root / INVENTORY + components = yaml.load(inventory.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + found: list[str] = [] + ids = [c["id"] for c in components] + found += [f"{i} appears more than once" for i in sorted({i for i in ids if ids.count(i) > 1})] + + base = inventory.parent + referenced = {c["textFile"] for c in components if c.get("textFile")} + for text in sorted(referenced): + path = base / text + if not path.exists() or not path.read_text(encoding="utf-8").strip(): + found.append(f"{text} is missing or empty") + found += [f"texts/{p.name} ships but no component references it" for p in sorted((base / "texts").iterdir()) + if f"texts/{p.name}" not in referenced and not p.name.startswith(".")] + + shell, packages = shell_pins(root), package_pins(root) + attributed = set() + for c in components: + kind, _, name = c.get("source", "").partition(":") + recorded = set(c["version"].split(", ")) + if kind == "sh": + attributed.add(name) + if name not in shell: + found.append(f"{c['id']} points at {name}, which no build script defines") + elif {bare(v) for v in recorded} != {bare(v) for v in shell[name]}: + found.append(f"{c['id']} is recorded as {c['version']} but {name} pins {', '.join(sorted(shell[name]))}") + elif kind == "spm": + if name not in packages: + found.append(f"{c['id']} points at SwiftPM package {name}, which nothing resolves") + elif recorded != packages[name]: + found.append(f"{c['id']} is recorded as {c['version']} but SwiftPM resolves {', '.join(sorted(packages[name]))}") + found += [f"{v} is built and shipped with no license entry" for v in sorted(set(shell) - attributed)] + + patched_on_disk = {p.name for p in (root / "scripts/patches").iterdir() if not p.name.startswith(".")} + patched = {c["id"] for c in components if c.get("patched") == "true"} + found += [f"scripts/patches/{i} exists but {i} is not marked patched" for i in sorted(patched_on_disk - patched)] + found += [f"{i} is marked patched but scripts/patches/{i} does not exist" for i in sorted(patched - patched_on_disk)] + + expected = sorted( + ({f: c.get(f) for f in PROJECTED} for c in components if "ios" in c.get("platforms", ["macos"])), + key=lambda c: (c["name"].lower(), c["id"]), + ) + if json.loads((root / ACKNOWLEDGEMENTS).read_text(encoding="utf-8")) != expected: + found.append(f"{ACKNOWLEDGEMENTS} is stale. Run scripts/generate-ios-acknowledgements.py") + return found + + +def main() -> int: + root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parents[2] + found = problems(root) + for line in found: + print(line) + return 1 if found else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/check-log-privacy.py b/scripts/ci/check-log-privacy.py deleted file mode 100644 index 5153aeb71c..0000000000 --- a/scripts/ci/check-log-privacy.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -"""Check no source publishes an error's text to the system log. - -`.swiftlint.yml` forbids it through the `public_error_text_in_log` custom rule, because a driver -error's text carries row values, paths and server messages, and `.public` hands them to every -process that reads the log. SwiftLint's `included:` does not reach `Plugins/`, where -`swiftlint lint --strict` still reports other violations, so this applies the one rule to every -Swift file under `TablePro/`, `Packages/` and `Plugins/`. It reads the regex out of -`.swiftlint.yml`, so the two cannot drift, and `test_check_log_privacy.py` pins what it matches. - -A regex sees one interpolation, not where its text came from: an error's description bound to a -variable first and published under another name passes. - -`TableProMobile/` is not scanned yet: it still holds log lines this rule rejects. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -RULE = "public_error_text_in_log" -SCANNED = ("TablePro", "Packages", "Plugins") -SKIPPED_DIRECTORIES = {".build", "checkouts", "DerivedData"} - - -def rule_pattern(config: Path) -> re.Pattern[str]: - lines = config.read_text(encoding="utf-8").splitlines() - try: - start = next(i for i, line in enumerate(lines) if line.strip() == f"{RULE}:") - except StopIteration: - raise SystemExit(f"error: {config.name} has no `{RULE}` custom rule") from None - - indent = len(lines[start]) - len(lines[start].lstrip()) - for line in lines[start + 1:]: - if line.strip() and len(line) - len(line.lstrip()) <= indent: - break - match = re.match(r"\s*regex:\s*'(?P(?:[^']|'')*)'\s*$", line) - if match: - return re.compile(match.group("body").replace("''", "'")) - raise SystemExit(f"error: the `{RULE}` rule in {config.name} has no single-quoted regex") - - -def sources(root: Path) -> list[Path]: - seen: set[Path] = set() - found = [] - for directory in SCANNED: - for path in sorted((root / directory).rglob("*.swift")): - if SKIPPED_DIRECTORIES.intersection(path.relative_to(root).parts): - continue - resolved = path.resolve() - if resolved in seen: - continue - seen.add(resolved) - found.append(path) - return found - - -def offenders(root: Path, pattern: re.Pattern[str]) -> list[str]: - found = [] - for path in sources(root): - text = path.read_text(encoding="utf-8", errors="replace") - for match in pattern.finditer(text): - number = text.count("\n", 0, match.start()) + 1 - found.append(f"{path.relative_to(root)}:{number}") - return found - - -def main() -> int: - root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parents[2] - pattern = rule_pattern(root / ".swiftlint.yml") - found = offenders(root, pattern) - if not found: - return 0 - - print("These log lines publish an error's text, which can carry row values, paths and server") - print("messages. Publish `error.publicLogShape` in the app, or") - print("`LogRedaction.publicDescription(of: error)` elsewhere, and log the description at .private:") - for site in found: - print(f" {site}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/check-plugin-os-import.py b/scripts/ci/check-plugin-os-import.py deleted file mode 100755 index cb9b93aeb3..0000000000 --- a/scripts/ci/check-plugin-os-import.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -"""Check every plugin source that logs through OSLog imports the module that defines it. - -The 40 plugin targets build with SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY, so a member is -only visible when the file importing it names a module that declares it. A file that writes - - logger.warning("read failed: \\(error.publicLogShape, privacy: .public)") - -with only `import Foundation` fails with - - instance method 'warning' is not available due to missing import of defining module 'os' - -and it fails once per interpolation segment, so one line produces eight errors. - -Either `import os` or `import OSLog` satisfies it, measured on this toolchain at --target arm64-apple-macos13.0 with the feature enabled: both compile a `Logger` built at file -scope, a `.debug` call and a `.warning` carrying a `privacy: .public` interpolation, and dropping -both fails at `Logger` itself. So this check accepts either and does not prefer one, or it would -report PostgreSQLPluginDriver+EnumTypes.swift, which imports OSLog alone and compiles. - -This is a merge-order trap rather than an ordinary compile error. The feature flag arrived in one -pull request and the files that needed the import were fixed in that same one; a file added in any -other pull request open at the time compiles fine on its own branch, where the flag is not set yet, -and only breaks once both land. That is how main broke with MySQLPluginDriver+CatalogFallback.swift -and MySQLPluginDriver+Schema.swift, each green on its own branch. - -Compiling every plugin catches it, but only after both sides are merged. This runs on Ubuntu in -under a second with no Xcode, so it catches it on whichever pull request adds the file. -""" - -from __future__ import annotations - -import pathlib -import re -import sys - -PLUGINS = pathlib.Path(__file__).resolve().parents[2] / "Plugins" - -LOGGING_USE = re.compile( - r"""\b(?: - Logger\s*\( - | logger\s*\.\s*(?:debug|info|notice|warning|error|fault|critical|log)\b - | os_log\b - | OSLogType\b - )""", - re.VERBOSE, -) -IMPORTS_OS = re.compile(r"^\s*import\s+(?:os|OSLog)\s*$", re.MULTILINE) - - -def offenders() -> list[pathlib.Path]: - found = [] - for path in sorted(PLUGINS.rglob("*.swift")): - text = path.read_text(encoding="utf-8", errors="replace") - if LOGGING_USE.search(text) and not IMPORTS_OS.search(text): - found.append(path) - return found - - -def main() -> int: - if not PLUGINS.is_dir(): - print(f"error: {PLUGINS} is not a directory", file=sys.stderr) - return 1 - - found = offenders() - if not found: - return 0 - - root = PLUGINS.parent - print("These plugin sources log through OSLog without importing a module that defines it.") - print("Add `import os` beside `import Foundation`:") - for path in found: - print(f" {path.relative_to(root)}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/check-string-catalogs.py b/scripts/ci/check-string-catalogs.py new file mode 100755 index 0000000000..38dc17624d --- /dev/null +++ b/scripts/ci/check-string-catalogs.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Check the string catalogs for translations that would format the wrong value. + +A translation is data, so the compiler never reads it, and `xcstringstool compile` accepts a `%lld` +retyped as `%@` or a dropped argument without a warning. String(format:) then formats garbage or +traps. The rules below are the ones the shipped catalogs already keep. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +CATALOGS = { + "TablePro": "TablePro/Resources/Localizable.xcstrings", + "TableProMobile": "TableProMobile/TableProMobile/Localizable.xcstrings", +} +TERMINAL_MARKS = {":": (":", ":"), "?": ("?", "?"), ".": (".", "。")} +# SQL notation, written the way SQL documentation writes it, not a UI ellipsis. +TECHNICAL_ELLIPSES = ("ALTER...DROP", "OVER(...)", ", ...)") +SPECIFIER = re.compile( + r"%(?:#@[^@]*@|(?:(?P\d+)\$)?[-+ #0]*\d*(?:\.\d*)?(?Pll|hh|l|h|z|q)?(?P[@diuUfFeEgGxXoscpaA%]))" +) +TOKEN = re.compile(r"\$\{[^}]*\}") + + +def specifiers(text: str) -> list[tuple[int | None, str]]: + found = [] + for match in SPECIFIER.finditer(text): + conversion = match.group("conversion") + if conversion is None or conversion == "%": + continue + index = match.group("index") + found.append((int(index) if index else None, (match.group("length") or "") + conversion)) + return found + + +def arguments(text: str, substitutions: dict[int, str]) -> dict[int, str]: + result = {index or position + 1: kind for position, (index, kind) in enumerate(specifiers(text))} + result.update(substitutions) + return result + + +def mismatch(source: str, source_subs: dict[int, str], value: str, value_subs: dict[int, str]) -> str | None: + expected = arguments(source, source_subs) + found = arguments(value, value_subs) + if not expected and not found: + return None + indices = [index for index, _ in specifiers(value)] + if None in indices and any(index is not None for index in indices): + return "mixes positional and plain specifiers" + if set(found) != set(expected): + want = ",".join(map(str, sorted(expected))) or "no arguments" + got = ",".join(map(str, sorted(found))) or "none" + return f"consumes {got} but the source passes {want}" + for index in sorted(expected): + if expected[index] != found[index]: + return f"argument {index} is %{found[index]} but the source passes %{expected[index]}" + return None + + +def ends_with_ellipsis(text: str) -> bool: + return text.endswith("...") or text.endswith("…") + + +def substitutions(localization: dict) -> dict[int, str]: + return {s["argNum"]: s["formatSpecifier"] for s in (localization.get("substitutions") or {}).values()} + + +def translated_units(payload: dict): + source_language = payload["sourceLanguage"] + for key, entry in sorted(payload["strings"].items()): + localizations = entry.get("localizations") or {} + source_localization = localizations.get(source_language) or {} + source = (source_localization.get("stringUnit") or {}).get("value", key) + source_subs = substitutions(source_localization) + for language, localization in sorted(localizations.items()): + if language == source_language: + continue + units = [localization["stringUnit"]] if "stringUnit" in localization else [] + for variation in (localization.get("variations") or {}).values(): + units += [case["stringUnit"] for case in variation.values() if "stringUnit" in case] + for unit in units: + if unit.get("state") == "translated": + yield key, source, source_subs, language, unit["value"], substitutions(localization) + + +def offenders(name: str, payload: dict) -> list[str]: + found = [] + for key, entry in sorted(payload["strings"].items()): + if name == "TablePro" and "..." in key and entry.get("extractionState") != "stale" and not any(t in key for t in TECHNICAL_ELLIPSES): + found.append(f'{name}: "{key}" spells its ellipsis with three periods, use …') + for key, source, source_subs, language, value, value_subs in translated_units(payload): + where = f'{name}:{language}: "{key}" -> "{value}"' + complaint = mismatch(source, source_subs, value, value_subs) + if complaint: + found.append(f"{where}: {complaint}") + if "^[" in value: + found.append(f"{where}: carries inflection markup, which only English applies") + if sorted(TOKEN.findall(key)) != sorted(TOKEN.findall(value)): + found.append(f"{where}: loses or adds a Shortcuts ${{parameter}} token") + if ends_with_ellipsis(key) != ends_with_ellipsis(value): + found.append(f"{where}: trailing ellipsis differs from the source") + elif not ends_with_ellipsis(key): + mark = next((m for m in TERMINAL_MARKS if key.endswith(m)), None) + if mark and not value.endswith(TERMINAL_MARKS[mark]): + found.append(f"{where}: drops the source's closing {mark}") + return found + + +def main() -> int: + root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parents[2] + found = [] + for name, path in CATALOGS.items(): + found += offenders(name, json.loads((root / path).read_text(encoding="utf-8"))) + for line in found: + print(line) + return 1 if found else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/check-test-suite-attributes.py b/scripts/ci/check-test-suite-attributes.py deleted file mode 100644 index 6f4436ab0f..0000000000 --- a/scripts/ci/check-test-suite-attributes.py +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env python3 -"""Check no top-level type in TableProTests carries a @Suite that has no trait. - -Swift Testing finds a type's @Test functions without any @Suite on it, so `@Suite("Some name")` -only renames the type in Xcode's test navigator and in the result bundle. What it costs is compile -time, and the cost grows with the square of how many there are. @Suite is a peer macro, and a peer -macro may introduce uniquely named declarations, so the compiler files every top-level one in -module-scope lookup under a single placeholder for "some unique name". Each @Suite expansion emits -declarations that refer to each other by unique name, and every one of those lookups walks the -whole placeholder list, expanding every other top-level @Suite in the module to see what it -declared. - -Measured on the macOS Tests build job (Xcode 26.4.1, 3 vCPU, 7 GB): with 2,336 top-level @Suite -attributes in TableProTests, 2,200 of them carrying only a display name, the target's emit-module -job ran for 544 to 626 seconds, the longest compile job in the whole build, and half of the samples -taken in it sat in that lookup. Removing 2,173 of those 2,200 took the emit-module job to 290 -seconds on the same runner, and the target's compile batches, summed, from 3,958 to 2,373 seconds. The emit-module -window had tracked the count as it grew: 238 seconds at 1,644 suites, 340 at 1,826, 622 at 2,163. - -So a top-level @Suite is allowed only when it carries a trait (.serialized, .enabled(if:), -.disabled, .timeLimit, a tag), which is the one thing an unannotated type cannot express. Nested -suites and @Test functions are not checked: their expansions are members of a type, and member -lookup does not walk the module. A nested @Suite still costs emit-module time that grows linearly, -so it is not a way to keep a display name for free. - -Only TableProTests is scanned. The cost is quadratic in each module's own count, and the other -test modules hold 108 top-level suites or fewer, where it stays under a second. - -Pure text, no Xcode: it runs on Ubuntu in about a second, and test_check_test_suite_attributes.py -pins what it must and must not report. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -SCANNED = "TableProTests" -ATTRIBUTE = re.compile(r"@(?:Testing\.)?Suite\b") -INTERESTING = re.compile(r'[{}"#/@]') - - -class SwiftText: - """Enough of a Swift lexer to tell code from comments and string literals, and to find the - brace depth of a position. Regex literals are not recognised; test sources do not put braces - or quotes in them.""" - - def __init__(self, text: str) -> None: - self.text = text - - def comment_end(self, index: int) -> int | None: - text = self.text - if text.startswith("//", index): - newline = text.find("\n", index) - return len(text) if newline < 0 else newline - if not text.startswith("/*", index): - return None - depth = 0 - position = index - while position < len(text): - if text.startswith("/*", position): - depth += 1 - position += 2 - elif text.startswith("*/", position): - depth -= 1 - position += 2 - if depth == 0: - return position - else: - position += 1 - return len(text) - - def string_end(self, index: int) -> int | None: - text = self.text - hashes = 0 - while text.startswith("#", index + hashes): - hashes += 1 - opening = index + hashes - if not text.startswith('"', opening): - return None - multiline = text.startswith('"""', opening) - delimiter = ('"""' if multiline else '"') + "#" * hashes - escape = "\\" + "#" * hashes - position = opening + (3 if multiline else 1) - while position < len(text): - if text.startswith(escape, position): - after = position + len(escape) - if text.startswith("(", after): - position = self.balanced_end(after) - else: - position = after + 1 - elif text.startswith(delimiter, position): - return position + len(delimiter) - elif not multiline and text[position] == "\n": - return position - else: - position += 1 - return len(text) - - def non_code_end(self, index: int) -> int | None: - """The end of the comment or string literal that starts at `index`, if one does.""" - comment = self.comment_end(index) - if comment is not None: - return comment - return self.string_end(index) - - def balanced_end(self, index: int) -> int: - """The position just past the bracket that closes the one at `index`.""" - text = self.text - closing = {"(": ")", "[": "]", "{": "}"} - stack = [closing[text[index]]] - position = index + 1 - while position < len(text) and stack: - skipped = self.non_code_end(position) - if skipped is not None: - position = skipped - continue - character = text[position] - if character in closing: - stack.append(closing[character]) - elif character == stack[-1]: - stack.pop() - position += 1 - return position - - def top_level_attributes(self) -> tuple[list[tuple[int, int]], bool]: - """(start, end) of every @Suite attribute at brace depth zero, arguments included, and whether - the file's braces balanced. When they do not, depth zero cannot be told from a nested scope, - so the attributes found are not a complete answer.""" - text = self.text - found: list[tuple[int, int]] = [] - depth = 0 - balanced = True - position = 0 - while True: - match = INTERESTING.search(text, position) - if match is None: - return found, balanced and depth == 0 - position = match.start() - skipped = self.non_code_end(position) - if skipped is not None: - position = skipped - continue - character = text[position] - if character == "{": - depth += 1 - elif character == "}": - if depth == 0: - balanced = False - else: - depth -= 1 - elif character == "@" and depth == 0: - attribute = ATTRIBUTE.match(text, position) - if attribute is not None: - end = self.arguments_end(attribute.end()) - found.append((position, end)) - position = end - continue - position += 1 - - def arguments_end(self, index: int) -> int: - position = index - while position < len(self.text) and self.text[position] in " \t": - position += 1 - if position < len(self.text) and self.text[position] == "(": - return self.balanced_end(position) - return index - - def arguments(self, start: int, end: int) -> list[str]: - """The top-level arguments of an attribute's argument list, comments removed.""" - text = self.text - opening = text.find("(", start, end) - if opening < 0: - return [] - items: list[str] = [] - current: list[str] = [] - position = opening + 1 - closing = end - 1 - while position < closing: - comment = self.comment_end(position) - if comment is not None: - current.append(" ") - position = comment - continue - skipped = self.string_end(position) - if skipped is None and text[position] in "([{": - skipped = self.balanced_end(position) - if skipped is not None: - current.append(text[position:skipped]) - position = skipped - continue - if text[position] == ",": - items.append("".join(current).strip()) - current = [] - else: - current.append(text[position]) - position += 1 - items.append("".join(current).strip()) - return [item for item in items if item] - - -def is_string_literal(expression: str) -> bool: - return SwiftText(expression).string_end(0) == len(expression) - - -def carries_a_trait(arguments: list[str]) -> bool: - return any(not is_string_literal(argument) for argument in arguments) - - -def scan(root: Path) -> tuple[list[tuple[str, int, str]], list[str]]: - """The top-level @Suite attributes with no trait, and the files whose braces did not balance.""" - found: list[tuple[str, int, str]] = [] - unreadable: list[str] = [] - for path in sorted((root / SCANNED).rglob("*.swift")): - text = path.read_text(encoding="utf-8", errors="replace") - if "Suite" not in text: - continue - source = SwiftText(text) - attributes, balanced = source.top_level_attributes() - relative = path.relative_to(root).as_posix() - if not balanced: - unreadable.append(relative) - for start, end in attributes: - if carries_a_trait(source.arguments(start, end)): - continue - line = text.count("\n", 0, start) + 1 - attribute = " ".join(text[start:end].split()) - found.append((relative, line, attribute)) - return found, unreadable - - -def offenders(root: Path) -> list[tuple[str, int, str]]: - return scan(root)[0] - - -def main_for(root: Path) -> int: - found, unreadable = scan(root) - if not found and not unreadable: - return 0 - - for path in unreadable: - print(f"{path}: braces do not balance as this check reads them, so it cannot tell top level from nested") - if unreadable: - print( - "The check reads Swift braces without a compiler and does not understand a regex literal or an #if whose " - "branches open a scope differently. Restructure the file so each branch opens and closes its own braces." - ) - print() - if not found: - return 1 - - for path, line, attribute in found: - print(f"{path}:{line}: {attribute}") - print() - print( - f"{len(found)} top-level @Suite attribute(s) with no trait. Each top-level @Suite adds compile time to the " - "test module quadratically, because every @Suite expansion walks every other top-level @Suite." - ) - print( - "Leave the type unannotated: Swift Testing finds its @Test functions anyway, and a @Test display name is " - "fine. Keep @Suite only to carry a trait such as .serialized or .enabled(if:)." - ) - return 1 - - -def main() -> int: - root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parents[2] - return main_for(root) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/test_check_log_privacy.py b/scripts/ci/test_check_log_privacy.py deleted file mode 100644 index 6b55321c82..0000000000 --- a/scripts/ci/test_check_log_privacy.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -"""Fixture tests for check-log-privacy.py, run against the real `.swiftlint.yml` regex.""" - -from __future__ import annotations - -import importlib.util -import shutil -import sys -import tempfile -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -SPEC = importlib.util.spec_from_file_location("check_log_privacy", Path(__file__).with_name("check-log-privacy.py")) -check = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(check) - -REJECTED = [ - 'logger.error("a \\(error.localizedDescription, privacy: .public)")', - 'logger.error("b \\(String(describing: error), privacy: .public)")', - 'logger.error("c \\(error, privacy: .public)")', - 'logger.error("d \\(String(describing: sqlError), privacy: .public)")', - 'logger.error("e \\(e, privacy: .public)")', - 'logger.error("f \\(error.message, privacy: .public)")', - 'logger.error("g \\(nioSslError.description, privacy: .public)")', - 'logger.error("h \\(error,\n privacy: .public)")', - 'logger.error("i \\(error?.localizedDescription ?? "nil", privacy: .public)")', - 'logger.error("j \\(error.localizedDescription.prefix(200), privacy: .public)")', - 'logger.error("k \\(scripting.errorDescription ?? "", privacy: .public)")', -] - -ACCEPTED = [ - 'logger.error("a \\(error.localizedDescription, privacy: .private)")', - 'logger.error("b \\(LogRedaction.publicDescription(of: error), privacy: .public)")', - 'logger.error("c \\(error.publicLogShape, privacy: .public)")', - 'logger.error("d \\(errorClass, privacy: .public)")', - 'logger.error("e \\(errors.count, privacy: .public)")', - 'logger.error("f \\(error.code, privacy: .public)")', - 'logger.error("g \\(String(describing: type(of: error)), privacy: .public)")', - 'logger.error("h \\(hasError, privacy: .public)")', - 'logger.error("i \\(isError, privacy: .public)")', -] - - -class CheckLogPrivacyTests(unittest.TestCase): - def setUp(self) -> None: - self.root = Path(tempfile.mkdtemp()) - shutil.copy(ROOT / ".swiftlint.yml", self.root / ".swiftlint.yml") - self.pattern = check.rule_pattern(self.root / ".swiftlint.yml") - - def tearDown(self) -> None: - shutil.rmtree(self.root) - - def write(self, directory: str, name: str, lines: list[str]) -> None: - folder = self.root / directory - folder.mkdir(parents=True, exist_ok=True) - (folder / name).write_text("\n".join(lines) + "\n", encoding="utf-8") - - def test_every_rejected_spelling_is_reported_on_its_own_line(self) -> None: - self.write("Plugins/Driver", "Rejected.swift", REJECTED) - found = check.offenders(self.root, self.pattern) - self.assertEqual(found, [f"Plugins/Driver/Rejected.swift:{number}" for number in [*range(1, 9), 10, 11, 12]]) - - def test_safe_spellings_are_not_reported(self) -> None: - self.write("Plugins/Driver", "Accepted.swift", ACCEPTED) - self.assertEqual(check.offenders(self.root, self.pattern), []) - - def test_app_and_packages_are_scanned_and_build_output_is_not(self) -> None: - self.write("TablePro/Core", "App.swift", REJECTED[:1]) - self.write("Packages/Core/Sources", "Package.swift", REJECTED[:1]) - self.write("Packages/Core/.build/checkouts/dep", "Vendored.swift", REJECTED[:1]) - found = check.offenders(self.root, self.pattern) - self.assertEqual(found, ["TablePro/Core/App.swift:1", "Packages/Core/Sources/Package.swift:1"]) - - def test_the_repository_itself_is_clean(self) -> None: - self.assertEqual(check.offenders(ROOT, check.rule_pattern(ROOT / ".swiftlint.yml")), []) - - -if __name__ == "__main__": - sys.exit(unittest.main()) diff --git a/scripts/ci/test_check_string_catalogs.py b/scripts/ci/test_check_string_catalogs.py new file mode 100644 index 0000000000..5f565b6af7 --- /dev/null +++ b/scripts/ci/test_check_string_catalogs.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Fixture tests for check-string-catalogs.py.""" + +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + +SPEC = importlib.util.spec_from_file_location("check", Path(__file__).with_name("check-string-catalogs.py")) +check = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(check) + + +def catalog(key: str, value: str, source_subs: dict | None = None, value_subs: dict | None = None) -> dict: + def localization(text: str, subs: dict | None) -> dict: + entry = {"stringUnit": {"state": "translated", "value": text}} + if subs: + entry["substitutions"] = {f"s{i}": {"argNum": i, "formatSpecifier": f} for i, f in subs.items()} + return entry + + return { + "sourceLanguage": "en", + "strings": {key: {"localizations": {"en": localization(key, source_subs), "tr": localization(value, value_subs)}}}, + } + + +class SpecifierTests(unittest.TestCase): + def test_plain_length_width_and_literal_percent(self) -> None: + self.assertEqual(check.specifiers("%@ rows"), [(None, "@")]) + self.assertEqual([t for _, t in check.specifiers("%lld of %d")], ["lld", "d"]) + self.assertEqual(check.specifiers("%-8.2f"), [(None, "f")]) + self.assertEqual(check.specifiers("100%% done"), []) + self.assertEqual(check.specifiers("%2$@ on %1$@"), [(2, "@"), (1, "@")]) + + def test_substitution_placeholder_is_not_a_specifier(self) -> None: + self.assertEqual(check.specifiers("with %#@points@"), []) + + +class RuleTests(unittest.TestCase): + def assertFlags(self, payload: dict, fragment: str | None) -> None: + found = check.offenders("TablePro", payload) + if fragment is None: + self.assertEqual(found, []) + else: + self.assertTrue(any(fragment in line for line in found), found) + + def test_reordered_positional_translation_passes(self) -> None: + self.assertFlags(catalog("%1$@ on %2$@", "%2$@ üzerinde %1$@"), None) + + def test_dropped_retyped_and_mixed_arguments_are_caught(self) -> None: + self.assertFlags(catalog("%1$@ on %2$@", "%1$@"), "consumes") + self.assertFlags(catalog("%lld rows", "%@ satır"), "argument 1") + self.assertFlags(catalog("%1$@ on %2$@", "%1$@ üzerinde %@"), "mixes") + + def test_plural_substitution_counts_as_its_argument(self) -> None: + source = "%1$@ chart of %2$@ by %3$@ with %#@points@" + self.assertFlags(catalog(source, "%3$@ %2$@ %4$d %1$@", {4: "d"}), None) + self.assertFlags(catalog("%1$@ with %#@points@", "%1$@", {2: "d"}), "consumes") + self.assertFlags(catalog("%1$@ with %#@points@", "%1$@ với %2$@", {2: "d"}), "argument 2") + + def test_markup_tokens_ellipsis_and_sentence_marks(self) -> None: + self.assertFlags(catalog("Rows", "^[satır](inflect: true)"), "inflection") + self.assertFlags(catalog("Add a row to ${table}", "Bir satır ekle"), "token") + self.assertFlags(catalog("Open…", "Aç"), "ellipsis") + self.assertFlags(catalog("Done.", "Bitti"), "closing .") + self.assertFlags(catalog("Done.", "已完成。"), None) + self.assertFlags(catalog("New Table...", "Yeni Tablo…"), "three periods") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_check_test_suite_attributes.py b/scripts/ci/test_check_test_suite_attributes.py deleted file mode 100644 index a9e7c1b404..0000000000 --- a/scripts/ci/test_check_test_suite_attributes.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python3 -"""Fixture tests for check-test-suite-attributes.py.""" - -from __future__ import annotations - -import importlib.util -import io -import shutil -import sys -import tempfile -import unittest -from contextlib import redirect_stdout -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -SPEC = importlib.util.spec_from_file_location( - "check_test_suite_attributes", Path(__file__).with_name("check-test-suite-attributes.py") -) -check = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(check) - -REJECTED = { - "DisplayName.swift": ('@Suite("Display name")\nstruct DisplayNameTests {}\n', 1), - "SameLineAsActor.swift": ('import Testing\n\n@MainActor @Suite("Display name")\nstruct ActorTests {}\n', 3), - "SameLineAsType.swift": ('@Suite("Display name") struct SameLineTests {}\n', 1), - "Wrapped.swift": ('@Suite(\n "Display name"\n)\nstruct WrappedTests {}\n', 1), - "Bare.swift": ("@Suite\nstruct BareTests {}\n", 1), - "Empty.swift": ("@Suite()\nstruct EmptyTests {}\n", 1), - "RawString.swift": ('@Suite(#"A "quoted" name"#)\nstruct RawStringTests {}\n', 1), - "EscapedQuote.swift": ('@Suite("A \\"quoted\\" name")\nstruct EscapedQuoteTests {}\n', 1), - "Qualified.swift": ('@Testing.Suite("Display name")\nstruct QualifiedTests {}\n', 1), - "CommentInArguments.swift": ('@Suite("Display name" /* , .serialized */)\nstruct CommentTests {}\n', 1), - "Conditional.swift": ('#if DEBUG\n @Suite("Display name")\n struct ConditionalTests {}\n#endif\n', 2), - "AfterMultilineString.swift": ( - 'let sql = """\n }\n """\n\n@Suite("Display name")\nstruct AfterStringTests {}\n', - 5, - ), -} - -ACCEPTED = { - "Serialized.swift": '@Suite("Display name", .serialized)\nstruct SerializedTests {}\n', - "TraitOnly.swift": "@Suite(.serialized)\nstruct TraitOnlyTests {}\n", - "WrappedTraits.swift": ( - '@Suite(\n "Display name",\n .serialized,\n .enabled(if: Server.isConfigured)\n)\n' - "struct WrappedTraitsTests {}\n" - ), - "ConditionWithString.swift": ( - '@Suite("Display name", .enabled(if: ProcessInfo.processInfo.environment["CI"] == nil))\n' - "struct ConditionTests {}\n" - ), - "Nested.swift": 'struct OuterTests {\n @Suite("Nested")\n struct InnerTests {}\n}\n', - "NestedInExtension.swift": 'extension OuterTests {\n @MainActor @Suite("Nested")\n struct MoreTests {}\n}\n', - "BracesInStrings.swift": ( - "struct BraceTests {\n" - ' let brace = "}"\n' - ' let raw = #"}"}"#\n' - ' let interpolated = "\\(["}": "}"].count) }"\n' - ' let block = """\n }\n """\n' - ' @Suite("Nested")\n' - " struct InnerTests {}\n" - "}\n" - ), - "Comments.swift": ( - '// @Suite("Commented out")\n' - '/* @Suite("Commented out") /* nested } */ @Suite("Still commented out") */\n' - "struct CommentedTests {}\n" - ), - "Strings.swift": 'let text = "@Suite(\\"In a string\\")"\nlet block = """\n@Suite("In a string")\n"""\n', - "OtherAttribute.swift": "@SuiteHelper\nstruct HelperTests {}\n", - "TestDisplayName.swift": 'struct NamedTests {\n @Test("A display name")\n func named() {}\n}\n', -} - - -class CheckTestSuiteAttributesTests(unittest.TestCase): - def setUp(self) -> None: - self.root = Path(tempfile.mkdtemp()) - - def tearDown(self) -> None: - shutil.rmtree(self.root) - - def write(self, directory: str, name: str, text: str) -> None: - folder = self.root / directory - folder.mkdir(parents=True, exist_ok=True) - (folder / name).write_text(text, encoding="utf-8") - - def test_every_suite_without_a_trait_is_reported_on_its_own_line(self) -> None: - for name, (text, _) in REJECTED.items(): - self.write("TableProTests/Rejected", name, text) - found = {(path, line) for path, line, _ in check.offenders(self.root)} - expected = {(f"TableProTests/Rejected/{name}", line) for name, (_, line) in REJECTED.items()} - self.assertEqual(found, expected) - - def test_suites_with_traits_nested_suites_and_non_code_are_not_reported(self) -> None: - for name, text in ACCEPTED.items(): - self.write("TableProTests/Accepted", name, text) - self.assertEqual(check.offenders(self.root), []) - - def test_the_report_quotes_the_attribute_on_one_line(self) -> None: - self.write("TableProTests", "Wrapped.swift", REJECTED["Wrapped.swift"][0]) - self.write("TableProTests", "SameLineAsActor.swift", REJECTED["SameLineAsActor.swift"][0]) - self.assertEqual( - check.offenders(self.root), - [ - ("TableProTests/SameLineAsActor.swift", 3, '@Suite("Display name")'), - ("TableProTests/Wrapped.swift", 1, '@Suite( "Display name" )'), - ], - ) - - def test_only_the_app_test_target_is_scanned(self) -> None: - self.write("TableProTests", "App.swift", REJECTED["DisplayName.swift"][0]) - self.write("Packages/Core/Tests/CoreTests", "Package.swift", REJECTED["DisplayName.swift"][0]) - self.write("TableProUITests", "UI.swift", REJECTED["DisplayName.swift"][0]) - found = [path for path, _, _ in check.offenders(self.root)] - self.assertEqual(found, ["TableProTests/App.swift"]) - - def test_a_failure_names_the_site_the_cause_and_the_remedy(self) -> None: - self.write("TableProTests", "App.swift", REJECTED["DisplayName.swift"][0]) - output = io.StringIO() - with redirect_stdout(output): - self.assertEqual(check.main_for(self.root), 1) - text = output.getvalue() - self.assertIn('TableProTests/App.swift:1: @Suite("Display name")', text) - self.assertIn("quadratically", text) - self.assertIn("Leave the type unannotated", text) - - def test_a_clean_tree_passes_silently(self) -> None: - self.write("TableProTests", "App.swift", ACCEPTED["Serialized.swift"]) - output = io.StringIO() - with redirect_stdout(output): - self.assertEqual(check.main_for(self.root), 0) - self.assertEqual(output.getvalue(), "") - - def test_a_file_whose_braces_do_not_balance_fails_instead_of_passing(self) -> None: - conditional_header = ( - "#if canImport(AppKit)\n" - "extension Foo: NSObjectProtocol {\n" - "#else\n" - "extension Foo {\n" - "#endif\n" - " func a() {}\n" - "}\n\n" - '@Suite("After the conditional header")\n' - "struct AfterTests {}\n" - ) - self.write("TableProTests", "ConditionalHeader.swift", conditional_header) - self.write("TableProTests", "ExtraClose.swift", '}\n@Suite("After a stray brace")\nstruct StrayTests {}\n') - _, unreadable = check.scan(self.root) - self.assertEqual( - unreadable, - ["TableProTests/ConditionalHeader.swift", "TableProTests/ExtraClose.swift"], - ) - output = io.StringIO() - with redirect_stdout(output): - self.assertEqual(check.main_for(self.root), 1) - self.assertIn("TableProTests/ConditionalHeader.swift: braces do not balance", output.getvalue()) - - def test_the_repository_itself_is_clean(self) -> None: - self.assertEqual(check.scan(ROOT), ([], [])) - - -if __name__ == "__main__": - sys.exit(unittest.main()) diff --git a/scripts/dynamodb-test-local.sh b/scripts/dynamodb-test-local.sh deleted file mode 100755 index a4007ca37d..0000000000 --- a/scripts/dynamodb-test-local.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -# -# dynamodb-test-local.sh: start or stop the DynamoDB Local that DynamoDBLocalIntegrationTests runs -# against. -# -# The DynamoDB unit suites are pure logic and never open a socket. The integration suite drives the -# real driver end to end, so it needs a DynamoDB, and it skips itself unless one answers on -# 127.0.0.1:18000. It creates its own uniquely named tables and deletes them afterwards. -# -# Usage: -# scripts/dynamodb-test-local.sh up # start it (in memory, shared database) -# scripts/dynamodb-test-local.sh down # remove it -# -# Then: -# .claude/skills/fix-issue/scripts/verify.sh test DynamoDBLocalIntegrationTests -# -# DynamoDB Local is not the service. It updates ItemCount live where AWS refreshes it about every -# six hours, it has no point-in-time recovery or tags, and it accepts any credentials, so a green -# run here says nothing about authentication, throttling or those settings. - -set -euo pipefail - -CONTAINER="${TABLEPRO_DYNAMODB_TEST_CONTAINER:-tp-dynamodb-it}" -PORT="${TABLEPRO_DYNAMODB_TEST_PORT:-18000}" -IMAGE="${TABLEPRO_DYNAMODB_TEST_IMAGE:-amazon/dynamodb-local:latest}" -ACTION="${1:-up}" - -case "$ACTION" in -up) - if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then - echo "$CONTAINER is already running on port $PORT." - exit 0 - fi - docker rm -f "$CONTAINER" > /dev/null 2>&1 || true - docker run -d --name "$CONTAINER" -p "127.0.0.1:${PORT}:8000" "$IMAGE" \ - -jar DynamoDBLocal.jar -inMemory -sharedDb > /dev/null - for _ in $(seq 1 30); do - if curl -s -o /dev/null "http://127.0.0.1:${PORT}"; then - echo "DynamoDB Local is answering on 127.0.0.1:${PORT}." - exit 0 - fi - sleep 1 - done - echo "DynamoDB Local did not answer on 127.0.0.1:${PORT} within 30 seconds." >&2 - exit 1 - ;; -down) - docker rm -f "$CONTAINER" > /dev/null 2>&1 || true - echo "Removed $CONTAINER." - ;; -*) - echo "Usage: $0 up|down" >&2 - exit 2 - ;; -esac diff --git a/scripts/generate-ios-acknowledgements.py b/scripts/generate-ios-acknowledgements.py index 0a465dd272..9d6e8841ec 100755 --- a/scripts/generate-ios-acknowledgements.py +++ b/scripts/generate-ios-acknowledgements.py @@ -3,7 +3,7 @@ The iOS app does not link Yams, so it reads a JSON projection of licenses.yml: every component whose platforms include ios, sorted by name, with only the fields its Acknowledgements screen shows. -ThirdPartyLicenseInventoryTests fails when the committed file differs from this output. +scripts/ci/check-license-inventory.py fails when the committed file differs from this output. Usage: scripts/generate-ios-acknowledgements.py diff --git a/scripts/kafka-test-broker.sh b/scripts/kafka-test-broker.sh deleted file mode 100755 index e4fbe749a0..0000000000 --- a/scripts/kafka-test-broker.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env bash -# -# kafka-test-broker.sh: start or stop the broker KafkaIntegrationTests runs against. -# -# The Kafka unit suites are pure logic and never open a socket. The integration suite drives -# the real driver end to end, so it needs a real broker, and it skips itself unless one -# answers on TABLEPRO_KAFKA_TEST_BOOTSTRAP. -# -# Usage: -# scripts/kafka-test-broker.sh up [--brokers N] # start it and print the exports -# scripts/kafka-test-broker.sh down # remove it -# scripts/kafka-test-broker.sh env # print the exports for one already running -# -# Then: -# eval "$(scripts/kafka-test-broker.sh env)" -# .claude/skills/fix-issue/scripts/verify.sh test KafkaIntegrationTests -# -# Two listeners, not one. The CLI runs inside a container and must reach the cluster at its -# INTERNAL advertised address, while the driver connects from the host to the EXTERNAL one. A -# single listener advertising "localhost" satisfies exactly one of those and fails the other -# with a node-assignment timeout. -# -# --brokers takes a count because one broker cannot reproduce a routing bug. On a single-node -# cluster that node leads every partition and coordinates every group, so a client that sends -# every request to whichever broker it happens to hold is indistinguishable from a correct one. -# That is why the suite passed for the whole life of issue #2993, and why three is the default -# the routing tests ask for: enough that a topic's partitions land on brokers the client did not -# connect to. - -set -euo pipefail - -CONTAINER="${TABLEPRO_KAFKA_TEST_CONTAINER:-tp-kafka-it}" -PORT="${TABLEPRO_KAFKA_TEST_PORT:-19092}" -IMAGE="${TABLEPRO_KAFKA_TEST_IMAGE:-apache/kafka:latest}" -NETWORK="${CONTAINER}-net" -# Fixed so every node of one cluster formats the same storage id. Generated ids differ per -# container and the nodes then refuse to form a quorum. -CLUSTER_ID="${TABLEPRO_KAFKA_TEST_CLUSTER_ID:-5L6g3nShT-eMCtK--X86sw}" - -BROKERS=1 -ACTION="${1:-up}" -shift || true -while [ $# -gt 0 ]; do - case "$1" in - --brokers) - BROKERS="${2:-1}" - shift 2 - ;; - *) - echo "Unknown option: $1" >&2 - exit 2 - ;; - esac -done - -if ! [ "$BROKERS" -ge 1 ] 2>/dev/null; then - echo "--brokers needs a count of 1 or more." >&2 - exit 2 -fi - -container_name() { - if [ "$1" -eq 1 ]; then echo "$CONTAINER"; else echo "${CONTAINER}-$1"; fi -} - -external_port() { - echo $((PORT + $1 - 1)) -} - -print_env() { - echo "export TABLEPRO_KAFKA_TEST_BOOTSTRAP=127.0.0.1:${PORT}" - echo "export TABLEPRO_KAFKA_TEST_CONTAINER=${CONTAINER}" -} - -remove_all() { - # A bounded sweep rather than a name glob, so this never reaches a container the script - # did not start. - for node in $(seq 1 16); do - docker rm -f "$(container_name "$node")" >/dev/null 2>&1 || true - done - docker network rm "$NETWORK" >/dev/null 2>&1 || true -} - -case "$ACTION" in -up) - if ! docker info >/dev/null 2>&1; then - echo "Docker is not running." >&2 - exit 1 - fi - - remove_all - docker network create "$NETWORK" >/dev/null 2>&1 || true - - voters="" - for node in $(seq 1 "$BROKERS"); do - voters="${voters:+$voters,}${node}@$(container_name "$node"):9093" - done - - for node in $(seq 1 "$BROKERS"); do - name="$(container_name "$node")" - port="$(external_port "$node")" - docker run -d --name "$name" --network "$NETWORK" -p "${port}:${port}" \ - -e CLUSTER_ID="$CLUSTER_ID" \ - -e KAFKA_NODE_ID="$node" \ - -e KAFKA_PROCESS_ROLES=broker,controller \ - -e "KAFKA_LISTENERS=INTERNAL://:9092,EXTERNAL://:${port},CONTROLLER://:9093" \ - -e "KAFKA_ADVERTISED_LISTENERS=INTERNAL://${name}:9092,EXTERNAL://127.0.0.1:${port}" \ - -e KAFKA_INTER_BROKER_LISTENER_NAME=INTERNAL \ - -e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \ - -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT \ - -e "KAFKA_CONTROLLER_QUORUM_VOTERS=$voters" \ - -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR="$BROKERS" \ - -e KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR="$BROKERS" \ - -e KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 \ - -e KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0 \ - -e KAFKA_AUTO_CREATE_TOPICS_ENABLE=false \ - "$IMAGE" >/dev/null - done - - printf 'Waiting for %s broker(s)' "$BROKERS" >&2 - for _ in $(seq 1 90); do - ready=$(docker exec "$CONTAINER" /opt/kafka/bin/kafka-broker-api-versions.sh \ - --bootstrap-server localhost:9092 2>/dev/null | grep -c 'id:' || true) - if [ "${ready:-0}" -ge "$BROKERS" ]; then - echo " ready." >&2 - print_env - exit 0 - fi - printf '.' >&2 - sleep 2 - done - - echo " gave up." >&2 - docker logs --tail 40 "$CONTAINER" >&2 || true - exit 1 - ;; -down) - remove_all - echo "Removed $CONTAINER." >&2 - ;; -env) - print_env - ;; -*) - echo "Usage: $0 [up [--brokers N]|down|env]" >&2 - exit 2 - ;; -esac From 328413f9e306169ed1c1394b779ed0927758b5da Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:09:45 +0700 Subject: [PATCH 3/3] ci: run tests from Unit and UI plans in one macOS job --- .../references/verification-apple.md | 2 +- .../fix-issue/scripts/lib/platform-apple.sh | 653 +- .claude/skills/fix-issue/scripts/verify.sh | 53 +- .claude/skills/release/SKILL.md | 67 +- .github/actionlint.yaml | 4 + .github/actions/setup-xcode/action.yml | 22 +- .../actions/unpack-test-products/action.yml | 86 - .github/macos-test-quarantine.txt | 4 - .github/macos-ui-test-quarantine.txt | 4 - .github/workflows/build-plugin.yml | 25 +- .github/workflows/build.yml | 102 +- .github/workflows/ios-tests.yml | 213 +- .github/workflows/macos-tests.yml | 558 +- .github/workflows/pr-title.yml | 14 +- .github/workflows/repo-hygiene.yml | 272 +- .github/workflows/swiftlint.yml | 20 +- Configs/Base.xcconfig | 10 +- RELEASING.md | 33 +- .../Compare/CompareSyncProfileStorage.swift | 2 +- .../Plugins/Registry/RegistryClient.swift | 2 +- .../Infrastructure/MacAnalyticsProvider.swift | 2 +- .../Core/Storage/ColumnLayoutPersister.swift | 2 +- TablePro/Core/Storage/ConnectionStorage.swift | 2 +- .../Storage/CustomSlashCommandStorage.swift | 2 +- .../ExternalConnectionTrustStore.swift | 2 +- .../Core/Storage/FavoriteTablesStorage.swift | 2 +- .../Core/Storage/FilterSettingsStorage.swift | 2 +- TablePro/Core/Storage/MCPApprovalStore.swift | 2 +- TablePro/Core/Storage/RecentTablesStore.swift | 2 +- .../Core/Storage/TransferDialogStorage.swift | 2 +- .../Storage/WorkspaceRailOrderStore.swift | 2 +- .../UsersRoles/PrivilegeExpansionStore.swift | 2 +- .../UI/QuickSwitcherFrecencyStore.swift | 2 +- TablePro/Models/UI/WindowSidebarState.swift | 2 +- .../ViewModels/QuickSwitcherViewModel.swift | 2 +- .../WelcomeViewModel+WelcomeSheet.swift | 2 +- TablePro/ViewModels/WelcomeViewModel.swift | 5 +- .../QueryPlan/QueryPlanOutlineView.swift | 3 +- .../PrivilegeScopeOutlineView.swift | 3 +- .../TableProMobile/Localizable.xcstrings | 8200 +++++++++-------- .../Core/Compare/CompareRunClaimTests.swift | 4 +- .../CompareSyncSessionDataScopeTests.swift | 4 +- .../CompareSyncSetupRestoreTests.swift | 4 +- .../Core/Plugins/PluginSettingsTests.swift | 2 +- .../Core/Plugins/RegistryClientURLTests.swift | 44 +- .../Core/Storage/KeychainHelperTests.swift | 11 +- .../StandardDomainIsolationTests.swift | 64 + .../SyncMetadataStorageEnvironmentTests.swift | 6 +- .../ViewModels/SidebarViewModelTests.swift | 36 +- .../ViewModels/WelcomeViewModelTests.swift | 15 +- .../Compare/CompareRowGridModelTests.swift | 4 +- .../Views/QueryPlan/QueryPlanThemeTests.swift | 7 + TestPlans/UI.xctestplan | 29 + TestPlans/Unit.xctestplan | 62 + project.yml | 10 +- scripts/ci/check-test-plans.py | 168 + scripts/ci/detect-changed-paths.sh | 124 +- scripts/ci/list-tests.sh | 58 - scripts/ci/list_tests.py | 107 - scripts/ci/pull-release.py | 16 + scripts/ci/summarize-xcresult.sh | 201 +- scripts/ci/test_check_test_plans.py | 199 + scripts/ci/test_list_tests.py | 106 - scripts/ci/test_pull_release.py | 62 + 64 files changed, 6004 insertions(+), 5728 deletions(-) create mode 100644 .github/actionlint.yaml delete mode 100644 .github/actions/unpack-test-products/action.yml delete mode 100644 .github/macos-test-quarantine.txt delete mode 100644 .github/macos-ui-test-quarantine.txt create mode 100644 TableProTests/Core/Storage/StandardDomainIsolationTests.swift create mode 100644 TestPlans/UI.xctestplan create mode 100644 TestPlans/Unit.xctestplan create mode 100755 scripts/ci/check-test-plans.py delete mode 100755 scripts/ci/list-tests.sh delete mode 100755 scripts/ci/list_tests.py create mode 100644 scripts/ci/test_check_test_plans.py delete mode 100644 scripts/ci/test_list_tests.py diff --git a/.claude/skills/fix-issue/references/verification-apple.md b/.claude/skills/fix-issue/references/verification-apple.md index 3c7159c77a..46b845d38c 100644 --- a/.claude/skills/fix-issue/references/verification-apple.md +++ b/.claude/skills/fix-issue/references/verification-apple.md @@ -42,7 +42,7 @@ A raw `xcodebuild` failure comes back as a head-and-tail excerpt of about 10,000 - **The accessibility tree differs between this machine and the CI runner.** A contextual `NSMenu` has its identifier locally and not on CI, so reach it with `app.windows.firstMatch.menus.firstMatch`. The data grid is identifier `data-grid`, since the window holds several tables. Click the grid at an offset from `data-grid`, never on a row or cell element, which XCUITest reads as obscured. - **`.accessibilityIdentifier` on a SwiftUI container replaces the identifier of every child** in that hosting tree. When the container needs one, put `.accessibilityElement(children: .contain)` before it. - **Diagnose by dumping the tree** (`print(app.windows.firstMatch.debugDescription)` inside a `UITestCase`), not by guessing. -- **When UI tests cannot start on this machine** (`Timed out while enabling automation mode`, zero cases), reproduce it on an untouched suite, then say in the PR that CI runs the suite. When a test fails only on the runner for a reason you cannot establish, quarantine it in `.github/macos-ui-test-quarantine.txt` with the reason instead of guessing at a fix. +- **When UI tests cannot start on this machine** (`Timed out while enabling automation mode`, zero cases), reproduce it on an untouched suite, then say in the PR that CI runs the suite. When a test fails only on the runner for a reason you cannot establish, mark it XCTExpectFailure with the reason and an issue link instead of guessing at a fix. ## Lint diff --git a/.claude/skills/fix-issue/scripts/lib/platform-apple.sh b/.claude/skills/fix-issue/scripts/lib/platform-apple.sh index 2a3d96404c..9e7fe6b7fa 100644 --- a/.claude/skills/fix-issue/scripts/lib/platform-apple.sh +++ b/.claude/skills/fix-issue/scripts/lib/platform-apple.sh @@ -5,22 +5,22 @@ # verify.sh [options] generate # verify.sh [options] build [Scheme] # default scheme: TablePro # verify.sh [options] plugins # AllPlugins aggregate -# verify.sh [options] test [Suite…] -# verify.sh [options] uitest [Suite…] -# verify.sh [options] package [filter] # swift test in Packages/ -# verify.sh [options] ios [Suite…] # TableProMobileTests on a simulator +# verify.sh [options] test [Test...] # TestPlans/Unit.xctestplan, all of it when no Test is named +# verify.sh [options] uitest [Test...] # TestPlans/UI.xctestplan, all of it when no Test is named +# verify.sh [options] package [regex] # swift test in Packages/ +# verify.sh [options] ios [Suite...] # TableProMobileTests on a simulator # verify.sh [options] abi -# verify.sh [options] lint [path…] +# verify.sh [options] lint [path...] # verify.sh [options] docs [docs-checkout] # TableProApp/docs checks against this tree (default ../tablepro-docs) -# verify.sh [options] l10n # plugin and package strings in the app catalog, managed manually +# verify.sh [options] l10n # plugin and package strings in the mac and ios catalogs # +# A Test is Suite, Suite/case(), or either behind its target name (TableProGrammarsTests/Suite). +# A bare Suite is a Swift type name, looked up in every test target of the tree. +# A test target no plan lists (TableProCore, TableProOracle) runs under swift test, not xcodebuild. +# `test` with no Test runs the Unit plan only; those two packages run with `package `. +# A name no target declares exits 3 before anything builds. A Test that runs nothing is FAIL. # Here --no-wait skips the wait for another xcodebuild, and --offline pins SwiftPM to Package.resolved. -# Sourced by verify.sh, which owns the options, the log and the verdict. A backend defines -# platform_step, which runs one step and ends in emit, and platform_parse, which re-derives a -# verdict from a stored log that has no receipt. STEP_DETAIL is read by emit over there, which is -# all SC2034 is off for. - MAX_WAIT_SECONDS=1800 # Known false failures in this checkout. Each one presents as a code defect and is not one. @@ -31,7 +31,7 @@ diagnose_environment() { found=0 fi if grep -q 'produced no further output' "$log" 2> /dev/null; then - note "cause: the Swift frontend crashed without a diagnostic. Flaky on this machine. Rerun before believing any 'cannot find type' error." + note "cause: the Swift frontend crashed without a diagnostic. Rerun before believing any 'cannot find type' error." found=0 fi if grep -q "Could not resolve package dependencies\|Couldn't fetch updates from remote" "$log" 2> /dev/null; then @@ -43,33 +43,33 @@ diagnose_environment() { found=0 fi if grep -q 'The test runner hung before establishing connection' "$log" 2> /dev/null; then - note "cause: the XCTest host wedged before running anything. Check the executed count below." + note "cause: the XCTest host wedged before running anything." + found=0 + fi + if grep -q 'Timed out while enabling automation mode\|failed to initialize for UI testing' "$log" 2> /dev/null; then + note "cause: Automation Mode is off and XCTest could not turn it on. Check with automationmodetool; enabling it needs an admin password." found=0 fi return $found } report_errors() { - local log="$1" - local errors + local log="$1" errors total errors="$(grep -E '(^|[^a-zA-Z])error: ' "$log" 2> /dev/null \ | sed 's/^.*\/\([^/]*\.swift:[0-9]*:[0-9]*\)/\1/' \ | sort -u | head -12)" - if [ -n "$errors" ]; then - echo "errors:" - printf '%s\n' "$errors" | sed 's/^/ /' - local total - total="$(grep -cE '(^|[^a-zA-Z])error: ' "$log" 2> /dev/null)" - [ "$total" -gt 12 ] && echo " … $((total - 12)) more, see the log" - fi + [ -n "$errors" ] || return 0 + echo "errors:" + printf '%s\n' "$errors" | sed 's/^/ /' + total="$(grep -cE '(^|[^a-zA-Z])error: ' "$log" 2> /dev/null)" + [ "$total" -gt 12 ] && echo " ... $((total - 12)) more, see the log" } # ---------------------------------------------------------------------------- environment setup_toolchain() { [ -x "${DEVELOPER_DIR:-/nonexistent}/usr/bin/xcodebuild" ] && return 0 - # The Xcode that xcode-select names wins when it is a full Xcode. Command Line Tools has no - # xcodebuild and no sourcekitd, so fall back to whichever Xcode is installed. + # Command Line Tools has no xcodebuild and no sourcekitd, so fall back to an installed Xcode. local candidate for candidate in "$(xcode-select -p 2> /dev/null)" \ /Applications/Xcode.app/Contents/Developer \ @@ -81,9 +81,7 @@ setup_toolchain() { done } -# Only a build of THIS checkout contends: every worktree builds into its own DerivedData, so a -# machine-wide wait stalled a worktree run for as long as a peer session built somewhere else. -# A build belongs here when it names this checkout's project or runs from its root. +# Every worktree builds into its own DerivedData, so only a build of this checkout contends. checkout_busy() { local pid for pid in $(pgrep -f 'Developer/usr/bin/xcodebuild' 2> /dev/null); do @@ -114,110 +112,317 @@ xcodebuild_flags() { is_offline && printf ' %s' "-disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile" } -# ---------------------------------------------------------------------------- test reporting - -# Mutes failing cases listed in the CI quarantine files, so a red run reports only the failures -# that are new. -PASS_PATTERN="^test case .* passed|✔ test .* passed" -FAIL_PATTERN="^test case .* failed|✘ test .* (failed|recorded an issue)" - -report_tests() { - local log="$1" - local passed failed executed - passed="$(grep -ciE "$PASS_PATTERN" "$log" 2> /dev/null)" - failed="$(grep -ciE "$FAIL_PATTERN" "$log" 2> /dev/null)" - executed=$((passed + failed)) - note "cases: $executed executed, $passed passed, $failed failed" - - if [ "$executed" -eq 0 ]; then - STATUS=INCONCLUSIVE - note "cause: zero cases executed. The filter matched nothing, or the host wedged. A -only-testing filter naming a Swift Testing @Test function silently matches nothing and still prints TEST SUCCEEDED. Filter by suite." - return - fi - if [ "$failed" -eq 0 ]; then - # A crash, a timeout or an unrecognised line format fails the run without a case line the - # patterns above can count. This printed PASS over a log that said TEST FAILED. - if grep -qE '^\*\* TEST FAILED \*\*|^Failing tests:' "$log" 2> /dev/null; then - STATUS=FAIL - note "cause: xcodebuild reported a failure that no counted case line names. Read the block below." - note "$(grep -A 8 '^Failing tests:' "$log" 2> /dev/null | sed 's/^/ /' | head -10)" - return +# ---------------------------------------------------------------------------- test selection + +# One line per test target, "": the plan that lists it (Unit, UI), or +# swift: for a package test target no plan lists. +test_targets_table() { + local plan manifest package target table="" + for plan in Unit UI; do + [ -f "$REPO_ROOT/TestPlans/$plan.xctestplan" ] || continue + table="$table$(jq -r --arg plan "$plan" '.testTargets[].target.name + "\t" + $plan' \ + "$REPO_ROOT/TestPlans/$plan.xctestplan")"$'\n' + done + for manifest in "$REPO_ROOT"/Packages/*/Package.swift; do + [ -f "$manifest" ] || continue + package="${manifest%/Package.swift}" + package="${package##*/}" + for target in $(awk '/\.testTarget\(/ { t = 1 } + t && /name:/ { if (match($0, /"[^"]+"/)) print substr($0, RSTART + 1, RLENGTH - 2); t = 0 }' "$manifest"); do + case $'\n'"$table" in + *$'\n'"$target"$'\t'*) ;; + *) table="$table$target"$'\t'"swift:$package"$'\n' ;; + esac + done + done + printf '%s' "$table" +} + +route_of() { + printf '%s' "$TEST_TARGETS" | awk -F'\t' -v t="$1" '$1 == t { print $2; exit }' +} + +# The test targets that declare a type named $1, one per line. A file outside every known target +# prints as ?. +targets_declaring() { + local file part target + git -C "$REPO_ROOT" grep --untracked -l -E \ + "^[^/\"]*(struct|class|enum|actor|extension)[[:space:]]+$1([^A-Za-z0-9_]|\$)" -- '*Tests/*.swift' 2> /dev/null \ + | while IFS= read -r file; do + target="" + IFS=/ read -r -a parts <<< "$file" + for part in "${parts[@]}"; do + [ -n "$(route_of "$part")" ] && { target="$part"; break; } + done + printf '%s\n' "${target:-?${file%/*}}" + done | sort -u +} + +# Sets TARGET, TEST_PATH and ROUTE for one Test argument, or says why it cannot and fails. +resolve_test() { + local first="${1%%/*}" rest="" found known + [ "$first" = "$1" ] || rest="${1#*/}" + case "$first" in + '' | *[!A-Za-z0-9_]*) echo "not a test name: $1" >&2; return 1 ;; + esac + TARGET="" TEST_PATH="$rest" + # TableProGrammarsTests/case() names a suite that shares its target's name, not a target. + if [ -n "$(route_of "$first")" ] \ + && ! { [ -n "$rest" ] && [ "${rest#*/}" = "$rest" ] && [ "${rest%)}" != "$rest" ]; }; then + TARGET="$first" + else + found="$(targets_declaring "$first")" + known="$(grep -v '^?' <<< "$found")" + if [ -z "$found" ]; then + echo "no test type named $first in this tree" >&2 + return 1 + elif [ -z "$known" ]; then + echo "$first is declared only under ${found#?}, which no test plan or package lists" >&2 + case "$found" in *TableProMobileTests*) echo " run it with: verify.sh ios $first" >&2 ;; esac + return 1 + elif [ "$(wc -l <<< "$known" | tr -d ' ')" -gt 1 ]; then + echo "$first is declared in more than one test target; name it as /$1:" >&2 + sed 's/^/ /' <<< "$known" >&2 + return 1 fi - STATUS=PASS - return + TARGET="$known" TEST_PATH="$1" fi + ROUTE="$(route_of "$TARGET")" +} - local quarantine="$REPO_ROOT/.github/macos-test-quarantine.txt" - local ui_quarantine="$REPO_ROOT/.github/macos-ui-test-quarantine.txt" - - # Both quarantine files hold one case per line as Suite/case(), never a bare suite name, so a - # failing line is matched by its own case id. Matching the suite instead did two wrong things - # at once: it never matched the unit file at all, so every quarantined case was reported as a - # new failure; and on the UI file it muted the whole suite, hiding a real regression sitting - # beside a quarantined case. - # xcodebuild prints a failing case in three spellings and the quarantine files use only the - # last one, so every line is normalised to Suite/case() before the lookup: - # Test Case '-[TableProUITests.FooUITests testBar]' failed (1.0 seconds). XCTest - # Test case 'FooUITests.testBar()' failed on 'My Mac' ... XCTest - # Test case 'FooTests/bar()' failed on 'My Mac' ... Swift Testing - # Matching only the third muted nothing written by XCTest, which is every entry in the UI - # quarantine file, so a clean uitest run reported both quarantined cases as new failures. - local fail_lines unmuted muted_cases=0 - fail_lines="$(grep -iE "$FAIL_PATTERN" "$log" 2> /dev/null)" - unmuted="" - while IFS= read -r line; do - [ -n "$line" ] || continue - local case_id - case_id="$(printf '%s\n' "$line" | sed -E \ - -e "s|.*'-\[[A-Za-z0-9_]+\.([A-Za-z0-9_]+) ([A-Za-z0-9_]+)\]'.*|\1/\2()|" \ - -e "s|.*'([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\(\)'.*|\1/\2()|" \ - -e "s|.*'([A-Za-z0-9_]+/[A-Za-z0-9_]+\(\))'.*|\1|")" - # A line none of the three matched comes back unchanged, so require the normalised shape - # before trusting it. Failing to extract must report the line, never mute it. - case "$case_id" in - *' '* | *'/'*'/'*) case_id="" ;; - */*'()') ;; - *) case_id="" ;; - esac - if [ -n "$case_id" ] \ - && { grep -qxF "$case_id" "$quarantine" 2> /dev/null \ - || grep -qxF "$case_id" "$ui_quarantine" 2> /dev/null; }; then - muted_cases=$((muted_cases + 1)) - continue - fi - unmuted="$unmuted$line"$'\n' - done <<< "$fail_lines" - - [ "$muted_cases" -gt 0 ] && note "muted: $muted_cases quarantined case(s)" - - local suites - suites="$(printf '%s\n' "$unmuted" \ - | grep -oE "[A-Za-z_][A-Za-z0-9_]*Tests" \ - | grep -vxE "TableProTests|TableProUITests" \ - | sort -u)" - - if [ -z "$suites" ]; then - if [ -z "${unmuted//[$'\n' ]/}" ]; then - STATUS=PASS - note "verdict: no unexplained failures. Every failing case is quarantined." - return - fi - STATUS=FAIL - note "failing cases (no suite name on the line, read the log):" - note "$(printf '%s\n' "$unmuted" | sed 's/^/ /' | head -10)" - return +ere_escape() { + printf '%s' "$1" | sed 's/[][\\.*^$+?(){}|]/\\&/g' +} + +# Matches a test identifier, /(): sep is / in a result bundle and . under +# swift test. XCTest names a case without the parentheses, so they are optional. +test_regex() { + local target="$1" path="$2" sep="$3" + if [ -z "$path" ]; then + printf '^%s(%s|$)' "$(ere_escape "$target")" "$(ere_escape "$sep")" + else + printf '^%s%s%s(\\(\\))?(/|$)' "$(ere_escape "$target")" "$(ere_escape "$sep")" "$(ere_escape "${path%()}")" + fi +} + +# ---------------------------------------------------------------------------- test results + +# Every test case in a result bundle as "//retried?". A runner +# that crashed or never started is reported as a case under "System Failures"; it is not a test. +xcresult_cases() { + xcrun xcresulttool get test-results tests --path "$1" --compact 2> /dev/null | jq -r ' + [.. | objects | select((.nodeType // "") | endswith("test bundle"))][] | .name as $target + | .children[]? | (.nodeType == "Test Suite" and .name == "System Failures") as $system + | .. | objects | select(.nodeType == "Test Case") + | [ "\($target)/\(.nodeIdentifier // .name)", (if $system then "System Failure" else .result end), + (if .result == "Passed" and any(.children[]?; .nodeType == "Repetition" and .result == "Failed") + then "retried" else "" end) ] | @tsv' +} + +# Every test case in swift test's xunit files as "./". +xunit_cases() { + local file + for file in "$@"; do + [ -f "$file" ] || continue + xmllint --format "$file" 2> /dev/null | awk ' + function attr(line, name, v) { + if (!match(line, " " name "=\"[^\"]*\"")) return "" + v = substr(line, RSTART + length(name) + 3, RLENGTH - length(name) - 4) + gsub(/"/, "\"", v); gsub(/'/, "\047", v) + gsub(/</, "<", v); gsub(/>/, ">", v); gsub(/&/, "\\&", v) + return v + } + function flush() { if (open) print key "\t" state; open = 0 } + /]/ { + flush() + # A nested Swift Testing suite comes as Target.Outer.Inner; its filters use Outer/Inner. + cls = attr($0, "classname"); dot = index(cls, ".") + if (dot) { rest = substr(cls, dot + 1); gsub(/\./, "/", rest); cls = substr(cls, 1, dot) rest } + key = cls "/" attr($0, "name"); state = "Passed"; open = 1 + if ($0 ~ /\/>[[:space:]]*$/) flush() + next + } + open && /\/]/ { state = "Failed" } + /<\/testcase>/ { flush() } + END { flush() }' + done +} + +# Tallies a case list against the run's filters ("