diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 79fc0235..db2e924f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -16,3 +16,9 @@ Do **not** include app-specific business context, private data, secrets, or toke ## Platform notes Most projects are multi-targeted (`net9.0`, `net9.0-android`, `net9.0-ios`). Prefer validating `net9.0` in CI unless platform-specific changes require more. + +## Integration tests +- Before editing `tests/Plugin.Firebase.IntegrationTests`, read `tests/Plugin.Firebase.IntegrationTests/README.md`. +- Use `tests/Plugin.Firebase.IntegrationTests/ACCEPTANCE_COVERAGE.md` as the package coverage checklist. +- Keep emulator-backed coverage automatic and real-backend, destructive, paid, or externally delivered checks opt-in. +- Run `scripts/check-integration-coverage.rb` after adding, renaming, moving, or splitting fixtures. diff --git a/.github/workflows/integration-emulators.yml b/.github/workflows/integration-emulators.yml index 8df8f9e2..a9dc01e8 100644 --- a/.github/workflows/integration-emulators.yml +++ b/.github/workflows/integration-emulators.yml @@ -42,6 +42,9 @@ jobs: - name: Install Firebase CLI and XHarness run: scripts/install-integration-test-tools.sh + - name: Check integration coverage metadata + run: scripts/check-integration-coverage.rb + - name: Build Cloud Functions run: | npm ci --prefix tests/cloud-functions/functions --legacy-peer-deps diff --git a/AGENTS.md b/AGENTS.md index ac8e3ed5..8993464b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ Do **not** include private app details, proprietary business context, or secrets - Build (all TFMs): `dotnet build Plugin.Firebase.sln -c Release` - Build only `net9.0` (no mobile workloads): `dotnet build src/Auth/Auth.csproj -c Release -f net9.0` - Unit tests: `dotnet test tests/Plugin.Firebase.UnitTests/Plugin.Firebase.UnitTests.csproj` +- Integration coverage audit: `scripts/check-integration-coverage.rb` - Integration tests are device-only. See `BUILDING.md`. ## AI workflow @@ -31,6 +32,13 @@ Do **not** include private app details, proprietary business context, or secrets 3) **Propose change set**: Design a small, verifiable change before editing code. 4) **Keep it documented**: Ensure public API changes are explicit and documented per [Documentation](CONTRIBUTING.md#documentation). +## Integration test workflow +- Before changing `tests/Plugin.Firebase.IntegrationTests`, read `tests/Plugin.Firebase.IntegrationTests/README.md` and `tests/Plugin.Firebase.IntegrationTests/ACCEPTANCE_COVERAGE.md`. +- Keep tests close to native Firebase behavior; avoid app-specific policy, private project details, or hidden real-backend assumptions. +- Use the harness fact attributes, fixture metadata, resource scopes, and file layout documented in the integration test README. +- Update acceptance coverage and real-backend setup docs when public API coverage or backend requirements change. +- Run `scripts/check-integration-coverage.rb` after adding, renaming, moving, or splitting fixtures. + ## Packaging & CI - Packaging guidelines: `docs/packaging-github-packages.md` - CI guidance: `docs/ci-cd.md` diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 1ae23a24..e5d3c192 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -325,7 +325,7 @@ Required functions: | `returnNumberPayload` | `https.onCall` | Verifies callable number response deserialization | | `returnBooleanPayload` | `https.onCall` | Verifies callable boolean response deserialization | | `returnNullPayload` | `https.onCall` | Verifies callable null response deserialization | -| `createCustomToken` | `https.onCall` | Mints custom tokens for Auth acceptance tests | +| `createCustomToken` | `https.onCall` | Emulator-only helper that mints custom tokens for Auth acceptance tests; it rejects non-emulator calls and must not be deployed as a real token-minting endpoint | | `echoAuthContext` | `https.onCall` | Verifies callable auth context propagation | | `throwStructuredError` | `https.onCall` | Verifies callable error propagation | | `regionalPing` | `https.onCall`, `southamerica-east1` | Verifies configured Functions regions | diff --git a/scripts/check-integration-coverage.rb b/scripts/check-integration-coverage.rb new file mode 100755 index 00000000..c1dc0011 --- /dev/null +++ b/scripts/check-integration-coverage.rb @@ -0,0 +1,121 @@ +#!/usr/bin/env ruby + +require "pathname" + +repo_root = Pathname.new(__dir__).join("..").realpath +tests_root = repo_root.join("tests", "Plugin.Firebase.IntegrationTests") +coverage_file = tests_root.join("ACCEPTANCE_COVERAGE.md") + +PACKAGE_LABELS = { + "Analytics" => "Analytics", + "AppCheck" => "App Check", + "Auth" => "Auth", + "Bundled" => "Bundled initializer", + "CloudMessaging" => "Cloud Messaging", + "Crashlytics" => "Crashlytics", + "Firestore" => "Firestore", + "Functions" => "Functions", + "Installations" => "Installations", + "PerformanceMonitoring" => "Performance Monitoring", + "RemoteConfig" => "Remote Config", + "Storage" => "Storage" +}.freeze + +def coverage_packages(coverage_file) + packages = {} + File.readlines(coverage_file).each do |line| + next unless line.start_with?("|") + + cells = line.split("|").map(&:strip) + package = cells[1] + next if package.nil? || package.empty? || package == "Package" || package.start_with?("---") + + packages[package] = true + end + packages +end + +def class_declarations(content) + declarations = [] + class_pattern = /^\s*(?:public|internal|private)?\s*(?:sealed\s+)?(?:partial\s+)?class\s+(\w+Fixture)\b/ + content.to_enum(:scan, class_pattern).each do + match = Regexp.last_match + prefix = content[0...match.begin(0)].lines.last(10).join + declarations << { + name: match[1], + package: prefix[/IntegrationTestFixture\(IntegrationTestPackage\.(\w+)\)/, 1], + ignored: prefix.include?("IntegrationTestCoverageIgnore(") + } + end + declarations +end + +def test_case_count(content) + content.scan(/^\s*\[(?:\w*(?:Fact|Theory)|Fact|Theory)(?:\(|\])/).size +end + +coverage = coverage_packages(coverage_file) +errors = [] +fixtures = {} +test_cases = 0 + +Dir.glob(tests_root.join("**", "*.cs")).sort.each do |file| + next if file.include?("/bin/") || file.include?("/obj/") + + content = File.read(file) + test_cases += test_case_count(content) + class_declarations(content).each do |declaration| + fixture = fixtures[declaration[:name]] ||= { + files: [], + packages: [], + ignored: false + } + fixture[:files] << Pathname.new(file).relative_path_from(repo_root).to_s + fixture[:packages] << declaration[:package] if declaration[:package] + fixture[:ignored] ||= declaration[:ignored] + end +end + +fixtures.each do |name, fixture| + next if fixture[:ignored] + + packages = fixture[:packages].uniq + if packages.empty? + errors << "#{name} is missing IntegrationTestFixture metadata." + next + end + + packages.each do |package| + label = PACKAGE_LABELS[package] + if label.nil? + errors << "#{name} references unknown IntegrationTestPackage.#{package}." + elsif !coverage.key?(label) + errors << "#{name} maps to #{label}, but #{coverage_file.relative_path_from(repo_root)} does not list that package." + end + end +end + +if coverage.key?("Dynamic Links") + errors << "Dynamic Links is intentionally excluded and must not be listed in #{coverage_file.relative_path_from(repo_root)}." +end + +if errors.any? + warn "Integration coverage metadata audit failed:" + errors.each { |error| warn " - #{error}" } + exit 1 +end + +tracked_packages = fixtures + .values + .flat_map { |fixture| fixture[:packages] } + .compact + .uniq + .sort + .map { |package| PACKAGE_LABELS.fetch(package, package) } + +ignored_fixtures = fixtures.count { |_, fixture| fixture[:ignored] } + +puts "Integration coverage metadata audit passed." +puts "Fixtures: #{fixtures.length} (#{ignored_fixtures} ignored harness fixture)" +puts "Packages: #{tracked_packages.join(", ")}" +puts "Test cases discovered: #{test_cases}" diff --git a/scripts/validate-cloudmessaging-trimming.sh b/scripts/validate-cloudmessaging-trimming.sh index 05100def..a091d671 100755 --- a/scripts/validate-cloudmessaging-trimming.sh +++ b/scripts/validate-cloudmessaging-trimming.sh @@ -9,11 +9,17 @@ manifest="$obj_dir/AndroidManifest.xml" managed_type="Plugin.Firebase.CloudMessaging.Platforms.Android.MyFirebaseMessagingService" messaging_action="com.google.firebase.MESSAGING_EVENT" +# This probe validates the generated Android service metadata after full trimming. +# The integration app includes broad test fixtures and runner packages that are not +# linker-warning-clean, so do not let unrelated trim warnings skip the manifest check. dotnet build "$project" \ -c Release \ -f net9.0-android \ /p:TrimMode=full \ - /p:AndroidPackageFormat=apk + /p:AndroidPackageFormat=apk \ + /p:TreatWarningsAsErrors=false \ + /p:WarningsAsErrors= \ + /p:ILLinkTreatWarningsAsErrors=false python3 - "$acw_map" "$manifest" "$managed_type" "$messaging_action" <<'PY' from pathlib import Path diff --git a/src/Analytics/Shared/IFirebaseAnalytics.cs b/src/Analytics/Shared/IFirebaseAnalytics.cs index 34ab9459..360f1b0b 100644 --- a/src/Analytics/Shared/IFirebaseAnalytics.cs +++ b/src/Analytics/Shared/IFirebaseAnalytics.cs @@ -122,4 +122,4 @@ public interface IFirebaseAnalytics : IDisposable /// By default it is enabled. /// bool IsAnalyticsCollectionEnabled { set; } -} \ No newline at end of file +} diff --git a/tests/Plugin.Firebase.IntegrationTests/ACCEPTANCE_COVERAGE.md b/tests/Plugin.Firebase.IntegrationTests/ACCEPTANCE_COVERAGE.md new file mode 100644 index 00000000..610802fe --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/ACCEPTANCE_COVERAGE.md @@ -0,0 +1,47 @@ +# Integration Acceptance Coverage + +`Plugin.Firebase.IntegrationTests` is the acceptance suite for active packages. Dynamic Links is intentionally excluded. + +Use this file as the checklist when adding or changing public mobile APIs: every active public surface should be covered automatically, explicitly gated behind a real-backend or opt-in attribute, or documented as not locally assertable. + +Run `scripts/check-integration-coverage.rb` after adding or renaming fixtures. The script verifies that every fixture is annotated with `IntegrationTestFixture` metadata and maps to a package listed in the table below. Harness-only fixtures must opt out with `IntegrationTestCoverageIgnore` and a reason. + +| Package | Default emulator gate | Real backend | Opt-in/manual | Notes | +|---|---:|---:|---:|---| +| Analytics | No | Yes | No | Verifies SDK acceptance and observable local state; Firebase Console ingestion is not asserted. | +| App Check | Partial | Optional | `PLUGIN_FIREBASE_RUN_APPCHECK_TOKEN_TESTS` | Disabled/debug/provider behavior is automatic; token enforcement requires a real project. | +| Auth | Yes | Partial | `PLUGIN_FIREBASE_RUN_PHONE_AUTH_TESTS` | Email/password, anonymous, custom tokens, email links, metadata, and claims are covered. Phone auth requires external credentials. | +| Bundled initializer | Yes | Yes | No | Verifies singleton access and dispose/reacquire behavior without reconfiguring initialized native SDKs. | +| Cloud Messaging | Partial | Optional | `PLUGIN_FIREBASE_RUN_FCM_TOKEN_TESTS`, `PLUGIN_FIREBASE_RUN_FCM_DELIVERY_TESTS` | Synthetic events are automatic. Token and push delivery require a real project; iOS delivery requires a physical device with APNs. | +| Crashlytics | Partial | Partial | `PLUGIN_FIREBASE_FORCE_CRASHLYTICS_CRASH`, `PLUGIN_FIREBASE_EXPECT_PREVIOUS_CRASH` | Non-crash APIs are automatic. Previous-crash detection needs a destructive two-run flow. | +| Firestore | Yes | Yes | No | Emulator-backed PR gate covers document, query, conversion, nullability, listener, lifecycle, and offline behavior. | +| Functions | Yes | Yes | No | Real backend requires deploying `tests/cloud-functions/functions`. | +| Installations | Partial | Yes | `PLUGIN_FIREBASE_RUN_INSTALLATIONS_DELETE_TESTS` | Delete is opt-in because it resets the shared installation identity. | +| Performance Monitoring | Partial | Yes | No | Verifies SDK acceptance and local state; Firebase Console ingestion is not asserted. | +| Remote Config | No | Yes | No | Requires published real-project parameters. | +| Storage | Yes | Yes | No | Real backend requires bucket seed files and permissive test rules. | + +## Expected Fixture Metadata + +| Metadata package | Acceptance coverage row | +|---|---| +| `IntegrationTestPackage.Analytics` | Analytics | +| `IntegrationTestPackage.AppCheck` | App Check | +| `IntegrationTestPackage.Auth` | Auth | +| `IntegrationTestPackage.Bundled` | Bundled initializer | +| `IntegrationTestPackage.CloudMessaging` | Cloud Messaging | +| `IntegrationTestPackage.Crashlytics` | Crashlytics | +| `IntegrationTestPackage.Firestore` | Firestore | +| `IntegrationTestPackage.Functions` | Functions | +| `IntegrationTestPackage.Installations` | Installations | +| `IntegrationTestPackage.PerformanceMonitoring` | Performance Monitoring | +| `IntegrationTestPackage.RemoteConfig` | Remote Config | +| `IntegrationTestPackage.Storage` | Storage | + +## Harness Rules + +- Prefer `Fact`, `EmulatorBackendFact`, `RealFirebaseFact`, `OptInFact`, or `RealFirebaseOptInFact` over runtime skips. Runtime skips can be reported as failures by device runners. +- Put `IntegrationTestFixture(IntegrationTestPackage.X)` on each package fixture root. Use the backend/platform/opt-in fact attributes for method-level metadata, and add `IntegrationTestCase` only when a plain xUnit attribute needs explicit metadata. +- Use `IntegrationTestData` for unique resource names and opt-in configuration values. +- Use `WaitForTestAsync` or `EventuallyAsync` for asynchronous device callbacks so timeout failures include the operation being awaited. +- Keep destructive, paid, external-delivery, or console-ingestion checks behind opt-in attributes. diff --git a/tests/Plugin.Firebase.IntegrationTests/Analytics/AnalyticsConsentMappingFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Analytics/AnalyticsConsentMappingFixture.cs index 80715674..d9d74e6d 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Analytics/AnalyticsConsentMappingFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Analytics/AnalyticsConsentMappingFixture.cs @@ -13,6 +13,7 @@ namespace Plugin.Firebase.IntegrationTests.Analytics { #if ANDROID || IOS + [IntegrationTestFixture(IntegrationTestPackage.Analytics)] [Preserve(AllMembers = true)] public sealed class AnalyticsConsentMappingFixture { @@ -232,10 +233,10 @@ public void mixed_valid_and_invalid_consent_status_settings_throw_argument_out_o [Fact] public void null_consent_settings_throw_argument_null_exception() { - IDictionary settings = null; + IDictionary? settings = null; var exception = Assert.Throws( - () => settings.ToNativeConsentSettings() + () => settings!.ToNativeConsentSettings() ); Assert.Equal("consentSettings", exception.ParamName); @@ -315,4 +316,4 @@ private static NativeConsentStatus ExpectedNativeStatus(ConsentStatus consentSta } } #endif -} \ No newline at end of file +} diff --git a/tests/Plugin.Firebase.IntegrationTests/Analytics/AnalyticsFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Analytics/AnalyticsFixture.cs index df3741ed..1af1ca15 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Analytics/AnalyticsFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Analytics/AnalyticsFixture.cs @@ -4,55 +4,10 @@ namespace Plugin.Firebase.IntegrationTests.Analytics { [Collection("Sequential")] [TestLogging] + [IntegrationTestFixture(IntegrationTestPackage.Analytics)] [Preserve(AllMembers = true)] public sealed class AnalyticsFixture { -#if ANDROID - [Fact] - public void throws_actionable_exception_when_android_analytics_is_not_initialized() - { - var firebaseAnalyticsField = typeof(FirebaseAnalyticsImplementation).GetField( - "_firebaseAnalytics", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static - ); - Assert.NotNull(firebaseAnalyticsField); - - var originalFirebaseAnalytics = firebaseAnalyticsField.GetValue(null); - try { - firebaseAnalyticsField.SetValue(null, null); - - var logEventException = Assert.Throws( - () => CrossFirebaseAnalytics.Current.LogEvent("test_uninitialized_analytics_guard") - ); - AssertAnalyticsNotInitializedException(logEventException); - - var setDefaultEventParametersException = Assert.Throws( - () => CrossFirebaseAnalytics.Current.SetDefaultEventParameters(new Dictionary { - { "default_string", "some_value" } - }) - ); - AssertAnalyticsNotInitializedException(setDefaultEventParametersException); - - var setConsentException = Assert.Throws( - () => CrossFirebaseAnalytics.Current.SetConsent(new Dictionary { - { ConsentType.AnalyticsStorage, ConsentStatus.Granted } - }) - ); - AssertAnalyticsNotInitializedException(setConsentException); - } - finally { - firebaseAnalyticsField.SetValue(null, originalFirebaseAnalytics); - } - } - - private static void AssertAnalyticsNotInitializedException(InvalidOperationException exception) - { - Assert.Contains("Firebase Analytics has not been initialized on Android", exception.Message); - Assert.Contains("FirebaseAnalyticsImplementation.Initialize(activity)", exception.Message); - Assert.Contains("isAnalyticsEnabled: true", exception.Message); - } -#endif - [RealFirebaseFact] public void does_not_throw_any_exception_when_logging_events() { @@ -99,7 +54,7 @@ public void does_not_throw_any_exception_when_setting_default_event_parameters_v sut.LogEvent("test_with_default_dictionary_parameters"); } finally { - sut.SetDefaultEventParameters((IDictionary) null); + sut.SetDefaultEventParameters((IDictionary?) null); } } @@ -117,14 +72,14 @@ public void does_not_throw_any_exception_when_setting_default_event_parameters_v sut.LogEvent("test_with_default_tuple_parameters"); } finally { - sut.SetDefaultEventParameters((IDictionary) null); + sut.SetDefaultEventParameters((IDictionary?) null); } } [RealFirebaseFact] public void does_not_throw_any_exception_when_clearing_default_event_parameters() { - CrossFirebaseAnalytics.Current.SetDefaultEventParameters((IDictionary) null); + CrossFirebaseAnalytics.Current.SetDefaultEventParameters((IDictionary?) null); } [RealFirebaseFact] @@ -135,6 +90,14 @@ public void does_not_throw_any_exception_when_setting_user_properties() sut.SetUserProperty("some_name", "some_value"); } + [RealFirebaseFact] + public void does_not_throw_any_exception_when_clearing_user_properties() + { + var sut = CrossFirebaseAnalytics.Current; + sut.SetUserId(null); + sut.SetUserProperty("some_name", null); + } + [RealFirebaseFact] public void does_not_throw_any_exception_when_setting_consent() { @@ -200,10 +163,10 @@ public void does_not_throw_any_exception_when_setting_partial_consent() [RealFirebaseFact] public void throws_argument_null_exception_when_setting_null_consent() { - IDictionary consentSettings = null; + IDictionary? consentSettings = null; var exception = Assert.Throws( - () => CrossFirebaseAnalytics.Current.SetConsent(consentSettings) + () => CrossFirebaseAnalytics.Current.SetConsent(consentSettings!) ); Assert.Equal("consentSettings", exception.ParamName); @@ -248,6 +211,42 @@ public async Task does_not_throw_any_exception_when_getting_app_instance_id() Assert.NotNull(await sut.GetAppInstanceIdAsync()); } + [RealFirebaseFact] + public async Task reset_analytics_data_keeps_instance_id_api_usable() + { + var sut = CrossFirebaseAnalytics.Current; + + sut.ResetAnalyticsData(); + + Assert.NotNull(await sut.GetAppInstanceIdAsync()); + } + + [RealFirebaseFact] + public void accepts_events_while_collection_is_disabled() + { + var sut = CrossFirebaseAnalytics.Current; + + try { + sut.IsAnalyticsCollectionEnabled = false; + sut.LogEvent("test_collection_disabled", ("some_parameter", "some_value")); + } + finally { + sut.IsAnalyticsCollectionEnabled = true; + } + } + + [RealFirebaseFact] + public void accepts_boundary_sized_parameters_and_user_properties() + { + var sut = CrossFirebaseAnalytics.Current; + var parameterName = new string('p', 40); + var userPropertyName = new string('u', 24); + var userPropertyValue = new string('v', 36); + + sut.LogEvent("test_boundary_parameters", (parameterName, "value")); + sut.SetUserProperty(userPropertyName, userPropertyValue); + } + [RealFirebaseFact] public void does_not_throw_any_exception_at_other_methods() { @@ -288,6 +287,5 @@ private static void SetConsentAndRestore(IDictionary CrossFirebaseAnalytics.Current.SetConsent(CreateAllConsentSettings(ConsentStatus.Granted)); } } - } -} \ No newline at end of file +} diff --git a/tests/Plugin.Firebase.IntegrationTests/AppCheck/AppCheckFixture.cs b/tests/Plugin.Firebase.IntegrationTests/AppCheck/AppCheckFixture.cs new file mode 100644 index 00000000..3ef4e8cd --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/AppCheck/AppCheckFixture.cs @@ -0,0 +1,85 @@ +using Plugin.Firebase.AppCheck; +using Plugin.Firebase.Auth; +using Plugin.Firebase.Functions; +using Plugin.Firebase.IntegrationTests.Functions; + +namespace Plugin.Firebase.IntegrationTests.AppCheck +{ + [Collection("Sequential")] + [TestLogging] + [IntegrationTestFixture(IntegrationTestPackage.AppCheck)] + [Preserve(AllMembers = true)] + public sealed class AppCheckFixture + { + [Fact] + public void rejects_null_options() + { + Assert.Throws(() => CrossFirebaseAppCheck.Configure(null!)); + } + + [Fact] + public void transitions_between_disabled_and_debug_providers() + { + try { + CrossFirebaseAppCheck.Configure(AppCheckOptions.Disabled); + CrossFirebaseAppCheck.Configure(AppCheckOptions.Debug); + CrossFirebaseAppCheck.Configure(AppCheckOptions.Disabled); + } + finally { + CrossFirebaseAppCheck.Configure(AppCheckOptions.Disabled); + } + } + + [Fact] + public void covers_platform_specific_unsupported_provider_behavior() + { + try { + if(OperatingSystem.IsAndroid()) { + Assert.Throws(() => CrossFirebaseAppCheck.Configure(AppCheckOptions.DeviceCheck)); + Assert.Throws(() => CrossFirebaseAppCheck.Configure(AppCheckOptions.AppAttest)); + } + + if(OperatingSystem.IsIOS()) { + CrossFirebaseAppCheck.Configure(AppCheckOptions.PlayIntegrity); + } + } + finally { + CrossFirebaseAppCheck.Configure(AppCheckOptions.Disabled); + } + } + + [EmulatorBackendFact] + public async Task disabled_app_check_does_not_break_auth_or_functions_on_emulator() + { + var auth = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("app-check-disabled"); + + try { + CrossFirebaseAppCheck.Configure(AppCheckOptions.Disabled); + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync(auth, email); + + var response = await CrossFirebaseFunctions.Current + .GetHttpsCallable("convertToLeet") + .CallAsync("{\"input_value\":777}"); + + Assert.Equal(777, response.InputValue); + Assert.Equal(1337, response.OutputValue); + } + finally { + CrossFirebaseAppCheck.Configure(AppCheckOptions.Disabled); + } + } + + [RealFirebaseOptInFact(IntegrationTestEnvironment.RunAppCheckTokenTestsEnvironmentVariableName)] + public async Task fetches_cached_and_forced_debug_tokens_when_enabled() + { + CrossFirebaseAppCheck.Configure(AppCheckOptions.Debug); + + var cachedToken = await CrossFirebaseAppCheck.GetTokenAsync(); + var forcedToken = await CrossFirebaseAppCheck.GetTokenAsync(forceRefresh: true); + + Assert.False(string.IsNullOrWhiteSpace(cachedToken)); + Assert.False(string.IsNullOrWhiteSpace(forcedToken)); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthAssertions.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthAssertions.cs new file mode 100644 index 00000000..d3fb6132 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthAssertions.cs @@ -0,0 +1,56 @@ +using Plugin.Firebase.Auth; +using Plugin.Firebase.Core.Exceptions; + +namespace Plugin.Firebase.IntegrationTests.Auth; + +internal static class AuthAssertions +{ + public static void NativeAuthExceptionCaptured(CrossPlatformFirebaseAuthException exception) + { + Assert.NotNull(exception.InnerException); + Assert.False(string.IsNullOrWhiteSpace(exception.NativeExceptionTypeName)); + Assert.False(string.IsNullOrWhiteSpace(exception.NativeErrorMessage)); + } + + public static void NestedCustomClaims(IAuthTokenResult idTokenResult) + { + Assert.True(idTokenResult.GetClaim("is_awesome")); + var nestedObject = Assert.IsAssignableFrom>( + idTokenResult.Claims["nested_object"] + ); + NestedClaimAssertions.AssertNestedCustomClaim(nestedObject); + + var typedNestedObject = idTokenResult.GetClaim>( + "nested_object" + ); + NestedClaimAssertions.AssertNestedCustomClaim(typedNestedObject); + + var concreteNestedObject = idTokenResult.GetClaim>( + "nested_object" + ); + NestedClaimAssertions.AssertNestedCustomClaim(concreteNestedObject); + + var objectNestedObject = Assert.IsAssignableFrom>( + idTokenResult.GetClaim("nested_object") + ); + NestedClaimAssertions.AssertNestedCustomClaim(objectNestedObject); + + var nestedArray = Assert.IsAssignableFrom>( + idTokenResult.Claims["nested_array"] + ); + NestedClaimAssertions.AssertNestedCustomArray(nestedArray); + + var typedNestedArray = idTokenResult.GetClaim>("nested_array"); + NestedClaimAssertions.AssertNestedCustomArray(typedNestedArray); + + var concreteNestedArray = idTokenResult.GetClaim>("nested_array"); + NestedClaimAssertions.AssertNestedCustomArray(concreteNestedArray); + + var objectNestedArray = Assert.IsAssignableFrom>( + idTokenResult.GetClaim("nested_array") + ); + NestedClaimAssertions.AssertNestedCustomArray(objectNestedArray); + + Assert.True(Assert.IsType(idTokenResult.GetClaim("is_awesome"))); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.AccountLifecycle.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.AccountLifecycle.cs new file mode 100644 index 00000000..93187ba3 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.AccountLifecycle.cs @@ -0,0 +1,78 @@ +using Plugin.Firebase.Auth; +using Plugin.Firebase.Core.Exceptions; + +namespace Plugin.Firebase.IntegrationTests.Auth +{ + public sealed partial class AuthFixture + { + [Fact] + public async Task signs_out_user() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync( + sut, + IntegrationTestUsers.SignOutEmail, + deleteOnDispose: false); + Assert.NotNull(sut.CurrentUser); + + await sut.SignOutAsync(); + Assert.Null(sut.CurrentUser); + } + + [Fact] + public async Task reloads_current_user() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync( + sut, + IntegrationTestUsers.ReloadCurrentUserEmail, + deleteOnDispose: false); + Assert.NotNull(sut.CurrentUser); + + var uid = sut.CurrentUser!.Uid; + await sut.CurrentUser!.ReloadAsync(); + + Assert.NotNull(sut.CurrentUser); + Assert.Equal(uid, sut.CurrentUser!.Uid); + } + + [Fact] + public async Task reloads_user() + { + var sut = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("reload-user"); + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync(sut, email); + Assert.NotNull(sut.CurrentUser); + + var uid = sut.CurrentUser!.Uid; + await sut.CurrentUser!.ReloadAsync(); + + Assert.NotNull(sut.CurrentUser); + Assert.Equal(uid, sut.CurrentUser!.Uid); + } + + [Fact] + public async Task reload_current_user_fails_when_signed_out() + { + var sut = CrossFirebaseAuth.Current; + await sut.SignOutAsync(); + +#pragma warning disable CS0618 + await Assert.ThrowsAnyAsync(sut.ReloadCurrentUserAsync); +#pragma warning restore CS0618 + } + + [Fact] + public async Task deletes_user() + { + var sut = CrossFirebaseAuth.Current; + var user = await sut.SignInWithEmailAndPasswordAsync( + IntegrationTestUsers.DeleteUserEmail, + IntegrationTestUsers.DefaultPassword); + Assert.NotNull(sut.CurrentUser); + + await user.DeleteAsync(); + Assert.Null(sut.CurrentUser); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.Credentials.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.Credentials.cs new file mode 100644 index 00000000..fd6c4d87 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.Credentials.cs @@ -0,0 +1,247 @@ +using System.Text.Json; +using Plugin.Firebase.Auth; +using Plugin.Firebase.Core.Exceptions; +using Plugin.Firebase.Functions; +using Plugin.Firebase.IntegrationTests.Functions; + +namespace Plugin.Firebase.IntegrationTests.Auth +{ + public sealed partial class AuthFixture + { + [Fact] + public async Task creates_user_with_email_and_password() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.CreateWithEmailAndPasswordAsync( + sut, + IntegrationTestUsers.CreatedUserEmail); + + Assert.NotNull(sut.CurrentUser); + Assert.Equal(user.User.Uid, sut.CurrentUser!.Uid); + } + + + [Fact] + public async Task signs_in_user_via_email_and_password() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync( + sut, + IntegrationTestUsers.SignInWithPasswordEmail, + deleteOnDispose: false); + Assert.Equal(IntegrationTestUsers.SignInWithPasswordEmail, user.User.Email); + Assert.Equal(IntegrationTestUsers.SignInWithPasswordEmail, sut.CurrentUser!.Email); + } + + + [Fact] + public async Task signs_in_user_with_native_credential() + { + var sut = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("native-sign-in"); + await using var createdUser = await AuthTestUserScope.CreateWithEmailAndPasswordAsync(sut, email); + await sut.SignOutAsync(); + var credential = CreateNativeEmailCredential(email, IntegrationTestUsers.DefaultPassword); + + var user = await sut.SignInWithCredentialAsync(credential); + + Assert.Equal(email, user.Email); + Assert.NotNull(sut.CurrentUser); + Assert.Equal(email, sut.CurrentUser!.Email); + Assert.Equal(user.Uid, sut.CurrentUser!.Uid); + } + + + [Fact] + public async Task sign_in_with_email_and_password_creates_user_automatically() + { + var sut = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("auto-create-sign-in"); + + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync(sut, email); + + Assert.NotNull(sut.CurrentUser); + Assert.Equal(email, user.User.Email); + Assert.Equal(email, sut.CurrentUser!.Email); + Assert.Equal(user.User.Uid, sut.CurrentUser!.Uid); + } + + + [Fact] + public async Task throws_error_if_credentials_are_invalid_when_signing_in_user_via_email_and_password() + { + var sut = CrossFirebaseAuth.Current; + var ex = await Assert.ThrowsAnyAsync( + () => sut.SignInWithEmailAndPasswordAsync( + IntegrationTestUsers.SignInWithPasswordEmail, + "000000", + createsUserAutomatically: false) + ); + + AuthAssertions.NativeAuthExceptionCaptured(ex); + } + + + [Fact] + public async Task throws_error_if_user_does_not_exist_and_should_not_be_created_automatically_due_sign_in_via_email_and_password() + { + var sut = CrossFirebaseAuth.Current; + var ex = await Assert.ThrowsAnyAsync( + () => sut.SignInWithEmailAndPasswordAsync( + IntegrationTestUsers.MissingUserEmail, + IntegrationTestUsers.DefaultPassword, + createsUserAutomatically: false) + ); + + AuthAssertions.NativeAuthExceptionCaptured(ex); + } + + + [Fact] + public async Task throws_cross_platform_exception_for_invalid_native_credential() + { + var sut = CrossFirebaseAuth.Current; + var credential = CreateNativeEmailCredential( + IntegrationTestData.UniqueEmail("invalid-native-sign-in"), + "000000"); + + var ex = await Assert.ThrowsAnyAsync( + () => sut.SignInWithCredentialAsync(credential) + ); + + AuthAssertions.NativeAuthExceptionCaptured(ex); + } + + + [Fact] + public async Task signs_in_user_anonymously() + { + var sut = CrossFirebaseAuth.Current; + Assert.Null(sut.CurrentUser); + + await using var user = await AuthTestUserScope.SignInAnonymouslyAsync(sut); + Assert.NotNull(user.User); + Assert.NotNull(sut.CurrentUser); + Assert.True(user.User.IsAnonymous); + } + + + [Fact] + public async Task links_anonymous_user_with_email_and_password() + { + var sut = CrossFirebaseAuth.Current; + await using var anonymousUser = await AuthTestUserScope.SignInAnonymouslyAsync(sut); + var email = IntegrationTestData.UniqueEmail("link-anonymous"); + + var linkedUser = await sut.LinkWithEmailAndPasswordAsync(email, IntegrationTestUsers.DefaultPassword); + + Assert.Equal(anonymousUser.User.Uid, linkedUser.Uid); + Assert.Equal(anonymousUser.User.Uid, sut.CurrentUser!.Uid); + Assert.False(linkedUser.IsAnonymous); + Assert.False(sut.CurrentUser!.IsAnonymous); + Assert.Equal(email, linkedUser.Email); + Assert.Equal(email, sut.CurrentUser!.Email); + } + + + [Fact] + public async Task links_anonymous_user_with_native_credential() + { + var sut = CrossFirebaseAuth.Current; + await using var anonymousUser = await AuthTestUserScope.SignInAnonymouslyAsync(sut); + var email = IntegrationTestData.UniqueEmail("native-link"); + var credential = CreateNativeEmailCredential(email, IntegrationTestUsers.DefaultPassword); + + var linkedUser = await sut.LinkWithCredentialAsync(credential); + + Assert.Equal(anonymousUser.User.Uid, linkedUser.Uid); + Assert.NotNull(sut.CurrentUser); + Assert.Equal(anonymousUser.User.Uid, sut.CurrentUser!.Uid); + Assert.False(linkedUser.IsAnonymous); + Assert.False(sut.CurrentUser!.IsAnonymous); + Assert.Equal(email, linkedUser.Email); + Assert.Equal(email, sut.CurrentUser!.Email); + } + + + [Fact] + public async Task unlinks_email_password_provider_from_linked_user() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInAnonymouslyAsync(sut); + var linkedUser = await sut.LinkWithEmailAndPasswordAsync( + IntegrationTestData.UniqueEmail("unlink-provider"), + IntegrationTestUsers.DefaultPassword); + + Assert.NotNull(linkedUser.ProviderInfos); + Assert.Contains(linkedUser.ProviderInfos, x => x.ProviderId == "password"); + + Assert.NotNull(sut.CurrentUser); + await sut.CurrentUser!.UnlinkAsync("password"); + await sut.CurrentUser!.ReloadAsync(); + + Assert.DoesNotContain(sut.CurrentUser!.ProviderInfos ?? Array.Empty(), x => x.ProviderId == "password"); + } + + [Fact] + public async Task reauthenticates_user_with_email_and_password() + { + var sut = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("reauth-email-password"); + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync(sut, email); + var uid = user.User.Uid; + + await sut.CurrentUser!.ReauthenticateWithEmailAndPasswordAsync( + email, + IntegrationTestUsers.DefaultPassword); + await sut.CurrentUser!.UpdatePasswordAsync(IntegrationTestUsers.UpdatedPassword); + + Assert.NotNull(sut.CurrentUser); + Assert.Equal(uid, sut.CurrentUser!.Uid); + + await sut.SignOutAsync(); + await Assert.ThrowsAnyAsync( + () => sut.SignInWithEmailAndPasswordAsync( + email, + IntegrationTestUsers.DefaultPassword, + createsUserAutomatically: false) + ); + + var updatedUser = await sut.SignInWithEmailAndPasswordAsync( + email, + IntegrationTestUsers.UpdatedPassword, + createsUserAutomatically: false); + Assert.Equal(uid, updatedUser.Uid); + } + + + [Fact] + public async Task throws_error_if_reauthenticating_with_invalid_email_password() + { + var sut = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("reauth-invalid"); + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync(sut, email); + + var ex = await Assert.ThrowsAnyAsync( + () => sut.CurrentUser!.ReauthenticateWithEmailAndPasswordAsync(email, "000000") + ); + + AuthAssertions.NativeAuthExceptionCaptured(ex); + } + + private static global::Firebase.Auth.AuthCredential CreateNativeEmailCredential( + string email, + string password + ) + { +#if ANDROID + return global::Firebase.Auth.EmailAuthProvider.GetCredential(email, password); +#elif IOS + return global::Firebase.Auth.EmailAuthProvider.GetCredentialFromPassword(email, password); +#else + throw new PlatformNotSupportedException(); +#endif + } + + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.CustomTokens.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.CustomTokens.cs new file mode 100644 index 00000000..6bf2587c --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.CustomTokens.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using Plugin.Firebase.Auth; +using Plugin.Firebase.Core.Exceptions; +using Plugin.Firebase.Functions; +using Plugin.Firebase.IntegrationTests.Functions; + +namespace Plugin.Firebase.IntegrationTests.Auth +{ + public sealed partial class AuthFixture + { + [EmulatorBackendFact] + public async Task signs_in_user_via_custom_token() + { + var auth = CrossFirebaseAuth.Current; + var uid = IntegrationTestData.UniqueId("custom-token"); + var claims = AuthTestPayloads.CreateNestedCustomClaims(); + var requestJson = JsonSerializer.Serialize(new { + uid, + claims, + }); + var response = await CrossFirebaseFunctions.Current + .GetHttpsCallable("createCustomToken") + .CallAsync(requestJson); + + await auth.SignOutAsync(); + var user = await auth.SignInWithCustomTokenAsync(response.Token); + await using var testUser = AuthTestUserScope.TrackCurrentUser(auth); + var idTokenResult = await user.GetIdTokenResultAsync(forceRefresh: true); + + Assert.Equal(uid, response.Uid); + Assert.Equal(uid, user.Uid); + Assert.Equal(uid, auth.CurrentUser!.Uid); + AuthAssertions.NestedCustomClaims(idTokenResult); + } + + + [Fact] + public async Task retrieves_custom_claims() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync( + sut, + IntegrationTestUsers.CustomClaimsEmail, + deleteOnDispose: false); + var idTokenResult = await user.User.GetIdTokenResultAsync(); + + AuthAssertions.NestedCustomClaims(idTokenResult); + } + + + [Fact] + public async Task exposes_id_token_metadata() + { + var sut = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("token-metadata"); + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync(sut, email); + + var idTokenResult = await user.User.GetIdTokenResultAsync(); + var refreshedIdTokenResult = await user.User.GetIdTokenResultAsync(forceRefresh: true); + + Assert.False(string.IsNullOrWhiteSpace(idTokenResult.Token)); + Assert.NotNull(idTokenResult.Claims); + Assert.NotEmpty(idTokenResult.Claims); + Assert.NotEqual(default, idTokenResult.AuthDate); + Assert.NotEqual(default, idTokenResult.IssuedAtDate); + Assert.NotEqual(default, idTokenResult.ExpirationDate); + Assert.False(string.IsNullOrWhiteSpace(refreshedIdTokenResult.Token)); + Assert.False(string.IsNullOrWhiteSpace(idTokenResult.SignInProvider)); + } + + } +} diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.EmailAndPhone.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.EmailAndPhone.cs new file mode 100644 index 00000000..80b31f92 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.EmailAndPhone.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using Plugin.Firebase.Auth; +using Plugin.Firebase.Core.Exceptions; +using Plugin.Firebase.Functions; +using Plugin.Firebase.IntegrationTests.Functions; + +namespace Plugin.Firebase.IntegrationTests.Auth +{ + public sealed partial class AuthFixture + { + [EmulatorBackendFact] + public async Task signs_in_user_via_email_link_on_auth_emulator() + { + var sut = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("email-link"); + var actionCodeSettings = CreateActionCodeSettings(); + + await sut.SendSignInLink(email, actionCodeSettings); + var emailLink = await GetLatestAuthEmulatorEmailLinkAsync(email, "EMAIL_SIGNIN"); + + Assert.True(sut.IsSignInWithEmailLink(emailLink)); + + var user = await sut.SignInWithEmailLinkAsync(email, emailLink); + await using var testUser = AuthTestUserScope.TrackCurrentUser(sut); + + Assert.Equal(email, user.Email); + Assert.Equal(email, sut.CurrentUser!.Email); + } + + + [OptInFact( + IntegrationTestOptions.RunPhoneAuthTestsEnvironmentVariableName, + IntegrationTestOptions.RunPhoneAuthTestsAndroidSystemPropertyName)] + public async Task signs_in_user_via_phone_number_when_enabled() + { + var sut = CrossFirebaseAuth.Current; + var phoneNumber = IntegrationTestData.GetRequiredConfigurationValue( + IntegrationTestOptions.PhoneAuthNumberEnvironmentVariableName, + IntegrationTestOptions.PhoneAuthNumberAndroidSystemPropertyName); + var verificationCode = IntegrationTestData.GetRequiredConfigurationValue( + IntegrationTestOptions.PhoneAuthCodeEnvironmentVariableName, + IntegrationTestOptions.PhoneAuthCodeAndroidSystemPropertyName); + + await sut.SignOutAsync(); + await sut.VerifyPhoneNumberAsync(phoneNumber); + + var user = await sut.SignInWithPhoneNumberVerificationCodeAsync(verificationCode); + await using var testUser = AuthTestUserScope.TrackCurrentUser(sut, deleteOnDispose: false); + + Assert.NotNull(user); + Assert.Equal(user.Uid, sut.CurrentUser!.Uid); + } + + + [OptInFact( + IntegrationTestOptions.RunPhoneAuthTestsEnvironmentVariableName, + IntegrationTestOptions.RunPhoneAuthTestsAndroidSystemPropertyName)] + public async Task links_signed_in_user_with_phone_number_when_enabled() + { + var sut = CrossFirebaseAuth.Current; + var phoneNumber = IntegrationTestData.GetRequiredConfigurationValue( + IntegrationTestOptions.PhoneAuthNumberEnvironmentVariableName, + IntegrationTestOptions.PhoneAuthNumberAndroidSystemPropertyName); + var verificationCode = IntegrationTestData.GetRequiredConfigurationValue( + IntegrationTestOptions.PhoneAuthCodeEnvironmentVariableName, + IntegrationTestOptions.PhoneAuthCodeAndroidSystemPropertyName); + + await using var user = await AuthTestUserScope.SignInAnonymouslyAsync(sut); + await sut.VerifyPhoneNumberAsync(phoneNumber); + + var linkedUser = await sut.LinkWithPhoneNumberVerificationCodeAsync(verificationCode); + + Assert.Equal(sut.CurrentUser!.Uid, linkedUser.Uid); + Assert.Contains(sut.CurrentUser!.ProviderInfos ?? Array.Empty(), x => x.ProviderId == "phone"); + } + + + [OptInFact( + IntegrationTestOptions.RunPhoneAuthTestsEnvironmentVariableName, + IntegrationTestOptions.RunPhoneAuthTestsAndroidSystemPropertyName)] + public async Task updates_user_phone_number_when_enabled() + { + var sut = CrossFirebaseAuth.Current; + var verificationId = IntegrationTestData.GetRequiredConfigurationValue( + IntegrationTestOptions.PhoneAuthVerificationIdEnvironmentVariableName, + IntegrationTestOptions.PhoneAuthVerificationIdAndroidSystemPropertyName); + var verificationCode = IntegrationTestData.GetRequiredConfigurationValue( + IntegrationTestOptions.PhoneAuthCodeEnvironmentVariableName, + IntegrationTestOptions.PhoneAuthCodeAndroidSystemPropertyName); + + await using var user = await AuthTestUserScope.SignInWithUniqueEmailAndPasswordAsync( + sut, + "update-phone"); + Assert.NotNull(sut.CurrentUser); + await sut.CurrentUser!.UpdatePhoneNumberAsync(verificationId, verificationCode); + + Assert.Contains(sut.CurrentUser!.ProviderInfos ?? Array.Empty(), x => x.ProviderId == "phone"); + } + + + [Fact] + public async Task sends_verification_email() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync( + sut, + IntegrationTestUsers.VerificationEmail, + deleteOnDispose: false); + Assert.NotNull(sut.CurrentUser); + + await sut.CurrentUser!.SendEmailVerificationAsync(); + } + + + [EmulatorBackendFact] + public async Task sends_verification_email_with_action_code_settings() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithUniqueEmailAndPasswordAsync( + sut, + "verification-settings"); + Assert.NotNull(sut.CurrentUser); + + await sut.CurrentUser!.SendEmailVerificationAsync(CreateActionCodeSettings()); + } + + + [Fact] + public async Task sends_password_reset_email_for_current_user() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithUniqueEmailAndPasswordAsync( + sut, + "pw-reset-current"); + + await sut.SendPasswordResetEmailAsync(); + } + + + [Fact] + public async Task sends_password_reset_email_for_explicit_email() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithUniqueEmailAndPasswordAsync( + sut, + "pw-reset-explicit"); + var email = user.Email ?? throw new InvalidOperationException("Expected scoped user email."); + + await sut.SendPasswordResetEmailAsync(email); + } + + } +} diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.EmailPassword.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.EmailPassword.cs new file mode 100644 index 00000000..ed123b1d --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.EmailPassword.cs @@ -0,0 +1,41 @@ +using Plugin.Firebase.Auth; + +namespace Plugin.Firebase.IntegrationTests.Auth +{ + public sealed partial class AuthFixture + { + // Firebase now requires verify-before-update for newer projects on iOS and Android, + // so this direct email update path only works with deprecated project configuration. + [Fact(Skip = "Firebase direct email updates on iOS and Android rely on deprecated project configuration.")] + public async Task updates_user_email() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync( + sut, + IntegrationTestUsers.UpdateEmailEmail, + deleteOnDispose: false); + Assert.NotNull(sut.CurrentUser); + + await sut.CurrentUser!.UpdateEmailAsync("updated@test.com"); + Assert.Equal("updated@test.com", sut.CurrentUser!.Email); + } + + [Fact] + public async Task updates_user_password() + { + var sut = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("update-password"); + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync(sut, email); + Assert.NotNull(sut.CurrentUser); + + await sut.CurrentUser!.UpdatePasswordAsync(IntegrationTestUsers.UpdatedPassword); + await sut.SignOutAsync(); + Assert.Null(sut.CurrentUser); + + await Assert.ThrowsAnyAsync( + () => sut.SignInWithEmailAndPasswordAsync(email, IntegrationTestUsers.DefaultPassword)); + await sut.SignInWithEmailAndPasswordAsync(email, IntegrationTestUsers.UpdatedPassword); + Assert.NotNull(sut.CurrentUser); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.Listeners.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.Listeners.cs new file mode 100644 index 00000000..817caaeb --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.Listeners.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using Plugin.Firebase.Auth; +using Plugin.Firebase.Core.Exceptions; +using Plugin.Firebase.Functions; +using Plugin.Firebase.IntegrationTests.Functions; + +namespace Plugin.Firebase.IntegrationTests.Auth +{ + public sealed partial class AuthFixture + { + [Fact] + public async Task invokes_auth_state_listener_on_sign_in_and_sign_out() + { + var sut = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("auth-state"); + await using var user = await AuthTestUserScope.CreateWithEmailAndPasswordAsync(sut, email); + await sut.SignOutAsync(); + var sawSignedIn = new CallbackProbe(); + var sawSignedOutAfterSignIn = new CallbackProbe(); + + using var listener = sut.AddAuthStateListener(auth => { + if(auth.CurrentUser != null) { + sawSignedIn.TrySetResult(true); + } else if(sawSignedIn.IsCompleted) { + sawSignedOutAfterSignIn.TrySetResult(true); + } + }); + + await sut.SignInWithEmailAndPasswordAsync( + email, + IntegrationTestUsers.DefaultPassword, + createsUserAutomatically: false); + await sawSignedIn.WaitAsync( + IntegrationTestTimeouts.Callback, + "auth state listener sign-in"); + + await sut.SignOutAsync(); + await sawSignedOutAfterSignIn.WaitAsync( + IntegrationTestTimeouts.Callback, + "auth state listener sign-out"); + + await sut.SignInWithEmailAndPasswordAsync( + email, + IntegrationTestUsers.DefaultPassword, + createsUserAutomatically: false); + Assert.NotNull(sut.CurrentUser); + } + + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.MetadataLanguage.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.MetadataLanguage.cs new file mode 100644 index 00000000..c799f4c1 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.MetadataLanguage.cs @@ -0,0 +1,38 @@ +using Plugin.Firebase.Auth; + +namespace Plugin.Firebase.IntegrationTests.Auth +{ + public sealed partial class AuthFixture + { + [Fact] + public async Task sets_language_code() + { + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync( + sut, + IntegrationTestUsers.SetLanguageCodeEmail, + deleteOnDispose: false); + + var ex = Record.Exception(() => { + sut.LanguageCode = "fr"; + sut.UseAppLanguage(); + }); + Assert.Null(ex); + } + + [Fact] + public async Task exposes_user_metadata_and_provider_infos_after_sign_in() + { + var sut = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("user-metadata"); + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync(sut, email); + + Assert.False(string.IsNullOrWhiteSpace(user.User.ProviderId)); + Assert.NotNull(user.User.ProviderInfos); + Assert.Contains(user.User.ProviderInfos, x => x.Email == email); + Assert.NotNull(user.User.Metadata); + Assert.NotEqual(default, user.User.Metadata.CreationDate); + Assert.NotEqual(default, user.User.Metadata.LastSignInDate); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.ProfileUpdates.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.ProfileUpdates.cs new file mode 100644 index 00000000..07abac16 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.ProfileUpdates.cs @@ -0,0 +1,94 @@ +using Plugin.Firebase.Auth; + +namespace Plugin.Firebase.IntegrationTests.Auth +{ + public sealed partial class AuthFixture + { + [Fact] + public async Task updates_user_profile() + { + const string displayName = "Bruce Wayne"; + const string photoUrl = "https://url.to/image.jpg"; + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithUniqueEmailAndPasswordAsync( + sut, + "update-profile"); + Assert.NotNull(sut.CurrentUser); + Assert.Null(sut.CurrentUser!.DisplayName); + Assert.Null(sut.CurrentUser!.PhotoUrl); + + await sut.CurrentUser!.UpdateProfileAsync( + new UserProfileChangeRequest.Builder() + .SetDisplayName(displayName) + .SetPhotoUrl(photoUrl) + .Build() + ); + Assert.Equal(displayName, sut.CurrentUser!.DisplayName); + Assert.Equal(photoUrl, sut.CurrentUser!.PhotoUrl); + + await sut.CurrentUser!.UpdateProfileAsync( + new UserProfileChangeRequest.Builder() + .SetDisplayName(null) + .Build() + ); + Assert.Null(sut.CurrentUser!.DisplayName); + Assert.Equal(photoUrl, sut.CurrentUser!.PhotoUrl); + + await sut.CurrentUser!.UpdateProfileAsync( + new UserProfileChangeRequest.Builder() + .SetDisplayName(displayName) + .Build() + ); + Assert.Equal(displayName, sut.CurrentUser!.DisplayName); + Assert.Equal(photoUrl, sut.CurrentUser!.PhotoUrl); + + await sut.CurrentUser!.UpdateProfileAsync( + new UserProfileChangeRequest.Builder() + .SetPhotoUrl(null) + .Build() + ); + Assert.Equal(displayName, sut.CurrentUser!.DisplayName); + Assert.Null(sut.CurrentUser!.PhotoUrl); + + await sut.CurrentUser!.UpdateProfileAsync( + new UserProfileChangeRequest.Builder() + .SetPhotoUrl(photoUrl) + .Build() + ); + Assert.Equal(displayName, sut.CurrentUser!.DisplayName); + Assert.Equal(photoUrl, sut.CurrentUser!.PhotoUrl); + + await sut.CurrentUser!.UpdateProfileAsync( + new UserProfileChangeRequest.Builder() + .SetDisplayName("") + .Build() + ); + Assert.Equal("", sut.CurrentUser!.DisplayName); + Assert.Equal(photoUrl, sut.CurrentUser!.PhotoUrl); + } + + [Fact] + public async Task legacy_update_user_profile_overload_treats_empty_string_as_omitted() + { + const string displayName = "Bruce Wayne"; + const string photoUrl = "https://url.to/image.jpg"; + var sut = CrossFirebaseAuth.Current; + await using var user = await AuthTestUserScope.SignInWithUniqueEmailAndPasswordAsync( + sut, + "legacy-update-profile"); + Assert.NotNull(sut.CurrentUser); + +#pragma warning disable CS0618 + await sut.CurrentUser!.UpdateProfileAsync(displayName, photoUrl); + await sut.CurrentUser!.UpdateProfileAsync(displayName: ""); + await sut.CurrentUser!.UpdateProfileAsync(photoUrl: ""); + Assert.Equal(displayName, sut.CurrentUser!.DisplayName); + Assert.Equal(photoUrl, sut.CurrentUser!.PhotoUrl); + + await sut.CurrentUser!.UpdateProfileAsync(null); + Assert.Null(sut.CurrentUser!.DisplayName); + Assert.Equal(photoUrl, sut.CurrentUser!.PhotoUrl); +#pragma warning restore CS0618 + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.cs index 86bb292d..c53e6829 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthFixture.cs @@ -1,544 +1,78 @@ +using System.Text.Json; +using System.Text.RegularExpressions; using Plugin.Firebase.Auth; -using Plugin.Firebase.Core.Exceptions; namespace Plugin.Firebase.IntegrationTests.Auth { [Collection("Sequential")] [TestLogging] + [IntegrationTestFixture(IntegrationTestPackage.Auth)] [Preserve(AllMembers = true)] - public sealed class AuthFixture : IAsyncLifetime + public sealed partial class AuthFixture : IAsyncLifetime { + private static readonly HttpClient HttpClient = new(); + public Task InitializeAsync() { return Task.CompletedTask; } - [Fact] - public async Task creates_user_with_email_and_password() - { - var sut = CrossFirebaseAuth.Current; - await sut.CreateUserAsync("created-user@test.com", "123456"); - - Assert.NotNull(sut.CurrentUser); - - await sut.CurrentUser.DeleteAsync(); - Assert.Null(sut.CurrentUser); - } - - [Fact] - public async Task signs_in_user_via_email_and_password() - { - var sut = CrossFirebaseAuth.Current; - var user = await sut.SignInWithEmailAndPasswordAsync("sign-in-with-pw@test.com", "123456"); - Assert.Equal("sign-in-with-pw@test.com", user.Email); - Assert.Equal("sign-in-with-pw@test.com", sut.CurrentUser.Email); - } - - [Fact] - public async Task signs_in_user_with_native_credential() - { - var sut = CrossFirebaseAuth.Current; - var email = CreateUniqueEmail("native-sign-in"); - await sut.CreateUserAsync(email, "123456"); - await sut.SignOutAsync(); - var credential = CreateNativeEmailCredential(email, "123456"); - - var user = await sut.SignInWithCredentialAsync(credential); - - Assert.Equal(email, user.Email); - Assert.NotNull(sut.CurrentUser); - Assert.Equal(email, sut.CurrentUser.Email); - Assert.Equal(user.Uid, sut.CurrentUser.Uid); - } - - [Fact] - public async Task sign_in_with_email_and_password_creates_user_automatically() - { - var sut = CrossFirebaseAuth.Current; - var email = CreateUniqueEmail("auto-create-sign-in"); - - var user = await sut.SignInWithEmailAndPasswordAsync(email, "123456"); - - Assert.NotNull(sut.CurrentUser); - Assert.Equal(email, user.Email); - Assert.Equal(email, sut.CurrentUser.Email); - Assert.Equal(user.Uid, sut.CurrentUser.Uid); - } - - [Fact] - public async Task throws_error_if_credentials_are_invalid_when_signing_in_user_via_email_and_password() - { - var sut = CrossFirebaseAuth.Current; - var ex = await Assert.ThrowsAnyAsync( - () => sut.SignInWithEmailAndPasswordAsync("sign-in-with-pw@test.com", "000000", createsUserAutomatically: false) - ); - - AssertNativeAuthExceptionCaptured(ex); - } - - [Fact] - public async Task throws_error_if_user_does_not_exist_and_should_not_be_created_automatically_due_sign_in_via_email_and_password() - { - var sut = CrossFirebaseAuth.Current; - var ex = await Assert.ThrowsAnyAsync( - () => sut.SignInWithEmailAndPasswordAsync("does-not-exist@test.com", "123456", createsUserAutomatically: false) - ); - - AssertNativeAuthExceptionCaptured(ex); - } - - [Fact] - public async Task throws_cross_platform_exception_for_invalid_native_credential() - { - var sut = CrossFirebaseAuth.Current; - var credential = CreateNativeEmailCredential(CreateUniqueEmail("invalid-native-sign-in"), "000000"); - - var ex = await Assert.ThrowsAnyAsync( - () => sut.SignInWithCredentialAsync(credential) - ); - - AssertNativeAuthExceptionCaptured(ex); - } - - [Fact] - public async Task signs_in_user_anonymously() - { - var sut = CrossFirebaseAuth.Current; - Assert.Null(sut.CurrentUser); - - var user = await sut.SignInAnonymouslyAsync(); - Assert.NotNull(user); - Assert.NotNull(sut.CurrentUser); - Assert.True(user.IsAnonymous); - } - - [Fact] - public async Task links_anonymous_user_with_email_and_password() - { - var sut = CrossFirebaseAuth.Current; - var anonymousUser = await sut.SignInAnonymouslyAsync(); - var email = CreateUniqueEmail("link-anonymous"); - - var linkedUser = await sut.LinkWithEmailAndPasswordAsync(email, "123456"); - - Assert.Equal(anonymousUser.Uid, linkedUser.Uid); - Assert.Equal(anonymousUser.Uid, sut.CurrentUser.Uid); - Assert.False(linkedUser.IsAnonymous); - Assert.False(sut.CurrentUser.IsAnonymous); - Assert.Equal(email, linkedUser.Email); - Assert.Equal(email, sut.CurrentUser.Email); - } - - [Fact] - public async Task links_anonymous_user_with_native_credential() - { - var sut = CrossFirebaseAuth.Current; - var anonymousUser = await sut.SignInAnonymouslyAsync(); - var email = CreateUniqueEmail("native-link"); - var credential = CreateNativeEmailCredential(email, "123456"); - - var linkedUser = await sut.LinkWithCredentialAsync(credential); - - Assert.Equal(anonymousUser.Uid, linkedUser.Uid); - Assert.NotNull(sut.CurrentUser); - Assert.Equal(anonymousUser.Uid, sut.CurrentUser.Uid); - Assert.False(linkedUser.IsAnonymous); - Assert.False(sut.CurrentUser.IsAnonymous); - Assert.Equal(email, linkedUser.Email); - Assert.Equal(email, sut.CurrentUser.Email); - } - - [Fact] - public async Task signs_out_user() - { - var sut = CrossFirebaseAuth.Current; - await sut.SignInWithEmailAndPasswordAsync("sign-out@test.com", "123456"); - Assert.NotNull(sut.CurrentUser); - - await sut.SignOutAsync(); - Assert.Null(sut.CurrentUser); - } - - // Firebase now requires verify-before-update for newer projects on iOS and Android, - // so this direct email update path only works with deprecated project configuration. -#if IOS || ANDROID - [Fact(Skip = "Firebase direct email updates on iOS and Android rely on deprecated project configuration.")] -#else - [Fact] -#endif - public async Task updates_user_email() - { - var sut = CrossFirebaseAuth.Current; - await sut.SignInWithEmailAndPasswordAsync("to-update-email@test.com", "123456"); - Assert.NotNull(sut.CurrentUser); - - await sut.CurrentUser.UpdateEmailAsync("updated@test.com"); - Assert.Equal("updated@test.com", sut.CurrentUser.Email); - } - - [Fact] - public async Task updates_user_password() - { - const string email = "to-update-pw@test.com"; - var sut = CrossFirebaseAuth.Current; - await sut.SignInWithEmailAndPasswordAsync(email, "123456"); - Assert.NotNull(sut.CurrentUser); - - await sut.CurrentUser.UpdatePasswordAsync("abcdefgh"); - await sut.SignOutAsync(); - Assert.Null(sut.CurrentUser); - - await Assert.ThrowsAnyAsync(() => sut.SignInWithEmailAndPasswordAsync(email, "123456")); - await sut.SignInWithEmailAndPasswordAsync(email, "abcdefgh"); - Assert.NotNull(sut.CurrentUser); - } - - [Fact] - public async Task reauthenticates_user_with_email_and_password() - { - var sut = CrossFirebaseAuth.Current; - var email = CreateUniqueEmail("reauth-email-password"); - var user = await sut.SignInWithEmailAndPasswordAsync(email, "123456"); - var uid = user.Uid; - - await sut.CurrentUser.ReauthenticateWithEmailAndPasswordAsync(email, "123456"); - await sut.CurrentUser.UpdatePasswordAsync("abcdefgh"); - - Assert.NotNull(sut.CurrentUser); - Assert.Equal(uid, sut.CurrentUser.Uid); - - await sut.SignOutAsync(); - await Assert.ThrowsAnyAsync( - () => sut.SignInWithEmailAndPasswordAsync(email, "123456", createsUserAutomatically: false) - ); - - var updatedUser = await sut.SignInWithEmailAndPasswordAsync(email, "abcdefgh", createsUserAutomatically: false); - Assert.Equal(uid, updatedUser.Uid); - } - - [Fact] - public async Task throws_error_if_reauthenticating_with_invalid_email_password() - { - var sut = CrossFirebaseAuth.Current; - var email = CreateUniqueEmail("reauth-invalid"); - await sut.SignInWithEmailAndPasswordAsync(email, "123456"); - - var ex = await Assert.ThrowsAnyAsync( - () => sut.CurrentUser.ReauthenticateWithEmailAndPasswordAsync(email, "000000") - ); - - AssertNativeAuthExceptionCaptured(ex); - } - - [Fact] - public async Task updates_user_profile() - { - const string displayName = "Bruce Wayne"; - const string photoUrl = "https://url.to/image.jpg"; - var sut = CrossFirebaseAuth.Current; - await sut.SignInWithEmailAndPasswordAsync(CreateUniqueEmail("update-profile"), "123456"); - Assert.NotNull(sut.CurrentUser); - Assert.Null(sut.CurrentUser.DisplayName); - Assert.Null(sut.CurrentUser.PhotoUrl); - - await sut.CurrentUser.UpdateProfileAsync( - new UserProfileChangeRequest.Builder() - .SetDisplayName(displayName) - .SetPhotoUrl(photoUrl) - .Build() - ); - Assert.Equal(displayName, sut.CurrentUser.DisplayName); - Assert.Equal(photoUrl, sut.CurrentUser.PhotoUrl); - - await sut.CurrentUser.UpdateProfileAsync( - new UserProfileChangeRequest.Builder() - .SetDisplayName(null) - .Build() - ); - Assert.Null(sut.CurrentUser.DisplayName); - Assert.Equal(photoUrl, sut.CurrentUser.PhotoUrl); - - await sut.CurrentUser.UpdateProfileAsync( - new UserProfileChangeRequest.Builder() - .SetDisplayName(displayName) - .Build() - ); - Assert.Equal(displayName, sut.CurrentUser.DisplayName); - Assert.Equal(photoUrl, sut.CurrentUser.PhotoUrl); - - await sut.CurrentUser.UpdateProfileAsync( - new UserProfileChangeRequest.Builder() - .SetPhotoUrl(null) - .Build() - ); - Assert.Equal(displayName, sut.CurrentUser.DisplayName); - Assert.Null(sut.CurrentUser.PhotoUrl); - - await sut.CurrentUser.UpdateProfileAsync( - new UserProfileChangeRequest.Builder() - .SetPhotoUrl(photoUrl) - .Build() - ); - Assert.Equal(displayName, sut.CurrentUser.DisplayName); - Assert.Equal(photoUrl, sut.CurrentUser.PhotoUrl); - - await sut.CurrentUser.UpdateProfileAsync( - new UserProfileChangeRequest.Builder() - .SetDisplayName("") - .Build() - ); - Assert.Equal("", sut.CurrentUser.DisplayName); - Assert.Equal(photoUrl, sut.CurrentUser.PhotoUrl); - } - - [Fact] - public async Task legacy_update_user_profile_overload_treats_empty_string_as_omitted() - { - const string displayName = "Bruce Wayne"; - const string photoUrl = "https://url.to/image.jpg"; - var sut = CrossFirebaseAuth.Current; - await sut.SignInWithEmailAndPasswordAsync(CreateUniqueEmail("legacy-update-profile"), "123456"); - Assert.NotNull(sut.CurrentUser); - -#pragma warning disable CS0618 - await sut.CurrentUser.UpdateProfileAsync(displayName, photoUrl); - await sut.CurrentUser.UpdateProfileAsync(displayName: ""); - await sut.CurrentUser.UpdateProfileAsync(photoUrl: ""); - Assert.Equal(displayName, sut.CurrentUser.DisplayName); - Assert.Equal(photoUrl, sut.CurrentUser.PhotoUrl); - - await sut.CurrentUser.UpdateProfileAsync(null); - Assert.Null(sut.CurrentUser.DisplayName); - Assert.Equal(photoUrl, sut.CurrentUser.PhotoUrl); -#pragma warning restore CS0618 - } - - [Fact] - public async Task sends_verification_email() - { - var sut = CrossFirebaseAuth.Current; - await sut.SignInWithEmailAndPasswordAsync("verification-email@test.com", "123456"); - Assert.NotNull(sut.CurrentUser); - - await sut.CurrentUser.SendEmailVerificationAsync(); - } - - [Fact] - public async Task sends_password_reset_email_for_current_user() - { - var sut = CrossFirebaseAuth.Current; - var email = CreateUniqueEmail("pw-reset-current"); - await sut.SignInWithEmailAndPasswordAsync(email, "123456"); - - await sut.SendPasswordResetEmailAsync(); - } - - [Fact] - public async Task sends_password_reset_email_for_explicit_email() - { - var sut = CrossFirebaseAuth.Current; - var email = CreateUniqueEmail("pw-reset-explicit"); - await sut.SignInWithEmailAndPasswordAsync(email, "123456"); - - await sut.SendPasswordResetEmailAsync(email); - } - - [Fact] - public async Task reloads_current_user() - { - var sut = CrossFirebaseAuth.Current; - await sut.SignInWithEmailAndPasswordAsync("reload-current-user@test.com", "123456"); - Assert.NotNull(sut.CurrentUser); - - var uid = sut.CurrentUser.Uid; -#pragma warning disable CS0618 - await sut.ReloadCurrentUserAsync(); -#pragma warning restore CS0618 - - Assert.NotNull(sut.CurrentUser); - Assert.Equal(uid, sut.CurrentUser.Uid); - } - - [Fact] - public async Task reloads_user() - { - var sut = CrossFirebaseAuth.Current; - var email = CreateUniqueEmail("reload-user"); - await sut.SignInWithEmailAndPasswordAsync(email, "123456"); - Assert.NotNull(sut.CurrentUser); - - var uid = sut.CurrentUser.Uid; - await sut.CurrentUser.ReloadAsync(); - - Assert.NotNull(sut.CurrentUser); - Assert.Equal(uid, sut.CurrentUser.Uid); - } - - [Fact] - public async Task invokes_auth_state_listener_on_sign_in_and_sign_out() + public async Task DisposeAsync() { var sut = CrossFirebaseAuth.Current; - var email = CreateUniqueEmail("auth-state"); - await sut.CreateUserAsync(email, "123456"); - await sut.SignOutAsync(); - var sawSignedIn = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var sawSignedOutAfterSignIn = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - using var listener = sut.AddAuthStateListener(auth => { - if(auth.CurrentUser != null) { - sawSignedIn.TrySetResult(true); - } else if(sawSignedIn.Task.IsCompleted) { - sawSignedOutAfterSignIn.TrySetResult(true); + if(sut.CurrentUser != null && IsEphemeralTestUser(sut.CurrentUser)) { + try { + await sut.CurrentUser.DeleteAsync(); + } catch(Exception e) { + TestLog.Write($"[AUTH FIXTURE CLEANUP ERROR] {sut.CurrentUser?.Email ?? sut.CurrentUser?.Uid ?? "unknown"}: {e}"); } - }); - - await sut.SignInWithEmailAndPasswordAsync(email, "123456", createsUserAutomatically: false); - await sawSignedIn.Task.WaitAsync(TimeSpan.FromSeconds(10)); - + } await sut.SignOutAsync(); - await sawSignedOutAfterSignIn.Task.WaitAsync(TimeSpan.FromSeconds(10)); - - await sut.SignInWithEmailAndPasswordAsync(email, "123456", createsUserAutomatically: false); - await sut.CurrentUser.DeleteAsync(); - } - - [Fact] - public async Task sets_language_code() - { - var sut = CrossFirebaseAuth.Current; - await sut.SignInWithEmailAndPasswordAsync("set-language-code@test.com", "123456"); - - var ex = Record.Exception(() => { - sut.LanguageCode = "fr"; - sut.UseAppLanguage(); - }); - Assert.Null(ex); } - [Fact] - public async Task exposes_user_metadata_and_provider_infos_after_sign_in() + private static bool IsEphemeralTestUser(IFirebaseUser user) { - var sut = CrossFirebaseAuth.Current; - var email = CreateUniqueEmail("user-metadata"); - var user = await sut.SignInWithEmailAndPasswordAsync(email, "123456"); - - Assert.False(string.IsNullOrWhiteSpace(user.ProviderId)); - Assert.NotNull(user.ProviderInfos); - Assert.Contains(user.ProviderInfos, x => x.Email == email); - Assert.NotNull(user.Metadata); - Assert.NotEqual(default, user.Metadata.CreationDate); - Assert.NotEqual(default, user.Metadata.LastSignInDate); + return user.IsAnonymous + || IsUniqueTestEmail(user.Email); } - [Fact] - public async Task deletes_user() + private static bool IsUniqueTestEmail(string? email) { - var sut = CrossFirebaseAuth.Current; - var user = await sut.SignInWithEmailAndPasswordAsync("to-delete@test.com", "123456"); - Assert.NotNull(sut.CurrentUser); - - await user.DeleteAsync(); - Assert.Null(sut.CurrentUser); - } - - [Fact] - public async Task retrieves_custom_claims() - { - var sut = CrossFirebaseAuth.Current; - var user = await sut.SignInWithEmailAndPasswordAsync("custom-claims@test.com", "123456"); - var idTokenResult = await user.GetIdTokenResultAsync(); - await sut.SignOutAsync(); // sign out so the user won't get deleted - - Assert.True(idTokenResult.GetClaim("is_awesome")); - var nestedObject = Assert.IsAssignableFrom>( - idTokenResult.Claims["nested_object"] - ); - NestedClaimAssertions.AssertNestedCustomClaim(nestedObject); - - var typedNestedObject = idTokenResult.GetClaim>( - "nested_object" - ); - NestedClaimAssertions.AssertNestedCustomClaim(typedNestedObject); - - var concreteNestedObject = idTokenResult.GetClaim>( - "nested_object" - ); - NestedClaimAssertions.AssertNestedCustomClaim(concreteNestedObject); - - var objectNestedObject = Assert.IsAssignableFrom>( - idTokenResult.GetClaim("nested_object") - ); - NestedClaimAssertions.AssertNestedCustomClaim(objectNestedObject); - - var nestedArray = Assert.IsAssignableFrom>( - idTokenResult.Claims["nested_array"] - ); - NestedClaimAssertions.AssertNestedCustomArray(nestedArray); - - var typedNestedArray = idTokenResult.GetClaim>("nested_array"); - NestedClaimAssertions.AssertNestedCustomArray(typedNestedArray); - - var concreteNestedArray = idTokenResult.GetClaim>("nested_array"); - NestedClaimAssertions.AssertNestedCustomArray(concreteNestedArray); - - var objectNestedArray = Assert.IsAssignableFrom>( - idTokenResult.GetClaim("nested_array") - ); - NestedClaimAssertions.AssertNestedCustomArray(objectNestedArray); - - Assert.True(Assert.IsType(idTokenResult.GetClaim("is_awesome"))); + return email != null + && Regex.IsMatch(email, "-[0-9a-fA-F]{32}@test\\.com$", RegexOptions.CultureInvariant); } - [Fact] - public async Task exposes_id_token_metadata() + private static ActionCodeSettings CreateActionCodeSettings() { - var sut = CrossFirebaseAuth.Current; - var email = CreateUniqueEmail("token-metadata"); - var user = await sut.SignInWithEmailAndPasswordAsync(email, "123456"); - - var idTokenResult = await user.GetIdTokenResultAsync(); - - Assert.False(string.IsNullOrWhiteSpace(idTokenResult.Token)); - Assert.NotNull(idTokenResult.Claims); - Assert.NotEmpty(idTokenResult.Claims); - Assert.NotEqual(default, idTokenResult.AuthDate); - Assert.NotEqual(default, idTokenResult.IssuedAtDate); - Assert.NotEqual(default, idTokenResult.ExpirationDate); + var settings = new ActionCodeSettings { + Url = "https://plugin.firebase.integrationtests/email-action", + HandleCodeInApp = true, + IOSBundleId = AppInfo.PackageName + }; + settings.SetAndroidPackageName(AppInfo.PackageName, false, "1"); + return settings; } - public async Task DisposeAsync() + private static async Task GetLatestAuthEmulatorEmailLinkAsync( + string email, + string requestType) { - var sut = CrossFirebaseAuth.Current; - if(sut.CurrentUser != null) { - await sut.CurrentUser.DeleteAsync(); - } - await sut.SignOutAsync(); - } + var endpoint = IntegrationTestEnvironment.AuthEmulatorEndpoint; + var uri = $"http://{endpoint.Host}:{endpoint.Port}/emulator/v1/projects/{IntegrationTestEnvironment.ProjectId}/oobCodes"; + using var document = JsonDocument.Parse(await HttpClient.GetStringAsync(uri)); - private static void AssertNativeAuthExceptionCaptured(CrossPlatformFirebaseAuthException exception) - { - Assert.NotNull(exception.InnerException); - Assert.False(string.IsNullOrWhiteSpace(exception.NativeExceptionTypeName)); - Assert.False(string.IsNullOrWhiteSpace(exception.NativeErrorMessage)); - } + var link = document.RootElement + .GetProperty("oobCodes") + .EnumerateArray() + .Where(x => + string.Equals(x.GetProperty("email").GetString(), email, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.GetProperty("requestType").GetString(), requestType, StringComparison.Ordinal)) + .Select(x => x.TryGetProperty("oobLink", out var oobLink) ? oobLink.GetString() : null) + .LastOrDefault(x => !string.IsNullOrWhiteSpace(x)); - private static string CreateUniqueEmail(string prefix) - { - return $"{prefix}-{Guid.NewGuid():N}@test.com"; + return link ?? throw new InvalidOperationException( + $"Auth emulator did not expose a {requestType} email action link for {email}."); } - private static global::Firebase.Auth.AuthCredential CreateNativeEmailCredential( - string email, - string password - ) - { -#if ANDROID - return global::Firebase.Auth.EmailAuthProvider.GetCredential(email, password); -#elif IOS - return global::Firebase.Auth.EmailAuthProvider.GetCredentialFromPassword(email, password); -#else - throw new PlatformNotSupportedException(); -#endif - } } } \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthTestPayloads.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthTestPayloads.cs new file mode 100644 index 00000000..24eb19d8 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthTestPayloads.cs @@ -0,0 +1,44 @@ +namespace Plugin.Firebase.IntegrationTests.Auth; + +internal static class AuthTestPayloads +{ + public static Dictionary CreateNestedCustomClaims() + { + return new Dictionary { + ["is_awesome"] = true, + ["nested_object"] = new Dictionary { + ["enabled"] = true, + ["roles"] = new[] { "admin", "tester" }, + ["metadata"] = new Dictionary { + ["source"] = "emulator", + ["version"] = 2, + }, + ["history"] = new[] { + new Dictionary { + ["action"] = "created", + ["count"] = 1, + }, + new Dictionary { + ["action"] = "updated", + ["count"] = 2, + }, + }, + ["score"] = 7, + ["ratio"] = 1.5, + ["optional"] = null, + }, + ["nested_array"] = new object[] { + new Dictionary { + ["name"] = "first", + ["flags"] = new[] { true, false }, + }, + new Dictionary { + ["name"] = "second", + ["metadata"] = new Dictionary { + ["source"] = "emulator", + }, + }, + }, + }; + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthTokenClaimConversionFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/AuthTokenClaimConversionFixture.cs deleted file mode 100644 index 0f4da842..00000000 --- a/tests/Plugin.Firebase.IntegrationTests/Auth/AuthTokenClaimConversionFixture.cs +++ /dev/null @@ -1,261 +0,0 @@ -#if ANDROID -using Java.Util; -using Plugin.Firebase.Auth.Platforms.Android.Extensions; -#elif IOS -using Foundation; -using Plugin.Firebase.Auth.Platforms.iOS.Extensions; -#endif - -namespace Plugin.Firebase.IntegrationTests.Auth -{ - [Collection("Sequential")] - [TestLogging] - [Microsoft.Maui.Controls.Internals.Preserve(AllMembers = true)] - public sealed class AuthTokenClaimConversionFixture - { -#if ANDROID - [Fact] - public void converts_android_hashmap_claim_objects_recursively() - { - var nestedObject = CreateAndroidNestedObject(); - - var converted = Assert.IsAssignableFrom>( - nestedObject.ToObject() - ); - NestedClaimAssertions.AssertNestedCustomClaim(converted); - - var typedInterface = Assert.IsAssignableFrom>( - nestedObject.ToObject(typeof(IDictionary)) - ); - NestedClaimAssertions.AssertNestedCustomClaim(typedInterface); - - var typedConcrete = Assert.IsType>( - nestedObject.ToObject(typeof(Dictionary)) - ); - NestedClaimAssertions.AssertNestedCustomClaim(typedConcrete); - - var objectConverted = Assert.IsAssignableFrom>( - nestedObject.ToObject(typeof(object)) - ); - NestedClaimAssertions.AssertNestedCustomClaim(objectConverted); - } - - [Fact] - public void converts_android_arraylist_claim_arrays_recursively() - { - var nestedArray = CreateAndroidNestedArray(); - - var converted = Assert.IsAssignableFrom>(nestedArray.ToObject()); - NestedClaimAssertions.AssertNestedCustomArray(converted); - - var typedInterface = Assert.IsAssignableFrom>( - nestedArray.ToObject(typeof(IList)) - ); - NestedClaimAssertions.AssertNestedCustomArray(typedInterface); - - var typedConcrete = Assert.IsType>( - nestedArray.ToObject(typeof(List)) - ); - NestedClaimAssertions.AssertNestedCustomArray(typedConcrete); - - var objectConverted = Assert.IsAssignableFrom>( - nestedArray.ToObject(typeof(object)) - ); - NestedClaimAssertions.AssertNestedCustomArray(objectConverted); - } - - private static HashMap CreateAndroidNestedObject() - { - var nested = new HashMap(); - nested.Put("enabled", true); - nested.Put("roles", CreateAndroidRoles()); - nested.Put("metadata", CreateAndroidMetadata()); - nested.Put("history", CreateAndroidHistory()); - nested.Put("score", 7); - nested.Put("ratio", 1.5); - nested.Put("optional", null); - return nested; - } - - private static ArrayList CreateAndroidNestedArray() - { - var first = new HashMap(); - first.Put("name", "first"); - var flags = new ArrayList(); - flags.Add(true); - flags.Add(false); - first.Put("flags", flags); - - var second = new HashMap(); - second.Put("name", "second"); - second.Put("metadata", CreateAndroidMetadata(includeVersion: false)); - - var nestedArray = new ArrayList(); - nestedArray.Add(first); - nestedArray.Add(second); - return nestedArray; - } - - private static ArrayList CreateAndroidRoles() - { - var roles = new ArrayList(); - roles.Add("admin"); - roles.Add("tester"); - return roles; - } - - private static HashMap CreateAndroidMetadata(bool includeVersion = true) - { - var metadata = new HashMap(); - metadata.Put("source", "emulator"); - if(includeVersion) { - metadata.Put("version", 2); - } - return metadata; - } - - private static ArrayList CreateAndroidHistory() - { - var created = new HashMap(); - created.Put("action", "created"); - created.Put("count", 1); - - var updated = new HashMap(); - updated.Put("action", "updated"); - updated.Put("count", 2); - - var history = new ArrayList(); - history.Add(created); - history.Add(updated); - return history; - } -#endif - -#if IOS - [Fact] - public void converts_ios_dictionary_claim_objects_recursively() - { - var nestedObject = CreateIosNestedObject(); - - var converted = Assert.IsAssignableFrom>( - nestedObject.ToObject() - ); - NestedClaimAssertions.AssertNestedCustomClaim(converted); - - var typedInterface = Assert.IsAssignableFrom>( - nestedObject.ToObject(typeof(IDictionary)) - ); - NestedClaimAssertions.AssertNestedCustomClaim(typedInterface); - - var typedConcrete = Assert.IsType>( - nestedObject.ToObject(typeof(Dictionary)) - ); - NestedClaimAssertions.AssertNestedCustomClaim(typedConcrete); - - var objectConverted = Assert.IsAssignableFrom>( - nestedObject.ToObject(typeof(object)) - ); - NestedClaimAssertions.AssertNestedCustomClaim(objectConverted); - } - - [Fact] - public void converts_ios_array_claim_arrays_recursively() - { - var nestedArray = CreateIosNestedArray(); - - var converted = Assert.IsAssignableFrom>(nestedArray.ToObject()); - NestedClaimAssertions.AssertNestedCustomArray(converted); - - var typedInterface = Assert.IsAssignableFrom>( - nestedArray.ToObject(typeof(IList)) - ); - NestedClaimAssertions.AssertNestedCustomArray(typedInterface); - - var typedConcrete = Assert.IsType>( - nestedArray.ToObject(typeof(List)) - ); - NestedClaimAssertions.AssertNestedCustomArray(typedConcrete); - - var objectConverted = Assert.IsAssignableFrom>( - nestedArray.ToObject(typeof(object)) - ); - NestedClaimAssertions.AssertNestedCustomArray(objectConverted); - } - - private static NSDictionary CreateIosNestedObject() - { - return NSDictionary.FromObjectsAndKeys( - new NSObject[] { - NSNumber.FromBoolean(true), - CreateIosRoles(), - CreateIosMetadata(), - CreateIosHistory(), - NSNumber.FromInt32(7), - NSNumber.FromDouble(1.5), - NSNull.Null, - }, - new NSObject[] { - new NSString("enabled"), - new NSString("roles"), - new NSString("metadata"), - new NSString("history"), - new NSString("score"), - new NSString("ratio"), - new NSString("optional"), - } - ); - } - - private static NSArray CreateIosNestedArray() - { - var first = NSDictionary.FromObjectsAndKeys( - new NSObject[] { - new NSString("first"), - NSArray.FromNSObjects(NSNumber.FromBoolean(true), NSNumber.FromBoolean(false)), - }, - new NSObject[] { new NSString("name"), new NSString("flags") } - ); - var second = NSDictionary.FromObjectsAndKeys( - new NSObject[] { new NSString("second"), CreateIosMetadata(includeVersion: false) }, - new NSObject[] { new NSString("name"), new NSString("metadata") } - ); - - return NSArray.FromNSObjects(first, second); - } - - private static NSArray CreateIosRoles() - { - return NSArray.FromNSObjects(new NSString("admin"), new NSString("tester")); - } - - private static NSDictionary CreateIosMetadata(bool includeVersion = true) - { - if(!includeVersion) { - return NSDictionary.FromObjectsAndKeys( - new NSObject[] { new NSString("emulator") }, - new NSObject[] { new NSString("source") } - ); - } - - return NSDictionary.FromObjectsAndKeys( - new NSObject[] { new NSString("emulator"), NSNumber.FromInt32(2) }, - new NSObject[] { new NSString("source"), new NSString("version") } - ); - } - - private static NSArray CreateIosHistory() - { - var created = NSDictionary.FromObjectsAndKeys( - new NSObject[] { new NSString("created"), NSNumber.FromInt32(1) }, - new NSObject[] { new NSString("action"), new NSString("count") } - ); - var updated = NSDictionary.FromObjectsAndKeys( - new NSObject[] { new NSString("updated"), NSNumber.FromInt32(2) }, - new NSObject[] { new NSString("action"), new NSString("count") } - ); - - return NSArray.FromNSObjects(created, updated); - } -#endif - } -} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Auth/NestedClaimAssertions.cs b/tests/Plugin.Firebase.IntegrationTests/Auth/NestedClaimAssertions.cs index 2af8deeb..f4c294a3 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Auth/NestedClaimAssertions.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Auth/NestedClaimAssertions.cs @@ -7,7 +7,7 @@ public static void AssertNestedCustomClaim(IDictionary nestedObj Assert.True(Convert.ToBoolean(nestedObject["enabled"])); var roles = Assert.IsAssignableFrom>(nestedObject["roles"]); - Assert.Equal(new[] { "admin", "tester" }, roles.Select(x => Assert.IsType(x))); + Assert.Equal(["admin", "tester"], roles.Select(x => Assert.IsType(x))); var metadata = Assert.IsAssignableFrom>( nestedObject["metadata"] @@ -36,7 +36,7 @@ public static void AssertNestedCustomArray(IList nestedArray) Assert.Equal("first", Assert.IsType(dict["name"])); var flags = Assert.IsAssignableFrom>(dict["flags"]); - Assert.Equal(new[] { true, false }, flags.Select(Convert.ToBoolean)); + Assert.Equal([true, false], flags.Select(Convert.ToBoolean)); }, item => { var dict = Assert.IsAssignableFrom>(item); diff --git a/tests/Plugin.Firebase.IntegrationTests/AuthTestUserScope.cs b/tests/Plugin.Firebase.IntegrationTests/AuthTestUserScope.cs new file mode 100644 index 00000000..d0a9181f --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/AuthTestUserScope.cs @@ -0,0 +1,119 @@ +using Plugin.Firebase.Auth; + +namespace Plugin.Firebase.IntegrationTests; + +internal sealed class AuthTestUserScope : IAsyncDisposable +{ + private readonly IFirebaseAuth _auth; + private readonly string? _email; + private readonly string? _password; + private readonly bool _deleteOnDispose; + private IFirebaseUser? _user; + + private AuthTestUserScope( + IFirebaseAuth auth, + IFirebaseUser user, + string? email, + string? password, + bool deleteOnDispose) + { + _auth = auth; + _user = user; + _email = email; + _password = password; + _deleteOnDispose = deleteOnDispose; + } + + public IFirebaseUser User => + _user ?? throw new ObjectDisposedException(nameof(AuthTestUserScope)); + + public string? Email => _email; + + public static async Task CreateWithEmailAndPasswordAsync( + IFirebaseAuth auth, + string email, + string password = IntegrationTestUsers.DefaultPassword, + bool deleteOnDispose = true) + { + await auth.CreateUserAsync(email, password); + var user = auth.CurrentUser ?? throw new InvalidOperationException("Expected created test user to be current."); + return new AuthTestUserScope(auth, user, email, password, deleteOnDispose); + } + + public static async Task SignInWithEmailAndPasswordAsync( + IFirebaseAuth auth, + string email, + string password = IntegrationTestUsers.DefaultPassword, + bool createsUserAutomatically = true, + bool deleteOnDispose = true) + { + var user = await auth.SignInWithEmailAndPasswordAsync( + email, + password, + createsUserAutomatically); + return new AuthTestUserScope(auth, user, email, password, deleteOnDispose); + } + + public static Task SignInWithUniqueEmailAndPasswordAsync( + IFirebaseAuth auth, + string prefix, + string password = IntegrationTestUsers.DefaultPassword, + bool deleteOnDispose = true) + { + return SignInWithEmailAndPasswordAsync( + auth, + IntegrationTestData.UniqueEmail(prefix), + password, + deleteOnDispose: deleteOnDispose); + } + + public static async Task SignInAnonymouslyAsync( + IFirebaseAuth auth, + bool deleteOnDispose = true) + { + var user = await auth.SignInAnonymouslyAsync(); + return new AuthTestUserScope(auth, user, null, null, deleteOnDispose); + } + + public static AuthTestUserScope TrackCurrentUser( + IFirebaseAuth auth, + string? email = null, + string? password = null, + bool deleteOnDispose = true) + { + var user = auth.CurrentUser ?? throw new InvalidOperationException("Expected a current test user to track."); + return new AuthTestUserScope(auth, user, email ?? user.Email, password, deleteOnDispose); + } + + public async ValueTask DisposeAsync() + { + try { + if(_deleteOnDispose && _user != null) { + await EnsureUserIsCurrentAsync(); + if(_auth.CurrentUser?.Uid == _user.Uid) { + await _auth.CurrentUser.DeleteAsync(); + } + } + } catch(Exception e) { + TestLog.Write($"[AUTH CLEANUP ERROR] {_email ?? _user?.Uid ?? "unknown"}: {e}"); + } + finally { + _user = null; + await _auth.SignOutAsync(); + } + } + + private async Task EnsureUserIsCurrentAsync() + { + if(_user == null || _auth.CurrentUser?.Uid == _user.Uid) { + return; + } + + if(!string.IsNullOrWhiteSpace(_email) && !string.IsNullOrWhiteSpace(_password)) { + _user = await _auth.SignInWithEmailAndPasswordAsync( + _email, + _password, + createsUserAutomatically: false); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Bundled/BundledInitializerFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Bundled/BundledInitializerFixture.cs new file mode 100644 index 00000000..dc962d98 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Bundled/BundledInitializerFixture.cs @@ -0,0 +1,68 @@ +using Plugin.Firebase.Analytics; +using Plugin.Firebase.AppCheck; +using Plugin.Firebase.Auth; +using Plugin.Firebase.CloudMessaging; +using Plugin.Firebase.Crashlytics; +using Plugin.Firebase.Firestore; +using Plugin.Firebase.Functions; +using Plugin.Firebase.Installations; +using Plugin.Firebase.PerformanceMonitoring; +using Plugin.Firebase.RemoteConfig; +using Plugin.Firebase.Storage; + +namespace Plugin.Firebase.IntegrationTests.Bundled +{ + [Collection("Sequential")] + [TestLogging] + [IntegrationTestFixture(IntegrationTestPackage.Bundled)] + [Preserve(AllMembers = true)] + public sealed class BundledInitializerFixture + { + [Fact] + public void active_services_are_available_after_bundled_initialization() + { + Assert.NotNull(CrossFirebaseAnalytics.Current); + Assert.NotNull(CrossFirebaseAppCheck.Current); + Assert.NotNull(CrossFirebaseAuth.Current); + Assert.NotNull(CrossFirebaseCloudMessaging.Current); + Assert.NotNull(CrossFirebaseCrashlytics.Current); + Assert.NotNull(CrossFirebaseFirestore.Current); + Assert.NotNull(CrossFirebaseFunctions.Current); + Assert.NotNull(CrossFirebaseInstallations.Current); + Assert.NotNull(CrossFirebasePerformanceMonitoring.Current); + Assert.NotNull(CrossFirebaseRemoteConfig.Current); + Assert.NotNull(CrossFirebaseStorage.Current); + } + + [Fact] + public void active_services_dispose_and_reacquire() + { + AssertRecreates(() => CrossFirebaseAnalytics.Current, CrossFirebaseAnalytics.Dispose); + AssertRecreates(() => CrossFirebaseAppCheck.Current, CrossFirebaseAppCheck.Dispose); + AssertRecreates(() => CrossFirebaseAuth.Current, CrossFirebaseAuth.Dispose); + AssertRecreates(() => CrossFirebaseCloudMessaging.Current, CrossFirebaseCloudMessaging.Dispose); + AssertRecreates(() => CrossFirebaseCrashlytics.Current, CrossFirebaseCrashlytics.Dispose); + AssertRecreates(() => CrossFirebaseFirestore.Current, CrossFirebaseFirestore.Dispose); + AssertRecreates(() => CrossFirebaseFunctions.Current, CrossFirebaseFunctions.Dispose); + AssertRecreates(() => CrossFirebaseInstallations.Current, CrossFirebaseInstallations.Dispose); + AssertRecreates(() => CrossFirebasePerformanceMonitoring.Current, CrossFirebasePerformanceMonitoring.Dispose); + AssertRecreates(() => CrossFirebaseRemoteConfig.Current, CrossFirebaseRemoteConfig.Dispose); + AssertRecreates(() => CrossFirebaseStorage.Current, CrossFirebaseStorage.Dispose); + } + + private static void AssertRecreates( + Func getCurrent, + Action dispose) + where T : class + { + var first = getCurrent(); + + dispose(); + + var second = getCurrent(); + + Assert.NotNull(second); + Assert.NotSame(first, second); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/CloudMessaging/CloudMessagingFixture.cs b/tests/Plugin.Firebase.IntegrationTests/CloudMessaging/CloudMessagingFixture.cs new file mode 100644 index 00000000..bf199b16 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/CloudMessaging/CloudMessagingFixture.cs @@ -0,0 +1,104 @@ +using Plugin.Firebase.CloudMessaging; +using Plugin.Firebase.CloudMessaging.EventArgs; + +namespace Plugin.Firebase.IntegrationTests.CloudMessaging +{ + [Collection("Sequential")] + [TestLogging] + [IntegrationTestFixture(IntegrationTestPackage.CloudMessaging)] + [Preserve(AllMembers = true)] + public sealed class CloudMessagingFixture + { + [Fact] + public async Task raises_notification_received_for_synthetic_notification() + { + var sut = CrossFirebaseCloudMessaging.Current; + using var notificationReceived = new EventProbe( + handler => sut.NotificationReceived += handler, + handler => sut.NotificationReceived -= handler); + + sut.OnNotificationReceived(new FCMNotification( + data: new Dictionary { + { "title", "Synthetic title" }, + { "body", "Synthetic body" }, + { "is_silent_in_foreground", "true" }, + { "custom", "value" } + })); + + var args = await notificationReceived.WaitAsync( + IntegrationTestTimeouts.ShortCallback, + "synthetic notification delivery"); + + Assert.Equal("Synthetic title", args.Notification.Title); + Assert.Equal("Synthetic body", args.Notification.Body); + Assert.True(args.Notification.IsSilentInForeground); + Assert.Equal("value", args.Notification.Data["custom"]); + } + + [Fact] + public async Task replays_notification_tap_when_handler_is_registered_late() + { + var sut = CrossFirebaseCloudMessaging.Current; + var missedNotificationField = sut.GetType().GetField( + "_missedTappedNotification", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(missedNotificationField); + + var expectedNotification = new FCMNotification( + body: "Tapped body", + title: "Tapped title", + data: new Dictionary { { "source", "reflection" } }); + missedNotificationField.SetValue(sut, expectedNotification); + + using var notificationTapped = new EventProbe( + handler => sut.NotificationTapped += handler, + handler => sut.NotificationTapped -= handler); + + var args = await notificationTapped.WaitAsync( + IntegrationTestTimeouts.ShortCallback, + "late notification tap replay"); + + Assert.Equal("Tapped title", args.Notification.Title); + Assert.Equal("Tapped body", args.Notification.Body); + Assert.Equal("reflection", args.Notification.Data["source"]); + } + + [RealFirebaseOptInFact(IntegrationTestOptions.RunFcmTokenTestsEnvironmentVariableName, skipIosSimulator: true)] + public async Task gets_token_raises_token_changed_and_manages_topic_when_enabled() + { + var sut = CrossFirebaseCloudMessaging.Current; + using var tokenChanged = new EventProbe( + handler => sut.TokenChanged += handler, + handler => sut.TokenChanged -= handler); + var topic = IntegrationTestData.UniqueId("acceptance"); + + var token = await sut.GetTokenAsync(); + await sut.OnTokenRefreshAsync(); + var args = await tokenChanged.WaitAsync( + IntegrationTestTimeouts.LongCallback, + "FCM token refresh event"); + await sut.CheckIfValidAsync(); + await sut.SubscribeToTopicAsync(topic); + await sut.UnsubscribeFromTopicAsync(topic); + + Assert.False(string.IsNullOrWhiteSpace(token)); + Assert.False(string.IsNullOrWhiteSpace(args.Token)); + } + + [RealFirebaseOptInFact(IntegrationTestOptions.RunFcmDeliveryTestsEnvironmentVariableName, skipIosSimulator: true)] + public async Task receives_real_push_delivery_when_enabled() + { + var sut = CrossFirebaseCloudMessaging.Current; + var token = await sut.GetTokenAsync(); + using var notificationReceived = new EventProbe( + handler => sut.NotificationReceived += handler, + handler => sut.NotificationReceived -= handler); + + TestLog.Write($"[FCM TOKEN] {token}"); + var args = await notificationReceived.WaitAsync( + IntegrationTestTimeouts.FcmDelivery, + "real FCM push delivery"); + Assert.NotNull(args.Notification); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Crashlytics/CrashlyticsFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Crashlytics/CrashlyticsFixture.cs index 3138f1aa..d60ebd86 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Crashlytics/CrashlyticsFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Crashlytics/CrashlyticsFixture.cs @@ -4,6 +4,7 @@ namespace Plugin.Firebase.IntegrationTests.Crashlytics { [Collection("Sequential")] [TestLogging] + [IntegrationTestFixture(IntegrationTestPackage.Crashlytics)] [Preserve(AllMembers = true)] public sealed class CrashlyticsFixture { @@ -24,26 +25,32 @@ public void configures_collection_and_custom_keys() { "bulk_int", 7 }, { "bulk_string", "bulk-value" } }); - sut.SetUserId($"integration-test-{Guid.NewGuid():N}"); + sut.SetUserId(IntegrationTestData.UniqueId("integration-test")); sut.Log("Crashlytics integration smoke test"); sut.SetCrashlyticsCollectionEnabled(true); } - [Fact] + [NonIosSimulatorFact] public async Task records_exception_and_queries_unsent_reports() { - if(OperatingSystem.IsIOS() && DeviceInfo.DeviceType == DeviceType.Virtual) { - return; - } - var sut = CrossFirebaseCrashlytics.Current; sut.SetCrashlyticsCollectionEnabled(false); sut.RecordException(new InvalidOperationException("Crashlytics integration smoke test")); - await sut.CheckForUnsentReportsAsync().WaitAsync(TimeSpan.FromSeconds(10)); + var hasUnsentReports = await sut.CheckForUnsentReportsAsync().WaitForTestAsync( + IntegrationTestTimeouts.Callback, + "Crashlytics unsent report check"); + Assert.IsType(hasUnsentReports); sut.SetCrashlyticsCollectionEnabled(true); } + [Fact] + public void exposes_previous_crash_state() + { + var didCrash = CrossFirebaseCrashlytics.Current.DidCrashOnPreviousExecution(); + Assert.IsType(didCrash); + } + [Fact] public void handles_unsent_report_controls() { @@ -54,5 +61,21 @@ public void handles_unsent_report_controls() sut.DeleteUnsentReports(); sut.SetCrashlyticsCollectionEnabled(true); } + + [OptInFact(IntegrationTestOptions.ExpectPreviousCrashEnvironmentVariableName)] + public void detects_previous_forced_crash_when_enabled() + { + Assert.True(CrossFirebaseCrashlytics.Current.DidCrashOnPreviousExecution()); + } + + [OptInFact(IntegrationTestOptions.ForceCrashlyticsCrashEnvironmentVariableName)] + public void forces_process_crash_when_enabled() + { + var sut = CrossFirebaseCrashlytics.Current; + sut.SetCrashlyticsCollectionEnabled(true); + sut.Log("Forcing Crashlytics acceptance-test crash."); + + Environment.FailFast("Forced Crashlytics acceptance-test crash."); + } } -} +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/CrewCheckIn.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/CrewCheckIn.cs index cd6f9b88..5e1be50d 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Firestore/CrewCheckIn.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/CrewCheckIn.cs @@ -22,25 +22,25 @@ public CrewCheckIn(List employees, List y } [FirestoreProperty("employees")] - public IList Employees { get; set; } + public IList Employees { get; set; } = null!; [FirestoreProperty("yardAssets")] - public IList YardAssets { get; set; } + public IList YardAssets { get; set; } = null!; [FirestoreProperty("clockInTime")] - public string ClockInTime { get; set; } + public string ClockInTime { get; set; } = null!; [FirestoreProperty("yardLocation")] - public string YardLocation { get; set; } + public string YardLocation { get; set; } = null!; [FirestoreProperty("emergencyCheckIn")] public bool EmergencyCheckIn { get; set; } [FirestoreProperty("removedAssets")] - public IList RemovedAssets { get; set; } + public IList RemovedAssets { get; set; } = null!; [FirestoreProperty("logs")] - public IList LogEntries { get; set; } + public IList LogEntries { get; set; } = null!; [FirestoreProperty("timestamp")] public DateTime Timestamp { get; set; } @@ -69,37 +69,37 @@ public CrewCheckInEmployee(string name, string clazz, int crew, List lan } [FirestoreProperty("name")] - public string Name { get; set; } + public string Name { get; set; } = null!; [FirestoreProperty("class")] - public string Clazz { get; set; } + public string Clazz { get; set; } = null!; [FirestoreProperty("crew")] public int Crew { get; set; } [FirestoreProperty("languages")] - public IList Languages { get; set; } + public IList Languages { get; set; } = null!; [FirestoreProperty("assignedEquipment")] - public IList AssignedEquipment { get; set; } + public IList AssignedEquipment { get; set; } = null!; [FirestoreProperty("assignedVehicles")] - public IList AssignedVehicles { get; set; } + public IList AssignedVehicles { get; set; } = null!; [FirestoreProperty("clockInTime")] - public string ClockInTime { get; set; } + public string ClockInTime { get; set; } = null!; [FirestoreProperty("status")] - public string Status { get; set; } + public string Status { get; set; } = null!; [FirestoreProperty("workType")] - public string WorkType { get; set; } + public string WorkType { get; set; } = null!; [FirestoreProperty("jobNumbers")] - public IList JobNumbers { get; set; } + public IList JobNumbers { get; set; } = null!; [FirestoreProperty("notes")] - public string Notes { get; set; } + public string Notes { get; set; } = null!; } public class CrewCheckInRemovedAsset : IFirestoreObject @@ -117,13 +117,13 @@ public CrewCheckInRemovedAsset(string assetName, string assetDescription, string } [FirestoreProperty("assetName")] - public string AssetName { get; private set; } + public string AssetName { get; private set; } = null!; [FirestoreProperty("assetDescription")] - public string AssetDescription { get; private set; } + public string AssetDescription { get; private set; } = null!; [FirestoreProperty("reason")] - public string Reason { get; private set; } + public string Reason { get; private set; } = null!; } public class CrewCheckInLog : IFirestoreObject @@ -144,10 +144,10 @@ public CrewCheckInLog(DateTime timestamp, string action, string message) public DateTime Timestamp { get; set; } // Plugin.Firebase maps to Firestore timestamp [FirestoreProperty("action")] - public string Action { get; set; } + public string Action { get; set; } = null!; [FirestoreProperty("message")] - public string Message { get; set; } + public string Message { get; set; } = null!; } public class CrewCheckInAsset : IFirestoreObject @@ -166,15 +166,15 @@ public CrewCheckInAsset(string description, string name, string @operator, strin } [FirestoreProperty("description")] - public string Description { get; set; } + public string Description { get; set; } = null!; [FirestoreProperty("name")] - public string Name { get; set; } + public string Name { get; set; } = null!; [FirestoreProperty("operator")] - public string Operator { get; set; } + public string Operator { get; set; } = null!; [FirestoreProperty("type")] - public string Type { get; set; } + public string Type { get; set; } = null!; } -} +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/Evolution.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/Evolution.cs index e5d73b68..0c408768 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Firestore/Evolution.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/Evolution.cs @@ -5,9 +5,9 @@ namespace Plugin.Firebase.IntegrationTests.Firestore public sealed class Evolution : IFirestoreObject { [FirestoreProperty("pokemon_id")] - public string PokemonId { get; private set; } + public string PokemonId { get; private set; } = null!; [FirestoreProperty("name")] - public string Name { get; private set; } + public string Name { get; private set; } = null!; } } \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreAssertions.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreAssertions.cs new file mode 100644 index 00000000..8307bf85 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreAssertions.cs @@ -0,0 +1,259 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +internal static class FirestoreAssertions +{ + public static ICollectionReference SeededPokemonCollection(IFirebaseFirestore firestore) + { + // The Pokemon collection is shared seed data for emulator and real-backend query coverage. + return firestore.GetCollection("pokemons"); + } + + public static IDocumentReference SeededPokemonDocument(IFirebaseFirestore firestore, string id) + { + return firestore.GetDocument($"pokemons/{id}"); + } + + public static void PokemonIds(IQuerySnapshot snapshot, params string[] expectedIds) + { + Assert.Equal(expectedIds, snapshot.Documents.Select(x => Require(x.Data).Id)); + } + + public static void PokemonNames(IQuerySnapshot snapshot, params string[] expectedNames) + { + Assert.Equal(expectedNames, snapshot.Documents.Select(x => Require(x.Data).Name)); + } + + public static void QuerySnapshotProperties(IQuerySnapshot snapshot) + { + Assert.False(snapshot.IsEmpty); + Assert.Equal(snapshot.Documents.Count(), snapshot.Count); + Assert.NotNull(snapshot.Query); + Assert.NotNull(snapshot.Metadata); + Assert.NotEmpty(snapshot.DocumentChanges); + Assert.NotEmpty(snapshot.GetDocumentChanges(includeMetadataChanges: false)); + } + + public static void WrittenDocument( + IDocumentSnapshot snapshot, + string expectedId, + string expectedPath, + T expectedData) + where T : class + { + Assert.False(snapshot.Metadata.HasPendingWrites); + Assert.Equal(expectedId, snapshot.Reference.Id); + Assert.Equal(expectedPath, snapshot.Reference.Path); + Assert.Equal(expectedData, Require(snapshot.Data)); + } + + public static void GeneratedSimpleItem( + IDocumentReference document, + IDocumentSnapshot snapshot, + string expectedTitle) + { + Assert.False(string.IsNullOrWhiteSpace(document.Id)); + Assert.False(string.IsNullOrWhiteSpace(document.Path)); + Assert.Equal(document.Id, snapshot.Reference.Id); + var data = Require(snapshot.Data); + Assert.Equal(document.Id, data.Id); + Assert.Equal(expectedTitle, data.Title); + } + + public static void PokemonOtherProperties(Pokemon? pokemon, long expectedLegs, long expectedColors) + { + var properties = Require(pokemon).OtherProperties; + Assert.NotNull(properties); + Assert.Equal(expectedLegs, Convert.ToInt64(properties["legs"])); + Assert.Equal(expectedColors, Convert.ToInt64(properties["colors"])); + } + + public static void PokemonNestedMapAndDateValues( + Pokemon? pokemon, + DateTime expectedCreationDate, + SightingLocation expectedLocation) + { + var data = Require(pokemon); + Assert.InRange(Math.Abs(data.CreationDate.Ticks - expectedCreationDate.Ticks), 0, 10); + Assert.Equal(expectedLocation, data.FirstSightingLocation); + PokemonOtherProperties(data, expectedLegs: 4L, expectedColors: 3L); + } + + public static async Task PokemonWriteOverloadResultsAsync( + IDocumentReference mergedDictionaryDocument, + string expectedMergedDictionaryName, + IDocumentReference? tupleDocument, + string? expectedTupleName, + long? expectedTupleSightingCount, + IDocumentReference mergedTupleDocument, + string expectedMergedTupleName, + long? expectedMergedTupleSightingCount = null) + { + var mergedDictionarySnapshot = await mergedDictionaryDocument.GetDocumentSnapshotAsync(); + var mergedTupleSnapshot = await mergedTupleDocument.GetDocumentSnapshotAsync(); + + Assert.Equal(expectedMergedDictionaryName, mergedDictionarySnapshot.Data?.Name); + Assert.Equal(60, mergedDictionarySnapshot.Data?.HeightInCm); + Assert.Equal(expectedMergedTupleName, mergedTupleSnapshot.Data?.Name); + Assert.Equal(50, mergedTupleSnapshot.Data?.HeightInCm); + if(expectedMergedTupleSightingCount != null) { + Assert.Equal(expectedMergedTupleSightingCount, mergedTupleSnapshot.Data?.SightingCount); + } + + if(tupleDocument == null) { + return; + } + + var tupleSnapshot = await tupleDocument.GetDocumentSnapshotAsync(); + Assert.Equal(expectedTupleName, tupleSnapshot.Data?.Name); + Assert.Equal(expectedTupleSightingCount, tupleSnapshot.Data?.SightingCount); + } + + public static void RawDictionaryData(IDictionary data, IDocumentReference document) + { + Assert.NotNull(data); + Assert.Equal("value", data["unknown_string"]); + Assert.Equal(123L, Convert.ToInt64(data["unknown_long"])); + Assert.Equal(12.5, Convert.ToDouble(data["unknown_double"])); + Assert.True((bool) data["unknown_bool"]!); + Assert.Null(data["unknown_null"]); + + var numbers = Assert.IsAssignableFrom>(data["unknown_numbers"]); + Assert.Equal([1L, 2L], numbers.Select(Convert.ToInt64)); + + Assert.Empty(Assert.IsAssignableFrom>(data["unknown_empty_array"])); + Assert.Empty(Assert.IsAssignableFrom>(data["unknown_empty_map"])); + + var arrayWithNulls = Assert.IsAssignableFrom>(data["unknown_array_with_nulls"]); + Assert.Null(arrayWithNulls[0]); + Assert.Equal("text", arrayWithNulls[1]); + Assert.Equal(3L, Convert.ToInt64(arrayWithNulls[2])); + + var childMap = Assert.IsAssignableFrom>(arrayWithNulls[3]); + Assert.Null(childMap["child_null"]); + Assert.Equal("child", childMap["child_text"]); + Assert.False((bool) arrayWithNulls[4]!); + + var mapArray = Assert.IsAssignableFrom>(data["unknown_map_array"]); + Assert.Equal(2, mapArray.Count); + var firstMap = Assert.IsAssignableFrom>(mapArray[0]); + Assert.Equal("first", firstMap["name"]); + Assert.Equal(1L, Convert.ToInt64(firstMap["score"])); + Assert.True((bool) firstMap["active"]!); + var secondMap = Assert.IsAssignableFrom>(mapArray[1]); + Assert.Equal("second", secondMap["name"]); + Assert.Equal(2L, Convert.ToInt64(secondMap["score"])); + Assert.False((bool) secondMap["active"]!); + + var nested = Assert.IsAssignableFrom>(data["nested"]); + Assert.Equal(42L, Convert.ToInt64(nested["answer"])); + Assert.Equal("nested value", nested["label"]); + Assert.Null(nested["null_value"]); + Assert.Empty(Assert.IsAssignableFrom>(nested["empty_values"])); + Assert.Empty(Assert.IsAssignableFrom>(nested["empty_map"])); + + var nestedValues = Assert.IsAssignableFrom>(nested["values"]); + Assert.Equal(["one", "two"], nestedValues.Select(x => x as string)); + + var deepNested = Assert.IsAssignableFrom>(nested["deep"]); + Assert.Equal(84L, Convert.ToInt64(deepNested["answer"])); + Assert.Null(deepNested["null_value"]); + + var directMap = Assert.IsAssignableFrom>(nested["direct_map"]); + Assert.Equal("direct", directMap["text"]); + Assert.Equal(9L, Convert.ToInt64(directMap["count"])); + Assert.Equal((short) 7, Convert.ToInt16(directMap["short_count"])); + var flags = Assert.IsAssignableFrom>(directMap["flags"]); + Assert.Equal([true, false], flags.Select(x => (bool) x!)); + var innerMap = Assert.IsAssignableFrom>(directMap["inner"]); + Assert.Equal("inside", innerMap["value"]); + + Assert.IsType(data["observed_at"]); + Assert.IsType(data["created_at"]); + Assert.IsType(data["generated_at"]); + + var reference = Assert.IsAssignableFrom(data["original_reference"]); + Assert.Equal(document.Path, reference.Path); + } + + public static void RawObjectDictionaryData(IDictionary data, IDocumentReference document) + { + Assert.NotNull(data); + Assert.All(data.Keys, key => Assert.IsType(key)); + RawDictionaryData(data.ToDictionary(x => (string) x.Key, x => x.Value), document); + } + + public static async Task NullableDocumentAsync(IDocumentReference document, string? expectedMarker) + { + var snapshot = await document.GetDocumentSnapshotAsync(Source.Server); + var item = Require(snapshot.Data); + Assert.Equal(expectedMarker, item.QueryMarker); + Assert.Null(item.NullableString); + Assert.Null(item.NullableNumber); + + var map = Require(item.NullableMap); + Assert.True(map.ContainsKey("inner_null")); + Assert.Null(map["inner_null"]); + Assert.Equal("nested-value", map["inner_value"]); + + var list = Require(item.NullableList); + Assert.Equal(["first", null, "last"], list); + } + + public static async Task Issue482NestedMapAsync(IDocumentReference document, string expectedMarker) + { + var snapshot = await document.GetDocumentSnapshotAsync(Source.Server); + var item = Require(snapshot.Data); + Assert.Equal(expectedMarker, item.QueryMarker); + + var map = Require(item.NullableMap); + Assert.True(map.ContainsKey("sub_field")); + Assert.Equal($"{expectedMarker}-value", map["sub_field"]); + } + + public static void CrewCheckInDocument(CrewCheckIn? crewCheckIn, DateTime timestamp, DateTime logTimestamp) + { + Assert.NotNull(crewCheckIn); + Assert.True(crewCheckIn!.EmergencyCheckIn); + Assert.Equal("07:30", crewCheckIn.ClockInTime); + Assert.Equal("north yard", crewCheckIn.YardLocation); + Assert.InRange(Math.Abs(crewCheckIn.Timestamp.Ticks - timestamp.Ticks), 0, 10); + + var employee = Assert.Single(crewCheckIn.Employees); + Assert.Equal("Ada Lovelace", employee.Name); + Assert.Equal("Foreman", employee.Clazz); + Assert.Equal(7, employee.Crew); + Assert.Equal(["en", "de"], employee.Languages); + Assert.Equal(["1001", "1002"], employee.JobNumbers); + Assert.Equal("yard", employee.WorkType); + Assert.Equal("ready", employee.Notes); + + var equipment = Assert.Single(employee.AssignedEquipment); + Assert.Equal("Bucket Attachment", equipment.Name); + Assert.Equal("Alice", equipment.Operator); + + var vehicle = Assert.Single(employee.AssignedVehicles); + Assert.Equal("Truck 12", vehicle.Name); + Assert.Equal("Bob", vehicle.Operator); + + Assert.Equal(2, crewCheckIn.YardAssets.Count); + Assert.Equal("Truck 12", crewCheckIn.YardAssets[0].Name); + Assert.Equal("Air Compressor", crewCheckIn.YardAssets[1].Name); + + var removedAsset = Assert.Single(crewCheckIn.RemovedAssets); + Assert.Equal("Spare Saw", removedAsset.AssetName); + Assert.Equal("damaged chainsaw", removedAsset.AssetDescription); + Assert.Equal("maintenance", removedAsset.Reason); + + var log = Assert.Single(crewCheckIn.LogEntries); + Assert.Equal("created", log.Action); + Assert.Equal("check-in created", log.Message); + Assert.InRange(Math.Abs(log.Timestamp.Ticks - logTimestamp.Ticks), 0, 10); + } + + public static T Require(T? value) where T : class + { + return value ?? throw new InvalidOperationException("Expected a non-null value."); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreDictionaryPayloads.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreDictionaryPayloads.cs new file mode 100644 index 00000000..bd7bd4fd --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreDictionaryPayloads.cs @@ -0,0 +1,138 @@ +using JetBrains.Annotations; +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +[Preserve(AllMembers = true)] +internal sealed class TypedMapValuesDocument : IFirestoreObject +{ + [UsedImplicitly] + public TypedMapValuesDocument() + { + // needed for firestore + } + + public TypedMapValuesDocument( + Dictionary booleanMaps, + Dictionary dateMaps) + { + BooleanMaps = booleanMaps; + DateMaps = dateMaps; + } + + [FirestoreProperty("boolean_maps")] + public Dictionary BooleanMaps { get; private set; } = null!; + + [FirestoreProperty("date_maps")] + public Dictionary DateMaps { get; private set; } = null!; +} + +[Preserve(AllMembers = true)] +internal sealed class DictionaryObjectValuesDocument : IFirestoreObject +{ + [UsedImplicitly] + public DictionaryObjectValuesDocument() + { + // needed for firestore + } + + public DictionaryObjectValuesDocument(Dictionary values) + { + Values = values; + } + + [FirestoreProperty("values")] + public Dictionary Values { get; private set; } = null!; +} + +[Preserve(AllMembers = true)] +internal sealed class EnumDictionaryDocument : IFirestoreObject +{ + [UsedImplicitly] + public EnumDictionaryDocument() + { + // needed for firestore + } + + public EnumDictionaryDocument(Dictionary values) + { + Values = values; + } + + [FirestoreProperty("values")] + public Dictionary Values { get; private set; } = null!; +} + +[Preserve(AllMembers = true)] +internal sealed class AndroidNumericCollectionsDocument : IFirestoreObject +{ + [FirestoreProperty("counts")] + public Dictionary Counts { get; private set; } = null!; + + [FirestoreProperty("nullable_counts")] + public IList NullableCounts { get; private set; } = null!; + + [FirestoreProperty("types")] + public Dictionary Types { get; private set; } = null!; +} + +[Preserve(AllMembers = true)] +internal sealed class NestedShortDictionaryDocument : IFirestoreObject +{ + [UsedImplicitly] + public NestedShortDictionaryDocument() + { + // needed for firestore + } + + public NestedShortDictionaryDocument(Dictionary> values) + { + Values = values; + } + + [FirestoreProperty("values")] + public Dictionary> Values { get; [UsedImplicitly] private set; } = null!; +} + +[Preserve(AllMembers = true)] +internal sealed class DictionaryContainer : IFirestoreObject +{ + [UsedImplicitly] + public DictionaryContainer() + { + // needed for firestore + } + + public DictionaryContainer( + Dictionary metadata, + IDictionary scores, + Dictionary flags, + Dictionary> mixedLists, + Dictionary> nested + ) + { + Metadata = metadata; + Scores = scores; + Flags = flags; + MixedLists = mixedLists; + Nested = nested; + } + + [FirestoreDocumentId] + public string Id { get; private set; } = null!; + + [FirestoreProperty("metadata")] + public Dictionary Metadata { get; private set; } = null!; + + [FirestoreProperty("scores")] + public IDictionary Scores { get; private set; } = null!; + + [FirestoreProperty("flags")] + public Dictionary Flags { get; private set; } = null!; + + [FirestoreProperty("mixed_lists")] + public Dictionary> MixedLists { get; private set; } = null!; + + [FirestoreProperty("nested")] + public Dictionary> Nested { get; private set; } = null!; +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.NullAndLists.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.NullAndLists.cs new file mode 100644 index 00000000..f3f76245 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.NullAndLists.cs @@ -0,0 +1,64 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore +{ + public sealed partial class FirestoreFixture + { + [Fact] + public async Task gets_null_or_empty_dictionary_data_for_missing_and_empty_documents() + { + var sut = CrossFirebaseFirestore.Current; + var missingDocument = GetTestingDocument(sut, "missing-raw-data"); + var emptyDocument = GetTestingDocument(sut, "empty-raw-data"); + + Assert.Null((await missingDocument.GetDocumentSnapshotAsync>()).Data); + Assert.Null((await missingDocument.GetDocumentSnapshotAsync()).Data); + + await emptyDocument.SetDataAsync(new Dictionary { { "temporary", "value" } }); + await emptyDocument.UpdateDataAsync(("temporary", FieldValue.Delete())); + + var dictionarySnapshot = await emptyDocument.GetDocumentSnapshotAsync>(); + Assert.NotNull(dictionarySnapshot.Data); + Assert.Empty(dictionarySnapshot.Data!); + + var objectSnapshot = await emptyDocument.GetDocumentSnapshotAsync(); + Assert.Empty(Assert.IsAssignableFrom>(objectSnapshot.Data!)); + } + + [IosFact] + public async Task reads_null_entries_inside_firestore_lists() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "list-null-values"); + await document.SetDataAsync(new ListNullDocument( + values: ["first", null, "third"], + nullableNumbers: [1L, null, 3L])); + + var snapshot = await document.GetDocumentSnapshotAsync(); + + Assert.Equal("first", snapshot.Data!.Values[0]); + Assert.Null(snapshot.Data!.Values[1]); + Assert.Equal("third", snapshot.Data!.Values[2]); + Assert.Equal([1L, null, 3L], snapshot.Data!.NullableNumbers); + } + + [Fact] + public async Task writes_geopoint_values_inside_firestore_lists() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "geopoint-list-values"); + var expected = new[] { + new GeoPoint(10.5, 20.25), + new GeoPoint(-33.875, 151.2) + }; + + await document.SetDataAsync(new GeoPointListDocument(expected)); + + var result = (await document.GetDocumentSnapshotAsync()).Data!; + Assert.Equal(expected[0].Latitude, result.Locations[0].Latitude); + Assert.Equal(expected[0].Longitude, result.Locations[0].Longitude); + Assert.Equal(expected[1].Latitude, result.Locations[1].Latitude); + Assert.Equal(expected[1].Longitude, result.Locations[1].Longitude); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.ObjectDictionaries.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.ObjectDictionaries.cs new file mode 100644 index 00000000..500a3df4 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.ObjectDictionaries.cs @@ -0,0 +1,32 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore +{ + public sealed partial class FirestoreFixture + { + [Fact] + public async Task gets_dictionary_properties_inside_firestore_objects() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "dictionary-container"); + var container = DictionaryContainerFactory.CreateDefault(); + + await document.SetDataAsync(container); + + var result = (await document.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("dictionary-container", result.Id); + Assert.Equal("container", result.Metadata["title"]); + Assert.Equal(5L, Convert.ToInt64(result.Metadata["count"])); + Assert.Null(result.Metadata["nullable"]); + Assert.Equal("nested", Assert.IsAssignableFrom>(result.Metadata["details"])["label"]); + Assert.Equal(10L, result.Scores["first"]); + Assert.Equal(20L, result.Scores["second"]); + Assert.True(result.Flags["active"]); + Assert.False(result.Flags["archived"]); + Assert.Equal(["first", null, 3L], result.MixedLists["values"]); + Assert.Empty(result.MixedLists["empty"]); + Assert.Equal("outer", result.Nested["outer"]["name"]); + Assert.Equal(2L, Convert.ToInt64(result.Nested["outer"]["count"])); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.PlatformDictionaries.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.PlatformDictionaries.cs new file mode 100644 index 00000000..9fc16b86 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.PlatformDictionaries.cs @@ -0,0 +1,178 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore +{ + public sealed partial class FirestoreFixture + { + [IosFact] + public async Task writes_nested_dictionary_properties_on_ios() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "ios-nested-dictionary"); + var expected = new Dictionary> { + { + "outer", + new Dictionary { + { "inner", 7 } + } + } + }; + + await document.SetDataAsync(new NestedShortDictionaryDocument(expected)); + + var snapshot = await document.GetDocumentSnapshotAsync(); + Assert.Equal((short) 7, snapshot.Data!.Values["outer"]["inner"]); + } + + [IosFact] + public async Task applies_ios_batch_tuple_set_options() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "ios-batch-tuple-set-options"); + await document.SetDataAsync(new Dictionary { + { "untouched", "keep" }, + { "selected", "old" } + }); + + var batch = sut.CreateBatch(); + batch.SetData( + document, + SetOptions.MergeFields("selected"), + ("selected", "from-batch"), + ("untouched", "should-not-change")); + await batch.CommitAsync(); + + var result = (await document.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("keep", result.Untouched); + Assert.Equal("from-batch", result.Selected); + } + + [IosFact] + public async Task writes_ios_dictionary_data_through_non_document_wrappers() + { + var sut = CrossFirebaseFirestore.Current; + var collection = GetTestingCollection(sut); + + var addedDocument = await collection.AddDocumentAsync(new Dictionary { + { "writer", "collection-add" }, + { "count", 1L } + }); + var addedResult = (await addedDocument.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("collection-add", addedResult.Writer); + Assert.Equal(1L, addedResult.Count); + + var batchSetDocument = collection.GetDocument("ios-batch-set-dictionary"); + var batchUpdateDocument = collection.GetDocument("ios-batch-update-dictionary"); + await batchUpdateDocument.SetDataAsync(new Dictionary { { "writer", "seed" } }); + var batch = sut.CreateBatch(); + batch.SetData(batchSetDocument, new Dictionary { + { "writer", "batch-set" }, + { "count", 2L } + }); + batch.UpdateData(batchUpdateDocument, new Dictionary { + { "writer", "batch-update" }, + { "count", 3L } + }); + await batch.CommitAsync(); + + var batchSetResult = (await batchSetDocument.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("batch-set", batchSetResult.Writer); + Assert.Equal(2L, batchSetResult.Count); + + var batchUpdateResult = (await batchUpdateDocument.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("batch-update", batchUpdateResult.Writer); + Assert.Equal(3L, batchUpdateResult.Count); + + var batchMergeDocument = collection.GetDocument("ios-batch-merge-dictionary"); + await batchMergeDocument.SetDataAsync(new Dictionary { + { "writer", "seed" }, + { "count", 30L }, + { "untouched", "kept-by-batch-merge" } + }); + var mergeBatch = sut.CreateBatch(); + mergeBatch.SetData( + batchMergeDocument, + new Dictionary { { "writer", "batch-merge" } }, + SetOptions.Merge() + ); + await mergeBatch.CommitAsync(); + + var batchMergeResult = (await batchMergeDocument.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("batch-merge", batchMergeResult.Writer); + Assert.Equal(30L, batchMergeResult.Count); + Assert.Equal("kept-by-batch-merge", batchMergeResult.Untouched); + + var transactionSetDocument = collection.GetDocument("ios-transaction-set-dictionary"); + var transactionUpdateDocument = collection.GetDocument("ios-transaction-update-dictionary"); + await transactionUpdateDocument.SetDataAsync(new Dictionary { { "writer", "seed" } }); + await sut.RunTransactionAsync(transaction => { + transaction.GetDocument(transactionUpdateDocument); + transaction.SetData(transactionSetDocument, new Dictionary { + { "writer", "transaction-set" }, + { "count", 4L } + }); + transaction.UpdateData(transactionUpdateDocument, new Dictionary { + { "writer", "transaction-update" }, + { "count", 5L } + }); + return true; + }); + + var transactionSetResult = (await transactionSetDocument.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("transaction-set", transactionSetResult.Writer); + Assert.Equal(4L, transactionSetResult.Count); + + var transactionUpdateResult = (await transactionUpdateDocument.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("transaction-update", transactionUpdateResult.Writer); + Assert.Equal(5L, transactionUpdateResult.Count); + + var transactionMergeDocument = collection.GetDocument("ios-transaction-merge-dictionary"); + await transactionMergeDocument.SetDataAsync(new Dictionary { + { "writer", "seed" }, + { "count", 50L }, + { "untouched", "kept-by-transaction-merge" } + }); + await sut.RunTransactionAsync(transaction => { + transaction.GetDocument(transactionMergeDocument); + transaction.SetData( + transactionMergeDocument, + new Dictionary { { "writer", "transaction-merge" } }, + SetOptions.Merge() + ); + return true; + }); + + var transactionMergeResult = (await transactionMergeDocument.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("transaction-merge", transactionMergeResult.Writer); + Assert.Equal(50L, transactionMergeResult.Count); + Assert.Equal("kept-by-transaction-merge", transactionMergeResult.Untouched); + } + + [IosFact] + public async Task updates_ios_transaction_dictionary_data_with_field_value_and_date_time_offset() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "ios-transaction-update-dictionary-field-value"); + var seedDate = new DateTimeOffset(2025, 8, 27, 2, 9, 54, TimeSpan.Zero); + var expectedDate = new DateTimeOffset(2025, 8, 27, 3, 9, 54, TimeSpan.Zero); + await document.SetDataAsync(new Dictionary { + { "array_values", new List { "seed" } }, + { "updated_at", seedDate } + }); + + await sut.RunTransactionAsync(transaction => { + transaction.GetDocument(document); + transaction.UpdateData(document, new Dictionary { + { "array_values", FieldValue.ArrayUnion("added") }, + { "updated_at", expectedDate } + }); + return true; + }); + + var result = (await document.GetDocumentSnapshotAsync()).Data!; + Assert.Contains("seed", result.ArrayValues); + Assert.Contains("added", result.ArrayValues); + Assert.Equal(expectedDate.ToUnixTimeMilliseconds(), result.UpdatedAt.ToUnixTimeMilliseconds()); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.Relationships.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.Relationships.cs new file mode 100644 index 00000000..8d430fc3 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.AdvancedValues.Relationships.cs @@ -0,0 +1,52 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore +{ + public sealed partial class FirestoreFixture + { + [Fact] + public async Task exposes_parent_relationships() + { + var sut = CrossFirebaseFirestore.Current; + var parentDocument = GetTestingDocument(sut, "parent"); + var subCollection = parentDocument.GetCollection("sub_items"); + var childDocument = subCollection.GetDocument("child"); + + await parentDocument.SetDataAsync(new SimpleItem("parent")); + await childDocument.SetDataAsync(new SimpleItem("child")); + + Assert.Equal(parentDocument.Path, subCollection.Parent!.Path); + Assert.Equal(parentDocument.Path, childDocument.Parent!.Parent!.Path); + Assert.Equal(childDocument.Path, childDocument.Parent.GetDocument(childDocument.Id).Path); + } + + [Fact] + public async Task queries_collection_group() + { + var sut = CrossFirebaseFirestore.Current; + var marker = Guid.NewGuid().ToString("N"); + var firstDocument = GetTestingDocument(sut, "group-parent-1") + .GetCollection("sub_items") + .GetDocument("first"); + var secondDocument = GetTestingDocument(sut, "group-parent-2") + .GetCollection("sub_items") + .GetDocument("second"); + + await firstDocument.SetDataAsync(new SimpleItem($"{marker}-one")); + await secondDocument.SetDataAsync(new SimpleItem($"{marker}-two")); + + var snapshot = await sut + .GetCollectionGroup("sub_items") + .GetDocumentsAsync(); + + var matchingTitles = snapshot.Documents + .Select(x => x.Data!.Title) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Where(x => x.StartsWith(marker, StringComparison.Ordinal)) + .OrderBy(x => x, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(new[] { $"{marker}-one", $"{marker}-two" }, matchingTitles); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.Documents.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.Documents.cs new file mode 100644 index 00000000..35e6f2c7 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.Documents.cs @@ -0,0 +1,101 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore +{ + public sealed partial class FirestoreFixture + { + [Fact] + public async Task deletes_document() + { + var sut = CrossFirebaseFirestore.Current; + var pokemon = PokemonFactory.CreateCharmander(); + var document = GetTestingDocument(sut, pokemon.Id); + + await document.SetDataAsync(pokemon); + Assert.NotNull((await GetTestingDocument(sut, pokemon.Id).GetDocumentSnapshotAsync()).Data); + + await document.DeleteDocumentAsync(); + Assert.Null((await GetTestingDocument(sut, pokemon.Id).GetDocumentSnapshotAsync()).Data); + } + + + [Fact] + public async Task deletes_fields_of_document() + { + var sut = CrossFirebaseFirestore.Current; + var pokemon = PokemonFactory.CreateCharmander(); + var document = GetTestingDocument(sut, pokemon.Id); + await document.SetDataAsync(pokemon); + + await document.UpdateDataAsync( + (Pokemon.MovesField, FieldValue.Delete()), + (Pokemon.ItemsField, FieldValue.Delete()), + (Pokemon.FirstSightingLocationField, FieldValue.Delete()), + (Pokemon.PokeTypeField, FieldValue.Delete())); + + var snapshot = await document.GetDocumentSnapshotAsync(); + Assert.Null(snapshot.Data!.Moves); + Assert.Null(snapshot.Data!.FirstSightingLocation); + Assert.Null(snapshot.Data!.Items); + Assert.Equal(PokeType.Undefined, snapshot.Data!.PokeType); + } + + + [Fact] + public async Task copies_document_id_in_firestore_document_id_attributed_property() + { + var sut = CrossFirebaseFirestore.Current; + var item = new SimpleItem(title: "test"); + var document = GetTestingDocument(sut, "1337"); + + await document.SetDataAsync(item); + + var snapshot = await document.GetDocumentSnapshotAsync(); + Assert.Equal("1337", snapshot.Data!.Id); + Assert.Equal("1337", snapshot.Reference.Id); + } + + + [Fact] + public async Task clones_pokemon_with_original_reference() + { + var sut = CrossFirebaseFirestore.Current; + var bulbasurReference = sut.GetDocument($"pokemons/1"); + var bulbasur = (await bulbasurReference.GetDocumentSnapshotAsync()).Data!; + var copy = bulbasur.Clone(bulbasurReference); + var copyPath = TestingDocumentPath(copy.Id); + var copyDocument = GetTestingDocument(sut, copy.Id); + await copyDocument.SetDataAsync(copy); + + var copySnapshot = await copyDocument.GetDocumentSnapshotAsync(); + Assert.False(copySnapshot.Metadata.HasPendingWrites); + Assert.Equal($"{bulbasur.Id}_copied", copySnapshot.Reference.Id); + Assert.Equal(copyPath, copySnapshot.Reference.Path); + Assert.Equal(copy, copySnapshot.Data!); + } + + + [Fact] + public async Task retrieves_subs_collection() + { + var sut = CrossFirebaseFirestore.Current; + var pokemon = PokemonFactory.CreateBulbasur(); + var path = TestingDocumentPath(pokemon.Id); + var subCollectionName = "sub_items"; + var subCollectionPath = $"{path}/{subCollectionName}"; + var document = GetTestingDocument(sut, pokemon.Id); + var subDocument = sut.GetDocument($"{subCollectionPath}/123"); + + await document.SetDataAsync(pokemon); + await subDocument.SetDataAsync(new Dictionary() { { "foo", "bar" } }); + + var subCollectionRef1 = sut.GetCollection(subCollectionPath); + var subCollectionRef2 = document.GetCollection(subCollectionName); + var snapshot1 = await subCollectionRef1.GetDocumentsAsync(); + var snapshot2 = await subCollectionRef2.GetDocumentsAsync(); + Assert.Single(snapshot1.Documents); + Assert.Single(snapshot2.Documents); + } + + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.Listeners.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.Listeners.cs new file mode 100644 index 00000000..9aa27fe4 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.Listeners.cs @@ -0,0 +1,78 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore +{ + public sealed partial class FirestoreFixture + { + [Fact] + public async Task gets_dictionary_data_from_document_snapshot_listener() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "raw-document-listener"); + var snapshotReceived = new CallbackProbe>(); + + await document.SetDataAsync(new Dictionary { { "seed", "true" } }); + + using var disposable = document.AddSnapshotListener>( + x => { + if( + x.Data?.TryGetValue("listener_value", out var value) == true + && Convert.ToInt64(value) == 5L + ) { + snapshotReceived.TrySetResult(x.Data!); + } + }, + e => snapshotReceived.TrySetException(e)); + + await document.UpdateDataAsync( + ("listener_value", 5L), + ("nested.listener", "seen")); + + var data = await snapshotReceived.WaitAsync( + IntegrationTestTimeouts.Callback, + "Firestore tuple listener snapshot"); + Assert.Equal(5L, Convert.ToInt64(data["listener_value"])); + + var nested = Assert.IsAssignableFrom>(data["nested"]); + Assert.Equal("seen", nested["listener"]); + } + + + [Fact] + public async Task gets_dictionary_data_from_query_snapshot_listener() + { + var sut = CrossFirebaseFirestore.Current; + var collection = GetTestingCollection(sut); + var document = collection.GetDocument("raw-query-listener"); + var snapshotReceived = new CallbackProbe>(); + + using var disposable = collection + .WhereEqualsTo("listener_marker", "query") + .AddSnapshotListener>( + x => { + var data = x.Documents + .Select(y => y.Data!) + .FirstOrDefault(y => + y?.TryGetValue("query_listener_value", out var value) == true + && value is string text + && text == "ready"); + + if(data != null) { + snapshotReceived.TrySetResult(data); + } + }, + e => snapshotReceived.TrySetException(e)); + + await document.SetDataAsync(new Dictionary { + { "listener_marker", "query" }, + { "query_listener_value", "ready" } + }); + + var result = await snapshotReceived.WaitAsync( + IntegrationTestTimeouts.Callback, + "Firestore dictionary listener snapshot"); + Assert.Equal("ready", result["query_listener_value"]); + } + + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.PlatformValues.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.PlatformValues.cs new file mode 100644 index 00000000..967aeffb --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.PlatformValues.cs @@ -0,0 +1,165 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore +{ + public sealed partial class FirestoreFixture + { + [Fact] + public async Task round_trips_typed_boolean_and_datetime_map_values() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "typed-boolean-and-datetime-map-values"); + var expectedEarlyDate = new DateTimeOffset(2026, 5, 2, 13, 14, 15, 123, TimeSpan.Zero); + var expectedLateDate = new DateTimeOffset(2026, 5, 3, 16, 17, 18, 456, TimeSpan.Zero); + + await document.SetDataAsync(new TypedMapValuesDocument( + new Dictionary { + { "enabled", true }, + { "disabled", false } + }, + new Dictionary { + { "early", expectedEarlyDate }, + { "late", expectedLateDate } + })); + + var snapshot = await document.GetDocumentSnapshotAsync(Source.Server); + + Assert.True(snapshot.Data!.BooleanMaps["enabled"]); + Assert.False(snapshot.Data!.BooleanMaps["disabled"]); + Assert.InRange( + Math.Abs(snapshot.Data!.DateMaps["early"].Ticks - expectedEarlyDate.Ticks), + 0, + IntegrationTestTimeouts.OneMillisecondTicks); + Assert.InRange( + Math.Abs(snapshot.Data!.DateMaps["late"].Ticks - expectedLateDate.Ticks), + 0, + IntegrationTestTimeouts.OneMillisecondTicks); + } + + + [IosFact] + public async Task reads_ios_dictionary_object_numeric_and_boolean_values() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "ios-dictionary-object-values"); + await document.SetDataAsync(new DictionaryObjectValuesDocument( + new Dictionary { + { "enabled", true }, + { "count", 5L }, + { "ratio", 1.25 } + })); + + var snapshot = await document.GetDocumentSnapshotAsync(); + + Assert.True((bool) snapshot.Data!.Values["enabled"]!); + Assert.Equal(5L, Convert.ToInt64(snapshot.Data!.Values["count"])); + Assert.Equal(1.25, Convert.ToDouble(snapshot.Data!.Values["ratio"])); + } + + + [IosFact] + public async Task reads_ios_enum_dictionary_values() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "ios-enum-dictionary-values"); + await document.SetDataAsync(new EnumDictionaryDocument( + new Dictionary { + { "fire", PokeType.Fire }, + { "water", PokeType.Water } + })); + + var snapshot = await document.GetDocumentSnapshotAsync(); + + Assert.Equal(PokeType.Fire, snapshot.Data!.Values["fire"]); + Assert.Equal(PokeType.Water, snapshot.Data!.Values["water"]); + } + + + [AndroidFact] + public async Task reads_android_typed_numeric_collection_values() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "android-typed-numeric-collections"); + await document.SetDataAsync(new Dictionary { + { + "counts", + new Dictionary { + { "one", 1L }, + { "two", 2L } + } + }, + { "nullable_counts", new object?[] { 1L, null, 3L } }, + { + "types", + new Dictionary { + { "fire", PokeType.Fire }, + { "water", PokeType.Water } + } + } + }); + + var snapshot = await document.GetDocumentSnapshotAsync(); + + Assert.Equal(1, snapshot.Data!.Counts["one"]); + Assert.Equal(2, snapshot.Data!.Counts["two"]); + Assert.Equal([1, null, 3], snapshot.Data!.NullableCounts); + Assert.Equal(PokeType.Fire, snapshot.Data!.Types["fire"]); + Assert.Equal(PokeType.Water, snapshot.Data!.Types["water"]); + } + + + [Fact] + public async Task writes_set_data_from_dictionary_and_tuple_payloads() + { + var sut = CrossFirebaseFirestore.Current; + + var dictionaryDocument = GetTestingDocument(sut, "setdata-string-dictionary"); + await dictionaryDocument.SetDataAsync(new Dictionary { + { "field_a", "dictionary-a" }, + { "field_b", "dictionary-b" } + }); + var dictionaryResult = (await dictionaryDocument.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("dictionary-a", dictionaryResult.FieldA); + Assert.Equal("dictionary-b", dictionaryResult.FieldB); + + var tupleDocument = GetTestingDocument(sut, "setdata-tuple"); + await tupleDocument.SetDataAsync( + ("field_a", "tuple-a"), + ("field_b", "tuple-b")); + var tupleResult = (await tupleDocument.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("tuple-a", tupleResult.FieldA); + Assert.Equal("tuple-b", tupleResult.FieldB); + } + + + [AndroidFact] + public async Task writes_transaction_set_data_from_dictionary_and_tuple_payloads_on_android() + { + var sut = CrossFirebaseFirestore.Current; + var transactionDictionaryDocument = GetTestingDocument(sut, "transaction-setdata-string-dictionary"); + var transactionTupleDocument = GetTestingDocument(sut, "transaction-setdata-tuple"); + await sut.RunTransactionAsync(transaction => { + transaction.SetData( + transactionDictionaryDocument, + new Dictionary { + { "field_a", "transaction-dictionary-a" }, + { "field_b", "transaction-dictionary-b" } + }); + transaction.SetData( + transactionTupleDocument, + ("field_a", "transaction-tuple-a"), + ("field_b", "transaction-tuple-b")); + return true; + }); + + var transactionDictionaryResult = (await transactionDictionaryDocument.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("transaction-dictionary-a", transactionDictionaryResult.FieldA); + Assert.Equal("transaction-dictionary-b", transactionDictionaryResult.FieldB); + + var transactionTupleResult = (await transactionTupleDocument.GetDocumentSnapshotAsync()).Data!; + Assert.Equal("transaction-tuple-a", transactionTupleResult.FieldA); + Assert.Equal("transaction-tuple-b", transactionTupleResult.FieldB); + } + + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.RawValues.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.RawValues.cs new file mode 100644 index 00000000..0542c539 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.RawValues.cs @@ -0,0 +1,87 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore +{ + public sealed partial class FirestoreFixture + { + [Fact] + public async Task gets_document_data_as_dictionary() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "raw-data"); + var observedAt = DateTimeOffset.Now; + + await document.SetDataAsync(new Dictionary { { "seed", "true" } }); + await document.UpdateDataAsync( + ("unknown_string", "value"), + ("unknown_long", 123L), + ("unknown_double", 12.5), + ("unknown_bool", true), + ("unknown_null", null), + ("unknown_numbers", new[] { 1L, 2L }), + ("unknown_empty_array", Array.Empty()), + ("unknown_empty_map", new Dictionary()), + ("unknown_array_with_nulls", new object?[] { + null, + "text", + 3L, + new Dictionary { + { "child_null", null }, + { "child_text", "child" } + }, + false + }), + ("unknown_map_array", new[] { + new Dictionary { + { "name", "first" }, + { "score", 1L }, + { "active", true } + }, + new Dictionary { + { "name", "second" }, + { "score", 2L }, + { "active", false } + } + }), + ("nested.answer", 42L), + ("nested.values", new[] { "one", "two" }), + ("nested.deep.answer", 84L), + ("nested.deep.null_value", null), + ("nested.label", "nested value"), + ("nested.null_value", null), + ("nested.empty_values", Array.Empty()), + ("nested.empty_map", new Dictionary()), + ("nested.direct_map", new Dictionary { + { "text", "direct" }, + { "count", 9L }, + { "short_count", 7L }, + { "flags", new[] { true, false } }, + { "inner", new Dictionary { { "value", "inside" } } } + }), + ("observed_at", observedAt), + ("created_at", observedAt.UtcDateTime), + ("generated_at", FieldValue.ServerTimestamp()), + ("original_reference", document)); + + var dictionarySnapshot = await document.GetDocumentSnapshotAsync>(); + FirestoreAssertions.RawDictionaryData(dictionarySnapshot.Data!, document); + + var interfaceSnapshot = await document.GetDocumentSnapshotAsync>(); + FirestoreAssertions.RawDictionaryData(interfaceSnapshot.Data!, document); + + var objectDictionarySnapshot = await document.GetDocumentSnapshotAsync>(); + FirestoreAssertions.RawObjectDictionaryData(objectDictionarySnapshot.Data!, document); + + var objectSnapshot = await document.GetDocumentSnapshotAsync(); + FirestoreAssertions.RawDictionaryData( + Assert.IsAssignableFrom>(objectSnapshot.Data!), + document); + + var querySnapshot = await GetTestingCollection(sut) + .WhereEqualsTo("unknown_string", "value") + .GetDocumentsAsync>(); + FirestoreAssertions.RawDictionaryData(Assert.Single(querySnapshot.Documents).Data!, document); + } + + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Lists.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Lists.cs new file mode 100644 index 00000000..306eb575 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Lists.cs @@ -0,0 +1,58 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed partial class FirestoreFixture +{ + [Fact] + public async Task gets_document_data_as_typed_list_dictionaries() + { + var sut = CrossFirebaseFirestore.Current; + + var longListDocument = GetTestingDocument(sut, "typed-long-list-map"); + await longListDocument.SetDataAsync(new Dictionary { + { "first", new[] { 1L, 2L } }, + { "second", new[] { 3, 4 } } + }); + var longLists = (await longListDocument.GetDocumentSnapshotAsync>>()).Data!; + Assert.Equal([1L, 2L], longLists["first"]); + Assert.Equal([3L, 4L], longLists["second"]); + + var nullableListDocument = GetTestingDocument(sut, "typed-nullable-list-map"); + await nullableListDocument.SetDataAsync(new Dictionary { + { "values", new object?[] { 1L, null, 3L } } + }); + var nullableLists = (await nullableListDocument.GetDocumentSnapshotAsync>>()).Data!; + Assert.Equal([1L, null, 3L], nullableLists["values"]); + + var objectListDocument = GetTestingDocument(sut, "typed-object-list-map"); + await objectListDocument.SetDataAsync(new Dictionary { + { + "values", + new object?[] { + null, + "text", + 5L, + new Dictionary { { "name", "map" } }, + new Dictionary { + { "active", true }, + { "nullable", null } + } + } + }, + { "empty", Array.Empty() } + }); + var objectLists = (await objectListDocument.GetDocumentSnapshotAsync>>()).Data!; + Assert.Empty(objectLists["empty"]); + + var values = objectLists["values"]; + Assert.Null(values[0]); + Assert.Equal("text", values[1]); + Assert.Equal(5L, Convert.ToInt64(values[2])); + Assert.Equal("map", Assert.IsAssignableFrom>(values[3])["name"]); + + var nestedMap = Assert.IsAssignableFrom>(values[4]); + Assert.True((bool) nestedMap["active"]!); + Assert.Null(nestedMap["nullable"]); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Nested.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Nested.cs new file mode 100644 index 00000000..7ed48814 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Nested.cs @@ -0,0 +1,63 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed partial class FirestoreFixture +{ + [Fact] + public async Task gets_document_data_as_typed_nested_dictionaries() + { + var sut = CrossFirebaseFirestore.Current; + + var objectMapDocument = GetTestingDocument(sut, "typed-nested-object-map"); + await objectMapDocument.SetDataAsync(new Dictionary { + { + "outer", + new Dictionary { + { "name", "nested" }, + { "count", 3L }, + { "active", true }, + { "empty", new Dictionary() }, + { "inner", new Dictionary { { "value", "deep" } } } + } + } + }); + var objectMaps = (await objectMapDocument.GetDocumentSnapshotAsync>>()).Data!; + var outerObjectMap = objectMaps["outer"]; + Assert.Equal("nested", outerObjectMap["name"]); + Assert.Equal(3L, Convert.ToInt64(outerObjectMap["count"])); + Assert.True((bool) outerObjectMap["active"]!); + Assert.Empty(Assert.IsAssignableFrom>(outerObjectMap["empty"])); + Assert.Equal( + "deep", + Assert.IsAssignableFrom>(outerObjectMap["inner"])["value"]); + + var interfaceMapDocument = GetTestingDocument(sut, "typed-nested-interface-map"); + await interfaceMapDocument.SetDataAsync(new Dictionary { + { + "outer", + new Dictionary { + { "label", "interface" }, + { "nullable", null } + } + } + }); + var interfaceMaps = (await interfaceMapDocument.GetDocumentSnapshotAsync>>()).Data!; + Assert.Equal("interface", interfaceMaps["outer"]["label"]); + Assert.Null(interfaceMaps["outer"]["nullable"]); + + var longMapDocument = GetTestingDocument(sut, "typed-nested-long-map"); + await longMapDocument.SetDataAsync(new Dictionary { + { + "outer", + new Dictionary { + { "one", 1L }, + { "two", 2 } + } + } + }); + var longMaps = (await longMapDocument.GetDocumentSnapshotAsync>>()).Data!; + Assert.Equal(1L, longMaps["outer"]["one"]); + Assert.Equal(2L, longMaps["outer"]["two"]); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Primitives.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Primitives.cs new file mode 100644 index 00000000..0446ff82 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Primitives.cs @@ -0,0 +1,145 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed partial class FirestoreFixture +{ + [Fact] + public async Task gets_document_data_as_strongly_typed_dictionaries() + { + var sut = CrossFirebaseFirestore.Current; + + var stringDocument = GetTestingDocument(sut, "typed-string-map"); + await stringDocument.SetDataAsync(new Dictionary { + { "alpha", "one" }, + { "beta", "two" } + }); + var strings = (await stringDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal("one", strings["alpha"]); + Assert.Equal("two", strings["beta"]); + + var boolDocument = GetTestingDocument(sut, "typed-bool-map"); + await boolDocument.SetDataAsync(new Dictionary { + { "enabled", true }, + { "archived", false } + }); + var bools = (await boolDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.All(bools.Keys, key => Assert.IsType(key)); + Assert.True(bools["enabled"]); + Assert.False(bools["archived"]); + + var longDocument = GetTestingDocument(sut, "typed-long-map"); + await longDocument.SetDataAsync(new Dictionary { + { "one", 1L }, + { "two", 2 } + }); + var longs = (await longDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal(1L, longs["one"]); + Assert.Equal(2L, longs["two"]); + + var intDocument = GetTestingDocument(sut, "typed-int-map"); + await intDocument.SetDataAsync(new Dictionary { + { "one", 1L }, + { "two", 2 } + }); + var ints = (await intDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal(1, ints["one"]); + Assert.Equal(2, ints["two"]); + + var doubleDocument = GetTestingDocument(sut, "typed-double-map"); + await doubleDocument.SetDataAsync(new Dictionary { + { "half", 0.5 }, + { "whole", 2L } + }); + var doubles = (await doubleDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal(0.5, doubles["half"]); + Assert.Equal(2.0, doubles["whole"]); + + var floatDocument = GetTestingDocument(sut, "typed-float-map"); + await floatDocument.SetDataAsync(new Dictionary { + { "half", 0.5 }, + { "whole", 2L } + }); + var floats = (await floatDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal(0.5f, floats["half"]); + Assert.Equal(2.0f, floats["whole"]); + + var enumDocument = GetTestingDocument(sut, "typed-enum-map"); + await enumDocument.SetDataAsync(new Dictionary { + { "fire", PokeType.Fire }, + { "water", PokeType.Water } + }); + var enums = (await enumDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal(PokeType.Fire, enums["fire"]); + Assert.Equal(PokeType.Water, enums["water"]); + } + + [Fact] + public async Task gets_document_data_as_additional_numeric_dictionaries() + { + var sut = CrossFirebaseFirestore.Current; + + var byteDocument = GetTestingDocument(sut, "typed-byte-map"); + await byteDocument.SetDataAsync(new Dictionary { + { "min", 0L }, + { "max", 255L } + }); + var bytes = (await byteDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal((byte) 0, bytes["min"]); + Assert.Equal(byte.MaxValue, bytes["max"]); + + var sbyteDocument = GetTestingDocument(sut, "typed-sbyte-map"); + await sbyteDocument.SetDataAsync(new Dictionary { + { "min", -128L }, + { "max", 127L } + }); + var sbytes = (await sbyteDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal(sbyte.MinValue, sbytes["min"]); + Assert.Equal(sbyte.MaxValue, sbytes["max"]); + + var shortDocument = GetTestingDocument(sut, "typed-short-map"); + await shortDocument.SetDataAsync(new Dictionary { + { "min", -32768L }, + { "max", 32767L } + }); + var shorts = (await shortDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal(short.MinValue, shorts["min"]); + Assert.Equal(short.MaxValue, shorts["max"]); + + var ushortDocument = GetTestingDocument(sut, "typed-ushort-map"); + await ushortDocument.SetDataAsync(new Dictionary { + { "min", 0L }, + { "max", 65535L } + }); + var ushorts = (await ushortDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal((ushort) 0, ushorts["min"]); + Assert.Equal(ushort.MaxValue, ushorts["max"]); + + var uintDocument = GetTestingDocument(sut, "typed-uint-map"); + await uintDocument.SetDataAsync(new Dictionary { + { "min", 0L }, + { "max", 4294967295L } + }); + var uints = (await uintDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal(0U, uints["min"]); + Assert.Equal(uint.MaxValue, uints["max"]); + + var ulongDocument = GetTestingDocument(sut, "typed-ulong-map"); + await ulongDocument.SetDataAsync(new Dictionary { + { "zero", 0L }, + { "value", 9223372036854775807L } + }); + var ulongs = (await ulongDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal(0UL, ulongs["zero"]); + Assert.Equal(9223372036854775807UL, ulongs["value"]); + + var nullableDocument = GetTestingDocument(sut, "typed-nullable-int-map"); + await nullableDocument.SetDataAsync(new Dictionary { + { "present", 123L }, + { "missing", null } + }); + var nullableInts = (await nullableDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal(123, nullableInts["present"]); + Assert.Null(nullableInts["missing"]); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Special.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Special.cs new file mode 100644 index 00000000..73d745a2 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.DictionaryReads.TypedValues.Special.cs @@ -0,0 +1,44 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed partial class FirestoreFixture +{ + [Fact] + public async Task gets_document_data_as_typed_special_value_dictionaries() + { + var sut = CrossFirebaseFirestore.Current; + var expectedDateTime = new DateTime(2026, 4, 29, 1, 2, 3, 456, DateTimeKind.Utc); + var expectedOffset = new DateTimeOffset(2026, 4, 29, 4, 5, 6, 789, TimeSpan.Zero); + var documentReference = GetTestingDocument(sut, "typed-reference-target"); + + var dateTimeDocument = GetTestingDocument(sut, "typed-datetime-map"); + await dateTimeDocument.SetDataAsync(new Dictionary { + { "created", expectedDateTime } + }); + var dateTimes = (await dateTimeDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.InRange( + Math.Abs(dateTimes["created"].Ticks - expectedDateTime.Ticks), + 0, + IntegrationTestTimeouts.OneMillisecondTicks); + + var dateTimeOffsetDocument = GetTestingDocument(sut, "typed-datetime-offset-map"); + await dateTimeOffsetDocument.SetDataAsync(new Dictionary { + { "observed", expectedOffset }, + { "generated", FieldValue.ServerTimestamp() } + }); + var dateTimeOffsets = (await dateTimeOffsetDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.InRange( + Math.Abs(dateTimeOffsets["observed"].Ticks - expectedOffset.Ticks), + 0, + IntegrationTestTimeouts.OneMillisecondTicks); + Assert.NotEqual(default, dateTimeOffsets["generated"]); + + var referenceDocument = GetTestingDocument(sut, "typed-reference-map"); + await referenceDocument.SetDataAsync(new Dictionary { + { "original", documentReference } + }); + var references = (await referenceDocument.GetDocumentSnapshotAsync>()).Data!; + Assert.Equal(documentReference.Path, references["original"].Path); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.Documents.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.Documents.cs new file mode 100644 index 00000000..0a17c387 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.Documents.cs @@ -0,0 +1,159 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed partial class FirestoreFixture +{ + [Fact] + public async Task adds_document_to_collection() + { + var sut = CrossFirebaseFirestore.Current; + var pokemon = PokemonFactory.CreateBulbasur(); + var path = TestingDocumentPath(pokemon.Id); + var document = GetTestingDocument(sut, pokemon.Id); + + await document.SetDataAsync(pokemon); + + var snapshot = await document.GetDocumentSnapshotAsync(); + FirestoreAssertions.WrittenDocument(snapshot, pokemon.Id, path, pokemon); + } + + [Fact] + public async Task creates_document_with_auto_generated_reference() + { + var sut = CrossFirebaseFirestore.Current; + var collection = GetTestingCollection(sut); + var document = collection.CreateDocument(); + var item = new SimpleItem("generated-item"); + + await document.SetDataAsync(item); + + var snapshot = await document.GetDocumentSnapshotAsync(); + FirestoreAssertions.GeneratedSimpleItem(document, snapshot, "generated-item"); + } + + [Fact] + public async Task adds_document_with_auto_generated_id() + { + var sut = CrossFirebaseFirestore.Current; + var collection = GetTestingCollection(sut); + + var document = await collection.AddDocumentAsync(new SimpleItem("added-item")); + + var snapshot = await document.GetDocumentSnapshotAsync(); + FirestoreAssertions.GeneratedSimpleItem(document, snapshot, "added-item"); + } + + [Fact] + public async Task sets_server_timestamp_via_property_attribute() + { + var sut = CrossFirebaseFirestore.Current; + var pokemon = PokemonFactory.CreateBulbasur(); + + var document = GetTestingDocument(sut, pokemon.Id); + await document.SetDataAsync(pokemon); + + var snapshot = await GetTestingDocument(sut, pokemon.Id) + .GetDocumentSnapshotAsync(Source.Server); + Assert.NotEqual(snapshot.Data!.ServerTimestamp, DateTimeOffset.MinValue); + Assert.NotEqual(snapshot.Data!.ServerTimestamp, DateTimeOffset.Now); + } + + [Fact] + public async Task updates_existing_document() + { + var sut = CrossFirebaseFirestore.Current; + var pokemon = PokemonFactory.CreateSquirtle(); + var document = GetTestingDocument(sut, pokemon.Id); + + await document.SetDataAsync(pokemon); + Assert.Equal(pokemon, (await document.GetDocumentSnapshotAsync()).Data!); + + var update = new Dictionary { + { Pokemon.NameField, "Cool Squirtle" }, + { Pokemon.MovesField, FieldValue.ArrayUnion("Bubble-Blast") }, + { $"{Pokemon.FirstSightingLocationField}.latitude", 13.37 }, + { "original_reference", document } + }; + + await document.UpdateDataAsync(update); + var snapshot = await document.GetDocumentSnapshotAsync(); + var data = snapshot.Data!; + Assert.Equal("Cool Squirtle", data.Name); + Assert.NotNull(data.Moves); + Assert.Contains("Bubble-Blast", data.Moves); + Assert.NotNull(data.FirstSightingLocation); + Assert.Equal(13.37, data.FirstSightingLocation.Latitude); + } + + [Fact] + public async Task increments_double_field_values() + { + var sut = CrossFirebaseFirestore.Current; + var pokemon = PokemonFactory.CreateBulbasur(); + var document = GetTestingDocument(sut, "double-increment"); + + await document.SetDataAsync(pokemon); + await document.UpdateDataAsync(("weight_in_kg", FieldValue.DoubleIncrement(0.25))); + + var snapshot = await document.GetDocumentSnapshotAsync(); + Assert.Equal(pokemon.WeightInKg + 0.25, snapshot.Data!.WeightInKg, 6); + } + + [Fact] + public async Task runs_transaction() + { + var sut = CrossFirebaseFirestore.Current; + var bulbasur = PokemonFactory.CreateBulbasur(); + var charmander = PokemonFactory.CreateCharmander(); + var squirtle = PokemonFactory.CreateSquirtle(); + var documentBulbasur = GetTestingDocument(sut, "1"); + var documentCharmander = GetTestingDocument(sut, "4"); + var documentSquirtle = GetTestingDocument(sut, "7"); + var otherMoves = new[] { "other_move", "another_move" }; + await documentBulbasur.SetDataAsync(bulbasur); + await documentCharmander.SetDataAsync(charmander); + + var charmanderSightingCount = await sut.RunTransactionAsync(transaction => { + var snapshotCharmander = transaction.GetDocument(documentCharmander)!; + var newSightingCount = snapshotCharmander.Data!.SightingCount + 1; + transaction.SetData(documentSquirtle, squirtle); + transaction.UpdateData(documentCharmander, (Pokemon.SightingCountField, newSightingCount)); + transaction.UpdateData(documentCharmander, (Pokemon.MovesField, otherMoves)); + transaction.UpdateData(documentCharmander, (Pokemon.ItemsField, FieldValue.Delete())); + transaction.DeleteDocument(documentBulbasur); + return newSightingCount; + }); + + var charmanderSnapshot = await documentCharmander.GetDocumentSnapshotAsync(); + Assert.Equal(squirtle, (await documentSquirtle.GetDocumentSnapshotAsync()).Data!); + Assert.Equal(charmander.SightingCount + 1, charmanderSightingCount); + Assert.Equal(otherMoves, charmanderSnapshot.Data!.Moves); + Assert.Null(charmanderSnapshot.Data!.Items); + Assert.Null((await documentBulbasur.GetDocumentSnapshotAsync()).Data); + } + + [Fact] + public async Task writes_data_as_batch() + { + var sut = CrossFirebaseFirestore.Current; + var bulbasur = PokemonFactory.CreateBulbasur(); + var charmander = PokemonFactory.CreateCharmander(); + var squirtle = PokemonFactory.CreateSquirtle(); + var documentBulbasur = GetTestingDocument(sut, "1"); + var documentCharmander = GetTestingDocument(sut, "4"); + var documentSquirtle = GetTestingDocument(sut, "7"); + await documentBulbasur.SetDataAsync(bulbasur); + await documentCharmander.SetDataAsync(charmander); + + var batch = sut.CreateBatch(); + batch.SetData(documentSquirtle, squirtle); + batch.UpdateData(documentCharmander, (Pokemon.SightingCountField, 1337)); + batch.DeleteDocument(documentBulbasur); + await batch.CommitAsync(); + + Assert.Equal(squirtle, (await documentSquirtle.GetDocumentSnapshotAsync()).Data!); + Assert.Equal(1337, (await documentCharmander.GetDocumentSnapshotAsync()).Data!.SightingCount); + Assert.Null((await documentBulbasur.GetDocumentSnapshotAsync()).Data); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.Listeners.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.Listeners.cs new file mode 100644 index 00000000..a7ceb8b8 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.Listeners.cs @@ -0,0 +1,99 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore +{ + public sealed partial class FirestoreFixture + { + [Fact] + public async Task gets_real_time_updates_on_single_document() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "1"); + await document.SetDataAsync(PokemonFactory.CreateBulbasur()); + + var expectedSightingCounts = new[] { 0L, 1L, 2L }; + var sightingCounts = new List(); + var sightingCountLock = new object(); + var receivedExpectedCounts = new CallbackProbe(); + using var disposable = document.AddSnapshotListener(x => { + if(x.Data != null) { + lock(sightingCountLock) { + sightingCounts.Add(x.Data!.SightingCount); + if(expectedSightingCounts.All(sightingCounts.Contains)) { + receivedExpectedCounts.TrySetResult(true); + } + } + } + }); + + for(var i = 0; i < 3; i++) { + await document.UpdateDataAsync((Pokemon.SightingCountField, i)); + } + + await receivedExpectedCounts.WaitAsync( + IntegrationTestTimeouts.Callback, + "single-document Firestore listener updates"); + lock(sightingCountLock) { + Assert.Equal(expectedSightingCounts, sightingCounts.Distinct()); + } + } + + [Fact] + public async Task gets_real_time_updates_on_multiple_documents() + { + var sut = CrossFirebaseFirestore.Current; + var collection = GetTestingCollection(sut); + var expectedChanges = new[] { + (DocumentChangeType.Added, "Charmander"), + (DocumentChangeType.Modified, "Charmander"), + (DocumentChangeType.Added, "Charmeleon"), + (DocumentChangeType.Modified, "Charmeleon"), + (DocumentChangeType.Added, "Charizard"), + (DocumentChangeType.Modified, "Charizard"), + (DocumentChangeType.Modified, "Charmander"), + (DocumentChangeType.Removed, "Charmeleon") + }; + + var changes = new List>(); + var changesLock = new object(); + using var disposable = collection + .WhereEqualsTo(Pokemon.PokeTypeField, PokeType.Fire) + .AddSnapshotListener(x => { + lock(changesLock) { + changes.Add(x.DocumentChanges.Select(y => (y.ChangeType, y.DocumentSnapshot.Data!.Name)).ToList()); + } + }); + + await collection.GetDocument("4").SetDataAsync(PokemonFactory.CreateCharmander()); + await WaitForChangeCountAsync(2); + + await collection.GetDocument("5").SetDataAsync(PokemonFactory.CreateCharmeleon()); + await WaitForChangeCountAsync(4); + + await collection.GetDocument("6").SetDataAsync(PokemonFactory.CreateCharizard()); + await WaitForChangeCountAsync(6); + + await collection.GetDocument("4").UpdateDataAsync((Pokemon.SightingCountField, 1337)); + await WaitForChangeCountAsync(7); + + await collection.GetDocument("5").DeleteDocumentAsync(); + await WaitForChangeCountAsync(expectedChanges.Length); + + Assert.Equal(expectedChanges, GetObservedChanges()); + + Task WaitForChangeCountAsync(int expectedMinimumCount) + { + return IntegrationTestTasks.EventuallyAsync( + () => Assert.True(GetObservedChanges().Count >= expectedMinimumCount), + IntegrationTestTimeouts.Callback); + } + + IReadOnlyList<(DocumentChangeType, string)> GetObservedChanges() + { + lock(changesLock) { + return changes.SelectMany(x => x).ToList(); + } + } + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.ObjectsAndMaps.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.ObjectsAndMaps.cs new file mode 100644 index 00000000..366d5af2 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.ObjectsAndMaps.cs @@ -0,0 +1,180 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed partial class FirestoreFixture +{ + [Fact] + public async Task set_and_get_a_map() + { + var sut = CrossFirebaseFirestore.Current; + var pokemon = PokemonFactory.CreateCharmeleon(); + var path = TestingDocumentPath(pokemon.Id); + var document = GetTestingDocument(sut, pokemon.Id); + + await document.SetDataAsync(pokemon); + + var snapshot = await document.GetDocumentSnapshotAsync(); + FirestoreAssertions.WrittenDocument(snapshot, pokemon.Id, path, pokemon); + FirestoreAssertions.PokemonOtherProperties(snapshot.Data, expectedLegs: 4L, expectedColors: 3L); + + var updates = new Dictionary { + { Pokemon.OtherPropertiesColorsPath, FieldValue.IntegerIncrement(1) } + }; + + await document.UpdateDataAsync(updates); + + snapshot = await document.GetDocumentSnapshotAsync(); + FirestoreAssertions.PokemonOtherProperties(snapshot.Data, expectedLegs: 4L, expectedColors: 4L); + } + + [Fact] + public async Task covers_document_set_overloads_with_merge() + { + var sut = CrossFirebaseFirestore.Current; + var mergedDictionaryDocument = GetTestingDocument(sut, "merge-dictionary"); + var tupleDocument = GetTestingDocument(sut, "tuple-set"); + var mergedTupleDocument = GetTestingDocument(sut, "merge-tuple"); + + await mergedDictionaryDocument.SetDataAsync(PokemonFactory.CreateCharmander()); + await mergedDictionaryDocument.SetDataAsync( + new Dictionary { + { Pokemon.NameField, "Merged Charmander" } + }, + SetOptions.Merge()); + + await tupleDocument.SetDataAsync( + (Pokemon.NameField, "Tuple Pokemon"), + (Pokemon.SightingCountField, 12L)); + + await mergedTupleDocument.SetDataAsync(PokemonFactory.CreateSquirtle()); + await mergedTupleDocument.SetDataAsync( + SetOptions.Merge(), + (Pokemon.NameField, "Merged Squirtle")); + + await FirestoreAssertions.PokemonWriteOverloadResultsAsync( + mergedDictionaryDocument, + "Merged Charmander", + tupleDocument, + "Tuple Pokemon", + 12L, + mergedTupleDocument, + "Merged Squirtle"); + } + + [Fact] + public async Task covers_batch_set_overloads_and_commit_local() + { + var sut = CrossFirebaseFirestore.Current; + var mergedDictionaryDocument = GetTestingDocument(sut, "batch-merge-dictionary"); + var tupleDocument = GetTestingDocument(sut, "batch-tuple"); + var mergedTupleDocument = GetTestingDocument(sut, "batch-merge-tuple"); + + await mergedDictionaryDocument.SetDataAsync(PokemonFactory.CreateCharmander()); + await mergedTupleDocument.SetDataAsync(PokemonFactory.CreateSquirtle()); + + var batch = sut.CreateBatch(); + batch.SetData( + mergedDictionaryDocument, + new Dictionary { + { Pokemon.NameField, "Batch Merged Charmander" } + }, + SetOptions.Merge()); + batch.SetData( + tupleDocument, + (Pokemon.NameField, "Batch Tuple Pokemon"), + (Pokemon.SightingCountField, 33L)); + batch.SetData( + mergedTupleDocument, + SetOptions.Merge(), + (Pokemon.NameField, "Batch Merged Squirtle")); + batch.CommitLocal(); + + await sut.WaitForPendingWritesAsync(); + + await FirestoreAssertions.PokemonWriteOverloadResultsAsync( + mergedDictionaryDocument, + "Batch Merged Charmander", + tupleDocument, + "Batch Tuple Pokemon", + 33L, + mergedTupleDocument, + "Batch Merged Squirtle"); + } + + [Fact] + public async Task covers_transaction_set_overloads() + { + var sut = CrossFirebaseFirestore.Current; + var mergedDictionaryDocument = GetTestingDocument(sut, "transaction-merge-dictionary"); + var mergedTupleDocument = GetTestingDocument(sut, "transaction-merge-tuple"); + + await mergedDictionaryDocument.SetDataAsync(PokemonFactory.CreateCharmander()); + await mergedTupleDocument.SetDataAsync(PokemonFactory.CreateSquirtle()); + + await sut.RunTransactionAsync(transaction => { + transaction.SetData( + mergedDictionaryDocument, + new Dictionary { + { Pokemon.NameField, "Transaction Merged Charmander" } + }, + SetOptions.Merge()); + transaction.SetData( + mergedTupleDocument, + SetOptions.Merge(), + (Pokemon.NameField, "Transaction Merged Squirtle"), + (Pokemon.SightingCountField, 91L)); + return true; + }); + + await FirestoreAssertions.PokemonWriteOverloadResultsAsync( + mergedDictionaryDocument, + "Transaction Merged Charmander", + null, + null, + null, + mergedTupleDocument, + "Transaction Merged Squirtle", + expectedMergedTupleSightingCount: 91L); + } + + [Fact] + public async Task updates_nested_map_and_datetime_values() + { + var sut = CrossFirebaseFirestore.Current; + var pokemon = PokemonFactory.CreateSquirtle(); + var document = GetTestingDocument(sut, pokemon.Id); + var expectedCreationDate = new DateTime(2024, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc); + var expectedLocation = new SightingLocation(13.37, 42.24); + + await document.SetDataAsync(pokemon); + await document.UpdateDataAsync( + ("creation_date", expectedCreationDate), + (Pokemon.FirstSightingLocationField, new Dictionary { + { "latitude", expectedLocation.Latitude }, + { "longitude", expectedLocation.Longitude } + }), + ("other_properties", new Dictionary { + { "legs", 4L }, + { "colors", 3L } + }) + ); + + var snapshot = await document.GetDocumentSnapshotAsync(); + FirestoreAssertions.PokemonNestedMapAndDateValues(snapshot.Data, expectedCreationDate, expectedLocation); + } + + [Fact] + public async Task reads_issue_422_crew_check_in_document() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "issue-422-crew-check-in"); + var scenario = CrewCheckInFactory.CreateIssue422Scenario(); + + await document.SetDataAsync(scenario.CrewCheckIn); + + var snapshot = await document.GetDocumentSnapshotAsync(); + + FirestoreAssertions.CrewCheckInDocument(snapshot.Data, scenario.Timestamp, scenario.LogTimestamp); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.Queries.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.Queries.cs new file mode 100644 index 00000000..e56f6e75 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.Queries.cs @@ -0,0 +1,248 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed partial class FirestoreFixture +{ + [Fact] + public async Task gets_data_with_simple_queries() + { + var sut = CrossFirebaseFirestore.Current; + var collection = FirestoreAssertions.SeededPokemonCollection(sut); + + var firePokemons = await collection + .WhereEqualsTo(Pokemon.PokeTypeField, PokeType.Fire) + .GetDocumentsAsync(); + + var smallPokemons = await collection + .WhereLessThanOrEqualsTo("height_in_cm", 100) + .GetDocumentsAsync(); + + Assert.Equal(3, firePokemons.Documents.Count()); + Assert.Equal(5, smallPokemons.Documents.Count()); + } + + [Fact] + public async Task gets_data_with_compound_query() + { + var sut = CrossFirebaseFirestore.Current; + var collection = FirestoreAssertions.SeededPokemonCollection(sut); + + var smallWaterPokemons = await collection + .WhereEqualsTo(Pokemon.PokeTypeField, PokeType.Water) + .WhereGreaterThanOrEqualsTo("height_in_cm", 50) + .WhereLessThan("height_in_cm", 100) + .GetDocumentsAsync(); + + Assert.Single(smallWaterPokemons.Documents); + } + + [Fact] + public async Task gets_data_with_array_contains_queries() + { + var sut = CrossFirebaseFirestore.Current; + + var pokemonsByContains = await FirestoreAssertions + .SeededPokemonCollection(sut) + .WhereArrayContains(Pokemon.MovesField, "Razor-Wind") + .GetDocumentsAsync(); + + var pokemonsByContainsAny = await FirestoreAssertions + .SeededPokemonCollection(sut) + .WhereArrayContainsAny(Pokemon.MovesField, ["Razor-Wind", "Fire-Punch"]) + .GetDocumentsAsync(); + + FirestoreAssertions.PokemonIds(pokemonsByContains, "1", "2", "3"); + FirestoreAssertions.PokemonIds(pokemonsByContainsAny, "1", "2", "3", "4", "5", "6"); + } + + [Fact] + public async Task gets_data_using_in_query() + { + var sut = CrossFirebaseFirestore.Current; + + var pokemons = await FirestoreAssertions + .SeededPokemonCollection(sut) + .WhereFieldIn(FieldPath.DocumentId(), ["1", "2", "3"]) + .GetDocumentsAsync(); + + FirestoreAssertions.PokemonIds(pokemons, "1", "2", "3"); + } + + [Fact] + public async Task uses_field_path_overloads() + { + var sut = CrossFirebaseFirestore.Current; + var nestedFieldPath = FieldPath.Of([Pokemon.FirstSightingLocationField, "latitude"]); + + var nestedPathResults = await FirestoreAssertions + .SeededPokemonCollection(sut) + .WhereEqualsTo(nestedFieldPath, 52.5042112) + .GetDocumentsAsync(); + + var documentIdResults = await FirestoreAssertions + .SeededPokemonCollection(sut) + .OrderBy(FieldPath.DocumentId()) + .StartingAt("2") + .EndingAt("4") + .GetDocumentsAsync(); + + Assert.Equal(9, nestedPathResults.Count); + FirestoreAssertions.PokemonIds(documentIdResults, "2", "3", "4"); + } + + [Fact] + public async Task orders_and_limits_data() + { + var sut = CrossFirebaseFirestore.Current; + + var pokemons = await FirestoreAssertions + .SeededPokemonCollection(sut) + .OrderBy(Pokemon.NameField, true) + .LimitedTo(3) + .GetDocumentsAsync(); + + FirestoreAssertions.PokemonNames(pokemons, "Wartortle", "Venusaur", "Squirtle"); + } + + [Fact] + public async Task uses_limited_to_last() + { + var sut = CrossFirebaseFirestore.Current; + + var pokemons = await FirestoreAssertions + .SeededPokemonCollection(sut) + .OrderBy(Pokemon.NameField) + .LimitedToLast(3) + .GetDocumentsAsync(); + + FirestoreAssertions.PokemonNames(pokemons, "Squirtle", "Venusaur", "Wartortle"); + } + + [Fact] + public async Task adds_simple_cursor_to_query() + { + var sut = CrossFirebaseFirestore.Current; + + var pokemonsByHeight = await FirestoreAssertions + .SeededPokemonCollection(sut) + .OrderBy("height_in_cm") + .StartingAt(50) + .EndingBefore(100) + .GetDocumentsAsync(); + + var pokemonsByWeight = await FirestoreAssertions + .SeededPokemonCollection(sut) + .OrderBy("weight_in_kg") + .StartingAfter(8.5) + .EndingAt(85.5) + .GetDocumentsAsync(); + + FirestoreAssertions.PokemonIds(pokemonsByHeight, "7", "4", "1"); + FirestoreAssertions.PokemonIds(pokemonsByWeight, "7", "2", "5", "8", "9"); + } + + [Fact] + public async Task uses_document_snapshot_to_define_query_cursor() + { + var sut = CrossFirebaseFirestore.Current; + + var snapshot = await FirestoreAssertions + .SeededPokemonDocument(sut, "2") + .GetDocumentSnapshotAsync(); + + var pokemons = await FirestoreAssertions + .SeededPokemonCollection(sut) + .OrderBy(Pokemon.NameField) + .StartingAt(snapshot) + .GetDocumentsAsync(); + + FirestoreAssertions.PokemonNames(pokemons, "Ivysaur", "Squirtle", "Venusaur", "Wartortle"); + } + + [Fact] + public async Task uses_snapshot_end_cursors() + { + var sut = CrossFirebaseFirestore.Current; + var snapshot = await FirestoreAssertions + .SeededPokemonDocument(sut, "7") + .GetDocumentSnapshotAsync(); + + var endingAt = await FirestoreAssertions + .SeededPokemonCollection(sut) + .OrderBy(Pokemon.NameField) + .EndingAt(snapshot) + .GetDocumentsAsync(); + + var endingBefore = await FirestoreAssertions + .SeededPokemonCollection(sut) + .OrderBy(Pokemon.NameField) + .EndingBefore(snapshot) + .GetDocumentsAsync(); + + FirestoreAssertions.PokemonNames( + endingAt, + "Blastoise", + "Bulbasaur", + "Charizard", + "Charmander", + "Charmeleon", + "Ivysaur", + "Squirtle"); + FirestoreAssertions.PokemonNames( + endingBefore, + "Blastoise", + "Bulbasaur", + "Charizard", + "Charmander", + "Charmeleon", + "Ivysaur"); + } + + [Fact] + public async Task sets_multiple_cursor_conditions() + { + var sut = CrossFirebaseFirestore.Current; + + var pokemons = await FirestoreAssertions + .SeededPokemonCollection(sut) + .OrderBy(Pokemon.PokeTypeField) + .OrderBy(Pokemon.NameField) + .StartingAt(PokeType.Water, "Squirtle") + .GetDocumentsAsync(); + + FirestoreAssertions.PokemonNames(pokemons, "Squirtle", "Wartortle", "Bulbasaur", "Ivysaur", "Venusaur"); + } + + [Fact] + public async Task paginates_data() + { + var sut = CrossFirebaseFirestore.Current; + var collection = FirestoreAssertions.SeededPokemonCollection(sut); + + var firstPageSnapshot = await collection + .LimitedTo(5) + .GetDocumentsAsync(); + + var nextPageSnapshot = await collection + .LimitedTo(5) + .StartingAfter(firstPageSnapshot.Documents.Last()) + .GetDocumentsAsync(); + + FirestoreAssertions.PokemonIds(firstPageSnapshot, "1", "2", "3", "4", "5"); + FirestoreAssertions.PokemonIds(nextPageSnapshot, "6", "7", "8", "9"); + } + + [Fact] + public async Task covers_query_snapshot_properties() + { + var sut = CrossFirebaseFirestore.Current; + var snapshot = await FirestoreAssertions + .SeededPokemonCollection(sut) + .WhereEqualsTo(Pokemon.PokeTypeField, PokeType.Fire) + .GetDocumentsAsync(); + + FirestoreAssertions.QuerySnapshotProperties(snapshot); + } + +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.cs index f1513ee7..076b382d 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreFixture.cs @@ -4,2114 +4,37 @@ namespace Plugin.Firebase.IntegrationTests.Firestore { [Collection("Sequential")] [TestLogging] + [IntegrationTestFixture(IntegrationTestPackage.Firestore)] [Preserve(AllMembers = true)] - public sealed class FirestoreFixture : IAsyncLifetime + public sealed partial class FirestoreFixture : IAsyncLifetime { private static readonly SemaphoreSlim SeedLock = new(1, 1); private static bool _basePokemonsSeeded; - private readonly string _testingCollectionPath = $"testing_{Guid.NewGuid():N}"; + private readonly FirestoreTestCollectionScope _testingCollection = FirestoreTestCollectionScope.Create("testing"); public async Task InitializeAsync() { await EnsureBasePokemonsSeededAsync(); } - [Fact] - public async Task adds_document_to_collection() - { - var sut = CrossFirebaseFirestore.Current; - var pokemon = PokemonFactory.CreateBulbasur(); - var path = TestingDocumentPath(pokemon.Id); - var document = GetTestingDocument(sut, pokemon.Id); - - await document.SetDataAsync(pokemon); - - var snapshot = await document.GetDocumentSnapshotAsync(); - Assert.False(snapshot.Metadata.HasPendingWrites); - Assert.Equal(pokemon.Id, snapshot.Reference.Id); - Assert.Equal(path, snapshot.Reference.Path); - Assert.Equal(pokemon, snapshot.Data); - } - - [Fact] - public async Task creates_document_with_auto_generated_reference() - { - var sut = CrossFirebaseFirestore.Current; - var collection = GetTestingCollection(sut); - var document = collection.CreateDocument(); - var item = new SimpleItem("generated-item"); - - await document.SetDataAsync(item); - - var snapshot = await document.GetDocumentSnapshotAsync(); - Assert.False(string.IsNullOrWhiteSpace(document.Id)); - Assert.False(string.IsNullOrWhiteSpace(document.Path)); - Assert.Equal(document.Id, snapshot.Reference.Id); - Assert.Equal(document.Id, snapshot.Data.Id); - Assert.Equal("generated-item", snapshot.Data.Title); - } - - [Fact] - public async Task adds_document_with_auto_generated_id() - { - var sut = CrossFirebaseFirestore.Current; - var collection = GetTestingCollection(sut); - - var document = await collection.AddDocumentAsync(new SimpleItem("added-item")); - - var snapshot = await document.GetDocumentSnapshotAsync(); - Assert.False(string.IsNullOrWhiteSpace(document.Id)); - Assert.False(string.IsNullOrWhiteSpace(document.Path)); - Assert.Equal(document.Id, snapshot.Reference.Id); - Assert.Equal(document.Id, snapshot.Data.Id); - Assert.Equal("added-item", snapshot.Data.Title); - } - - [Fact] - public async Task sets_server_timestamp_via_property_attribute() - { - var sut = CrossFirebaseFirestore.Current; - var pokemon = PokemonFactory.CreateBulbasur(); - var path = TestingDocumentPath(pokemon.Id); - - var document = GetTestingDocument(sut, pokemon.Id); - await document.SetDataAsync(pokemon); - - var snapshot = await GetTestingDocument(sut, pokemon.Id) - .GetDocumentSnapshotAsync(Source.Server); - Assert.NotEqual(snapshot.Data.ServerTimestamp, DateTimeOffset.MinValue); - Assert.NotEqual(snapshot.Data.ServerTimestamp, DateTimeOffset.Now); - } - - [Fact] - public async Task updates_existing_document() - { - var sut = CrossFirebaseFirestore.Current; - var pokemon = PokemonFactory.CreateSquirtle(); - var path = TestingDocumentPath(pokemon.Id); - var document = GetTestingDocument(sut, pokemon.Id); - - await document.SetDataAsync(pokemon); - Assert.Equal(pokemon, (await document.GetDocumentSnapshotAsync()).Data); - - var update = new Dictionary { - { "name", "Cool Squirtle" }, - { "moves", FieldValue.ArrayUnion("Bubble-Blast") }, - { "first_sighting_location.latitude", 13.37 }, - { "original_reference", document } - }; - - await document.UpdateDataAsync(update); - var snapshot = await document.GetDocumentSnapshotAsync(); - Assert.Equal("Cool Squirtle", snapshot.Data.Name); - Assert.True(snapshot.Data.Moves.Contains("Bubble-Blast")); - Assert.Equal(13.37, snapshot.Data.FirstSightingLocation.Latitude); - } - - [Fact] - public async Task increments_double_field_values() - { - var sut = CrossFirebaseFirestore.Current; - var pokemon = PokemonFactory.CreateBulbasur(); - var document = GetTestingDocument(sut, "double-increment"); - - await document.SetDataAsync(pokemon); - await document.UpdateDataAsync(("weight_in_kg", FieldValue.DoubleIncrement(0.25))); - - var snapshot = await document.GetDocumentSnapshotAsync(); - Assert.Equal(pokemon.WeightInKg + 0.25, snapshot.Data.WeightInKg, 6); - } - - [Fact] - public async Task runs_transaction() - { - var sut = CrossFirebaseFirestore.Current; - var bulbasur = PokemonFactory.CreateBulbasur(); - var charmander = PokemonFactory.CreateCharmander(); - var squirtle = PokemonFactory.CreateSquirtle(); - var documentBulbasur = GetTestingDocument(sut, "1"); - var documentCharmander = GetTestingDocument(sut, "4"); - var documentSquirtle = GetTestingDocument(sut, "7"); - var otherMoves = new[] { "other_move", "another_move" }; - await documentBulbasur.SetDataAsync(bulbasur); - await documentCharmander.SetDataAsync(charmander); - - var charmanderSightingCount = await sut.RunTransactionAsync(transaction => { - var snapshotCharmander = transaction.GetDocument(documentCharmander); - var newSightingCount = snapshotCharmander.Data.SightingCount + 1; - transaction.SetData(documentSquirtle, squirtle); - transaction.UpdateData(documentCharmander, ("sighting_count", newSightingCount)); - transaction.UpdateData(documentCharmander, ("moves", otherMoves)); - transaction.UpdateData(documentCharmander, ("items", FieldValue.Delete())); - transaction.DeleteDocument(documentBulbasur); - return newSightingCount; - }); - - var charmanderSnapshot = await documentCharmander.GetDocumentSnapshotAsync(); - Assert.Equal(squirtle, (await documentSquirtle.GetDocumentSnapshotAsync()).Data); - Assert.Equal(charmander.SightingCount + 1, charmanderSightingCount); - Assert.Equal(otherMoves, charmanderSnapshot.Data.Moves); - Assert.Null(charmanderSnapshot.Data.Items); - Assert.Null((await documentBulbasur.GetDocumentSnapshotAsync()).Data); - } - - [Fact] - public async Task writes_data_as_batch() - { - var sut = CrossFirebaseFirestore.Current; - var bulbasur = PokemonFactory.CreateBulbasur(); - var charmander = PokemonFactory.CreateCharmander(); - var squirtle = PokemonFactory.CreateSquirtle(); - var documentBulbasur = GetTestingDocument(sut, "1"); - var documentCharmander = GetTestingDocument(sut, "4"); - var documentSquirtle = GetTestingDocument(sut, "7"); - await documentBulbasur.SetDataAsync(bulbasur); - await documentCharmander.SetDataAsync(charmander); - - var batch = sut.CreateBatch(); - batch.SetData(documentSquirtle, squirtle); - batch.UpdateData(documentCharmander, ("sighting_count", 1337)); - batch.DeleteDocument(documentBulbasur); - await batch.CommitAsync(); - - Assert.Equal(squirtle, (await documentSquirtle.GetDocumentSnapshotAsync()).Data); - Assert.Equal(1337, (await documentCharmander.GetDocumentSnapshotAsync()).Data.SightingCount); - Assert.Null((await documentBulbasur.GetDocumentSnapshotAsync()).Data); - } - - [Fact] - public async Task gets_data_with_simple_queries() - { - var sut = CrossFirebaseFirestore.Current; - var collection = sut.GetCollection("pokemons"); - - var firePokemons = await collection - .WhereEqualsTo("poke_type", PokeType.Fire) - .GetDocumentsAsync(); - - var smallPokemons = await collection - .WhereLessThanOrEqualsTo("height_in_cm", 100) - .GetDocumentsAsync(); - - Assert.Equal(3, firePokemons.Documents.Count()); - Assert.Equal(5, smallPokemons.Documents.Count()); - } - - [Fact] - public async Task gets_data_with_compound_query() - { - var sut = CrossFirebaseFirestore.Current; - var collection = sut.GetCollection("pokemons"); - - var smallWaterPokemons = await collection - .WhereEqualsTo("poke_type", PokeType.Water) - .WhereGreaterThanOrEqualsTo("height_in_cm", 50) - .WhereLessThan("height_in_cm", 100) - .GetDocumentsAsync(); - - Assert.Single(smallWaterPokemons.Documents); - } - - [Fact] - public async Task gets_data_with_array_contains_queries() - { - var sut = CrossFirebaseFirestore.Current; - - var pokemonsByContains = await sut - .GetCollection("pokemons") - .WhereArrayContains("moves", "Razor-Wind") - .GetDocumentsAsync(); - - var pokemonsByContainsAny = await sut - .GetCollection("pokemons") - .WhereArrayContainsAny("moves", new object[] { "Razor-Wind", "Fire-Punch" }) - .GetDocumentsAsync(); - - Assert.Equal(new[] { "1", "2", "3" }, pokemonsByContains.Documents.Select(x => x.Data.Id)); - Assert.Equal(new[] { "1", "2", "3", "4", "5", "6" }, pokemonsByContainsAny.Documents.Select(x => x.Data.Id)); - } - - [Fact] - public async Task gets_data_using_in_query() - { - var sut = CrossFirebaseFirestore.Current; - - var pokemons = await sut - .GetCollection("pokemons") - .WhereFieldIn(FieldPath.DocumentId(), new object[] { "1", "2", "3" }) - .GetDocumentsAsync(); - - Assert.Equal(new[] { "1", "2", "3" }, pokemons.Documents.Select(x => x.Data.Id)); - } - - [Fact] - public async Task uses_field_path_overloads() - { - var sut = CrossFirebaseFirestore.Current; - var nestedFieldPath = FieldPath.Of(new[] { "first_sighting_location", "latitude" }); - - var nestedPathResults = await sut - .GetCollection("pokemons") - .WhereEqualsTo(nestedFieldPath, 52.5042112) - .GetDocumentsAsync(); - - var documentIdResults = await sut - .GetCollection("pokemons") - .OrderBy(FieldPath.DocumentId()) - .StartingAt("2") - .EndingAt("4") - .GetDocumentsAsync(); - - Assert.Equal(9, nestedPathResults.Count); - Assert.Equal(new[] { "2", "3", "4" }, documentIdResults.Documents.Select(x => x.Data.Id)); - } - - [Fact] - public async Task orders_and_limits_data() - { - var sut = CrossFirebaseFirestore.Current; - - var pokemons = await sut - .GetCollection("pokemons") - .OrderBy("name", true) - .LimitedTo(3) - .GetDocumentsAsync(); - - Assert.Equal(new[] { "Wartortle", "Venusaur", "Squirtle" }, pokemons.Documents.Select(x => x.Data.Name)); - } - - [Fact] - public async Task uses_limited_to_last() - { - var sut = CrossFirebaseFirestore.Current; - - var pokemons = await sut - .GetCollection("pokemons") - .OrderBy("name") - .LimitedToLast(3) - .GetDocumentsAsync(); - - Assert.Equal(new[] { "Squirtle", "Venusaur", "Wartortle" }, pokemons.Documents.Select(x => x.Data.Name)); - } - - [Fact] - public async Task adds_simple_cursor_to_query() - { - var sut = CrossFirebaseFirestore.Current; - - var pokemonsByHeight = await sut - .GetCollection("pokemons") - .OrderBy("height_in_cm") - .StartingAt(50) - .EndingBefore(100) - .GetDocumentsAsync(); - - var pokemonsByWeight = await sut - .GetCollection("pokemons") - .OrderBy("weight_in_kg") - .StartingAfter(8.5) - .EndingAt(85.5) - .GetDocumentsAsync(); - - Assert.Equal(new[] { "7", "4", "1" }, pokemonsByHeight.Documents.Select(x => x.Data.Id)); - Assert.Equal(new[] { "7", "2", "5", "8", "9" }, pokemonsByWeight.Documents.Select(x => x.Data.Id)); - } - - [Fact] - public async Task uses_document_snapshot_to_define_query_cursor() - { - var sut = CrossFirebaseFirestore.Current; - - var snapshot = await sut - .GetDocument("pokemons/2") - .GetDocumentSnapshotAsync(); - - var pokemons = await sut - .GetCollection("pokemons") - .OrderBy("name") - .StartingAt(snapshot) - .GetDocumentsAsync(); - - Assert.Equal(new[] { "Ivysaur", "Squirtle", "Venusaur", "Wartortle" }, pokemons.Documents.Select(x => x.Data.Name)); - } - - [Fact] - public async Task uses_snapshot_end_cursors() - { - var sut = CrossFirebaseFirestore.Current; - var snapshot = await sut - .GetDocument("pokemons/7") - .GetDocumentSnapshotAsync(); - - var endingAt = await sut - .GetCollection("pokemons") - .OrderBy("name") - .EndingAt(snapshot) - .GetDocumentsAsync(); - - var endingBefore = await sut - .GetCollection("pokemons") - .OrderBy("name") - .EndingBefore(snapshot) - .GetDocumentsAsync(); - - Assert.Equal( - new[] { "Blastoise", "Bulbasaur", "Charizard", "Charmander", "Charmeleon", "Ivysaur", "Squirtle" }, - endingAt.Documents.Select(x => x.Data.Name)); - Assert.Equal( - new[] { "Blastoise", "Bulbasaur", "Charizard", "Charmander", "Charmeleon", "Ivysaur" }, - endingBefore.Documents.Select(x => x.Data.Name)); - } - - [Fact] - public async Task sets_multiple_cursor_conditions() - { - var sut = CrossFirebaseFirestore.Current; - - var pokemons = await sut - .GetCollection("pokemons") - .OrderBy("poke_type") - .OrderBy("name") - .StartingAt(PokeType.Water, "Squirtle") - .GetDocumentsAsync(); - - Assert.Equal(new[] { "Squirtle", "Wartortle", "Bulbasaur", "Ivysaur", "Venusaur" }, pokemons.Documents.Select(x => x.Data.Name)); - } - - [Fact] - public async Task paginates_data() - { - var sut = CrossFirebaseFirestore.Current; - var collection = sut.GetCollection("pokemons"); - - var firstPageSnapshot = await collection - .LimitedTo(5) - .GetDocumentsAsync(); - - var nextPageSnapshot = await collection - .LimitedTo(5) - .StartingAfter(firstPageSnapshot.Documents.Last()) - .GetDocumentsAsync(); - - Assert.Equal(new[] { "1", "2", "3", "4", "5" }, firstPageSnapshot.Documents.Select(x => x.Data.Id)); - Assert.Equal(new[] { "6", "7", "8", "9" }, nextPageSnapshot.Documents.Select(x => x.Data.Id)); - } - - [Fact] - public async Task covers_query_snapshot_properties() - { - var sut = CrossFirebaseFirestore.Current; - var snapshot = await sut - .GetCollection("pokemons") - .WhereEqualsTo("poke_type", PokeType.Fire) - .GetDocumentsAsync(); - - Assert.False(snapshot.IsEmpty); - Assert.Equal(snapshot.Documents.Count(), snapshot.Count); - Assert.NotNull(snapshot.Query); - Assert.NotNull(snapshot.Metadata); - Assert.NotEmpty(snapshot.DocumentChanges); - Assert.NotEmpty(snapshot.GetDocumentChanges(includeMetadataChanges: false)); - } - - [Fact] - public async Task gets_real_time_updates_on_single_document() - { - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "1"); - await document.SetDataAsync(PokemonFactory.CreateBulbasur()); - - var sightingCounts = new List(); - var disposable = document.AddSnapshotListener(x => { - if(x.Data != null) { - sightingCounts.Add(x.Data.SightingCount); - } - }); - - for(var i = 0; i < 3; i++) { - await document.UpdateDataAsync(("sighting_count", i)); - await Task.Delay(TimeSpan.FromMilliseconds(100)); - } - - Assert.Equal(new[] { 0L, 1L, 2L }, sightingCounts.Distinct()); - disposable.Dispose(); - } - - [Fact] - public async Task set_and_get_a_map() - { - var sut = CrossFirebaseFirestore.Current; - var pokemon = PokemonFactory.CreateCharmeleon(); - var path = TestingDocumentPath(pokemon.Id); - var document = GetTestingDocument(sut, pokemon.Id); - - await document.SetDataAsync(pokemon); - - var snapshot = await document.GetDocumentSnapshotAsync(); - Assert.False(snapshot.Metadata.HasPendingWrites); - Assert.Equal(pokemon.Id, snapshot.Reference.Id); - Assert.Equal(path, snapshot.Reference.Path); - Assert.Equal(pokemon, snapshot.Data); - - Assert.Equal(4, snapshot.Data.OtherProperties["legs"]); - Assert.Equal(3, snapshot.Data.OtherProperties["colors"]); - - var updates = new Dictionary { - { "other_properties.colors", FieldValue.IntegerIncrement(1) } - }; - - await document.UpdateDataAsync(updates); - - snapshot = await document.GetDocumentSnapshotAsync(); - Assert.Equal(4, snapshot.Data.OtherProperties["colors"]); - } - - [Fact] - public async Task covers_document_set_overloads_with_merge() - { - var sut = CrossFirebaseFirestore.Current; - var mergedDictionaryDocument = GetTestingDocument(sut, "merge-dictionary"); - var tupleDocument = GetTestingDocument(sut, "tuple-set"); - var mergedTupleDocument = GetTestingDocument(sut, "merge-tuple"); - - await mergedDictionaryDocument.SetDataAsync(PokemonFactory.CreateCharmander()); - await mergedDictionaryDocument.SetDataAsync( - new Dictionary { - { "name", "Merged Charmander" } - }, - SetOptions.Merge()); - - await tupleDocument.SetDataAsync( - ("name", "Tuple Pokemon"), - ("sighting_count", 12L)); - - await mergedTupleDocument.SetDataAsync(PokemonFactory.CreateSquirtle()); - await mergedTupleDocument.SetDataAsync( - SetOptions.Merge(), - ("name", "Merged Squirtle")); - - var mergedDictionarySnapshot = await mergedDictionaryDocument.GetDocumentSnapshotAsync(); - var tupleSnapshot = await tupleDocument.GetDocumentSnapshotAsync(); - var mergedTupleSnapshot = await mergedTupleDocument.GetDocumentSnapshotAsync(); - - Assert.Equal("Merged Charmander", mergedDictionarySnapshot.Data.Name); - Assert.Equal(60, mergedDictionarySnapshot.Data.HeightInCm); - Assert.Equal("Tuple Pokemon", tupleSnapshot.Data.Name); - Assert.Equal(12L, tupleSnapshot.Data.SightingCount); - Assert.Equal("Merged Squirtle", mergedTupleSnapshot.Data.Name); - Assert.Equal(50, mergedTupleSnapshot.Data.HeightInCm); - } - - [Fact] - public async Task covers_batch_set_overloads_and_commit_local() - { - var sut = CrossFirebaseFirestore.Current; - var mergedDictionaryDocument = GetTestingDocument(sut, "batch-merge-dictionary"); - var tupleDocument = GetTestingDocument(sut, "batch-tuple"); - var mergedTupleDocument = GetTestingDocument(sut, "batch-merge-tuple"); - - await mergedDictionaryDocument.SetDataAsync(PokemonFactory.CreateCharmander()); - await mergedTupleDocument.SetDataAsync(PokemonFactory.CreateSquirtle()); - - var batch = sut.CreateBatch(); - batch.SetData( - mergedDictionaryDocument, - new Dictionary { - { "name", "Batch Merged Charmander" } - }, - SetOptions.Merge()); - batch.SetData( - tupleDocument, - ("name", "Batch Tuple Pokemon"), - ("sighting_count", 33L)); - batch.SetData( - mergedTupleDocument, - SetOptions.Merge(), - ("name", "Batch Merged Squirtle")); - batch.CommitLocal(); - - await sut.WaitForPendingWritesAsync(); - - var mergedDictionarySnapshot = await mergedDictionaryDocument.GetDocumentSnapshotAsync(); - var tupleSnapshot = await tupleDocument.GetDocumentSnapshotAsync(); - var mergedTupleSnapshot = await mergedTupleDocument.GetDocumentSnapshotAsync(); - - Assert.Equal("Batch Merged Charmander", mergedDictionarySnapshot.Data.Name); - Assert.Equal(60, mergedDictionarySnapshot.Data.HeightInCm); - Assert.Equal("Batch Tuple Pokemon", tupleSnapshot.Data.Name); - Assert.Equal(33L, tupleSnapshot.Data.SightingCount); - Assert.Equal("Batch Merged Squirtle", mergedTupleSnapshot.Data.Name); - Assert.Equal(50, mergedTupleSnapshot.Data.HeightInCm); - } - - [Fact] - public async Task covers_transaction_set_overloads() - { - var sut = CrossFirebaseFirestore.Current; - var mergedDictionaryDocument = GetTestingDocument(sut, "transaction-merge-dictionary"); - var mergedTupleDocument = GetTestingDocument(sut, "transaction-merge-tuple"); - - await mergedDictionaryDocument.SetDataAsync(PokemonFactory.CreateCharmander()); - await mergedTupleDocument.SetDataAsync(PokemonFactory.CreateSquirtle()); - - await sut.RunTransactionAsync(transaction => { - transaction.SetData( - mergedDictionaryDocument, - new Dictionary { - { "name", "Transaction Merged Charmander" } - }, - SetOptions.Merge()); - transaction.SetData( - mergedTupleDocument, - SetOptions.Merge(), - ("name", "Transaction Merged Squirtle"), - ("sighting_count", 91L)); - return true; - }); - - var mergedDictionarySnapshot = await mergedDictionaryDocument.GetDocumentSnapshotAsync(); - var mergedTupleSnapshot = await mergedTupleDocument.GetDocumentSnapshotAsync(); - - Assert.Equal("Transaction Merged Charmander", mergedDictionarySnapshot.Data.Name); - Assert.Equal(60, mergedDictionarySnapshot.Data.HeightInCm); - Assert.Equal("Transaction Merged Squirtle", mergedTupleSnapshot.Data.Name); - Assert.Equal(91L, mergedTupleSnapshot.Data.SightingCount); - Assert.Equal(50, mergedTupleSnapshot.Data.HeightInCm); - } - - [Fact] - public async Task updates_nested_map_and_datetime_values() - { - var sut = CrossFirebaseFirestore.Current; - var pokemon = PokemonFactory.CreateSquirtle(); - var path = TestingDocumentPath(pokemon.Id); - var document = GetTestingDocument(sut, pokemon.Id); - var expectedCreationDate = new DateTime(2024, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc); - var expectedLocation = new SightingLocation(13.37, 42.24); - - await document.SetDataAsync(pokemon); - await document.UpdateDataAsync( - ("creation_date", expectedCreationDate), - ("first_sighting_location", new Dictionary { - { "latitude", expectedLocation.Latitude }, - { "longitude", expectedLocation.Longitude } - }), - ("other_properties", new Dictionary { - { "legs", 4L }, - { "colors", 3L } - }) - ); - - var snapshot = await document.GetDocumentSnapshotAsync(); - Assert.InRange(Math.Abs(snapshot.Data.CreationDate.Ticks - expectedCreationDate.Ticks), 0, 10); - Assert.Equal(expectedLocation, snapshot.Data.FirstSightingLocation); - Assert.Equal(4L, snapshot.Data.OtherProperties["legs"]); - Assert.Equal(3L, snapshot.Data.OtherProperties["colors"]); - } - - [Fact] - public async Task reads_issue_422_crew_check_in_document() - { - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "issue-422-crew-check-in"); - var timestamp = new DateTime(2025, 2, 27, 14, 48, 2, 625, DateTimeKind.Utc); - var logTimestamp = new DateTime(2025, 2, 27, 14, 49, 3, 123, DateTimeKind.Utc); - var assignedEquipment = new List { - new("bucket truck attachment", "Bucket Attachment", "Alice", "equipment") - }; - var assignedVehicles = new List { - new("crew truck", "Truck 12", "Bob", "vehicle") - }; - var yardAssets = new List { - new("crew truck", "Truck 12", "Bob", "vehicle"), - new("compressor", "Air Compressor", "Charlie", "equipment") - }; - var crewCheckIn = new CrewCheckIn( - employees: new List { - new( - "Ada Lovelace", - "Foreman", - 7, - new List { "en", "de" }, - assignedEquipment, - assignedVehicles, - "07:30", - "checked-in", - "yard", - new List { "1001", "1002" }, - "ready") - }, - yardAssets: yardAssets, - clockInTime: "07:30", - yardLocation: "north yard", - emergencyCheckIn: true, - removedAssets: new List { - new("Spare Saw", "damaged chainsaw", "maintenance") - }, - logEntries: new List { - new(logTimestamp, "created", "check-in created") - }, - timestamp: timestamp); - - await document.SetDataAsync(crewCheckIn); - - var snapshot = await document.GetDocumentSnapshotAsync(); - - Assert.NotNull(snapshot.Data); - Assert.True(snapshot.Data.EmergencyCheckIn); - Assert.Equal("07:30", snapshot.Data.ClockInTime); - Assert.Equal("north yard", snapshot.Data.YardLocation); - Assert.InRange(Math.Abs(snapshot.Data.Timestamp.Ticks - timestamp.Ticks), 0, 10); - - var employee = Assert.Single(snapshot.Data.Employees); - Assert.Equal("Ada Lovelace", employee.Name); - Assert.Equal("Foreman", employee.Clazz); - Assert.Equal(7, employee.Crew); - Assert.Equal(new[] { "en", "de" }, employee.Languages); - Assert.Equal(new[] { "1001", "1002" }, employee.JobNumbers); - Assert.Equal("yard", employee.WorkType); - Assert.Equal("ready", employee.Notes); - - var equipment = Assert.Single(employee.AssignedEquipment); - Assert.Equal("Bucket Attachment", equipment.Name); - Assert.Equal("Alice", equipment.Operator); - - var vehicle = Assert.Single(employee.AssignedVehicles); - Assert.Equal("Truck 12", vehicle.Name); - Assert.Equal("Bob", vehicle.Operator); - - Assert.Equal(2, snapshot.Data.YardAssets.Count); - Assert.Equal("Truck 12", snapshot.Data.YardAssets[0].Name); - Assert.Equal("Air Compressor", snapshot.Data.YardAssets[1].Name); - - var removedAsset = Assert.Single(snapshot.Data.RemovedAssets); - Assert.Equal("Spare Saw", removedAsset.AssetName); - Assert.Equal("damaged chainsaw", removedAsset.AssetDescription); - Assert.Equal("maintenance", removedAsset.Reason); - - var log = Assert.Single(snapshot.Data.LogEntries); - Assert.Equal("created", log.Action); - Assert.Equal("check-in created", log.Message); - Assert.InRange(Math.Abs(log.Timestamp.Ticks - logTimestamp.Ticks), 0, 10); - } - - [Fact] - public async Task gets_real_time_updates_on_multiple_documents() - { - var sut = CrossFirebaseFirestore.Current; - var collection = GetTestingCollection(sut); - - var changes = new List>(); - var disposable = collection - .WhereEqualsTo("poke_type", PokeType.Fire) - .AddSnapshotListener(x => { - changes.Add(x.DocumentChanges.Select(y => (y.ChangeType, y.DocumentSnapshot.Data.Name))); - }); - - await collection.GetDocument("4").SetDataAsync(PokemonFactory.CreateCharmander()); - await Task.Delay(TimeSpan.FromMilliseconds(500)); - - await collection.GetDocument("5").SetDataAsync(PokemonFactory.CreateCharmeleon()); - await Task.Delay(TimeSpan.FromMilliseconds(500)); - - await collection.GetDocument("6").SetDataAsync(PokemonFactory.CreateCharizard()); - await Task.Delay(TimeSpan.FromMilliseconds(500)); - - await collection.GetDocument("4").UpdateDataAsync(("sighting_count", 1337)); - await Task.Delay(TimeSpan.FromMilliseconds(500)); - - await collection.GetDocument("5").DeleteDocumentAsync(); - await Task.Delay(TimeSpan.FromMilliseconds(500)); - - var expectedChanges = new[] { - (DocumentChangeType.Added, "Charmander"), - (DocumentChangeType.Modified, "Charmander"), - (DocumentChangeType.Added, "Charmeleon"), - (DocumentChangeType.Modified, "Charmeleon"), - (DocumentChangeType.Added, "Charizard"), - (DocumentChangeType.Modified, "Charizard"), - (DocumentChangeType.Modified, "Charmander"), - (DocumentChangeType.Removed, "Charmeleon") - }; - - Assert.Equal(expectedChanges, changes.SelectMany(x => x)); - disposable.Dispose(); - } - - [Fact] - public async Task deletes_document() - { - var sut = CrossFirebaseFirestore.Current; - var pokemon = PokemonFactory.CreateCharmander(); - var path = TestingDocumentPath(pokemon.Id); - var document = GetTestingDocument(sut, pokemon.Id); - - await document.SetDataAsync(pokemon); - Assert.NotNull((await GetTestingDocument(sut, pokemon.Id).GetDocumentSnapshotAsync()).Data); - - await document.DeleteDocumentAsync(); - Assert.Null((await GetTestingDocument(sut, pokemon.Id).GetDocumentSnapshotAsync()).Data); - } - - [Fact] - public async Task deletes_fields_of_document() - { - var sut = CrossFirebaseFirestore.Current; - var pokemon = PokemonFactory.CreateCharmander(); - var path = TestingDocumentPath(pokemon.Id); - var document = GetTestingDocument(sut, pokemon.Id); - await document.SetDataAsync(pokemon); - - await document.UpdateDataAsync( - ("moves", FieldValue.Delete()), - ("items", FieldValue.Delete()), - ("first_sighting_location", FieldValue.Delete()), - ("poke_type", FieldValue.Delete())); - - var snapshot = await document.GetDocumentSnapshotAsync(); - Assert.Null(snapshot.Data.Moves); - Assert.Null(snapshot.Data.FirstSightingLocation); - Assert.Null(snapshot.Data.Items); - Assert.Equal(PokeType.Undefined, snapshot.Data.PokeType); - } - - [Fact] - public async Task copies_document_id_in_firestore_document_id_attributed_property() - { - var sut = CrossFirebaseFirestore.Current; - var item = new SimpleItem(title: "test"); - var path = TestingDocumentPath("1337"); - var document = GetTestingDocument(sut, "1337"); - - await document.SetDataAsync(item); - - var snapshot = await document.GetDocumentSnapshotAsync(); - Assert.Equal("1337", snapshot.Data.Id); - Assert.Equal("1337", snapshot.Reference.Id); - } - - [Fact] - public async Task clones_pokemon_with_original_reference() - { - var sut = CrossFirebaseFirestore.Current; - var bulbasurReference = sut.GetDocument($"pokemons/1"); - var bulbasur = (await bulbasurReference.GetDocumentSnapshotAsync()).Data; - var copy = bulbasur.Clone(bulbasurReference); - var copyPath = TestingDocumentPath(copy.Id); - var copyDocument = GetTestingDocument(sut, copy.Id); - await copyDocument.SetDataAsync(copy); - - var copySnapshot = await copyDocument.GetDocumentSnapshotAsync(); - Assert.False(copySnapshot.Metadata.HasPendingWrites); - Assert.Equal($"{bulbasur.Id}_copied", copySnapshot.Reference.Id); - Assert.Equal(copyPath, copySnapshot.Reference.Path); - Assert.Equal(copy, copySnapshot.Data); - } - - [Fact] - public async Task retrieves_subs_collection() - { - var sut = CrossFirebaseFirestore.Current; - var pokemon = PokemonFactory.CreateBulbasur(); - var path = TestingDocumentPath(pokemon.Id); - var subCollectionName = "sub_items"; - var subCollectionPath = $"{path}/{subCollectionName}"; - var document = GetTestingDocument(sut, pokemon.Id); - var subDocument = sut.GetDocument($"{subCollectionPath}/123"); - - await document.SetDataAsync(pokemon); - await subDocument.SetDataAsync(new Dictionary() { { "foo", "bar" } }); - - var subCollectionRef1 = sut.GetCollection(subCollectionPath); - var subCollectionRef2 = document.GetCollection(subCollectionName); - var snapshot1 = await subCollectionRef1.GetDocumentsAsync(); - var snapshot2 = await subCollectionRef2.GetDocumentsAsync(); - Assert.Single(snapshot1.Documents); - Assert.Single(snapshot2.Documents); - } - - [Fact] - public async Task round_trips_typed_boolean_and_datetime_map_values() - { - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "typed-boolean-and-datetime-map-values"); - var expectedEarlyDate = new DateTimeOffset(2026, 5, 2, 13, 14, 15, 123, TimeSpan.Zero); - var expectedLateDate = new DateTimeOffset(2026, 5, 3, 16, 17, 18, 456, TimeSpan.Zero); - - await document.SetDataAsync(new TypedMapValuesDocument( - new Dictionary { - { "enabled", true }, - { "disabled", false } - }, - new Dictionary { - { "early", expectedEarlyDate }, - { "late", expectedLateDate } - })); - - var snapshot = await document.GetDocumentSnapshotAsync(Source.Server); - - Assert.True(snapshot.Data.BooleanMaps["enabled"]); - Assert.False(snapshot.Data.BooleanMaps["disabled"]); - Assert.InRange( - Math.Abs(snapshot.Data.DateMaps["early"].Ticks - expectedEarlyDate.Ticks), - 0, - TimeSpan.FromMilliseconds(1).Ticks); - Assert.InRange( - Math.Abs(snapshot.Data.DateMaps["late"].Ticks - expectedLateDate.Ticks), - 0, - TimeSpan.FromMilliseconds(1).Ticks); - } - - [Fact] - public async Task reads_ios_dictionary_object_numeric_and_boolean_values() - { - if(!OperatingSystem.IsIOS()) { - return; - } - - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "ios-dictionary-object-values"); - await document.SetDataAsync(new DictionaryObjectValuesDocument( - new Dictionary { - { "enabled", true }, - { "count", 5L }, - { "ratio", 1.25 } - })); - - var snapshot = await document.GetDocumentSnapshotAsync(); - - Assert.True((bool) snapshot.Data.Values["enabled"]); - Assert.Equal(5L, Convert.ToInt64(snapshot.Data.Values["count"])); - Assert.Equal(1.25, Convert.ToDouble(snapshot.Data.Values["ratio"])); - } - - [Fact] - public async Task reads_ios_enum_dictionary_values() - { - if(!OperatingSystem.IsIOS()) { - return; - } - - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "ios-enum-dictionary-values"); - await document.SetDataAsync(new EnumDictionaryDocument( - new Dictionary { - { "fire", PokeType.Fire }, - { "water", PokeType.Water } - })); - - var snapshot = await document.GetDocumentSnapshotAsync(); - - Assert.Equal(PokeType.Fire, snapshot.Data.Values["fire"]); - Assert.Equal(PokeType.Water, snapshot.Data.Values["water"]); - } - - [Fact] - public async Task reads_android_typed_numeric_collection_values() - { - if(!OperatingSystem.IsAndroid()) { - return; - } - - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "android-typed-numeric-collections"); - await document.SetDataAsync(new Dictionary { - { - "counts", - new Dictionary { - { "one", 1L }, - { "two", 2L } - } - }, - { "nullable_counts", new object[] { 1L, null, 3L } }, - { - "types", - new Dictionary { - { "fire", PokeType.Fire }, - { "water", PokeType.Water } - } - } - }); - - var snapshot = await document.GetDocumentSnapshotAsync(); - - Assert.Equal(1, snapshot.Data.Counts["one"]); - Assert.Equal(2, snapshot.Data.Counts["two"]); - Assert.Equal(new int?[] { 1, null, 3 }, snapshot.Data.NullableCounts); - Assert.Equal(PokeType.Fire, snapshot.Data.Types["fire"]); - Assert.Equal(PokeType.Water, snapshot.Data.Types["water"]); - } - - [Fact] - public async Task writes_set_data_from_dictionary_and_tuple_payloads() - { - var sut = CrossFirebaseFirestore.Current; - - var dictionaryDocument = GetTestingDocument(sut, "setdata-string-dictionary"); - await dictionaryDocument.SetDataAsync(new Dictionary { - { "field_a", "dictionary-a" }, - { "field_b", "dictionary-b" } - }); - var dictionaryResult = (await dictionaryDocument.GetDocumentSnapshotAsync()).Data; - Assert.Equal("dictionary-a", dictionaryResult.FieldA); - Assert.Equal("dictionary-b", dictionaryResult.FieldB); - - var tupleDocument = GetTestingDocument(sut, "setdata-tuple"); - await tupleDocument.SetDataAsync( - ("field_a", "tuple-a"), - ("field_b", "tuple-b")); - var tupleResult = (await tupleDocument.GetDocumentSnapshotAsync()).Data; - Assert.Equal("tuple-a", tupleResult.FieldA); - Assert.Equal("tuple-b", tupleResult.FieldB); - - if(OperatingSystem.IsAndroid() is false) { - return; - } - - var transactionDictionaryDocument = GetTestingDocument(sut, "transaction-setdata-string-dictionary"); - var transactionTupleDocument = GetTestingDocument(sut, "transaction-setdata-tuple"); - await sut.RunTransactionAsync(transaction => { - transaction.SetData( - transactionDictionaryDocument, - new Dictionary { - { "field_a", "transaction-dictionary-a" }, - { "field_b", "transaction-dictionary-b" } - }); - transaction.SetData( - transactionTupleDocument, - ("field_a", "transaction-tuple-a"), - ("field_b", "transaction-tuple-b")); - return true; - }); - - var transactionDictionaryResult = (await transactionDictionaryDocument.GetDocumentSnapshotAsync()).Data; - Assert.Equal("transaction-dictionary-a", transactionDictionaryResult.FieldA); - Assert.Equal("transaction-dictionary-b", transactionDictionaryResult.FieldB); - - var transactionTupleResult = (await transactionTupleDocument.GetDocumentSnapshotAsync()).Data; - Assert.Equal("transaction-tuple-a", transactionTupleResult.FieldA); - Assert.Equal("transaction-tuple-b", transactionTupleResult.FieldB); - } - - [Fact] - public async Task gets_document_data_as_dictionary() - { - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "raw-data"); - var observedAt = DateTimeOffset.Now; - - await document.SetDataAsync(new Dictionary { { "seed", "true" } }); - await document.UpdateDataAsync( - ("unknown_string", "value"), - ("unknown_long", 123L), - ("unknown_double", 12.5), - ("unknown_bool", true), - ("unknown_null", null), - ("unknown_numbers", new[] { 1L, 2L }), - ("unknown_empty_array", Array.Empty()), - ("unknown_empty_map", new Dictionary()), - ("unknown_array_with_nulls", new object[] { - null, - "text", - 3L, - new Dictionary { - { "child_null", null }, - { "child_text", "child" } - }, - false - }), - ("unknown_map_array", new[] { - new Dictionary { - { "name", "first" }, - { "score", 1L }, - { "active", true } - }, - new Dictionary { - { "name", "second" }, - { "score", 2L }, - { "active", false } - } - }), - ("nested.answer", 42L), - ("nested.values", new[] { "one", "two" }), - ("nested.deep.answer", 84L), - ("nested.deep.null_value", null), - ("nested.label", "nested value"), - ("nested.null_value", null), - ("nested.empty_values", Array.Empty()), - ("nested.empty_map", new Dictionary()), - ("nested.direct_map", new Dictionary { - { "text", "direct" }, - { "count", 9L }, - { "short_count", 7L }, - { "flags", new[] { true, false } }, - { "inner", new Dictionary { { "value", "inside" } } } - }), - ("observed_at", observedAt), - ("created_at", observedAt.UtcDateTime), - ("generated_at", FieldValue.ServerTimestamp()), - ("original_reference", document)); - - var dictionarySnapshot = await document.GetDocumentSnapshotAsync>(); - AssertRawDictionaryData(dictionarySnapshot.Data, document); - - var interfaceSnapshot = await document.GetDocumentSnapshotAsync>(); - AssertRawDictionaryData(interfaceSnapshot.Data, document); - - var objectDictionarySnapshot = await document.GetDocumentSnapshotAsync>(); - AssertRawObjectDictionaryData(objectDictionarySnapshot.Data, document); - - var objectSnapshot = await document.GetDocumentSnapshotAsync(); - AssertRawDictionaryData( - Assert.IsAssignableFrom>(objectSnapshot.Data), - document); - - var querySnapshot = await GetTestingCollection(sut) - .WhereEqualsTo("unknown_string", "value") - .GetDocumentsAsync>(); - AssertRawDictionaryData(Assert.Single(querySnapshot.Documents).Data, document); - } - - [Fact] - public async Task gets_document_data_as_strongly_typed_dictionaries() - { - var sut = CrossFirebaseFirestore.Current; - - var stringDocument = GetTestingDocument(sut, "typed-string-map"); - await stringDocument.SetDataAsync(new Dictionary { - { "alpha", "one" }, - { "beta", "two" } - }); - var strings = (await stringDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal("one", strings["alpha"]); - Assert.Equal("two", strings["beta"]); - - var boolDocument = GetTestingDocument(sut, "typed-bool-map"); - await boolDocument.SetDataAsync(new Dictionary { - { "enabled", true }, - { "archived", false } - }); - var bools = (await boolDocument.GetDocumentSnapshotAsync>()).Data; - Assert.All(bools.Keys, key => Assert.IsType(key)); - Assert.True(bools["enabled"]); - Assert.False(bools["archived"]); - - var longDocument = GetTestingDocument(sut, "typed-long-map"); - await longDocument.SetDataAsync(new Dictionary { - { "one", 1L }, - { "two", 2 } - }); - var longs = (await longDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal(1L, longs["one"]); - Assert.Equal(2L, longs["two"]); - - var intDocument = GetTestingDocument(sut, "typed-int-map"); - await intDocument.SetDataAsync(new Dictionary { - { "one", 1L }, - { "two", 2 } - }); - var ints = (await intDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal(1, ints["one"]); - Assert.Equal(2, ints["two"]); - - var doubleDocument = GetTestingDocument(sut, "typed-double-map"); - await doubleDocument.SetDataAsync(new Dictionary { - { "half", 0.5 }, - { "whole", 2L } - }); - var doubles = (await doubleDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal(0.5, doubles["half"]); - Assert.Equal(2.0, doubles["whole"]); - - var floatDocument = GetTestingDocument(sut, "typed-float-map"); - await floatDocument.SetDataAsync(new Dictionary { - { "half", 0.5 }, - { "whole", 2L } - }); - var floats = (await floatDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal(0.5f, floats["half"]); - Assert.Equal(2.0f, floats["whole"]); - - var enumDocument = GetTestingDocument(sut, "typed-enum-map"); - await enumDocument.SetDataAsync(new Dictionary { - { "fire", PokeType.Fire }, - { "water", PokeType.Water } - }); - var enums = (await enumDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal(PokeType.Fire, enums["fire"]); - Assert.Equal(PokeType.Water, enums["water"]); - } - - [Fact] - public async Task gets_document_data_as_additional_numeric_dictionaries() - { - var sut = CrossFirebaseFirestore.Current; - - var byteDocument = GetTestingDocument(sut, "typed-byte-map"); - await byteDocument.SetDataAsync(new Dictionary { - { "min", 0L }, - { "max", 255L } - }); - var bytes = (await byteDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal((byte) 0, bytes["min"]); - Assert.Equal(byte.MaxValue, bytes["max"]); - - var sbyteDocument = GetTestingDocument(sut, "typed-sbyte-map"); - await sbyteDocument.SetDataAsync(new Dictionary { - { "min", -128L }, - { "max", 127L } - }); - var sbytes = (await sbyteDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal(sbyte.MinValue, sbytes["min"]); - Assert.Equal(sbyte.MaxValue, sbytes["max"]); - - var shortDocument = GetTestingDocument(sut, "typed-short-map"); - await shortDocument.SetDataAsync(new Dictionary { - { "min", -32768L }, - { "max", 32767L } - }); - var shorts = (await shortDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal(short.MinValue, shorts["min"]); - Assert.Equal(short.MaxValue, shorts["max"]); - - var ushortDocument = GetTestingDocument(sut, "typed-ushort-map"); - await ushortDocument.SetDataAsync(new Dictionary { - { "min", 0L }, - { "max", 65535L } - }); - var ushorts = (await ushortDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal((ushort) 0, ushorts["min"]); - Assert.Equal(ushort.MaxValue, ushorts["max"]); - - var uintDocument = GetTestingDocument(sut, "typed-uint-map"); - await uintDocument.SetDataAsync(new Dictionary { - { "min", 0L }, - { "max", 4294967295L } - }); - var uints = (await uintDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal(0U, uints["min"]); - Assert.Equal(uint.MaxValue, uints["max"]); - - var ulongDocument = GetTestingDocument(sut, "typed-ulong-map"); - await ulongDocument.SetDataAsync(new Dictionary { - { "zero", 0L }, - { "value", 9223372036854775807L } - }); - var ulongs = (await ulongDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal(0UL, ulongs["zero"]); - Assert.Equal(9223372036854775807UL, ulongs["value"]); - - var nullableDocument = GetTestingDocument(sut, "typed-nullable-int-map"); - await nullableDocument.SetDataAsync(new Dictionary { - { "present", 123L }, - { "missing", null } - }); - var nullableInts = (await nullableDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal(123, nullableInts["present"]); - Assert.Null(nullableInts["missing"]); - } - - [Fact] - public async Task gets_document_data_as_typed_nested_dictionaries() - { - var sut = CrossFirebaseFirestore.Current; - - var objectMapDocument = GetTestingDocument(sut, "typed-nested-object-map"); - await objectMapDocument.SetDataAsync(new Dictionary { - { - "outer", - new Dictionary { - { "name", "nested" }, - { "count", 3L }, - { "active", true }, - { "empty", new Dictionary() }, - { "inner", new Dictionary { { "value", "deep" } } } - } - } - }); - var objectMaps = (await objectMapDocument.GetDocumentSnapshotAsync>>()).Data; - var outerObjectMap = objectMaps["outer"]; - Assert.Equal("nested", outerObjectMap["name"]); - Assert.Equal(3L, Convert.ToInt64(outerObjectMap["count"])); - Assert.True((bool) outerObjectMap["active"]); - Assert.Empty(Assert.IsAssignableFrom>(outerObjectMap["empty"])); - Assert.Equal( - "deep", - Assert.IsAssignableFrom>(outerObjectMap["inner"])["value"]); - - var interfaceMapDocument = GetTestingDocument(sut, "typed-nested-interface-map"); - await interfaceMapDocument.SetDataAsync(new Dictionary { - { - "outer", - new Dictionary { - { "label", "interface" }, - { "nullable", null } - } - } - }); - var interfaceMaps = (await interfaceMapDocument.GetDocumentSnapshotAsync>>()).Data; - Assert.Equal("interface", interfaceMaps["outer"]["label"]); - Assert.Null(interfaceMaps["outer"]["nullable"]); - - var longMapDocument = GetTestingDocument(sut, "typed-nested-long-map"); - await longMapDocument.SetDataAsync(new Dictionary { - { - "outer", - new Dictionary { - { "one", 1L }, - { "two", 2 } - } - } - }); - var longMaps = (await longMapDocument.GetDocumentSnapshotAsync>>()).Data; - Assert.Equal(1L, longMaps["outer"]["one"]); - Assert.Equal(2L, longMaps["outer"]["two"]); - } - - [Fact] - public async Task gets_document_data_as_typed_list_dictionaries() - { - var sut = CrossFirebaseFirestore.Current; - - var longListDocument = GetTestingDocument(sut, "typed-long-list-map"); - await longListDocument.SetDataAsync(new Dictionary { - { "first", new[] { 1L, 2L } }, - { "second", new[] { 3, 4 } } - }); - var longLists = (await longListDocument.GetDocumentSnapshotAsync>>()).Data; - Assert.Equal(new[] { 1L, 2L }, longLists["first"]); - Assert.Equal(new[] { 3L, 4L }, longLists["second"]); - - var nullableListDocument = GetTestingDocument(sut, "typed-nullable-list-map"); - await nullableListDocument.SetDataAsync(new Dictionary { - { "values", new object[] { 1L, null, 3L } } - }); - var nullableLists = (await nullableListDocument.GetDocumentSnapshotAsync>>()).Data; - Assert.Equal(new long?[] { 1L, null, 3L }, nullableLists["values"]); - - var objectListDocument = GetTestingDocument(sut, "typed-object-list-map"); - await objectListDocument.SetDataAsync(new Dictionary { - { - "values", - new object[] { - null, - "text", - 5L, - new Dictionary { { "name", "map" } }, - new Dictionary { - { "active", true }, - { "nullable", null } - } - } - }, - { "empty", Array.Empty() } - }); - var objectLists = (await objectListDocument.GetDocumentSnapshotAsync>>()).Data; - Assert.Empty(objectLists["empty"]); - - var values = objectLists["values"]; - Assert.Null(values[0]); - Assert.Equal("text", values[1]); - Assert.Equal(5L, Convert.ToInt64(values[2])); - Assert.Equal("map", Assert.IsAssignableFrom>(values[3])["name"]); - - var nestedMap = Assert.IsAssignableFrom>(values[4]); - Assert.True((bool) nestedMap["active"]); - Assert.Null(nestedMap["nullable"]); - } - - [Fact] - public async Task gets_document_data_as_typed_special_value_dictionaries() - { - var sut = CrossFirebaseFirestore.Current; - var expectedDateTime = new DateTime(2026, 4, 29, 1, 2, 3, 456, DateTimeKind.Utc); - var expectedOffset = new DateTimeOffset(2026, 4, 29, 4, 5, 6, 789, TimeSpan.Zero); - var documentReference = GetTestingDocument(sut, "typed-reference-target"); - - var dateTimeDocument = GetTestingDocument(sut, "typed-datetime-map"); - await dateTimeDocument.SetDataAsync(new Dictionary { - { "created", expectedDateTime } - }); - var dateTimes = (await dateTimeDocument.GetDocumentSnapshotAsync>()).Data; - Assert.InRange( - Math.Abs(dateTimes["created"].Ticks - expectedDateTime.Ticks), - 0, - TimeSpan.FromMilliseconds(1).Ticks); - - var dateTimeOffsetDocument = GetTestingDocument(sut, "typed-datetime-offset-map"); - await dateTimeOffsetDocument.SetDataAsync(new Dictionary { - { "observed", expectedOffset }, - { "generated", FieldValue.ServerTimestamp() } - }); - var dateTimeOffsets = (await dateTimeOffsetDocument.GetDocumentSnapshotAsync>()).Data; - Assert.InRange( - Math.Abs(dateTimeOffsets["observed"].Ticks - expectedOffset.Ticks), - 0, - TimeSpan.FromMilliseconds(1).Ticks); - Assert.NotEqual(default, dateTimeOffsets["generated"]); - - var referenceDocument = GetTestingDocument(sut, "typed-reference-map"); - await referenceDocument.SetDataAsync(new Dictionary { - { "original", documentReference } - }); - var references = (await referenceDocument.GetDocumentSnapshotAsync>()).Data; - Assert.Equal(documentReference.Path, references["original"].Path); - } - - [Fact] - public async Task gets_dictionary_data_from_document_snapshot_listener() - { - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "raw-document-listener"); - var snapshotReceived = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); - - await document.SetDataAsync(new Dictionary { { "seed", "true" } }); - - using var disposable = document.AddSnapshotListener>( - x => { - if( - x.Data?.TryGetValue("listener_value", out var value) == true - && Convert.ToInt64(value) == 5L - ) { - snapshotReceived.TrySetResult(x.Data); - } - }, - e => snapshotReceived.TrySetException(e)); - - await document.UpdateDataAsync( - ("listener_value", 5L), - ("nested.listener", "seen")); - - var data = await snapshotReceived.Task.WaitAsync(TimeSpan.FromSeconds(10)); - Assert.Equal(5L, Convert.ToInt64(data["listener_value"])); - - var nested = Assert.IsAssignableFrom>(data["nested"]); - Assert.Equal("seen", nested["listener"]); - } - - [Fact] - public async Task gets_dictionary_data_from_query_snapshot_listener() - { - var sut = CrossFirebaseFirestore.Current; - var collection = GetTestingCollection(sut); - var document = collection.GetDocument("raw-query-listener"); - var snapshotReceived = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); - - using var disposable = collection - .WhereEqualsTo("listener_marker", "query") - .AddSnapshotListener>( - x => { - var data = x.Documents - .Select(y => y.Data) - .FirstOrDefault(y => - y?.TryGetValue("query_listener_value", out var value) == true - && value is string text - && text == "ready"); - - if(data != null) { - snapshotReceived.TrySetResult(data); - } - }, - e => snapshotReceived.TrySetException(e)); - - await document.SetDataAsync(new Dictionary { - { "listener_marker", "query" }, - { "query_listener_value", "ready" } - }); - - var result = await snapshotReceived.Task.WaitAsync(TimeSpan.FromSeconds(10)); - Assert.Equal("ready", result["query_listener_value"]); - } - - [Fact] - public async Task gets_null_or_empty_dictionary_data_for_missing_and_empty_documents() - { - var sut = CrossFirebaseFirestore.Current; - var missingDocument = GetTestingDocument(sut, "missing-raw-data"); - var emptyDocument = GetTestingDocument(sut, "empty-raw-data"); - - Assert.Null((await missingDocument.GetDocumentSnapshotAsync>()).Data); - Assert.Null((await missingDocument.GetDocumentSnapshotAsync()).Data); - - await emptyDocument.SetDataAsync(new Dictionary { { "temporary", "value" } }); - await emptyDocument.UpdateDataAsync(("temporary", FieldValue.Delete())); - - var dictionarySnapshot = await emptyDocument.GetDocumentSnapshotAsync>(); - Assert.NotNull(dictionarySnapshot.Data); - Assert.Empty(dictionarySnapshot.Data); - - var objectSnapshot = await emptyDocument.GetDocumentSnapshotAsync(); - Assert.Empty(Assert.IsAssignableFrom>(objectSnapshot.Data)); - } - - [Fact] - public async Task writes_nested_dictionary_properties_on_ios() - { - if(!OperatingSystem.IsIOS()) { - return; - } - - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "ios-nested-dictionary"); - var expected = new Dictionary> { - { - "outer", - new Dictionary { - { "inner", 7 } - } - } - }; - - await document.SetDataAsync(new NestedShortDictionaryDocument(expected)); - - var snapshot = await document.GetDocumentSnapshotAsync(); - Assert.Equal((short) 7, snapshot.Data.Values["outer"]["inner"]); - } - - [Fact] - public async Task applies_ios_batch_tuple_set_options() - { - if(OperatingSystem.IsIOS() is false) { - return; - } - - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "ios-batch-tuple-set-options"); - await document.SetDataAsync(new Dictionary { - { "untouched", "keep" }, - { "selected", "old" } - }); - - var batch = sut.CreateBatch(); - batch.SetData( - document, - SetOptions.MergeFields("selected"), - ("selected", "from-batch"), - ("untouched", "should-not-change")); - await batch.CommitAsync(); - - var result = (await document.GetDocumentSnapshotAsync()).Data; - Assert.Equal("keep", result.Untouched); - Assert.Equal("from-batch", result.Selected); - } - - [Fact] - public async Task writes_ios_dictionary_data_through_non_document_wrappers() - { - if(OperatingSystem.IsIOS() is false) { - return; - } - - var sut = CrossFirebaseFirestore.Current; - var collection = GetTestingCollection(sut); - - var addedDocument = await collection.AddDocumentAsync(new Dictionary { - { "writer", "collection-add" }, - { "count", 1L } - }); - var addedResult = (await addedDocument.GetDocumentSnapshotAsync()).Data; - Assert.Equal("collection-add", addedResult.Writer); - Assert.Equal(1L, addedResult.Count); - - var batchSetDocument = collection.GetDocument("ios-batch-set-dictionary"); - var batchUpdateDocument = collection.GetDocument("ios-batch-update-dictionary"); - await batchUpdateDocument.SetDataAsync(new Dictionary { { "writer", "seed" } }); - var batch = sut.CreateBatch(); - batch.SetData(batchSetDocument, new Dictionary { - { "writer", "batch-set" }, - { "count", 2L } - }); - batch.UpdateData(batchUpdateDocument, new Dictionary { - { "writer", "batch-update" }, - { "count", 3L } - }); - await batch.CommitAsync(); - - var batchSetResult = (await batchSetDocument.GetDocumentSnapshotAsync()).Data; - Assert.Equal("batch-set", batchSetResult.Writer); - Assert.Equal(2L, batchSetResult.Count); - - var batchUpdateResult = (await batchUpdateDocument.GetDocumentSnapshotAsync()).Data; - Assert.Equal("batch-update", batchUpdateResult.Writer); - Assert.Equal(3L, batchUpdateResult.Count); - - var batchMergeDocument = collection.GetDocument("ios-batch-merge-dictionary"); - await batchMergeDocument.SetDataAsync(new Dictionary { - { "writer", "seed" }, - { "count", 30L }, - { "untouched", "kept-by-batch-merge" } - }); - var mergeBatch = sut.CreateBatch(); - mergeBatch.SetData( - batchMergeDocument, - new Dictionary { { "writer", "batch-merge" } }, - SetOptions.Merge() - ); - await mergeBatch.CommitAsync(); - - var batchMergeResult = (await batchMergeDocument.GetDocumentSnapshotAsync()).Data; - Assert.Equal("batch-merge", batchMergeResult.Writer); - Assert.Equal(30L, batchMergeResult.Count); - Assert.Equal("kept-by-batch-merge", batchMergeResult.Untouched); - - var transactionSetDocument = collection.GetDocument("ios-transaction-set-dictionary"); - var transactionUpdateDocument = collection.GetDocument("ios-transaction-update-dictionary"); - await transactionUpdateDocument.SetDataAsync(new Dictionary { { "writer", "seed" } }); - await sut.RunTransactionAsync(transaction => { - transaction.GetDocument(transactionUpdateDocument); - transaction.SetData(transactionSetDocument, new Dictionary { - { "writer", "transaction-set" }, - { "count", 4L } - }); - transaction.UpdateData(transactionUpdateDocument, new Dictionary { - { "writer", "transaction-update" }, - { "count", 5L } - }); - return true; - }); - - var transactionSetResult = (await transactionSetDocument.GetDocumentSnapshotAsync()).Data; - Assert.Equal("transaction-set", transactionSetResult.Writer); - Assert.Equal(4L, transactionSetResult.Count); - - var transactionUpdateResult = (await transactionUpdateDocument.GetDocumentSnapshotAsync()).Data; - Assert.Equal("transaction-update", transactionUpdateResult.Writer); - Assert.Equal(5L, transactionUpdateResult.Count); - - var transactionMergeDocument = collection.GetDocument("ios-transaction-merge-dictionary"); - await transactionMergeDocument.SetDataAsync(new Dictionary { - { "writer", "seed" }, - { "count", 50L }, - { "untouched", "kept-by-transaction-merge" } - }); - await sut.RunTransactionAsync(transaction => { - transaction.GetDocument(transactionMergeDocument); - transaction.SetData( - transactionMergeDocument, - new Dictionary { { "writer", "transaction-merge" } }, - SetOptions.Merge() - ); - return true; - }); - - var transactionMergeResult = (await transactionMergeDocument.GetDocumentSnapshotAsync()).Data; - Assert.Equal("transaction-merge", transactionMergeResult.Writer); - Assert.Equal(50L, transactionMergeResult.Count); - Assert.Equal("kept-by-transaction-merge", transactionMergeResult.Untouched); - } - - [Fact] - public async Task updates_ios_transaction_dictionary_data_with_field_value_and_date_time_offset() - { - if(OperatingSystem.IsIOS() is false) { - return; - } - - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "ios-transaction-update-dictionary-field-value"); - var seedDate = new DateTimeOffset(2025, 8, 27, 2, 9, 54, TimeSpan.Zero); - var expectedDate = new DateTimeOffset(2025, 8, 27, 3, 9, 54, TimeSpan.Zero); - await document.SetDataAsync(new Dictionary { - { "array_values", new List { "seed" } }, - { "updated_at", seedDate } - }); - - await sut.RunTransactionAsync(transaction => { - transaction.GetDocument(document); - transaction.UpdateData(document, new Dictionary { - { "array_values", FieldValue.ArrayUnion("added") }, - { "updated_at", expectedDate } - }); - return true; - }); - - var result = (await document.GetDocumentSnapshotAsync()).Data; - Assert.Contains("seed", result.ArrayValues); - Assert.Contains("added", result.ArrayValues); - Assert.Equal(expectedDate.ToUnixTimeMilliseconds(), result.UpdatedAt.ToUnixTimeMilliseconds()); - } - - [Fact] - public async Task exposes_parent_relationships() - { - var sut = CrossFirebaseFirestore.Current; - var parentDocument = GetTestingDocument(sut, "parent"); - var subCollection = parentDocument.GetCollection("sub_items"); - var childDocument = subCollection.GetDocument("child"); - - await parentDocument.SetDataAsync(new SimpleItem("parent")); - await childDocument.SetDataAsync(new SimpleItem("child")); - - Assert.Equal(parentDocument.Path, subCollection.Parent.Path); - Assert.Equal(parentDocument.Path, childDocument.Parent.Parent.Path); - Assert.Equal(childDocument.Path, childDocument.Parent.GetDocument(childDocument.Id).Path); - } - - [Fact] - public async Task queries_collection_group() - { - var sut = CrossFirebaseFirestore.Current; - var marker = Guid.NewGuid().ToString("N"); - var firstDocument = GetTestingDocument(sut, "group-parent-1") - .GetCollection("sub_items") - .GetDocument("first"); - var secondDocument = GetTestingDocument(sut, "group-parent-2") - .GetCollection("sub_items") - .GetDocument("second"); - - await firstDocument.SetDataAsync(new SimpleItem($"{marker}-one")); - await secondDocument.SetDataAsync(new SimpleItem($"{marker}-two")); - - var snapshot = await sut - .GetCollectionGroup("sub_items") - .GetDocumentsAsync(); - - var matchingTitles = snapshot.Documents - .Select(x => x.Data.Title) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Where(x => x.StartsWith(marker, StringComparison.Ordinal)) - .OrderBy(x => x, StringComparer.Ordinal) - .ToArray(); - - Assert.Equal(new[] { $"{marker}-one", $"{marker}-two" }, matchingTitles); - } - - [Fact] - public async Task gets_dictionary_properties_inside_firestore_objects() - { - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "dictionary-container"); - var container = new DictionaryContainer( - metadata: new Dictionary { - { "title", "container" }, - { "count", 5L }, - { "nullable", null }, - { - "details", - new Dictionary { - { "enabled", true }, - { "label", "nested" } - } - } - }, - scores: new Dictionary { - { "first", 10L }, - { "second", 20L } - }, - flags: new Dictionary { - { "active", true }, - { "archived", false } - }, - mixedLists: new Dictionary> { - { "values", new object[] { "first", null, 3L } }, - { "empty", Array.Empty() } - }, - nested: new Dictionary> { - { - "outer", - new Dictionary { - { "name", "outer" }, - { "count", 2L } - } - } - }); - - await document.SetDataAsync(container); - - var result = (await document.GetDocumentSnapshotAsync()).Data; - Assert.Equal("dictionary-container", result.Id); - Assert.Equal("container", result.Metadata["title"]); - Assert.Equal(5L, Convert.ToInt64(result.Metadata["count"])); - Assert.Null(result.Metadata["nullable"]); - Assert.Equal("nested", Assert.IsAssignableFrom>(result.Metadata["details"])["label"]); - Assert.Equal(10L, result.Scores["first"]); - Assert.Equal(20L, result.Scores["second"]); - Assert.True(result.Flags["active"]); - Assert.False(result.Flags["archived"]); - Assert.Equal(new object[] { "first", null, 3L }, result.MixedLists["values"]); - Assert.Empty(result.MixedLists["empty"]); - Assert.Equal("outer", result.Nested["outer"]["name"]); - Assert.Equal(2L, Convert.ToInt64(result.Nested["outer"]["count"])); - } - - [Fact] - public async Task reads_null_entries_inside_firestore_lists() - { - if(!OperatingSystem.IsIOS()) { - return; - } - - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "list-null-values"); - await document.SetDataAsync(new ListNullDocument( - values: new object[] { "first", null, "third" }, - nullableNumbers: new long?[] { 1L, null, 3L })); - - var snapshot = await document.GetDocumentSnapshotAsync(); - - Assert.Equal("first", snapshot.Data.Values[0]); - Assert.Null(snapshot.Data.Values[1]); - Assert.Equal("third", snapshot.Data.Values[2]); - Assert.Equal(new long?[] { 1L, null, 3L }, snapshot.Data.NullableNumbers); - } - - [Fact] - public async Task writes_geopoint_values_inside_firestore_lists() - { - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "geopoint-list-values"); - var expected = new[] { - new GeoPoint(10.5, 20.25), - new GeoPoint(-33.875, 151.2) - }; - - await document.SetDataAsync(new GeoPointListDocument(expected)); - - var result = (await document.GetDocumentSnapshotAsync()).Data; - Assert.Equal(expected[0].Latitude, result.Locations[0].Latitude); - Assert.Equal(expected[0].Longitude, result.Locations[0].Longitude); - Assert.Equal(expected[1].Latitude, result.Locations[1].Latitude); - Assert.Equal(expected[1].Longitude, result.Locations[1].Longitude); - } - public async Task DisposeAsync() { - TestLog.Write($"[FIRESTORE CLEANUP START] {_testingCollectionPath}"); - - try { - await CrossFirebaseFirestore.Current - .DeleteCollectionAsync(_testingCollectionPath, batchSize: 10) - .WaitAsync(TimeSpan.FromSeconds(15)); - TestLog.Write($"[FIRESTORE CLEANUP END] {_testingCollectionPath}"); - } catch(TimeoutException) { - TestLog.Write($"[FIRESTORE CLEANUP TIMEOUT] {_testingCollectionPath}"); - } catch(Exception e) { - TestLog.Write($"[FIRESTORE CLEANUP ERROR] {_testingCollectionPath}: {e}"); - } + await _testingCollection.DisposeAsync(); } private string TestingDocumentPath(string documentId) { - return $"{_testingCollectionPath}/{documentId}"; + return _testingCollection.DocumentPath(documentId); } private IDocumentReference GetTestingDocument(IFirebaseFirestore firestore, string documentId) { - return firestore.GetDocument(TestingDocumentPath(documentId)); + return _testingCollection.GetDocument(firestore, documentId); } private ICollectionReference GetTestingCollection(IFirebaseFirestore firestore) { - return firestore.GetCollection(_testingCollectionPath); - } - - [Preserve(AllMembers = true)] - private sealed class TypedMapValuesDocument : IFirestoreObject - { - public TypedMapValuesDocument() - { - // needed for firestore - } - - public TypedMapValuesDocument( - Dictionary booleanMaps, - Dictionary dateMaps) - { - BooleanMaps = booleanMaps; - DateMaps = dateMaps; - } - - [FirestoreProperty("boolean_maps")] - public Dictionary BooleanMaps { get; private set; } - - [FirestoreProperty("date_maps")] - public Dictionary DateMaps { get; private set; } - } - - [Preserve(AllMembers = true)] - private sealed class ListNullDocument : IFirestoreObject - { - public ListNullDocument() - { - // needed for firestore - } - - public ListNullDocument(IList values, IList nullableNumbers) - { - Values = values; - NullableNumbers = nullableNumbers; - } - - [FirestoreProperty("values")] - public IList Values { get; private set; } - - [FirestoreProperty("nullable_numbers")] - public IList NullableNumbers { get; private set; } - } - - [Preserve(AllMembers = true)] - private sealed class GeoPointListDocument : IFirestoreObject - { - public GeoPointListDocument() - { - // needed for firestore - } - - public GeoPointListDocument(IList locations) - { - Locations = locations; - } - - [FirestoreProperty("locations")] - public IList Locations { get; private set; } - } - - [Preserve(AllMembers = true)] - private sealed class DictionaryObjectValuesDocument : IFirestoreObject - { - public DictionaryObjectValuesDocument() - { - // needed for firestore - } - - public DictionaryObjectValuesDocument(Dictionary values) - { - Values = values; - } - - [FirestoreProperty("values")] - public Dictionary Values { get; private set; } - } - - [Preserve(AllMembers = true)] - private sealed class EnumDictionaryDocument : IFirestoreObject - { - public EnumDictionaryDocument() - { - // needed for firestore - } - - public EnumDictionaryDocument(Dictionary values) - { - Values = values; - } - - [FirestoreProperty("values")] - public Dictionary Values { get; private set; } - } - - [Preserve(AllMembers = true)] - private sealed class AndroidNumericCollectionsDocument : IFirestoreObject - { - public AndroidNumericCollectionsDocument() - { - // needed for firestore - } - - [FirestoreProperty("counts")] - public Dictionary Counts { get; private set; } - - [FirestoreProperty("nullable_counts")] - public IList NullableCounts { get; private set; } - - [FirestoreProperty("types")] - public Dictionary Types { get; private set; } - } - - [Preserve(AllMembers = true)] - private sealed class SetDataPayloadDocument : IFirestoreObject - { - public SetDataPayloadDocument() - { - // needed for firestore - } - - [FirestoreProperty("field_a")] - public string FieldA { get; private set; } - - [FirestoreProperty("field_b")] - public string FieldB { get; private set; } - } - - [Preserve(AllMembers = true)] - private sealed class NestedShortDictionaryDocument : IFirestoreObject - { - public NestedShortDictionaryDocument() - { - // needed for firestore - } - - public NestedShortDictionaryDocument(Dictionary> values) - { - Values = values; - } - - [FirestoreProperty("values")] - public Dictionary> Values { get; private set; } - } - - [Preserve(AllMembers = true)] - private sealed class Issue522TransactionUpdateDocument : IFirestoreObject - { - public Issue522TransactionUpdateDocument() - { - // needed for firestore - } - - [FirestoreProperty("array_values")] - public IList ArrayValues { get; private set; } - - [FirestoreProperty("updated_at")] - public DateTimeOffset UpdatedAt { get; private set; } - } - - [Preserve(AllMembers = true)] - private sealed class BatchMergeFieldsDocument : IFirestoreObject - { - public BatchMergeFieldsDocument() - { - // needed for firestore - } - - [FirestoreProperty("untouched")] - public string Untouched { get; private set; } - - [FirestoreProperty("selected")] - public string Selected { get; private set; } - } - - [Preserve(AllMembers = true)] - private sealed class WriteWrapperDictionaryDocument : IFirestoreObject - { - public WriteWrapperDictionaryDocument() - { - // needed for firestore - } - - [FirestoreProperty("writer")] - public string Writer { get; private set; } - - [FirestoreProperty("count")] - public long Count { get; private set; } - - [FirestoreProperty("untouched")] - public string Untouched { get; private set; } - } - - private static void AssertRawDictionaryData(IDictionary data, IDocumentReference document) - { - Assert.NotNull(data); - Assert.Equal("value", data["unknown_string"]); - Assert.Equal(123L, Convert.ToInt64(data["unknown_long"])); - Assert.Equal(12.5, Convert.ToDouble(data["unknown_double"])); - Assert.True((bool) data["unknown_bool"]); - Assert.Null(data["unknown_null"]); - - var numbers = Assert.IsAssignableFrom>(data["unknown_numbers"]); - Assert.Equal(new[] { 1L, 2L }, numbers.Select(Convert.ToInt64)); - - Assert.Empty(Assert.IsAssignableFrom>(data["unknown_empty_array"])); - Assert.Empty(Assert.IsAssignableFrom>(data["unknown_empty_map"])); - - var arrayWithNulls = Assert.IsAssignableFrom>(data["unknown_array_with_nulls"]); - Assert.Null(arrayWithNulls[0]); - Assert.Equal("text", arrayWithNulls[1]); - Assert.Equal(3L, Convert.ToInt64(arrayWithNulls[2])); - - var childMap = Assert.IsAssignableFrom>(arrayWithNulls[3]); - Assert.Null(childMap["child_null"]); - Assert.Equal("child", childMap["child_text"]); - Assert.False((bool) arrayWithNulls[4]); - - var mapArray = Assert.IsAssignableFrom>(data["unknown_map_array"]); - Assert.Equal(2, mapArray.Count); - var firstMap = Assert.IsAssignableFrom>(mapArray[0]); - Assert.Equal("first", firstMap["name"]); - Assert.Equal(1L, Convert.ToInt64(firstMap["score"])); - Assert.True((bool) firstMap["active"]); - var secondMap = Assert.IsAssignableFrom>(mapArray[1]); - Assert.Equal("second", secondMap["name"]); - Assert.Equal(2L, Convert.ToInt64(secondMap["score"])); - Assert.False((bool) secondMap["active"]); - - var nested = Assert.IsAssignableFrom>(data["nested"]); - Assert.Equal(42L, Convert.ToInt64(nested["answer"])); - Assert.Equal("nested value", nested["label"]); - Assert.Null(nested["null_value"]); - Assert.Empty(Assert.IsAssignableFrom>(nested["empty_values"])); - Assert.Empty(Assert.IsAssignableFrom>(nested["empty_map"])); - - var nestedValues = Assert.IsAssignableFrom>(nested["values"]); - Assert.Equal(new[] { "one", "two" }, nestedValues.Select(x => x as string)); - - var deepNested = Assert.IsAssignableFrom>(nested["deep"]); - Assert.Equal(84L, Convert.ToInt64(deepNested["answer"])); - Assert.Null(deepNested["null_value"]); - - var directMap = Assert.IsAssignableFrom>(nested["direct_map"]); - Assert.Equal("direct", directMap["text"]); - Assert.Equal(9L, Convert.ToInt64(directMap["count"])); - Assert.Equal((short) 7, Convert.ToInt16(directMap["short_count"])); - var flags = Assert.IsAssignableFrom>(directMap["flags"]); - Assert.Equal(new[] { true, false }, flags.Select(x => (bool) x)); - var innerMap = Assert.IsAssignableFrom>(directMap["inner"]); - Assert.Equal("inside", innerMap["value"]); - - Assert.IsType(data["observed_at"]); - Assert.IsType(data["created_at"]); - Assert.IsType(data["generated_at"]); - - var reference = Assert.IsAssignableFrom(data["original_reference"]); - Assert.Equal(document.Path, reference.Path); - } - - private static void AssertRawObjectDictionaryData(IDictionary data, IDocumentReference document) - { - Assert.NotNull(data); - Assert.All(data.Keys, key => Assert.IsType(key)); - AssertRawDictionaryData(data.ToDictionary(x => (string) x.Key, x => x.Value), document); - } - - [Preserve(AllMembers = true)] - private sealed class DictionaryContainer : IFirestoreObject - { - public DictionaryContainer() - { - // needed for firestore - } - - public DictionaryContainer( - Dictionary metadata, - IDictionary scores, - Dictionary flags, - Dictionary> mixedLists, - Dictionary> nested - ) - { - Metadata = metadata; - Scores = scores; - Flags = flags; - MixedLists = mixedLists; - Nested = nested; - } - - [FirestoreDocumentId] - public string Id { get; private set; } - - [FirestoreProperty("metadata")] - public Dictionary Metadata { get; private set; } - - [FirestoreProperty("scores")] - public IDictionary Scores { get; private set; } - - [FirestoreProperty("flags")] - public Dictionary Flags { get; private set; } - - [FirestoreProperty("mixed_lists")] - public Dictionary> MixedLists { get; private set; } - - [FirestoreProperty("nested")] - public Dictionary> Nested { get; private set; } + return _testingCollection.GetCollection(firestore); } private static async Task EnsureBasePokemonsSeededAsync() @@ -2136,4 +59,4 @@ private static async Task EnsureBasePokemonsSeededAsync() } } } -} +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreLifecycleFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreLifecycleFixture.cs new file mode 100644 index 00000000..e1273a8b --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreLifecycleFixture.cs @@ -0,0 +1,129 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +[Collection("Sequential")] +[TestLogging] +[IntegrationTestFixture(IntegrationTestPackage.Firestore)] +[Preserve(AllMembers = true)] +public sealed class FirestoreLifecycleFixture : IAsyncLifetime +{ + private readonly List _documentPaths = []; + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + [Fact] + public async Task reads_explicit_server_and_cache_sources() + { + var document = CreateDocument("sources"); + + await document.SetDataAsync(("title", "source-test")); + await CrossFirebaseFirestore.Current.WaitForPendingWritesAsync(); + + var serverSnapshot = await document.GetDocumentSnapshotAsync>(Source.Server); + var cacheSnapshot = await document.GetDocumentSnapshotAsync>(Source.Cache); + + Assert.Equal("source-test", serverSnapshot.Data!["title"]); + Assert.Equal("source-test", cacheSnapshot.Data!["title"]); + Assert.False(serverSnapshot.Metadata.HasPendingWrites); + Assert.True(cacheSnapshot.Metadata.IsFromCache); + } + + [Fact] + public async Task reports_pending_write_metadata_while_network_is_disabled() + { + var firestore = CrossFirebaseFirestore.Current; + var document = CreateDocument("offline"); + var pendingSnapshot = new CallbackProbe>>(); + + using var listener = document.AddSnapshotListener>( + snapshot => { + if(snapshot.Metadata.HasPendingWrites) { + pendingSnapshot.TrySetResult(snapshot); + } + }, + error => pendingSnapshot.TrySetException(error), + includeMetaDataChanges: true); + + await firestore.DisableNetworkAsync(); + var writeTask = document.SetDataAsync(("title", "offline-write")); + + try { + var snapshot = await pendingSnapshot.WaitAsync( + IntegrationTestTimeouts.Callback, + "pending Firestore write metadata"); + Assert.True(snapshot.Metadata.HasPendingWrites); + + var cacheSnapshot = await document.GetDocumentSnapshotAsync>(Source.Cache); + Assert.True(cacheSnapshot.Metadata.IsFromCache); + } + finally { + await firestore.EnableNetworkAsync(); + await writeTask.WaitForTestAsync( + IntegrationTestTimeouts.LongCallback, + "offline Firestore write flush"); + await firestore.WaitForPendingWritesAsync(); + } + } + + [Fact] + public async Task terminates_clears_persistence_and_restarts() + { + var firestore = CrossFirebaseFirestore.Current; + + await firestore.TerminateAsync(); + try { + await firestore.ClearPersistenceAsync(); + } + finally { + firestore.Restart(); + ConfigureFirestoreEmulatorIfNeeded(firestore); + } + + var document = CreateDocument("restart"); + await document.SetDataAsync(("title", "after-restart")); + + var snapshot = await document.GetDocumentSnapshotAsync>(Source.Server); + Assert.Equal("after-restart", snapshot.Data!["title"]); + } + + [Fact] + public async Task fails_when_updating_missing_document() + { + var document = CreateDocument("missing-update"); + + await Assert.ThrowsAnyAsync( + () => document.UpdateDataAsync(("title", "missing"))); + } + + public async Task DisposeAsync() + { + foreach(var path in _documentPaths) { + try { + await CrossFirebaseFirestore.Current.GetDocument(path).DeleteDocumentAsync(); + } catch { + // Cleanup is best-effort because some tests intentionally terminate/restart Firestore. + } + } + } + + private IDocumentReference CreateDocument(string prefix) + { + var path = $"acceptance_lifecycle/{IntegrationTestData.UniqueId(prefix)}"; + _documentPaths.Add(path); + return CrossFirebaseFirestore.Current.GetDocument(path); + } + + private static void ConfigureFirestoreEmulatorIfNeeded(IFirebaseFirestore firestore) + { + if(!IntegrationTestEnvironment.ShouldUseFirestoreEmulator) { + return; + } + + var endpoint = IntegrationTestEnvironment.FirestoreEmulatorEndpoint; + firestore.UseEmulator(endpoint.Host, endpoint.Port); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreListPayloads.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreListPayloads.cs new file mode 100644 index 00000000..a7bc63e1 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreListPayloads.cs @@ -0,0 +1,44 @@ +using JetBrains.Annotations; +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +[Preserve(AllMembers = true)] +internal sealed class ListNullDocument : IFirestoreObject +{ + [UsedImplicitly] + public ListNullDocument() + { + // needed for firestore + } + + public ListNullDocument(IList values, IList nullableNumbers) + { + Values = values; + NullableNumbers = nullableNumbers; + } + + [FirestoreProperty("values")] + public IList Values { get; private set; } = null!; + + [FirestoreProperty("nullable_numbers")] + public IList NullableNumbers { get; private set; } = null!; +} + +[Preserve(AllMembers = true)] +internal sealed class GeoPointListDocument : IFirestoreObject +{ + [UsedImplicitly] + public GeoPointListDocument() + { + // needed for firestore + } + + public GeoPointListDocument(IList locations) + { + Locations = locations; + } + + [FirestoreProperty("locations")] + public IList Locations { get; private set; } = null!; +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.Queries.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.Queries.cs new file mode 100644 index 00000000..fba1a1d3 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.Queries.cs @@ -0,0 +1,33 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed partial class FirestoreNullabilityFixture +{ + [Fact] + public async Task queries_documents_by_null_field_values() + { + var sut = CrossFirebaseFirestore.Current; + var collection = GetTestingCollection(sut); + + await GetTestingDocument(sut, "null-a").SetDataAsync(NullableFirestoreItemFactory.CreateNullableItem(null)); + await GetTestingDocument(sut, "null-b").SetDataAsync(NullableFirestoreItemFactory.CreateNullableItem(null)); + await GetTestingDocument(sut, "value").SetDataAsync(NullableFirestoreItemFactory.CreateNullableItem("value")); + + var stringFieldSnapshot = await collection + .WhereEqualsTo(NullableFirestoreItem.QueryMarkerField, null) + .GetDocumentsAsync(); + var fieldPathSnapshot = await collection + .WhereEqualsTo(FieldPath.Of([NullableFirestoreItem.QueryMarkerField]), null) + .GetDocumentsAsync(); + + Assert.Equal( + ["null-a", "null-b"], + stringFieldSnapshot.Documents.Select(x => FirestoreAssertions.Require(x.Data).Id).OrderBy(x => x) + ); + Assert.Equal( + ["null-a", "null-b"], + fieldPathSnapshot.Documents.Select(x => FirestoreAssertions.Require(x.Data).Id).OrderBy(x => x) + ); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.ReadWrite.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.ReadWrite.cs new file mode 100644 index 00000000..f9594fbd --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.ReadWrite.cs @@ -0,0 +1,29 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed partial class FirestoreNullabilityFixture +{ + [Fact] + public async Task writes_and_reads_null_values_from_supported_payload_shapes() + { + var sut = CrossFirebaseFirestore.Current; + var collection = GetTestingCollection(sut); + + var objectDocument = GetTestingDocument(sut, "object"); + await objectDocument.SetDataAsync(NullableFirestoreItemFactory.CreateNullableItem("object")); + + var dictionaryDocument = GetTestingDocument(sut, "dictionary"); + await dictionaryDocument.SetDataAsync(NullableFirestoreItemFactory.CreateNullableDictionary("dictionary")); + + var tupleDocument = GetTestingDocument(sut, "tuple"); + await tupleDocument.SetDataAsync(NullableFirestoreItemFactory.CreateNullableTuples("tuple")); + + var addedDocument = await collection.AddDocumentAsync(NullableFirestoreItemFactory.CreateNullableItem("added")); + + await FirestoreAssertions.NullableDocumentAsync(objectDocument, "object"); + await FirestoreAssertions.NullableDocumentAsync(dictionaryDocument, "dictionary"); + await FirestoreAssertions.NullableDocumentAsync(tupleDocument, "tuple"); + await FirestoreAssertions.NullableDocumentAsync(addedDocument, "added"); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.Rejections.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.Rejections.cs new file mode 100644 index 00000000..6a271012 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.Rejections.cs @@ -0,0 +1,49 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed partial class FirestoreNullabilityFixture +{ + [Fact] + public async Task rejects_required_api_arguments_when_null() + { + var sut = CrossFirebaseFirestore.Current; + var collection = GetTestingCollection(sut); + var document = GetTestingDocument(sut, "required-null-rejection"); + await document.SetDataAsync(NullableFirestoreItemFactory.CreateNonNullItem("required-null-rejection")); + + AssertRejects(() => sut.GetDocument(RequiredNull())); + AssertRejects(() => sut.GetCollection(RequiredNull())); + AssertRejects(() => collection.GetDocument(RequiredNull())); + AssertRejects(() => document.GetCollection(RequiredNull())); + AssertRejects(() => collection.WhereEqualsTo(RequiredNull(), "value")); + AssertRejects(() => collection.OrderBy(RequiredNull())); + AssertRejects(() => collection.WhereFieldIn(NullableFirestoreItem.QueryMarkerField, RequiredNull())); + AssertRejects(() => collection.StartingAt(RequiredNull())); + AssertRejects( + () => document.AddSnapshotListener( + RequiredNull>>() + ) + ); + AssertRejects( + () => collection.AddSnapshotListener( + RequiredNull>>() + ) + ); + + await AssertRejectsAsync(() => collection.AddDocumentAsync(RequiredNull())); + await AssertRejectsAsync(() => document.SetDataAsync(RequiredNull())); + await AssertRejectsAsync(() => document.SetDataAsync(RequiredNull>())); + await AssertRejectsAsync(() => document.UpdateDataAsync(RequiredNull>())); + + var batch = sut.CreateBatch(); + AssertRejects(() => batch.SetData(RequiredNull(), NullableFirestoreItemFactory.CreateNonNullItem("batch"))); + AssertRejects(() => batch.SetData(document, RequiredNull>())); + AssertRejects(() => batch.UpdateData(document, RequiredNull>())); + + await AssertRejectsAsync(() => sut.RunTransactionAsync(transaction => { + transaction.SetData(RequiredNull(), NullableFirestoreItemFactory.CreateNonNullItem("transaction")); + return "unreachable"; + })); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.Updates.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.Updates.cs new file mode 100644 index 00000000..31a500e4 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.Updates.cs @@ -0,0 +1,97 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed partial class FirestoreNullabilityFixture +{ + [Fact] + public async Task updates_null_values_from_document_batch_and_transaction_writes() + { + var sut = CrossFirebaseFirestore.Current; + + var documentUpdate = GetTestingDocument(sut, "document-update"); + await documentUpdate.SetDataAsync(NullableFirestoreItemFactory.CreateNonNullItem("document-update-seed")); + await documentUpdate.UpdateDataAsync(NullableFirestoreItemFactory.CreateNullUpdateTuples("document-update")); + + var batchSet = GetTestingDocument(sut, "batch-set"); + var batchUpdate = GetTestingDocument(sut, "batch-update"); + await batchUpdate.SetDataAsync(NullableFirestoreItemFactory.CreateNonNullItem("batch-update-seed")); + + var batch = sut.CreateBatch(); + batch.SetData(batchSet, NullableFirestoreItemFactory.CreateNullableDictionary("batch-set")); + batch.UpdateData( + batchUpdate, + NullableFirestoreItemFactory.CreateNullUpdate("batch-update") + ); + await batch.CommitAsync(); + + var transactionDocument = GetTestingDocument(sut, "transaction"); + await transactionDocument.SetDataAsync(NullableFirestoreItemFactory.CreateNonNullItem("transaction-seed")); + var transactionResult = await sut.RunTransactionAsync(transaction => { + transaction.UpdateData( + transactionDocument, + NullableFirestoreItemFactory.CreateNullUpdateTuples(null) + ); + return null; + }); + + Assert.Null(transactionResult); + await FirestoreAssertions.NullableDocumentAsync(documentUpdate, "document-update"); + await FirestoreAssertions.NullableDocumentAsync(batchSet, "batch-set"); + await FirestoreAssertions.NullableDocumentAsync(batchUpdate, "batch-update"); + await FirestoreAssertions.NullableDocumentAsync(transactionDocument, null); + } + + [Fact] + public async Task updates_nested_object_dictionary_maps_from_document_batch_and_transaction_writes() + { + var sut = CrossFirebaseFirestore.Current; + + var documentUpdate = GetTestingDocument(sut, "issue-482-document-update"); + await documentUpdate.SetDataAsync(NullableFirestoreItemFactory.CreateNonNullItem("issue-482-document-update-seed")); + await documentUpdate.UpdateDataAsync(NullableFirestoreItemFactory.CreateIssue482NestedMapUpdate("document-update")); + + var batchUpdate = GetTestingDocument(sut, "issue-482-batch-update"); + await batchUpdate.SetDataAsync(NullableFirestoreItemFactory.CreateNonNullItem("issue-482-batch-update-seed")); + + var batch = sut.CreateBatch(); + batch.UpdateData(batchUpdate, NullableFirestoreItemFactory.CreateIssue482NestedMapUpdate("batch-update")); + await batch.CommitAsync(); + + var transactionUpdate = GetTestingDocument(sut, "issue-482-transaction-update"); + await transactionUpdate.SetDataAsync(NullableFirestoreItemFactory.CreateNonNullItem("issue-482-transaction-update-seed")); + await sut.RunTransactionAsync(transaction => { + transaction.UpdateData(transactionUpdate, NullableFirestoreItemFactory.CreateIssue482NestedMapUpdate("transaction-update")); + return true; + }); + + await FirestoreAssertions.Issue482NestedMapAsync(documentUpdate, "document-update"); + await FirestoreAssertions.Issue482NestedMapAsync(batchUpdate, "batch-update"); + await FirestoreAssertions.Issue482NestedMapAsync(transactionUpdate, "transaction-update"); + } + + [Fact] + public async Task applies_null_array_transforms() + { + var sut = CrossFirebaseFirestore.Current; + var document = GetTestingDocument(sut, "array-transforms"); + await document.SetDataAsync(new Dictionary { + { NullableFirestoreItem.NullableListField, new List { "existing" } }, + { NullableFirestoreItem.QueryMarkerField, "array-transforms" } + }); + + await document.UpdateDataAsync((NullableFirestoreItem.NullableListField, FieldValue.ArrayUnion(null, "added"))); + + var afterUnion = FirestoreAssertions.Require( + (await document.GetDocumentSnapshotAsync()).Data?.NullableList + ); + Assert.Equal(["existing", null, "added"], afterUnion); + + await document.UpdateDataAsync((NullableFirestoreItem.NullableListField, FieldValue.ArrayRemove([null]))); + + var afterRemove = FirestoreAssertions.Require( + (await document.GetDocumentSnapshotAsync()).Data?.NullableList + ); + Assert.Equal(["existing", "added"], afterRemove); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.cs index f7bf6a5d..3fbc9af1 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreNullabilityFixture.cs @@ -1,380 +1,56 @@ -#nullable enable - using Plugin.Firebase.Firestore; -namespace Plugin.Firebase.IntegrationTests.Firestore -{ - [Collection("Sequential")] - [TestLogging] - [Preserve(AllMembers = true)] - public sealed class FirestoreNullabilityFixture : IAsyncLifetime - { - private readonly string _testingCollectionPath = $"nullability_testing_{Guid.NewGuid():N}"; - - public Task InitializeAsync() - { - return Task.CompletedTask; - } - - [Fact] - public async Task writes_and_reads_null_values_from_supported_payload_shapes() - { - var sut = CrossFirebaseFirestore.Current; - var collection = GetTestingCollection(sut); - - var objectDocument = GetTestingDocument(sut, "object"); - await objectDocument.SetDataAsync(CreateNullableItem("object")); - - var dictionaryDocument = GetTestingDocument(sut, "dictionary"); - await dictionaryDocument.SetDataAsync(CreateNullableDictionary("dictionary")); - - var tupleDocument = GetTestingDocument(sut, "tuple"); - await tupleDocument.SetDataAsync( - ("nullable_string", null), - ("nullable_number", null), - ("nullable_map", CreateNestedMap()), - ("nullable_list", CreateNullableList()), - ("query_marker", "tuple") - ); - - var addedDocument = await collection.AddDocumentAsync(CreateNullableItem("added")); - - await AssertNullableDocumentAsync(objectDocument, "object"); - await AssertNullableDocumentAsync(dictionaryDocument, "dictionary"); - await AssertNullableDocumentAsync(tupleDocument, "tuple"); - await AssertNullableDocumentAsync(addedDocument, "added"); - } - - [Fact] - public async Task updates_null_values_from_document_batch_and_transaction_writes() - { - var sut = CrossFirebaseFirestore.Current; - - var documentUpdate = GetTestingDocument(sut, "document-update"); - await documentUpdate.SetDataAsync(CreateNonNullItem("document-update-seed")); - await documentUpdate.UpdateDataAsync( - ("nullable_string", null), - ("nullable_number", null), - ("nullable_map.inner_null", null), - ("nullable_map.inner_value", "nested-value"), - ("nullable_list", CreateNullableList()), - ("query_marker", "document-update") - ); - - var batchSet = GetTestingDocument(sut, "batch-set"); - var batchUpdate = GetTestingDocument(sut, "batch-update"); - await batchUpdate.SetDataAsync(CreateNonNullItem("batch-update-seed")); - - var batch = sut.CreateBatch(); - batch.SetData(batchSet, CreateNullableDictionary("batch-set")); - batch.UpdateData( - batchUpdate, - new Dictionary { - { "nullable_string", null }, - { "nullable_number", null }, - { "nullable_map.inner_null", null }, - { "nullable_map.inner_value", "nested-value" }, - { "nullable_list", CreateNullableList() }, - { "query_marker", "batch-update" } - } - ); - await batch.CommitAsync(); - - var transactionDocument = GetTestingDocument(sut, "transaction"); - await transactionDocument.SetDataAsync(CreateNonNullItem("transaction-seed")); - var transactionResult = await sut.RunTransactionAsync(transaction => { - transaction.UpdateData( - transactionDocument, - ("nullable_string", null), - ("nullable_number", null), - ("nullable_map.inner_null", null), - ("nullable_map.inner_value", "nested-value"), - ("nullable_list", CreateNullableList()), - ("query_marker", null) - ); - return null; - }); - - Assert.Null(transactionResult); - await AssertNullableDocumentAsync(documentUpdate, "document-update"); - await AssertNullableDocumentAsync(batchSet, "batch-set"); - await AssertNullableDocumentAsync(batchUpdate, "batch-update"); - await AssertNullableDocumentAsync(transactionDocument, null); - } - - [Fact] - public async Task updates_nested_object_dictionary_maps_from_document_batch_and_transaction_writes() - { - var sut = CrossFirebaseFirestore.Current; - - var documentUpdate = GetTestingDocument(sut, "issue-482-document-update"); - await documentUpdate.SetDataAsync(CreateNonNullItem("issue-482-document-update-seed")); - await documentUpdate.UpdateDataAsync(CreateIssue482NestedMapUpdate("document-update")); - - var batchUpdate = GetTestingDocument(sut, "issue-482-batch-update"); - await batchUpdate.SetDataAsync(CreateNonNullItem("issue-482-batch-update-seed")); - - var batch = sut.CreateBatch(); - batch.UpdateData(batchUpdate, CreateIssue482NestedMapUpdate("batch-update")); - await batch.CommitAsync(); - - var transactionUpdate = GetTestingDocument(sut, "issue-482-transaction-update"); - await transactionUpdate.SetDataAsync(CreateNonNullItem("issue-482-transaction-update-seed")); - await sut.RunTransactionAsync(transaction => { - transaction.UpdateData(transactionUpdate, CreateIssue482NestedMapUpdate("transaction-update")); - return true; - }); - - await AssertIssue482NestedMapAsync(documentUpdate, "document-update"); - await AssertIssue482NestedMapAsync(batchUpdate, "batch-update"); - await AssertIssue482NestedMapAsync(transactionUpdate, "transaction-update"); - } - - [Fact] - public async Task queries_documents_by_null_field_values() - { - var sut = CrossFirebaseFirestore.Current; - var collection = GetTestingCollection(sut); - - await GetTestingDocument(sut, "null-a").SetDataAsync(CreateNullableItem(null)); - await GetTestingDocument(sut, "null-b").SetDataAsync(CreateNullableItem(null)); - await GetTestingDocument(sut, "value").SetDataAsync(CreateNullableItem("value")); - - var stringFieldSnapshot = await collection - .WhereEqualsTo("query_marker", null) - .GetDocumentsAsync(); - var fieldPathSnapshot = await collection - .WhereEqualsTo(FieldPath.Of(["query_marker"]), null) - .GetDocumentsAsync(); - - Assert.Equal( - ["null-a", "null-b"], - stringFieldSnapshot.Documents.Select(x => Require(x.Data).Id).OrderBy(x => x) - ); - Assert.Equal( - ["null-a", "null-b"], - fieldPathSnapshot.Documents.Select(x => Require(x.Data).Id).OrderBy(x => x) - ); - } - - [Fact] - public async Task applies_null_array_transforms() - { - var sut = CrossFirebaseFirestore.Current; - var document = GetTestingDocument(sut, "array-transforms"); - await document.SetDataAsync(new Dictionary { - { "nullable_list", new List { "existing" } }, - { "query_marker", "array-transforms" } - }); - - await document.UpdateDataAsync(("nullable_list", FieldValue.ArrayUnion(null, "added"))); - - var afterUnion = Require( - (await document.GetDocumentSnapshotAsync()).Data?.NullableList - ); - Assert.Equal(["existing", null, "added"], afterUnion); - - await document.UpdateDataAsync(("nullable_list", FieldValue.ArrayRemove(new object?[] { null }))); - - var afterRemove = Require( - (await document.GetDocumentSnapshotAsync()).Data?.NullableList - ); - Assert.Equal(["existing", "added"], afterRemove); - } - - [Fact] - public async Task rejects_required_api_arguments_when_null() - { - var sut = CrossFirebaseFirestore.Current; - var collection = GetTestingCollection(sut); - var document = GetTestingDocument(sut, "required-null-rejection"); - await document.SetDataAsync(CreateNonNullItem("required-null-rejection")); +namespace Plugin.Firebase.IntegrationTests.Firestore; - AssertRejects(() => sut.GetDocument(RequiredNull())); - AssertRejects(() => sut.GetCollection(RequiredNull())); - AssertRejects(() => collection.GetDocument(RequiredNull())); - AssertRejects(() => document.GetCollection(RequiredNull())); - AssertRejects(() => collection.WhereEqualsTo(RequiredNull(), "value")); - AssertRejects(() => collection.OrderBy(RequiredNull())); - AssertRejects(() => collection.WhereFieldIn("query_marker", RequiredNull())); - AssertRejects(() => collection.StartingAt(RequiredNull())); - AssertRejects( - () => document.AddSnapshotListener( - RequiredNull>>() - ) - ); - AssertRejects( - () => collection.AddSnapshotListener( - RequiredNull>>() - ) - ); - - await AssertRejectsAsync(() => collection.AddDocumentAsync(RequiredNull())); - await AssertRejectsAsync(() => document.SetDataAsync(RequiredNull())); - await AssertRejectsAsync(() => document.SetDataAsync(RequiredNull>())); - await AssertRejectsAsync(() => document.UpdateDataAsync(RequiredNull>())); - - var batch = sut.CreateBatch(); - AssertRejects(() => batch.SetData(RequiredNull(), CreateNonNullItem("batch"))); - AssertRejects(() => batch.SetData(document, RequiredNull>())); - AssertRejects(() => batch.UpdateData(document, RequiredNull>())); - - await AssertRejectsAsync(() => sut.RunTransactionAsync(transaction => { - transaction.SetData(RequiredNull(), CreateNonNullItem("transaction")); - return "unreachable"; - })); - } - - public async Task DisposeAsync() - { - TestLog.Write($"[FIRESTORE NULLABILITY CLEANUP START] {_testingCollectionPath}"); - - try { - await CrossFirebaseFirestore.Current - .DeleteCollectionAsync(_testingCollectionPath, batchSize: 10) - .WaitAsync(TimeSpan.FromSeconds(15)); - TestLog.Write($"[FIRESTORE NULLABILITY CLEANUP END] {_testingCollectionPath}"); - } catch(TimeoutException) { - TestLog.Write($"[FIRESTORE NULLABILITY CLEANUP TIMEOUT] {_testingCollectionPath}"); - } catch(Exception e) { - TestLog.Write($"[FIRESTORE NULLABILITY CLEANUP ERROR] {_testingCollectionPath}: {e}"); - } - } - - private string TestingDocumentPath(string documentId) - { - return $"{_testingCollectionPath}/{documentId}"; - } - - private IDocumentReference GetTestingDocument(IFirebaseFirestore firestore, string documentId) - { - return firestore.GetDocument(TestingDocumentPath(documentId)); - } - - private ICollectionReference GetTestingCollection(IFirebaseFirestore firestore) - { - return firestore.GetCollection(_testingCollectionPath); - } - - private static NullableFirestoreItem CreateNullableItem(string? queryMarker) - { - return new NullableFirestoreItem( - nullableString: null, - nullableNumber: null, - nullableMap: new Dictionary { - { "inner_null", null }, - { "inner_value", "nested-value" } - }, - nullableList: CreateNullableList(), - queryMarker: queryMarker - ); - } - - private static NullableFirestoreItem CreateNonNullItem(string queryMarker) - { - return new NullableFirestoreItem( - nullableString: "seed", - nullableNumber: 42, - nullableMap: new Dictionary { - { "inner_null", "seed" }, - { "inner_value", "seed" } - }, - nullableList: new List { "seed" }, - queryMarker: queryMarker - ); - } - - private static Dictionary CreateNullableDictionary(string? queryMarker) - { - return new Dictionary { - { "nullable_string", null }, - { "nullable_number", null }, - { "nullable_map", CreateNestedMap() }, - { "nullable_list", CreateNullableList() }, - { "query_marker", queryMarker } - }; - } - - private static Dictionary CreateIssue482NestedMapUpdate(string marker) - { - return new Dictionary { - { - "nullable_map", - new Dictionary { - { "sub_field", $"{marker}-value" } - } - }, - { "query_marker", marker } - }; - } - - private static Dictionary CreateNestedMap() - { - return new Dictionary { - { "inner_null", null }, - { "inner_value", "nested-value" } - }; - } - - private static List CreateNullableList() - { - return new List { "first", null, "last" }; - } - - private static async Task AssertNullableDocumentAsync(IDocumentReference document, string? expectedMarker) - { - var snapshot = await document.GetDocumentSnapshotAsync(Source.Server); - var item = Require(snapshot.Data); - Assert.Equal(expectedMarker, item.QueryMarker); - Assert.Null(item.NullableString); - Assert.Null(item.NullableNumber); - - var map = Require(item.NullableMap); - Assert.True(map.ContainsKey("inner_null")); - Assert.Null(map["inner_null"]); - Assert.Equal("nested-value", map["inner_value"]); +[Collection("Sequential")] +[TestLogging] +[IntegrationTestFixture(IntegrationTestPackage.Firestore)] +[Preserve(AllMembers = true)] +public sealed partial class FirestoreNullabilityFixture : IAsyncLifetime +{ + private readonly FirestoreTestCollectionScope _testingCollection = + FirestoreTestCollectionScope.Create("nullability_testing"); - var list = Require(item.NullableList); - Assert.Equal(["first", null, "last"], list); - } + public Task InitializeAsync() + { + return Task.CompletedTask; + } - private static async Task AssertIssue482NestedMapAsync(IDocumentReference document, string expectedMarker) - { - var snapshot = await document.GetDocumentSnapshotAsync(Source.Server); - var item = Require(snapshot.Data); - Assert.Equal(expectedMarker, item.QueryMarker); + public async Task DisposeAsync() + { + await _testingCollection.DisposeAsync(); + } - var map = Require(item.NullableMap); - Assert.True(map.ContainsKey("sub_field")); - Assert.Equal($"{expectedMarker}-value", map["sub_field"]); - } + private string TestingDocumentPath(string documentId) + { + return _testingCollection.DocumentPath(documentId); + } - private static T Require(T? value) where T : class - { - if(value is null) { - throw new InvalidOperationException("Expected a non-null value."); - } + private IDocumentReference GetTestingDocument(IFirebaseFirestore firestore, string documentId) + { + return _testingCollection.GetDocument(firestore, documentId); + } - return value; - } + private ICollectionReference GetTestingCollection(IFirebaseFirestore firestore) + { + return _testingCollection.GetCollection(firestore); + } - private static void AssertRejects(Action action) - { - var exception = Record.Exception(action); - Assert.NotNull(exception); - } + private static void AssertRejects(Action action) + { + var exception = Record.Exception(action); + Assert.NotNull(exception); + } - private static async Task AssertRejectsAsync(Func action) - { - var exception = await Record.ExceptionAsync(action); - Assert.NotNull(exception); - } + private static async Task AssertRejectsAsync(Func action) + { + var exception = await Record.ExceptionAsync(action); + Assert.NotNull(exception); + } #nullable disable - private static T RequiredNull() where T : class - { - return null; - } -#nullable enable + private static T RequiredNull() where T : class + { + return null; } -} +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestorePayloadFactories.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestorePayloadFactories.cs new file mode 100644 index 00000000..a3fc530b --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestorePayloadFactories.cs @@ -0,0 +1,190 @@ +namespace Plugin.Firebase.IntegrationTests.Firestore; + +internal static class NullableFirestoreItemFactory +{ + public static NullableFirestoreItem CreateNullableItem(string? queryMarker) + { + return new NullableFirestoreItem( + nullableString: null, + nullableNumber: null, + nullableMap: CreateNestedMap(), + nullableList: CreateNullableList(), + queryMarker: queryMarker + ); + } + + public static NullableFirestoreItem CreateNonNullItem(string queryMarker) + { + return new NullableFirestoreItem( + nullableString: "seed", + nullableNumber: 42, + nullableMap: new Dictionary { + { "inner_null", "seed" }, + { "inner_value", "seed" } + }, + nullableList: ["seed"], + queryMarker: queryMarker + ); + } + + public static Dictionary CreateNullableDictionary(string? queryMarker) + { + return new Dictionary { + { NullableFirestoreItem.NullableStringField, null }, + { NullableFirestoreItem.NullableNumberField, null }, + { NullableFirestoreItem.NullableMapField, CreateNestedMap() }, + { NullableFirestoreItem.NullableListField, CreateNullableList() }, + { NullableFirestoreItem.QueryMarkerField, queryMarker } + }; + } + + public static (object Key, object? Value)[] CreateNullableTuples(string? queryMarker) + { + return [ + (NullableFirestoreItem.NullableStringField, null), + (NullableFirestoreItem.NullableNumberField, null), + (NullableFirestoreItem.NullableMapField, CreateNestedMap()), + (NullableFirestoreItem.NullableListField, CreateNullableList()), + (NullableFirestoreItem.QueryMarkerField, queryMarker) + ]; + } + + public static Dictionary CreateNullUpdate(string? queryMarker) + { + return new Dictionary { + { NullableFirestoreItem.NullableStringField, null }, + { NullableFirestoreItem.NullableNumberField, null }, + { $"{NullableFirestoreItem.NullableMapField}.inner_null", null }, + { $"{NullableFirestoreItem.NullableMapField}.inner_value", "nested-value" }, + { NullableFirestoreItem.NullableListField, CreateNullableList() }, + { NullableFirestoreItem.QueryMarkerField, queryMarker } + }; + } + + public static (string Key, object? Value)[] CreateNullUpdateTuples(string? queryMarker) + { + return [ + (NullableFirestoreItem.NullableStringField, null), + (NullableFirestoreItem.NullableNumberField, null), + ($"{NullableFirestoreItem.NullableMapField}.inner_null", null), + ($"{NullableFirestoreItem.NullableMapField}.inner_value", "nested-value"), + (NullableFirestoreItem.NullableListField, CreateNullableList()), + (NullableFirestoreItem.QueryMarkerField, queryMarker) + ]; + } + + public static Dictionary CreateIssue482NestedMapUpdate(string marker) + { + return new Dictionary { + { + NullableFirestoreItem.NullableMapField, + new Dictionary { + { "sub_field", $"{marker}-value" } + } + }, + { NullableFirestoreItem.QueryMarkerField, marker } + }; + } + + public static Dictionary CreateNestedMap() + { + return new Dictionary { + { "inner_null", null }, + { "inner_value", "nested-value" } + }; + } + + public static List CreateNullableList() + { + return ["first", null, "last"]; + } +} + +internal static class DictionaryContainerFactory +{ + public static DictionaryContainer CreateDefault() + { + return new DictionaryContainer( + metadata: new Dictionary { + { "title", "container" }, + { "count", 5L }, + { "nullable", null }, + { + "details", + new Dictionary { + { "enabled", true }, + { "label", "nested" } + } + } + }, + scores: new Dictionary { + { "first", 10L }, + { "second", 20L } + }, + flags: new Dictionary { + { "active", true }, + { "archived", false } + }, + mixedLists: new Dictionary> { + { "values", ["first", null, 3L] }, + { "empty", Array.Empty() } + }, + nested: new Dictionary> { + { + "outer", + new Dictionary { + { "name", "outer" }, + { "count", 2L } + } + } + }); + } +} + +internal sealed record CrewCheckInScenario( + CrewCheckIn CrewCheckIn, + DateTime Timestamp, + DateTime LogTimestamp); + +internal static class CrewCheckInFactory +{ + public static CrewCheckInScenario CreateIssue422Scenario() + { + var timestamp = new DateTime(2025, 2, 27, 14, 48, 2, 625, DateTimeKind.Utc); + var logTimestamp = new DateTime(2025, 2, 27, 14, 49, 3, 123, DateTimeKind.Utc); + var assignedEquipment = new List { + new("bucket truck attachment", "Bucket Attachment", "Alice", "equipment") + }; + var assignedVehicles = new List { + new("crew truck", "Truck 12", "Bob", "vehicle") + }; + var yardAssets = new List { + new("crew truck", "Truck 12", "Bob", "vehicle"), + new("compressor", "Air Compressor", "Charlie", "equipment") + }; + var crewCheckIn = new CrewCheckIn( + employees: [ + new( + "Ada Lovelace", + "Foreman", + 7, + ["en", "de"], + assignedEquipment, + assignedVehicles, + "07:30", + "checked-in", + "yard", + ["1001", "1002"], + "ready") + ], + yardAssets: yardAssets, + clockInTime: "07:30", + yardLocation: "north yard", + emergencyCheckIn: true, + removedAssets: [new("Spare Saw", "damaged chainsaw", "maintenance")], + logEntries: [new(logTimestamp, "created", "check-in created")], + timestamp: timestamp); + + return new CrewCheckInScenario(crewCheckIn, timestamp, logTimestamp); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreTestCollectionScope.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreTestCollectionScope.cs new file mode 100644 index 00000000..39a81a73 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreTestCollectionScope.cs @@ -0,0 +1,62 @@ +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +internal sealed class FirestoreTestCollectionScope : IAsyncDisposable +{ + private readonly TimeSpan _cleanupTimeout; + private bool _isDisposed; + + private FirestoreTestCollectionScope(string path, TimeSpan cleanupTimeout) + { + Path = path; + _cleanupTimeout = cleanupTimeout; + } + + public string Path { get; } + + public static FirestoreTestCollectionScope Create( + string prefix, + TimeSpan? cleanupTimeout = null) + { + return new FirestoreTestCollectionScope( + IntegrationTestData.UniqueId(prefix).Replace('-', '_'), + cleanupTimeout ?? IntegrationTestTimeouts.Cleanup); + } + + public string DocumentPath(string documentId) + { + return $"{Path}/{documentId}"; + } + + public IDocumentReference GetDocument(IFirebaseFirestore firestore, string documentId) + { + return firestore.GetDocument(DocumentPath(documentId)); + } + + public ICollectionReference GetCollection(IFirebaseFirestore firestore) + { + return firestore.GetCollection(Path); + } + + public async ValueTask DisposeAsync() + { + if(_isDisposed) { + return; + } + + _isDisposed = true; + TestLog.Write($"[FIRESTORE CLEANUP START] {Path}"); + + try { + await CrossFirebaseFirestore.Current + .DeleteCollectionAsync>(Path, batchSize: 10) + .WaitForTestAsync(_cleanupTimeout, "Firestore collection cleanup"); + TestLog.Write($"[FIRESTORE CLEANUP END] {Path}"); + } catch(TimeoutException) { + TestLog.Write($"[FIRESTORE CLEANUP TIMEOUT] {Path}"); + } catch(Exception e) { + TestLog.Write($"[FIRESTORE CLEANUP ERROR] {Path}: {e}"); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreWritePayloads.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreWritePayloads.cs new file mode 100644 index 00000000..9227d77e --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/FirestoreWritePayloads.cs @@ -0,0 +1,47 @@ +using JetBrains.Annotations; +using Plugin.Firebase.Firestore; + +namespace Plugin.Firebase.IntegrationTests.Firestore; + +[Preserve(AllMembers = true)] +internal sealed class SetDataPayloadDocument : IFirestoreObject +{ + [FirestoreProperty("field_a")] + public string FieldA { get; [UsedImplicitly] private set; } = null!; + + [FirestoreProperty("field_b")] + public string FieldB { get; [UsedImplicitly] private set; } = null!; +} + +[Preserve(AllMembers = true)] +internal sealed class Issue522TransactionUpdateDocument : IFirestoreObject +{ + [FirestoreProperty("array_values")] + public IList ArrayValues { get; [UsedImplicitly] private set; } = null!; + + [FirestoreProperty("updated_at")] + public DateTimeOffset UpdatedAt { get; [UsedImplicitly] private set; } +} + +[Preserve(AllMembers = true)] +internal sealed class BatchMergeFieldsDocument : IFirestoreObject +{ + [FirestoreProperty("untouched")] + public string Untouched { get; [UsedImplicitly] private set; } = null!; + + [FirestoreProperty("selected")] + public string Selected { get; [UsedImplicitly] private set; } = null!; +} + +[Preserve(AllMembers = true)] +internal sealed class WriteWrapperDictionaryDocument : IFirestoreObject +{ + [FirestoreProperty("writer")] + public string Writer { get; [UsedImplicitly] private set; } = null!; + + [FirestoreProperty("count")] + public long Count { get; [UsedImplicitly] private set; } + + [FirestoreProperty("untouched")] + public string Untouched { get; [UsedImplicitly] private set; } = null!; +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/NullableFirestoreItem.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/NullableFirestoreItem.cs index dfbdbe5d..544eff12 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Firestore/NullableFirestoreItem.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/NullableFirestoreItem.cs @@ -1,48 +1,52 @@ -#nullable enable - +using JetBrains.Annotations; using Plugin.Firebase.Firestore; -namespace Plugin.Firebase.IntegrationTests.Firestore +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed class NullableFirestoreItem : IFirestoreObject { - public sealed class NullableFirestoreItem : IFirestoreObject + internal const string NullableStringField = "nullable_string"; + internal const string NullableNumberField = "nullable_number"; + internal const string NullableMapField = "nullable_map"; + internal const string NullableListField = "nullable_list"; + internal const string QueryMarkerField = "query_marker"; + + [Preserve] + public NullableFirestoreItem() + { + // needed for firestore + } + + public NullableFirestoreItem( + string? nullableString, + long? nullableNumber, + Dictionary? nullableMap, + List? nullableList, + string? queryMarker + ) { - [Preserve] - public NullableFirestoreItem() - { - // needed for firestore - } - - public NullableFirestoreItem( - string? nullableString, - long? nullableNumber, - Dictionary? nullableMap, - List? nullableList, - string? queryMarker - ) - { - NullableString = nullableString; - NullableNumber = nullableNumber; - NullableMap = nullableMap; - NullableList = nullableList; - QueryMarker = queryMarker; - } - - [FirestoreDocumentId] - public string? Id { get; private set; } - - [FirestoreProperty("nullable_string")] - public string? NullableString { get; set; } - - [FirestoreProperty("nullable_number")] - public long? NullableNumber { get; set; } - - [FirestoreProperty("nullable_map")] - public Dictionary? NullableMap { get; set; } - - [FirestoreProperty("nullable_list")] - public List? NullableList { get; set; } - - [FirestoreProperty("query_marker")] - public string? QueryMarker { get; set; } + NullableString = nullableString; + NullableNumber = nullableNumber; + NullableMap = nullableMap; + NullableList = nullableList; + QueryMarker = queryMarker; } + + [FirestoreDocumentId] + public string? Id { get; [UsedImplicitly] private set; } + + [FirestoreProperty(NullableStringField)] + public string? NullableString { get; set; } + + [FirestoreProperty(NullableNumberField)] + public long? NullableNumber { get; set; } + + [FirestoreProperty(NullableMapField)] + public Dictionary? NullableMap { get; set; } + + [FirestoreProperty(NullableListField)] + public List? NullableList { get; set; } + + [FirestoreProperty(QueryMarkerField)] + public string? QueryMarker { get; set; } } \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/Pokemon.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/Pokemon.cs index 7406367c..88b4f538 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Firestore/Pokemon.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/Pokemon.cs @@ -1,133 +1,167 @@ +using JetBrains.Annotations; using Plugin.Firebase.Core.Extensions; using Plugin.Firebase.Firestore; -namespace Plugin.Firebase.IntegrationTests.Firestore +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed class Pokemon : IFirestoreObject { - public sealed class Pokemon : IFirestoreObject + internal const string NameField = "name"; + internal const string SightingCountField = "sighting_count"; + internal const string PokeTypeField = "poke_type"; + internal const string ItemsField = "items"; + internal const string MovesField = "moves"; + internal const string FirstSightingLocationField = "first_sighting_location"; + internal const string OtherPropertiesColorsPath = "other_properties.colors"; + + [Preserve] + public Pokemon() { - [Preserve] - public Pokemon() - { - // needed for firestore - } + // needed for firestore + } - public Pokemon( - string id = null, - string name = null, - double weightInKg = 0, - float heightInCm = 0, - long sightingCount = 0, - bool isFromFirstGeneration = false, - PokeType pokeType = default, - IList moves = null, - IList someNumbers = null, - SightingLocation firstSightingLocation = null, - IList items = null, - IDictionary otherProperties = null, - IDocumentReference originalReference = null) - { - Id = id; - Name = name; - WeightInKg = weightInKg; - HeightInCm = heightInCm; - SightingCount = sightingCount; - IsFromFirstGeneration = isFromFirstGeneration; - PokeType = pokeType; - Moves = moves; - SomeNumbers = someNumbers; - FirstSightingLocation = firstSightingLocation; - Items = items; - CreationDate = DateTime.Now; - OriginalReference = originalReference; - OtherProperties = otherProperties; - } + public Pokemon( + string id, + string name, + double weightInKg = 0, + float heightInCm = 0, + long sightingCount = 0, + bool isFromFirstGeneration = false, + PokeType pokeType = default, + IList? moves = null, + IList? someNumbers = null, + SightingLocation? firstSightingLocation = null, + IList? items = null, + IDictionary? otherProperties = null, + IDocumentReference? originalReference = null) + { + Id = id; + Name = name; + WeightInKg = weightInKg; + HeightInCm = heightInCm; + SightingCount = sightingCount; + IsFromFirstGeneration = isFromFirstGeneration; + PokeType = pokeType; + Moves = moves; + SomeNumbers = someNumbers; + FirstSightingLocation = firstSightingLocation; + CreationDate = DateTime.Now; + Items = items; + OriginalReference = originalReference; + OtherProperties = otherProperties; + } - /// - /// Get a clone from the current pokemon - /// - /// Reference to the original document that shall be cloned - /// A copy of the current pokemon - public Pokemon Clone(IDocumentReference originalReference) - { - return new Pokemon( - id: $"{this.Id}_copied", - name: this.Name, - weightInKg: this.WeightInKg, - heightInCm: this.HeightInCm, - isFromFirstGeneration: this.IsFromFirstGeneration, - pokeType: this.PokeType, - moves: this.Moves.ToList(), - someNumbers: this.SomeNumbers.ToList(), - firstSightingLocation: this.FirstSightingLocation, - items: this.Items.ToList(), - originalReference: originalReference); - } + /// + /// Get a clone from the current pokemon + /// + /// Reference to the original document that shall be cloned + /// A copy of the current pokemon + public Pokemon Clone(IDocumentReference originalReference) + { + return new Pokemon( + id: $"{Id}_copied", + name: Name, + weightInKg: WeightInKg, + heightInCm: HeightInCm, + isFromFirstGeneration: IsFromFirstGeneration, + pokeType: PokeType, + moves: Moves?.ToList(), + someNumbers: SomeNumbers?.ToList(), + firstSightingLocation: FirstSightingLocation, + items: Items?.ToList(), + originalReference: originalReference); + } - public override bool Equals(object obj) - { - if(obj is Pokemon other) { - return (Id, Name, WeightInKg, HeightInCm, SightingCount, IsFromFirstGeneration, PokeType, FirstSightingLocation) - .Equals((other.Id, other.Name, other.WeightInKg, other.HeightInCm, other.SightingCount, other.IsFromFirstGeneration, other.PokeType, other.FirstSightingLocation)) && - Moves.SequenceEqualSafe(other.Moves) && - SomeNumbers.SequenceEqualSafe(other.SomeNumbers) && - Items.SequenceEqualSafe(other.Items); - } - return false; + public override bool Equals(object? obj) + { + if(obj is Pokemon other) { + return (Id, Name, WeightInKg, HeightInCm, SightingCount, IsFromFirstGeneration, PokeType, FirstSightingLocation) + .Equals((other.Id, other.Name, other.WeightInKg, other.HeightInCm, other.SightingCount, other.IsFromFirstGeneration, other.PokeType, other.FirstSightingLocation)) && + Moves.SequenceEqualSafe(other.Moves) && + SomeNumbers.SequenceEqualSafe(other.SomeNumbers) && + Items.SequenceEqualSafe(other.Items); } + return false; + } - public override int GetHashCode() - { - return (Id, Name, WeightInKg, HeightInCm, SightingCount, IsFromFirstGeneration, PokeType, Moves, SomeNumbers, FirstSightingLocation, Items, CreationDate).GetHashCode(); - } + public override int GetHashCode() + { + var hash = new HashCode(); + // ReSharper disable NonReadonlyMemberInGetHashCode + hash.Add(Id); + hash.Add(Name); + hash.Add(WeightInKg); + hash.Add(HeightInCm); + hash.Add(SightingCount); + hash.Add(IsFromFirstGeneration); + hash.Add(PokeType); + hash.Add(FirstSightingLocation); + AddSequenceToHash(ref hash, Moves); + AddSequenceToHash(ref hash, SomeNumbers); + AddSequenceToHash(ref hash, Items); + return hash.ToHashCode(); + // ReSharper restore NonReadonlyMemberInGetHashCode + } - public override string ToString() - { - return $"[{nameof(Pokemon)}: {nameof(Id)}={Id}, {nameof(Name)}={Name}]"; - } + public override string ToString() + { + return $"[{nameof(Pokemon)}: {nameof(Id)}={Id}, {nameof(Name)}={Name}]"; + } - [FirestoreDocumentId] - public string Id { get; private set; } + [FirestoreDocumentId] + public string Id { get; [UsedImplicitly] private set; } = null!; - [FirestoreProperty("name")] - public string Name { get; private set; } + [FirestoreProperty(NameField)] + public string Name { get; [UsedImplicitly] private set; } = null!; - [FirestoreProperty("weight_in_kg")] - public double WeightInKg { get; private set; } + [FirestoreProperty("weight_in_kg")] + public double WeightInKg { get; [UsedImplicitly] private set; } - [FirestoreProperty("height_in_cm")] - public float HeightInCm { get; private set; } + [FirestoreProperty("height_in_cm")] + public float HeightInCm { get; [UsedImplicitly] private set; } - [FirestoreProperty("sighting_count")] - public long SightingCount { get; private set; } + [FirestoreProperty(SightingCountField)] + public long SightingCount { get; [UsedImplicitly] private set; } - [FirestoreProperty("is_from_first_generation")] - public bool IsFromFirstGeneration { get; private set; } + [FirestoreProperty("is_from_first_generation")] + public bool IsFromFirstGeneration { get; [UsedImplicitly] private set; } - [FirestoreProperty("poke_type")] - public PokeType PokeType { get; private set; } + [FirestoreProperty(PokeTypeField)] + public PokeType PokeType { get; [UsedImplicitly] private set; } - [FirestoreProperty("moves")] - public IList Moves { get; private set; } + [FirestoreProperty(MovesField)] + public IList? Moves { get; [UsedImplicitly] private set; } - [FirestoreProperty("some_numbers")] - public IList SomeNumbers { get; private set; } + [FirestoreProperty("some_numbers")] + public IList? SomeNumbers { get; [UsedImplicitly] private set; } - [FirestoreProperty("first_sighting_location")] - public SightingLocation FirstSightingLocation { get; private set; } + [FirestoreProperty(FirstSightingLocationField)] + public SightingLocation? FirstSightingLocation { get; [UsedImplicitly] private set; } - [FirestoreProperty("items")] - public IList Items { get; private set; } + [FirestoreProperty(ItemsField)] + public IList? Items { get; [UsedImplicitly] private set; } - [FirestoreProperty("creation_date")] - public DateTime CreationDate { get; private set; } + [FirestoreProperty("creation_date")] + public DateTime CreationDate { get; private set; } - [FirestoreServerTimestamp("server_timestamp")] - public DateTimeOffset ServerTimestamp { get; private set; } + [FirestoreServerTimestamp("server_timestamp")] + public DateTimeOffset ServerTimestamp { get; [UsedImplicitly] private set; } - [FirestoreProperty("original_reference")] - public IDocumentReference OriginalReference { get; private set; } + [FirestoreProperty("original_reference")] + public IDocumentReference? OriginalReference { [UsedImplicitly] get; private set; } - [FirestoreProperty("other_properties")] - public IDictionary OtherProperties { get; set; } + [FirestoreProperty("other_properties")] + public IDictionary? OtherProperties { get; [UsedImplicitly] set; } + + private static void AddSequenceToHash(ref HashCode hash, IEnumerable? values) + { + if(values == null) { + hash.Add(0); + return; + } + + foreach(var value in values) { + hash.Add(value); + } } -} +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/PokemonFactory.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/PokemonFactory.cs index 0995c23f..5f88a766 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Firestore/PokemonFactory.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/PokemonFactory.cs @@ -1,167 +1,166 @@ using Plugin.Firebase.Firestore; -namespace Plugin.Firebase.IntegrationTests.Firestore +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public static class PokemonFactory { - public static class PokemonFactory + public static Pokemon CreateBulbasur() { - public static Pokemon CreateBulbasur() - { - return new Pokemon( - id: "1", - name: "Bulbasaur", - weightInKg: 6.9, - heightInCm: 70, - isFromFirstGeneration: true, - pokeType: PokeType.Plant, - moves: new List { "Razor-Wind", "Swords-Dance", "Cut" }, - someNumbers: new List { 1, 2, 3 }, - firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), - items: CreateSimpleItems("Wonder Berry", "Air Balloon")); - } + return new Pokemon( + id: "1", + name: "Bulbasaur", + weightInKg: 6.9, + heightInCm: 70, + isFromFirstGeneration: true, + pokeType: PokeType.Plant, + moves: new List { "Razor-Wind", "Swords-Dance", "Cut" }, + someNumbers: new List { 1, 2, 3 }, + firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), + items: CreateSimpleItems("Wonder Berry", "Air Balloon")); + } - private static IList CreateSimpleItems(params string[] titles) - { - return titles.Select(title => new SimpleItem(title)).ToList(); - } + private static IList CreateSimpleItems(params string[] titles) + { + return titles.Select(title => new SimpleItem(title)).ToList(); + } - public static Pokemon CreateIvysaur() - { - return new Pokemon( - id: "2", - name: "Ivysaur", - weightInKg: 13.0, - heightInCm: 100, - isFromFirstGeneration: true, - pokeType: PokeType.Plant, - moves: new List { "Razor-Wind", "Swords-Dance", "Cut" }, - firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), - items: CreateSimpleItems("Wonder Berry", "Air Balloon")); - } + private static Pokemon CreateIvysaur() + { + return new Pokemon( + id: "2", + name: "Ivysaur", + weightInKg: 13.0, + heightInCm: 100, + isFromFirstGeneration: true, + pokeType: PokeType.Plant, + moves: new List { "Razor-Wind", "Swords-Dance", "Cut" }, + firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), + items: CreateSimpleItems("Wonder Berry", "Air Balloon")); + } - public static Pokemon CreateVenusaur() - { - return new Pokemon( - id: "3", - name: "Venusaur", - weightInKg: 100.0, - heightInCm: 200, - isFromFirstGeneration: true, - pokeType: PokeType.Plant, - moves: new List { "Razor-Wind", "Swords-Dance", "Cut" }, - firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), - items: CreateSimpleItems("Apicot Berry", "Antidote")); - } + private static Pokemon CreateVenusaur() + { + return new Pokemon( + id: "3", + name: "Venusaur", + weightInKg: 100.0, + heightInCm: 200, + isFromFirstGeneration: true, + pokeType: PokeType.Plant, + moves: new List { "Razor-Wind", "Swords-Dance", "Cut" }, + firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), + items: CreateSimpleItems("Apicot Berry", "Antidote")); + } - public static Pokemon CreateCharmander() - { - return new Pokemon( - id: "4", - name: "Charmander", - weightInKg: 8.5, - heightInCm: 60, - isFromFirstGeneration: true, - pokeType: PokeType.Fire, - moves: new List { "Scratch", "Body-Slam", "Fire-Punch" }, - firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), - items: CreateSimpleItems("Antidote", "Air Balloon")); - } + public static Pokemon CreateCharmander() + { + return new Pokemon( + id: "4", + name: "Charmander", + weightInKg: 8.5, + heightInCm: 60, + isFromFirstGeneration: true, + pokeType: PokeType.Fire, + moves: new List { "Scratch", "Body-Slam", "Fire-Punch" }, + firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), + items: CreateSimpleItems("Antidote", "Air Balloon")); + } - public static Pokemon CreateCharmeleon() - { - return new Pokemon( - id: "5", - name: "Charmeleon", - weightInKg: 19.0, - heightInCm: 110, - isFromFirstGeneration: true, - pokeType: PokeType.Fire, - moves: new List { "Scratch", "Body-Slam", "Fire-Punch" }, - firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), - otherProperties: new Dictionary { { "legs", 4 },{ "colors", 3} }, - items: CreateSimpleItems("Antidote")); - } + public static Pokemon CreateCharmeleon() + { + return new Pokemon( + id: "5", + name: "Charmeleon", + weightInKg: 19.0, + heightInCm: 110, + isFromFirstGeneration: true, + pokeType: PokeType.Fire, + moves: new List { "Scratch", "Body-Slam", "Fire-Punch" }, + firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), + otherProperties: new Dictionary { { "legs", 4 }, { "colors", 3 } }, + items: CreateSimpleItems("Antidote")); + } - public static Pokemon CreateCharizard() - { - return new Pokemon( - id: "6", - name: "Charizard", - weightInKg: 90.5, - heightInCm: 170, - isFromFirstGeneration: true, - pokeType: PokeType.Fire, - moves: new List { "Scratch", "Body-Slam", "Fire-Punch" }, - firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), - items: CreateSimpleItems("Wonder Berry")); - } + public static Pokemon CreateCharizard() + { + return new Pokemon( + id: "6", + name: "Charizard", + weightInKg: 90.5, + heightInCm: 170, + isFromFirstGeneration: true, + pokeType: PokeType.Fire, + moves: new List { "Scratch", "Body-Slam", "Fire-Punch" }, + firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), + items: CreateSimpleItems("Wonder Berry")); + } - public static Pokemon CreateSquirtle() - { - return new Pokemon( - id: "7", - name: "Squirtle", - weightInKg: 9, - heightInCm: 50, - isFromFirstGeneration: true, - pokeType: PokeType.Water, - moves: new List { "Tackel", "Body-Slam", "Water-Gun" }, - firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), - items: CreateSimpleItems("Wonder Berry", "Antidote")); - } + public static Pokemon CreateSquirtle() + { + return new Pokemon( + id: "7", + name: "Squirtle", + weightInKg: 9, + heightInCm: 50, + isFromFirstGeneration: true, + pokeType: PokeType.Water, + moves: new List { "Tackel", "Body-Slam", "Water-Gun" }, + firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), + items: CreateSimpleItems("Wonder Berry", "Antidote")); + } - public static Pokemon CreateWartortle() - { - return new Pokemon( - id: "8", - name: "Wartortle", - weightInKg: 22.5, - heightInCm: 100, - isFromFirstGeneration: true, - pokeType: PokeType.Water, - moves: new List { "Tackel", "Body-Slam", "Water-Gun" }, - firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), - items: CreateSimpleItems("Air Balloon", "Antidote")); - } + public static Pokemon CreateWartortle() + { + return new Pokemon( + id: "8", + name: "Wartortle", + weightInKg: 22.5, + heightInCm: 100, + isFromFirstGeneration: true, + pokeType: PokeType.Water, + moves: new List { "Tackel", "Body-Slam", "Water-Gun" }, + firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), + items: CreateSimpleItems("Air Balloon", "Antidote")); + } - public static Pokemon CreateBlastoise() - { - return new Pokemon( - id: "9", - name: "Blastoise", - weightInKg: 85.5, - heightInCm: 160.5f, - isFromFirstGeneration: true, - pokeType: PokeType.Water, - moves: new List { "Tackel", "Body-Slam", "Water-Gun" }, - firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), - items: CreateSimpleItems("Wonder Berry", "Apicot Berry")); - } + private static Pokemon CreateBlastoise() + { + return new Pokemon( + id: "9", + name: "Blastoise", + weightInKg: 85.5, + heightInCm: 160.5f, + isFromFirstGeneration: true, + pokeType: PokeType.Water, + moves: new List { "Tackel", "Body-Slam", "Water-Gun" }, + firstSightingLocation: new SightingLocation(52.5042112, 13.5290173), + items: CreateSimpleItems("Wonder Berry", "Apicot Berry")); + } - /// - /// Use this method to create mock data on your firestore project. - /// - public static async Task CreateBasePokemonsAtFirestoreAsync() - { - var firestore = CrossFirebaseFirestore.Current; - var pokemons = CreateBasePokemons(); - foreach(var pokemon in pokemons) { - var path = $"pokemons/{pokemon.Id}"; - var document = firestore.GetDocument(path); - await document.SetDataAsync(pokemon); - } + /// + /// Use this method to create mock data on your firestore project. + /// + public static async Task CreateBasePokemonsAtFirestoreAsync() + { + var firestore = CrossFirebaseFirestore.Current; + var pokemons = CreateBasePokemons(); + foreach(var pokemon in pokemons) { + var path = $"pokemons/{pokemon.Id}"; + var document = firestore.GetDocument(path); + await document.SetDataAsync(pokemon); } + } - private static IEnumerable CreateBasePokemons() - { - yield return CreateBulbasur(); - yield return CreateIvysaur(); - yield return CreateVenusaur(); - yield return CreateCharmander(); - yield return CreateCharmeleon(); - yield return CreateCharizard(); - yield return CreateSquirtle(); - yield return CreateWartortle(); - yield return CreateBlastoise(); - } + private static IEnumerable CreateBasePokemons() + { + yield return CreateBulbasur(); + yield return CreateIvysaur(); + yield return CreateVenusaur(); + yield return CreateCharmander(); + yield return CreateCharmeleon(); + yield return CreateCharizard(); + yield return CreateSquirtle(); + yield return CreateWartortle(); + yield return CreateBlastoise(); } -} +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/PokemonType.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/PokemonType.cs index bbb5e11a..7f62ddd1 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Firestore/PokemonType.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/PokemonType.cs @@ -1,11 +1,10 @@ -namespace Plugin.Firebase.IntegrationTests.Firestore +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public enum PokeType : long { - public enum PokeType : long - { - Undefined, - Fire, - Water, - Plant, - Electric - } + Undefined, + Fire, + Water, + Plant, + Electric } \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/SightingLocation.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/SightingLocation.cs index f2bc6787..86ed3941 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Firestore/SightingLocation.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/SightingLocation.cs @@ -1,45 +1,48 @@ +using JetBrains.Annotations; using Plugin.Firebase.Firestore; -namespace Plugin.Firebase.IntegrationTests.Firestore +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed class SightingLocation : IFirestoreObject { - public sealed class SightingLocation : IFirestoreObject + [Preserve] + [UsedImplicitly] + public SightingLocation() { - [Preserve] - public SightingLocation() - { - // needed for firestore - } + // needed for firestore + } - public SightingLocation( - double latitude = 0, - double longitude = 0) - { - Latitude = latitude; - Longitude = longitude; - } + public SightingLocation( + double latitude = 0, + double longitude = 0) + { + Latitude = latitude; + Longitude = longitude; + } - public override bool Equals(object obj) - { - if(obj is SightingLocation other) { - return (Latitude, Longitude).Equals((other.Latitude, other.Longitude)); - } - return false; + public override bool Equals(object? obj) + { + if(obj is SightingLocation other) { + return (Latitude, Longitude).Equals((other.Latitude, other.Longitude)); } + return false; + } - public override int GetHashCode() - { - return (Latitude, Longitude).GetHashCode(); - } + public override int GetHashCode() + { + // ReSharper disable NonReadonlyMemberInGetHashCode + return (Latitude, Longitude).GetHashCode(); + // ReSharper restore NonReadonlyMemberInGetHashCode + } - public override string ToString() - { - return $"[{nameof(SightingLocation)}: {nameof(Latitude)}={Latitude}, {nameof(Longitude)}={Longitude}]"; - } + public override string ToString() + { + return $"[{nameof(SightingLocation)}: {nameof(Latitude)}={Latitude}, {nameof(Longitude)}={Longitude}]"; + } - [FirestoreProperty("latitude")] - public double Latitude { get; private set; } + [FirestoreProperty("latitude")] + public double Latitude { get; [UsedImplicitly] private set; } - [FirestoreProperty("longitude")] - public double Longitude { get; private set; } - } + [FirestoreProperty("longitude")] + public double Longitude { get; [UsedImplicitly] private set; } } \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Firestore/SimpleItem.cs b/tests/Plugin.Firebase.IntegrationTests/Firestore/SimpleItem.cs index ba9d7263..45fad296 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Firestore/SimpleItem.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Firestore/SimpleItem.cs @@ -1,37 +1,36 @@ +using JetBrains.Annotations; using Plugin.Firebase.Firestore; -namespace Plugin.Firebase.IntegrationTests.Firestore +namespace Plugin.Firebase.IntegrationTests.Firestore; + +public sealed class SimpleItem : IFirestoreObject { - public sealed class SimpleItem : IFirestoreObject + [Preserve] + public SimpleItem() { - [Preserve] - public SimpleItem() - { - // needed for firestore - } + // needed for firestore + } - public SimpleItem(string title) - { - Title = title; - } + public SimpleItem(string title) + { + Title = title; + } - public override bool Equals(object obj) - { - if(obj is SimpleItem other) { - return (Id, Title).Equals((Id, Title)); - } - return false; - } + public override bool Equals(object? obj) + { + return obj is SimpleItem other && (Id, Title).Equals((other.Id, other.Title)); + } - public override int GetHashCode() - { - return (Id, Title).GetHashCode(); - } + public override int GetHashCode() + { + // ReSharper disable NonReadonlyMemberInGetHashCode + return (Id, Title).GetHashCode(); + // ReSharper restore NonReadonlyMemberInGetHashCode + } - [FirestoreDocumentId] - public string Id { get; private set; } + [FirestoreDocumentId] + public string Id { get; [UsedImplicitly] private set; } = null!; - [FirestoreProperty("title")] - public string Title { get; private set; } - } + [FirestoreProperty("title")] + public string Title { get; [UsedImplicitly] private set; } = null!; } \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Functions/AuthContextResponseData.cs b/tests/Plugin.Firebase.IntegrationTests/Functions/AuthContextResponseData.cs new file mode 100644 index 00000000..ae909e18 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Functions/AuthContextResponseData.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace Plugin.Firebase.IntegrationTests.Functions; + +[Preserve(AllMembers = true)] +public sealed class AuthContextResponseData +{ + [JsonPropertyName("has_auth")] + public bool HasAuth { get; [UsedImplicitly] set; } + + [JsonPropertyName("uid")] + public string? Uid { get; [UsedImplicitly] set; } + + [JsonPropertyName("token_email")] + public string? TokenEmail { get; [UsedImplicitly] set; } + + [JsonPropertyName("input_value")] + public long? InputValue { get; [UsedImplicitly] set; } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Functions/CallableObjectResponseData.cs b/tests/Plugin.Firebase.IntegrationTests/Functions/CallableObjectResponseData.cs index 8f906d9d..598a4c8a 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Functions/CallableObjectResponseData.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Functions/CallableObjectResponseData.cs @@ -1,64 +1,54 @@ using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace Plugin.Firebase.IntegrationTests.Functions -{ - [Preserve(AllMembers = true)] - public sealed class CallableObjectResponseData - { - public CallableObjectResponseData() - { - } - - [JsonPropertyName("input_value")] - public long InputValue { get; set; } - - [JsonPropertyName("output_value")] - public long OutputValue { get; set; } +namespace Plugin.Firebase.IntegrationTests.Functions; - [JsonPropertyName("message")] - public string Message { get; set; } +[Preserve(AllMembers = true)] +public sealed class CallableObjectResponseData +{ + [JsonPropertyName("input_value")] + public long InputValue { get; [UsedImplicitly] set; } - [JsonPropertyName("is_valid")] - public bool IsValid { get; set; } + [JsonPropertyName("output_value")] + public long OutputValue { get; [UsedImplicitly] set; } - [JsonPropertyName("nested")] - public CallableNestedResponseData Nested { get; set; } + [JsonPropertyName("message")] + public string Message { get; [UsedImplicitly] set; } = string.Empty; - [JsonPropertyName("items")] - public List Items { get; set; } + [JsonPropertyName("is_valid")] + public bool IsValid { get; [UsedImplicitly] set; } - [JsonPropertyName("tags")] - public List Tags { get; set; } + [JsonPropertyName("nested")] + public CallableNestedResponseData Nested { get; [UsedImplicitly] set; } = new(); - [JsonPropertyName("scores")] - public List Scores { get; set; } - } + [JsonPropertyName("items")] + // ReSharper disable CollectionNeverUpdated.Global + public List Items { get; [UsedImplicitly] set; } = []; - [Preserve(AllMembers = true)] - public sealed class CallableNestedResponseData - { - public CallableNestedResponseData() - { - } + [JsonPropertyName("tags")] + public List Tags { get; [UsedImplicitly] set; } = []; - [JsonPropertyName("name")] - public string Name { get; set; } + [JsonPropertyName("scores")] + public List Scores { get; [UsedImplicitly] set; } = []; + // ReSharper restore CollectionNeverUpdated.Global +} - [JsonPropertyName("count")] - public long Count { get; set; } - } +[Preserve(AllMembers = true)] +public sealed class CallableNestedResponseData +{ + [JsonPropertyName("name")] + public string Name { get; [UsedImplicitly] set; } = string.Empty; - [Preserve(AllMembers = true)] - public sealed class CallableArrayItemData - { - public CallableArrayItemData() - { - } + [JsonPropertyName("count")] + public long Count { get; [UsedImplicitly] set; } +} - [JsonPropertyName("title")] - public string Title { get; set; } +[Preserve(AllMembers = true)] +public sealed class CallableArrayItemData +{ + [JsonPropertyName("title")] + public string Title { get; [UsedImplicitly] set; } = string.Empty; - [JsonPropertyName("value")] - public long Value { get; set; } - } + [JsonPropertyName("value")] + public long Value { get; [UsedImplicitly] set; } } \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Functions/CustomTokenResponseData.cs b/tests/Plugin.Firebase.IntegrationTests/Functions/CustomTokenResponseData.cs new file mode 100644 index 00000000..8cd971c2 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Functions/CustomTokenResponseData.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace Plugin.Firebase.IntegrationTests.Functions; + +[Preserve(AllMembers = true)] +public sealed class CustomTokenResponseData +{ + [JsonPropertyName("uid")] + public string Uid { get; [UsedImplicitly] set; } = string.Empty; + + [JsonPropertyName("token")] + public string Token { get; [UsedImplicitly] set; } = string.Empty; +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.AuthContext.cs b/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.AuthContext.cs new file mode 100644 index 00000000..81b5a4ad --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.AuthContext.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using Plugin.Firebase.Auth; +using Plugin.Firebase.Functions; + +namespace Plugin.Firebase.IntegrationTests.Functions; + +public sealed partial class FunctionsFixture +{ + [Fact] + public async Task exposes_unauthenticated_callable_context() + { + await CrossFirebaseAuth.Current.SignOutAsync(); + + var response = await CrossFirebaseFunctions.Current + .GetHttpsCallable("echoAuthContext") + .CallAsync(new SimpleRequestData(321).ToJson()); + + Assert.False(response.HasAuth); + Assert.Null(response.Uid); + Assert.Null(response.TokenEmail); + Assert.Equal(321, response.InputValue); + } + + + [Fact] + public async Task exposes_authenticated_callable_context() + { + var auth = CrossFirebaseAuth.Current; + var email = IntegrationTestData.UniqueEmail("functions-auth-context"); + + await using var user = await AuthTestUserScope.SignInWithEmailAndPasswordAsync(auth, email); + var response = await CrossFirebaseFunctions.Current + .GetHttpsCallable("echoAuthContext") + .CallAsync(new SimpleRequestData(654).ToJson()); + + Assert.True(response.HasAuth); + Assert.Equal(user.User.Uid, response.Uid); + Assert.Equal(email, response.TokenEmail); + Assert.Equal(654, response.InputValue); + } + +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.Errors.cs b/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.Errors.cs new file mode 100644 index 00000000..b8717d1a --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.Errors.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using Plugin.Firebase.Auth; +using Plugin.Firebase.Functions; + +namespace Plugin.Firebase.IntegrationTests.Functions; + +public sealed partial class FunctionsFixture +{ + [Fact] + public async Task throws_exception_when_function_does_not_exist() + { + var sut = CrossFirebaseFunctions.Current; + await Assert.ThrowsAnyAsync(() => sut.GetHttpsCallable("doesNotExist").CallAsync()); + } + + + [Fact] + public async Task propagates_structured_callable_error() + { + var sut = CrossFirebaseFunctions.Current; + + var exception = await Assert.ThrowsAnyAsync( + () => sut.GetHttpsCallable("throwStructuredError").CallAsync()); + + Assert.Contains( + "acceptance", + exception.ToString(), + StringComparison.OrdinalIgnoreCase); + } + +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.Region.cs b/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.Region.cs new file mode 100644 index 00000000..b14c0b0e --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.Region.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using Plugin.Firebase.Auth; +using Plugin.Firebase.Functions; + +namespace Plugin.Firebase.IntegrationTests.Functions; + +public sealed partial class FunctionsFixture +{ + [EmulatorBackendFact] + public async Task uses_configured_region_when_initialize_runs_after_emulator_configuration() + { + try { + ResetFunctionsToDefaultRegion(); + ConfigureFunctionsEmulator(); + + CrossFirebaseFunctions.Initialize(RegionalFunctionsRegion); + + var response = await CrossFirebaseFunctions.Current + .GetHttpsCallable(RegionalPingFunctionName) + .CallAsync("{}"); + + Assert.Equal(RegionalPingOutputValue, response.OutputValue); + } + finally { + RestoreDefaultFunctionsConfiguration(); + } + } + + + [EmulatorBackendFact] + public async Task uses_configured_region_after_is_supported_was_checked() + { + try { + ResetFunctionsToDefaultRegion(); + Assert.True(CrossFirebaseFunctions.IsSupported); + + CrossFirebaseFunctions.Initialize(RegionalFunctionsRegion); + ConfigureFunctionsEmulator(); + + var response = await CrossFirebaseFunctions.Current + .GetHttpsCallable(RegionalPingFunctionName) + .CallAsync("{}"); + + Assert.Equal(RegionalPingOutputValue, response.OutputValue); + } + finally { + RestoreDefaultFunctionsConfiguration(); + } + } + +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.Serialization.cs b/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.Serialization.cs new file mode 100644 index 00000000..27a7811b --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.Serialization.cs @@ -0,0 +1,202 @@ +using System.Text.Json; +using Plugin.Firebase.Auth; +using Plugin.Firebase.Functions; + +namespace Plugin.Firebase.IntegrationTests.Functions; + +public sealed partial class FunctionsFixture +{ + [Fact] + public async Task deserializes_callable_function_with_json_string_response_as_raw_string_and_json_value() + { + var sut = CrossFirebaseFunctions.Current; + var json = new SimpleRequestData(123).ToJson(); + + var responseJson = await sut.GetHttpsCallable("convertToLeet").CallAsync(json); + using var response = JsonDocument.Parse(responseJson); + AssertSimpleResponse(response.RootElement, 123); + + var responseElement = await sut.GetHttpsCallable("convertToLeet").CallAsync(json); + AssertSimpleResponse(responseElement, 123); + } + + + [Fact] + public async Task deserializes_callable_function_with_native_object_response() + { + var sut = CrossFirebaseFunctions.Current; + var json = new SimpleRequestData(123).ToJson(); + var response = await sut.GetHttpsCallable("returnObjectPayload").CallAsync(json); + + AssertObjectResponse(response, 123); + } + + + [Fact] + public async Task deserializes_callable_function_with_native_object_response_without_json_body() + { + var sut = CrossFirebaseFunctions.Current; + var response = await sut.GetHttpsCallable("returnObjectPayload").CallAsync(); + + AssertObjectResponse(response, 0); + } + + + [Fact] + public async Task returns_callable_native_object_response_as_json_string() + { + var sut = CrossFirebaseFunctions.Current; + var json = new SimpleRequestData(123).ToJson(); + var responseJson = await sut.GetHttpsCallable("returnObjectPayload").CallAsync(json); + + using var response = JsonDocument.Parse(responseJson); + var root = response.RootElement; + Assert.Equal(123, root.GetProperty("input_value").GetInt64()); + Assert.Equal(1337, root.GetProperty("output_value").GetInt64()); + Assert.Equal("object response", root.GetProperty("message").GetString()); + Assert.True(root.GetProperty("is_valid").GetBoolean()); + Assert.Equal("nested response", root.GetProperty("nested").GetProperty("name").GetString()); + Assert.Equal(2, root.GetProperty("items").GetArrayLength()); + } + + + [Fact] + public async Task deserializes_callable_function_with_native_object_response_as_json_value() + { + var sut = CrossFirebaseFunctions.Current; + var json = new SimpleRequestData(123).ToJson(); + var response = await sut.GetHttpsCallable("returnObjectPayload").CallAsync(json); + + AssertObjectJsonElement(response, 123); + } + + + [Fact] + public async Task deserializes_callable_function_with_native_array_response() + { + var sut = CrossFirebaseFunctions.Current; + var response = await sut.GetHttpsCallable("returnArrayPayload").CallAsync>(); + + // ReSharper disable ParameterOnlyUsedForPreconditionCheck.Local + Assert.Collection( + response, + first => { + Assert.Equal("first", first.Title); + Assert.Equal(1, first.Value); + }, + second => { + Assert.Equal("second", second.Title); + Assert.Equal(2, second.Value); + }); + // ReSharper restore ParameterOnlyUsedForPreconditionCheck.Local + } + + + [Fact] + public async Task deserializes_callable_function_with_native_array_response_as_json_string_and_json_value() + { + var sut = CrossFirebaseFunctions.Current; + + var responseJson = await sut.GetHttpsCallable("returnArrayPayload").CallAsync(); + using var response = JsonDocument.Parse(responseJson); + AssertArrayJsonElement(response.RootElement); + + var responseElement = await sut.GetHttpsCallable("returnArrayPayload").CallAsync(); + AssertArrayJsonElement(responseElement); + } + + + [Fact] + public async Task deserializes_callable_function_with_native_string_response() + { + var sut = CrossFirebaseFunctions.Current; + var response = await sut.GetHttpsCallable("returnStringPayload").CallAsync(); + + Assert.Equal("callable-string", response); + } + + + [Fact] + public async Task deserializes_callable_function_with_native_string_response_as_json_value() + { + var sut = CrossFirebaseFunctions.Current; + var response = await sut.GetHttpsCallable("returnStringPayload").CallAsync(); + + Assert.Equal(JsonValueKind.String, response.ValueKind); + Assert.Equal("callable-string", response.GetString()); + } + + + [Fact] + public async Task deserializes_callable_function_with_native_escaped_string_response() + { + var sut = CrossFirebaseFunctions.Current; + const string expected = "escaped \"quote\" and backslash \\\\ path"; + + var response = await sut.GetHttpsCallable("returnEscapedStringPayload").CallAsync(); + Assert.Equal(expected, response); + + var responseElement = await sut.GetHttpsCallable("returnEscapedStringPayload").CallAsync(); + Assert.Equal(JsonValueKind.String, responseElement.ValueKind); + Assert.Equal(expected, responseElement.GetString()); + } + + + [Fact] + public async Task deserializes_callable_function_with_native_number_response() + { + var sut = CrossFirebaseFunctions.Current; + var response = await sut.GetHttpsCallable("returnNumberPayload").CallAsync(); + + Assert.Equal(42, response); + } + + + [Fact] + public async Task deserializes_callable_function_with_native_number_response_as_json_string_and_json_value() + { + var sut = CrossFirebaseFunctions.Current; + + Assert.Equal("42", await sut.GetHttpsCallable("returnNumberPayload").CallAsync()); + + var response = await sut.GetHttpsCallable("returnNumberPayload").CallAsync(); + Assert.Equal(JsonValueKind.Number, response.ValueKind); + Assert.Equal(42, response.GetInt64()); + } + + + [Fact] + public async Task deserializes_callable_function_with_native_boolean_response() + { + var sut = CrossFirebaseFunctions.Current; + var response = await sut.GetHttpsCallable("returnBooleanPayload").CallAsync(); + + Assert.True(response); + } + + + [Fact] + public async Task deserializes_callable_function_with_native_boolean_response_as_json_string_and_json_value() + { + var sut = CrossFirebaseFunctions.Current; + + Assert.Equal("true", await sut.GetHttpsCallable("returnBooleanPayload").CallAsync()); + + var response = await sut.GetHttpsCallable("returnBooleanPayload").CallAsync(); + Assert.Equal(JsonValueKind.True, response.ValueKind); + Assert.True(response.GetBoolean()); + } + + + [Fact] + public async Task returns_default_for_callable_function_with_native_null_response() + { + var sut = CrossFirebaseFunctions.Current; + + Assert.Null(await sut.GetHttpsCallable("returnNullPayload").CallAsync()); + Assert.Null(await sut.GetHttpsCallable("returnNullPayload").CallAsync()); + Assert.Equal(0, await sut.GetHttpsCallable("returnNullPayload").CallAsync()); + Assert.Equal(JsonValueKind.Undefined, (await sut.GetHttpsCallable("returnNullPayload").CallAsync()).ValueKind); + } + +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.cs index 80135b2c..a07f7f75 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Functions/FunctionsFixture.cs @@ -1,350 +1,119 @@ using System.Text.Json; +using Plugin.Firebase.Auth; using Plugin.Firebase.Functions; -using Xunit.Sdk; -namespace Plugin.Firebase.IntegrationTests.Functions -{ - [Collection("Sequential")] - [TestLogging] - [Preserve(AllMembers = true)] - public sealed class FunctionsFixture - { - private const string RegionalFunctionsRegion = "southamerica-east1"; - private const string RegionalPingFunctionName = "regionalPing"; - private const long RegionalPingOutputValue = 541; - - [Fact] - public async Task executes_simple_callable_function() - { - var sut = CrossFirebaseFunctions.Current; - await sut.GetHttpsCallable("convertToLeet").CallAsync(); - } - - [Fact] - public async Task executes_callable_function_with_json_body() - { - var sut = CrossFirebaseFunctions.Current; - var json = new SimpleRequestData(123).ToJson(); - await sut.GetHttpsCallable("convertToLeet").CallAsync(json); - } - - [Fact] - public async Task executes_callable_function_with_json_body_and_response() - { - var sut = CrossFirebaseFunctions.Current; - var json = new SimpleRequestData(123).ToJson(); - var response = await sut.GetHttpsCallable("convertToLeet").CallAsync(json); - - Assert.Equal(123, response.InputValue); - Assert.Equal(1337, response.OutputValue); - } - - [Fact] - public async Task deserializes_callable_function_with_json_string_response_as_raw_string_and_json_value() - { - var sut = CrossFirebaseFunctions.Current; - var json = new SimpleRequestData(123).ToJson(); - - var responseJson = await sut.GetHttpsCallable("convertToLeet").CallAsync(json); - using var response = JsonDocument.Parse(responseJson); - AssertSimpleResponse(response.RootElement, 123); - - var responseElement = await sut.GetHttpsCallable("convertToLeet").CallAsync(json); - AssertSimpleResponse(responseElement, 123); - } - - [Fact] - public async Task deserializes_callable_function_with_native_object_response() - { - var sut = CrossFirebaseFunctions.Current; - var json = new SimpleRequestData(123).ToJson(); - var response = await sut.GetHttpsCallable("returnObjectPayload").CallAsync(json); - - AssertObjectResponse(response, 123); - } - - [Fact] - public async Task deserializes_callable_function_with_native_object_response_without_json_body() - { - var sut = CrossFirebaseFunctions.Current; - var response = await sut.GetHttpsCallable("returnObjectPayload").CallAsync(); - - AssertObjectResponse(response, 0); - } - - [Fact] - public async Task returns_callable_native_object_response_as_json_string() - { - var sut = CrossFirebaseFunctions.Current; - var json = new SimpleRequestData(123).ToJson(); - var responseJson = await sut.GetHttpsCallable("returnObjectPayload").CallAsync(json); - - using var response = JsonDocument.Parse(responseJson); - var root = response.RootElement; - Assert.Equal(123, root.GetProperty("input_value").GetInt64()); - Assert.Equal(1337, root.GetProperty("output_value").GetInt64()); - Assert.Equal("object response", root.GetProperty("message").GetString()); - Assert.True(root.GetProperty("is_valid").GetBoolean()); - Assert.Equal("nested response", root.GetProperty("nested").GetProperty("name").GetString()); - Assert.Equal(2, root.GetProperty("items").GetArrayLength()); - } - - [Fact] - public async Task deserializes_callable_function_with_native_object_response_as_json_value() - { - var sut = CrossFirebaseFunctions.Current; - var json = new SimpleRequestData(123).ToJson(); - var response = await sut.GetHttpsCallable("returnObjectPayload").CallAsync(json); - - AssertObjectJsonElement(response, 123); - } - - [Fact] - public async Task deserializes_callable_function_with_native_array_response() - { - var sut = CrossFirebaseFunctions.Current; - var response = await sut.GetHttpsCallable("returnArrayPayload").CallAsync>(); - - Assert.Collection( - response, - first => { - Assert.Equal("first", first.Title); - Assert.Equal(1, first.Value); - }, - second => { - Assert.Equal("second", second.Title); - Assert.Equal(2, second.Value); - }); - } - - [Fact] - public async Task deserializes_callable_function_with_native_array_response_as_json_string_and_json_value() - { - var sut = CrossFirebaseFunctions.Current; - - var responseJson = await sut.GetHttpsCallable("returnArrayPayload").CallAsync(); - using var response = JsonDocument.Parse(responseJson); - AssertArrayJsonElement(response.RootElement); - - var responseElement = await sut.GetHttpsCallable("returnArrayPayload").CallAsync(); - AssertArrayJsonElement(responseElement); - } - - [Fact] - public async Task deserializes_callable_function_with_native_string_response() - { - var sut = CrossFirebaseFunctions.Current; - var response = await sut.GetHttpsCallable("returnStringPayload").CallAsync(); - - Assert.Equal("callable-string", response); - } - - [Fact] - public async Task deserializes_callable_function_with_native_string_response_as_json_value() - { - var sut = CrossFirebaseFunctions.Current; - var response = await sut.GetHttpsCallable("returnStringPayload").CallAsync(); - - Assert.Equal(JsonValueKind.String, response.ValueKind); - Assert.Equal("callable-string", response.GetString()); - } - - [Fact] - public async Task deserializes_callable_function_with_native_escaped_string_response() - { - var sut = CrossFirebaseFunctions.Current; - const string expected = "escaped \"quote\" and backslash \\\\ path"; - - var response = await sut.GetHttpsCallable("returnEscapedStringPayload").CallAsync(); - Assert.Equal(expected, response); - - var responseElement = await sut.GetHttpsCallable("returnEscapedStringPayload").CallAsync(); - Assert.Equal(JsonValueKind.String, responseElement.ValueKind); - Assert.Equal(expected, responseElement.GetString()); - } - - [Fact] - public async Task deserializes_callable_function_with_native_number_response() - { - var sut = CrossFirebaseFunctions.Current; - var response = await sut.GetHttpsCallable("returnNumberPayload").CallAsync(); - - Assert.Equal(42, response); - } - - [Fact] - public async Task deserializes_callable_function_with_native_number_response_as_json_string_and_json_value() - { - var sut = CrossFirebaseFunctions.Current; - - Assert.Equal("42", await sut.GetHttpsCallable("returnNumberPayload").CallAsync()); - - var response = await sut.GetHttpsCallable("returnNumberPayload").CallAsync(); - Assert.Equal(JsonValueKind.Number, response.ValueKind); - Assert.Equal(42, response.GetInt64()); - } +namespace Plugin.Firebase.IntegrationTests.Functions; - [Fact] - public async Task deserializes_callable_function_with_native_boolean_response() - { - var sut = CrossFirebaseFunctions.Current; - var response = await sut.GetHttpsCallable("returnBooleanPayload").CallAsync(); - - Assert.True(response); - } - - [Fact] - public async Task deserializes_callable_function_with_native_boolean_response_as_json_string_and_json_value() - { - var sut = CrossFirebaseFunctions.Current; - - Assert.Equal("true", await sut.GetHttpsCallable("returnBooleanPayload").CallAsync()); - - var response = await sut.GetHttpsCallable("returnBooleanPayload").CallAsync(); - Assert.Equal(JsonValueKind.True, response.ValueKind); - Assert.True(response.GetBoolean()); - } - - [Fact] - public async Task returns_default_for_callable_function_with_native_null_response() - { - var sut = CrossFirebaseFunctions.Current; - - Assert.Null(await sut.GetHttpsCallable("returnNullPayload").CallAsync()); - Assert.Null(await sut.GetHttpsCallable("returnNullPayload").CallAsync()); - Assert.Equal(default, await sut.GetHttpsCallable("returnNullPayload").CallAsync()); - Assert.Equal(JsonValueKind.Undefined, (await sut.GetHttpsCallable("returnNullPayload").CallAsync()).ValueKind); - } - - [Fact] - public async Task throws_exception_when_function_does_not_exist() - { - var sut = CrossFirebaseFunctions.Current; - await Assert.ThrowsAnyAsync(() => sut.GetHttpsCallable("doesNotExist").CallAsync()); - } - - [Fact] - public async Task uses_configured_region_when_initialize_runs_after_emulator_configuration() - { - SkipIfRealBackend(); - - try { - ResetFunctionsToDefaultRegion(); - ConfigureFunctionsEmulator(); - - CrossFirebaseFunctions.Initialize(RegionalFunctionsRegion); - - var response = await CrossFirebaseFunctions.Current - .GetHttpsCallable(RegionalPingFunctionName) - .CallAsync("{}"); - - Assert.Equal(RegionalPingOutputValue, response.OutputValue); - } finally { - RestoreDefaultFunctionsConfiguration(); - } - } - - [Fact] - public async Task uses_configured_region_after_is_supported_was_checked() - { - SkipIfRealBackend(); - - try { - ResetFunctionsToDefaultRegion(); - Assert.True(CrossFirebaseFunctions.IsSupported); +[Collection("Sequential")] +[TestLogging] +[IntegrationTestFixture(IntegrationTestPackage.Functions)] +[Preserve(AllMembers = true)] +public sealed partial class FunctionsFixture +{ + private const string RegionalFunctionsRegion = "southamerica-east1"; + private const string RegionalPingFunctionName = "regionalPing"; + private const long RegionalPingOutputValue = 541; + private static readonly string[] Expected = ["alpha", "beta"]; - CrossFirebaseFunctions.Initialize(RegionalFunctionsRegion); - ConfigureFunctionsEmulator(); + [Fact] + public async Task executes_simple_callable_function() + { + var sut = CrossFirebaseFunctions.Current; + await sut.GetHttpsCallable("convertToLeet").CallAsync(); + } - var response = await CrossFirebaseFunctions.Current - .GetHttpsCallable(RegionalPingFunctionName) - .CallAsync("{}"); + [Fact] + public async Task executes_callable_function_with_json_body() + { + var sut = CrossFirebaseFunctions.Current; + var json = new SimpleRequestData(123).ToJson(); + await sut.GetHttpsCallable("convertToLeet").CallAsync(json); + } - Assert.Equal(RegionalPingOutputValue, response.OutputValue); - } finally { - RestoreDefaultFunctionsConfiguration(); - } - } + [Fact] + public async Task executes_callable_function_with_json_body_and_response() + { + var sut = CrossFirebaseFunctions.Current; + var json = new SimpleRequestData(123).ToJson(); + var response = await sut.GetHttpsCallable("convertToLeet").CallAsync(json); - private static void SkipIfRealBackend() - { - if(IntegrationTestEnvironment.UsesRealBackend) { - throw SkipException.ForSkip( - "This test uses the emulator-only regional function fixture."); - } - } + Assert.Equal(123, response.InputValue); + Assert.Equal(1337, response.OutputValue); + } - private static void RestoreDefaultFunctionsConfiguration() - { - ResetFunctionsToDefaultRegion(); - ConfigureFunctionsEmulator(); - } + private static void RestoreDefaultFunctionsConfiguration() + { + ResetFunctionsToDefaultRegion(); + ConfigureFunctionsEmulator(); + } - private static void ResetFunctionsToDefaultRegion() - { - CrossFirebaseFunctions.Initialize(null); - CrossFirebaseFunctions.Dispose(); - } + private static void ResetFunctionsToDefaultRegion() + { + CrossFirebaseFunctions.Initialize(null); + CrossFirebaseFunctions.Dispose(); + } - private static void ConfigureFunctionsEmulator() - { - var functions = IntegrationTestEnvironment.FunctionsEmulatorEndpoint; - CrossFirebaseFunctions.Current.UseEmulator(functions.Host, functions.Port); - } + private static void ConfigureFunctionsEmulator() + { + var functions = IntegrationTestEnvironment.FunctionsEmulatorEndpoint; + CrossFirebaseFunctions.Current.UseEmulator(functions.Host, functions.Port); + } - private static void AssertSimpleResponse(JsonElement response, long expectedInputValue) - { - Assert.Equal(JsonValueKind.Object, response.ValueKind); - Assert.Equal(expectedInputValue, response.GetProperty("input_value").GetInt64()); - Assert.Equal(1337, response.GetProperty("output_value").GetInt64()); - } + private static void AssertSimpleResponse(JsonElement response, long expectedInputValue) + { + Assert.Equal(JsonValueKind.Object, response.ValueKind); + Assert.Equal(expectedInputValue, response.GetProperty("input_value").GetInt64()); + Assert.Equal(1337, response.GetProperty("output_value").GetInt64()); + } - private static void AssertObjectResponse(CallableObjectResponseData response, long expectedInputValue) - { - Assert.NotNull(response); - Assert.Equal(expectedInputValue, response.InputValue); - Assert.Equal(1337, response.OutputValue); - Assert.Equal("object response", response.Message); - Assert.True(response.IsValid); - Assert.NotNull(response.Nested); - Assert.Equal("nested response", response.Nested.Name); - Assert.Equal(2, response.Nested.Count); - Assert.Collection( - response.Items, - first => { - Assert.Equal("first", first.Title); - Assert.Equal(1, first.Value); - }, - second => { - Assert.Equal("second", second.Title); - Assert.Equal(2, second.Value); - }); - Assert.Equal(new[] { "alpha", "beta" }, response.Tags); - Assert.Equal(new long[] { 3, 5, 8 }, response.Scores); - } + // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local + private static void AssertObjectResponse(CallableObjectResponseData response, long expectedInputValue) + { + Assert.NotNull(response); + Assert.Equal(expectedInputValue, response.InputValue); + Assert.Equal(1337, response.OutputValue); + Assert.Equal("object response", response.Message); + Assert.True(response.IsValid); + Assert.NotNull(response.Nested); + Assert.Equal("nested response", response.Nested.Name); + Assert.Equal(2, response.Nested.Count); + // ReSharper disable ParameterOnlyUsedForPreconditionCheck.Local + Assert.Collection( + response.Items, + first => { + Assert.Equal("first", first.Title); + Assert.Equal(1, first.Value); + }, + second => { + Assert.Equal("second", second.Title); + Assert.Equal(2, second.Value); + }); + // ReSharper restore ParameterOnlyUsedForPreconditionCheck.Local + Assert.Equal(Expected, response.Tags); + Assert.Equal(new long[] { 3, 5, 8 }, response.Scores); + } - private static void AssertObjectJsonElement(JsonElement response, long expectedInputValue) - { - Assert.Equal(JsonValueKind.Object, response.ValueKind); - Assert.Equal(expectedInputValue, response.GetProperty("input_value").GetInt64()); - Assert.Equal(1337, response.GetProperty("output_value").GetInt64()); - Assert.Equal("object response", response.GetProperty("message").GetString()); - Assert.True(response.GetProperty("is_valid").GetBoolean()); - Assert.Equal("nested response", response.GetProperty("nested").GetProperty("name").GetString()); - Assert.Equal(2, response.GetProperty("nested").GetProperty("count").GetInt64()); - AssertArrayJsonElement(response.GetProperty("items")); - Assert.Equal(new[] { "alpha", "beta" }, response.GetProperty("tags").EnumerateArray().Select(x => x.GetString())); - Assert.Equal(new long[] { 3, 5, 8 }, response.GetProperty("scores").EnumerateArray().Select(x => x.GetInt64())); - } + private static void AssertObjectJsonElement(JsonElement response, long expectedInputValue) + { + Assert.Equal(JsonValueKind.Object, response.ValueKind); + Assert.Equal(expectedInputValue, response.GetProperty("input_value").GetInt64()); + Assert.Equal(1337, response.GetProperty("output_value").GetInt64()); + Assert.Equal("object response", response.GetProperty("message").GetString()); + Assert.True(response.GetProperty("is_valid").GetBoolean()); + Assert.Equal("nested response", response.GetProperty("nested").GetProperty("name").GetString()); + Assert.Equal(2, response.GetProperty("nested").GetProperty("count").GetInt64()); + AssertArrayJsonElement(response.GetProperty("items")); + Assert.Equal(["alpha", "beta"], response.GetProperty("tags").EnumerateArray().Select(x => x.GetString()!)); + Assert.Equal([3, 5, 8], response.GetProperty("scores").EnumerateArray().Select(x => x.GetInt64())); + } - private static void AssertArrayJsonElement(JsonElement response) - { - Assert.Equal(JsonValueKind.Array, response.ValueKind); - Assert.Equal(2, response.GetArrayLength()); - Assert.Equal("first", response[0].GetProperty("title").GetString()); - Assert.Equal(1, response[0].GetProperty("value").GetInt64()); - Assert.Equal("second", response[1].GetProperty("title").GetString()); - Assert.Equal(2, response[1].GetProperty("value").GetInt64()); - } + private static void AssertArrayJsonElement(JsonElement response) + { + Assert.Equal(JsonValueKind.Array, response.ValueKind); + Assert.Equal(2, response.GetArrayLength()); + Assert.Equal("first", response[0].GetProperty("title").GetString()); + Assert.Equal(1, response[0].GetProperty("value").GetInt64()); + Assert.Equal("second", response[1].GetProperty("title").GetString()); + Assert.Equal(2, response[1].GetProperty("value").GetInt64()); } -} +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Functions/SimpleRequestData.cs b/tests/Plugin.Firebase.IntegrationTests/Functions/SimpleRequestData.cs index 17b303ee..ca6e4576 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Functions/SimpleRequestData.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Functions/SimpleRequestData.cs @@ -1,26 +1,26 @@ using System.Text.Json; using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace Plugin.Firebase.IntegrationTests.Functions +namespace Plugin.Firebase.IntegrationTests.Functions; + +[Preserve(AllMembers = true)] +public sealed class SimpleRequestData { - [Preserve(AllMembers = true)] - public sealed class SimpleRequestData + public SimpleRequestData() { - public SimpleRequestData() - { - } - - public SimpleRequestData(long inputValue) - { - InputValue = inputValue; - } + } - public string ToJson() - { - return JsonSerializer.Serialize(this); - } + public SimpleRequestData(long inputValue) + { + InputValue = inputValue; + } - [JsonPropertyName("input_value")] - public long InputValue { get; private set; } + public string ToJson() + { + return JsonSerializer.Serialize(this); } + + [JsonPropertyName("input_value")] + public long InputValue { [UsedImplicitly] get; private set; } } \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Functions/SimpleResponseData.cs b/tests/Plugin.Firebase.IntegrationTests/Functions/SimpleResponseData.cs index 329dd8da..34e641d5 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Functions/SimpleResponseData.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Functions/SimpleResponseData.cs @@ -1,18 +1,14 @@ using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace Plugin.Firebase.IntegrationTests.Functions -{ - [Preserve(AllMembers = true)] - public sealed class SimpleResponseData - { - public SimpleResponseData() - { - } +namespace Plugin.Firebase.IntegrationTests.Functions; - [JsonPropertyName("input_value")] - public long InputValue { get; set; } +[Preserve(AllMembers = true)] +public sealed class SimpleResponseData +{ + [JsonPropertyName("input_value")] + public long InputValue { get; [UsedImplicitly] set; } - [JsonPropertyName("output_value")] - public long OutputValue { get; set; } - } + [JsonPropertyName("output_value")] + public long OutputValue { get; [UsedImplicitly] set; } } \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Installations/InstallationsFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Installations/InstallationsFixture.cs index 20fd5fb6..8aa86039 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Installations/InstallationsFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Installations/InstallationsFixture.cs @@ -1,44 +1,53 @@ using Plugin.Firebase.Installations; -namespace Plugin.Firebase.IntegrationTests.Installations +namespace Plugin.Firebase.IntegrationTests.Installations; + +[Collection("Sequential")] +[TestLogging] +[IntegrationTestFixture(IntegrationTestPackage.Installations)] +[Preserve(AllMembers = true)] +public class InstallationsFixture { - [Collection("Sequential")] - [TestLogging] - [Preserve(AllMembers = true)] - public class InstallationsFixture + [Fact] + public void disposes_and_reacquires_installations_singleton() + { + var first = CrossFirebaseInstallations.Current; + + CrossFirebaseInstallations.Dispose(); + + var second = CrossFirebaseInstallations.Current; + Assert.NotNull(second); + Assert.NotSame(first, second); + } + + [RealFirebaseFact] + public async Task gets_stable_installation_id() { - private const string RunInstallationsDeleteTestsEnvironmentVariableName = - "PLUGIN_FIREBASE_RUN_INSTALLATIONS_DELETE_TESTS"; - - [RealFirebaseFact] - public async Task gets_stable_installation_id() - { - var firstInstallationId = await CrossFirebaseInstallations.GetIdAsync(); - var secondInstallationId = await CrossFirebaseInstallations.GetIdAsync(); - - Assert.False(string.IsNullOrWhiteSpace(firstInstallationId)); - Assert.Equal(firstInstallationId, secondInstallationId); - } - - [RealFirebaseFact] - public async Task gets_installation_tokens() - { - var token = await CrossFirebaseInstallations.GetTokenAsync(); - var refreshedToken = await CrossFirebaseInstallations.GetTokenAsync(forceRefresh: true); - - Assert.False(string.IsNullOrWhiteSpace(token)); - Assert.False(string.IsNullOrWhiteSpace(refreshedToken)); - } - - [RealFirebaseOptInFact(RunInstallationsDeleteTestsEnvironmentVariableName)] - public async Task deletes_installation_when_enabled_via_environment() - { - var installationIdBeforeDelete = await CrossFirebaseInstallations.GetIdAsync(); - await CrossFirebaseInstallations.DeleteAsync(); - var installationIdAfterDelete = await CrossFirebaseInstallations.GetIdAsync(); - - Assert.False(string.IsNullOrWhiteSpace(installationIdAfterDelete)); - Assert.NotEqual(installationIdBeforeDelete, installationIdAfterDelete); - } + var firstInstallationId = await CrossFirebaseInstallations.GetIdAsync(); + var secondInstallationId = await CrossFirebaseInstallations.GetIdAsync(); + + Assert.False(string.IsNullOrWhiteSpace(firstInstallationId)); + Assert.Equal(firstInstallationId, secondInstallationId); + } + + [RealFirebaseFact] + public async Task gets_installation_tokens() + { + var token = await CrossFirebaseInstallations.GetTokenAsync(); + var refreshedToken = await CrossFirebaseInstallations.GetTokenAsync(forceRefresh: true); + + Assert.False(string.IsNullOrWhiteSpace(token)); + Assert.False(string.IsNullOrWhiteSpace(refreshedToken)); + } + + [RealFirebaseOptInFact(IntegrationTestOptions.RunInstallationsDeleteTestsEnvironmentVariableName)] + public async Task deletes_installation_when_enabled_via_environment() + { + var installationIdBeforeDelete = await CrossFirebaseInstallations.GetIdAsync(); + await CrossFirebaseInstallations.DeleteAsync(); + var installationIdAfterDelete = await CrossFirebaseInstallations.GetIdAsync(); + + Assert.False(string.IsNullOrWhiteSpace(installationIdAfterDelete)); + Assert.NotEqual(installationIdBeforeDelete, installationIdAfterDelete); } } \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/IntegrationTestConfiguration.cs b/tests/Plugin.Firebase.IntegrationTests/IntegrationTestConfiguration.cs deleted file mode 100644 index 393e81dd..00000000 --- a/tests/Plugin.Firebase.IntegrationTests/IntegrationTestConfiguration.cs +++ /dev/null @@ -1,89 +0,0 @@ -#if ANDROID -using AndroidRuntime = Android.Runtime; -#endif - -namespace Plugin.Firebase.IntegrationTests; - -internal static class IntegrationTestConfiguration -{ - public static bool IsFeatureEnabled(string environmentVariableName, string androidSystemPropertyName) - { - return string.Equals( - GetConfigurationValue(environmentVariableName, androidSystemPropertyName), - "1", - StringComparison.Ordinal); - } - - public static string GetEmulatorHost(string environmentVariableName, string androidSystemPropertyName) - { - var host = GetConfigurationValue(environmentVariableName, androidSystemPropertyName); - return string.IsNullOrWhiteSpace(host) - ? OperatingSystem.IsAndroid() ? "10.0.2.2" : "localhost" - : host; - } - - public static int GetEmulatorPort( - string environmentVariableName, - string androidSystemPropertyName, - int defaultPort) - { - var portValue = GetConfigurationValue(environmentVariableName, androidSystemPropertyName); - if(string.IsNullOrWhiteSpace(portValue)) { - return defaultPort; - } - - if(!int.TryParse(portValue, out var port)) { - throw new InvalidOperationException( - $"{environmentVariableName} must be an integer, but was '{portValue}'."); - } - - return port; - } - - private static string GetConfigurationValue(string environmentVariableName, string androidSystemPropertyName) - { - var environmentVariableValue = Environment.GetEnvironmentVariable(environmentVariableName); - if(!string.IsNullOrWhiteSpace(environmentVariableValue)) { - return environmentVariableValue; - } - -#if ANDROID - return GetAndroidSystemProperty(androidSystemPropertyName); -#else - return null; -#endif - } - -#if ANDROID - private static string GetAndroidSystemProperty(string propertyName) - { - IntPtr? propertyValuePointer = null; - - try { - var systemPropertiesClass = AndroidRuntime.JNIEnv.FindClass("android/os/SystemProperties"); - var getMethodId = AndroidRuntime.JNIEnv.GetStaticMethodID( - systemPropertiesClass, - "get", - "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); - - using var propertyNameValue = new Java.Lang.String(propertyName); - using var defaultValue = new Java.Lang.String(string.Empty); - propertyValuePointer = AndroidRuntime.JNIEnv.CallStaticObjectMethod( - systemPropertiesClass, - getMethodId, - new AndroidRuntime.JValue(propertyNameValue), - new AndroidRuntime.JValue(defaultValue)); - - return AndroidRuntime.JNIEnv.GetString( - propertyValuePointer.Value, - AndroidRuntime.JniHandleOwnership.DoNotTransfer); - } catch { - return null; - } finally { - if(propertyValuePointer.HasValue && propertyValuePointer.Value != IntPtr.Zero) { - AndroidRuntime.JNIEnv.DeleteLocalRef(propertyValuePointer.Value); - } - } - } -#endif -} diff --git a/tests/Plugin.Firebase.IntegrationTests/IntegrationTestResourceScope.cs b/tests/Plugin.Firebase.IntegrationTests/IntegrationTestResourceScope.cs new file mode 100644 index 00000000..cd76b71c --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/IntegrationTestResourceScope.cs @@ -0,0 +1,19 @@ +namespace Plugin.Firebase.IntegrationTests; + +internal sealed class IntegrationTestResourceScope : IAsyncDisposable +{ + private readonly Stack _resources = new(); + + public T Add(T resource) where T : IAsyncDisposable + { + _resources.Push(resource); + return resource; + } + + public async ValueTask DisposeAsync() + { + while(_resources.TryPop(out var resource)) { + await resource.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/IntegrationTestUsers.cs b/tests/Plugin.Firebase.IntegrationTests/IntegrationTestUsers.cs new file mode 100644 index 00000000..6cf07470 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/IntegrationTestUsers.cs @@ -0,0 +1,19 @@ +namespace Plugin.Firebase.IntegrationTests; + +internal static class IntegrationTestUsers +{ + public const string DefaultPassword = "123456"; + public const string UpdatedPassword = "abcdefgh"; + + public const string CreatedUserEmail = "created-user@test.com"; + public const string SignInWithPasswordEmail = "sign-in-with-pw@test.com"; + public const string MissingUserEmail = "does-not-exist@test.com"; + public const string SignOutEmail = "sign-out@test.com"; + public const string UpdateEmailEmail = "to-update-email@test.com"; + public const string UpdatePasswordEmail = "to-update-pw@test.com"; + public const string VerificationEmail = "verification-email@test.com"; + public const string ReloadCurrentUserEmail = "reload-current-user@test.com"; + public const string SetLanguageCodeEmail = "set-language-code@test.com"; + public const string DeleteUserEmail = "to-delete@test.com"; + public const string CustomClaimsEmail = "custom-claims@test.com"; +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/MustPassFixture.cs b/tests/Plugin.Firebase.IntegrationTests/MustPassFixture.cs index ad7b4bec..266a547d 100644 --- a/tests/Plugin.Firebase.IntegrationTests/MustPassFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/MustPassFixture.cs @@ -9,51 +9,51 @@ using Plugin.Firebase.RemoteConfig; using Plugin.Firebase.Storage; -namespace Plugin.Firebase.IntegrationTests +namespace Plugin.Firebase.IntegrationTests; + +[Collection("Sequential")] +[TestLogging] +[IntegrationTestCoverageIgnore("Harness smoke test; package acceptance is tracked by package fixtures.")] +[Preserve(AllMembers = true)] +public class MustPassFixture { - [Collection("Sequential")] - [TestLogging] - [Preserve(AllMembers = true)] - public class MustPassFixture + [Fact] + public void must_pass() { - [Fact] - public void must_pass() - { - Assert.True(true); - Assert.True(CrossFirebaseAnalytics.IsSupported); - Assert.True(CrossFirebaseAuth.IsSupported); - Assert.True(CrossFirebaseCloudMessaging.IsSupported); - Assert.True(CrossFirebaseFirestore.IsSupported); - Assert.True(CrossFirebaseFunctions.IsSupported); - Assert.True(CrossFirebaseInstallations.IsSupported); - Assert.True(CrossFirebasePerformanceMonitoring.IsSupported); - Assert.True(CrossFirebaseStorage.IsSupported); - Assert.True(CrossFirebaseRemoteConfig.IsSupported); - Assert.True(CrossFirebaseAppCheck.IsSupported); - CrossFirebaseAppCheck.Configure(AppCheckOptions.Disabled); - - if(OperatingSystem.IsAndroid()) { - Assert.Throws(() => CrossFirebaseAppCheck.Configure(AppCheckOptions.DeviceCheck)); - Assert.Throws(() => CrossFirebaseAppCheck.Configure(AppCheckOptions.AppAttest)); - CrossFirebaseAppCheck.Configure(AppCheckOptions.Debug); - CrossFirebaseAppCheck.Configure(AppCheckOptions.PlayIntegrity); - } + Assert.True(true); + Assert.True(CrossFirebaseAnalytics.IsSupported); + Assert.True(CrossFirebaseAuth.IsSupported); + Assert.True(CrossFirebaseCloudMessaging.IsSupported); + Assert.True(CrossFirebaseFirestore.IsSupported); + Assert.True(CrossFirebaseFunctions.IsSupported); + Assert.True(CrossFirebaseInstallations.IsSupported); + Assert.True(CrossFirebasePerformanceMonitoring.IsSupported); + Assert.True(CrossFirebaseStorage.IsSupported); + Assert.True(CrossFirebaseRemoteConfig.IsSupported); + Assert.True(CrossFirebaseAppCheck.IsSupported); + CrossFirebaseAppCheck.Configure(AppCheckOptions.Disabled); - if(OperatingSystem.IsIOS()) { - CrossFirebaseAppCheck.Configure(AppCheckOptions.PlayIntegrity); - CrossFirebaseAppCheck.Configure(AppCheckOptions.Debug); - CrossFirebaseAppCheck.Configure(AppCheckOptions.DeviceCheck); - CrossFirebaseAppCheck.Configure(AppCheckOptions.AppAttest); - } + if(OperatingSystem.IsAndroid()) { + Assert.Throws(() => CrossFirebaseAppCheck.Configure(AppCheckOptions.DeviceCheck)); + Assert.Throws(() => CrossFirebaseAppCheck.Configure(AppCheckOptions.AppAttest)); + CrossFirebaseAppCheck.Configure(AppCheckOptions.Debug); + CrossFirebaseAppCheck.Configure(AppCheckOptions.PlayIntegrity); } - [RealFirebaseOptInFact(IntegrationTestEnvironment.RunAppCheckTokenTestsEnvironmentVariableName)] - public async Task fetches_app_check_token_when_enabled_via_environment() - { + if(OperatingSystem.IsIOS()) { + CrossFirebaseAppCheck.Configure(AppCheckOptions.PlayIntegrity); CrossFirebaseAppCheck.Configure(AppCheckOptions.Debug); - var token = await CrossFirebaseAppCheck.GetTokenAsync(forceRefresh: true); - - Assert.False(string.IsNullOrWhiteSpace(token)); + CrossFirebaseAppCheck.Configure(AppCheckOptions.DeviceCheck); + CrossFirebaseAppCheck.Configure(AppCheckOptions.AppAttest); } } + + [RealFirebaseOptInFact(IntegrationTestEnvironment.RunAppCheckTokenTestsEnvironmentVariableName)] + public async Task fetches_app_check_token_when_enabled_via_environment() + { + CrossFirebaseAppCheck.Configure(AppCheckOptions.Debug); + var token = await CrossFirebaseAppCheck.GetTokenAsync(forceRefresh: true); + + Assert.False(string.IsNullOrWhiteSpace(token)); + } } \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/PerformanceMonitoring/PerformanceAssertions.cs b/tests/Plugin.Firebase.IntegrationTests/PerformanceMonitoring/PerformanceAssertions.cs new file mode 100644 index 00000000..61f9890e --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/PerformanceMonitoring/PerformanceAssertions.cs @@ -0,0 +1,110 @@ +using Plugin.Firebase.PerformanceMonitoring; + +namespace Plugin.Firebase.IntegrationTests.PerformanceMonitoring; + +internal static class PerformanceAssertions +{ + public const string AttributeName = "source"; + public const string AttributeValue = "integration_test"; + public const string MetricName = "items"; + + public static void DataCollectionEnabled(IFirebasePerformanceMonitoring monitoring, bool expectedValue) + { + Assert.Equal(expectedValue, monitoring.IsDataCollectionEnabled); + } + + public static void TraceName(IFirebasePerformanceTrace trace, string expectedName) + { + Assert.Equal(expectedName, trace.Name); + } + + public static void TraceAttribute(IFirebasePerformanceTrace trace, string attributeValue) + { + Assert.Equal(attributeValue, trace.GetAttribute(AttributeName)); + Assert.True(trace.Attributes.ContainsKey(AttributeName)); + Assert.Equal(attributeValue, trace.Attributes[AttributeName]); + } + + public static void TraceMetric(IFirebasePerformanceTrace trace, long expectedValue) + { + Assert.Equal(expectedValue, trace.GetLongMetric(MetricName)); + } + + public static void TraceAttributeRemoved(IFirebasePerformanceTrace trace) + { + Assert.Null(trace.GetAttribute(AttributeName)); + Assert.False(trace.Attributes.ContainsKey(AttributeName)); + } + + public static void HttpMetricContract(IFirebasePerformanceHttpMetric metric) + { + var started = false; + + try { + metric.PutAttribute(AttributeName, AttributeValue); + HttpMetricAttribute(metric); + + metric.Start(); + started = true; + + metric.SetHttpResponseCode(200); + metric.SetRequestPayloadSize(10); + metric.SetResponsePayloadSize(20); + metric.SetResponseContentType("text/plain"); + + metric.RemoveAttribute(AttributeName); + HttpMetricAttributeRemoved(metric); + } + finally { + if(started) { + metric.Stop(); + } + } + } + + public static void NullHttpMetricUriThrows(IFirebasePerformanceMonitoring monitoring) + { + var exception = Assert.Throws( + () => monitoring.NewHttpMetric( + ((Uri) null!)!, + HttpMethod.Get + ) + ); + Assert.Equal("url", exception.ParamName); + } + + public static void UnsupportedStringHttpMetricMethodThrows(IFirebasePerformanceMonitoring monitoring) + { + var exception = Assert.Throws( + () => monitoring.NewHttpMetric( + "https://example.com/performance-monitoring/unsupported-string-method", + new HttpMethod("BREW") + ) + ); + Assert.Equal("httpMethod", exception.ParamName); + } + + public static void UnsupportedUriHttpMetricMethodThrows(IFirebasePerformanceMonitoring monitoring) + { + var exception = Assert.Throws( + () => monitoring.NewHttpMetric( + new Uri("https://example.com/performance-monitoring/unsupported-uri-method"), + new HttpMethod("BREW") + ) + ); + Assert.Equal("httpMethod", exception.ParamName); + } + + private static void HttpMetricAttribute(IFirebasePerformanceHttpMetric metric) + { + Assert.Equal(AttributeValue, metric.GetAttribute(AttributeName)); + Assert.True(metric.Attributes.ContainsKey(AttributeName)); + Assert.Equal(AttributeValue, metric.Attributes[AttributeName]); + } + + private static void HttpMetricAttributeRemoved(IFirebasePerformanceHttpMetric metric) + { + Assert.Null(metric.GetAttribute(AttributeName)); + Assert.False(metric.Attributes.ContainsKey(AttributeName)); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/PerformanceMonitoring/PerformanceMonitoringFixture.cs b/tests/Plugin.Firebase.IntegrationTests/PerformanceMonitoring/PerformanceMonitoringFixture.cs index d2508198..e4c14182 100644 --- a/tests/Plugin.Firebase.IntegrationTests/PerformanceMonitoring/PerformanceMonitoringFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/PerformanceMonitoring/PerformanceMonitoringFixture.cs @@ -1,222 +1,166 @@ -using System.Net.Http; using Plugin.Firebase.PerformanceMonitoring; -namespace Plugin.Firebase.IntegrationTests.PerformanceMonitoring +namespace Plugin.Firebase.IntegrationTests.PerformanceMonitoring; + +[Collection("Sequential")] +[TestLogging] +[IntegrationTestFixture(IntegrationTestPackage.PerformanceMonitoring)] +[Preserve(AllMembers = true)] +public sealed class PerformanceMonitoringFixture { - [Collection("Sequential")] - [TestLogging] - [Preserve(AllMembers = true)] - public sealed class PerformanceMonitoringFixture + public static TheoryData HttpMethods => [ + HttpMethod.Get, + HttpMethod.Put, + HttpMethod.Post, + HttpMethod.Delete, + HttpMethod.Head, + HttpMethod.Patch, + HttpMethod.Options, + HttpMethod.Trace, + HttpMethod.Connect + ]; + + [Fact] + public void round_trips_collection_enabled_state() { - private const string AttributeName = "source"; - private const string AttributeValue = "integration_test"; - private const string MetricName = "items"; - - public static TheoryData HttpMethods => new() - { - HttpMethod.Get, - HttpMethod.Put, - HttpMethod.Post, - HttpMethod.Delete, - HttpMethod.Head, - HttpMethod.Patch, - HttpMethod.Options, - HttpMethod.Trace, - HttpMethod.Connect - }; - - [Fact] - public void round_trips_collection_enabled_state() - { - var sut = CrossFirebasePerformanceMonitoring.Current; - var originalValue = sut.IsDataCollectionEnabled; - - try { - sut.IsDataCollectionEnabled = false; - Assert.False(sut.IsDataCollectionEnabled); - - sut.IsDataCollectionEnabled = true; - Assert.True(sut.IsDataCollectionEnabled); - } - finally { - sut.IsDataCollectionEnabled = originalValue; - Assert.Equal(originalValue, sut.IsDataCollectionEnabled); - } - } + var sut = CrossFirebasePerformanceMonitoring.Current; + var originalValue = sut.IsDataCollectionEnabled; - [Fact] - public void records_custom_trace_attributes_and_metrics() - { - var trace = CrossFirebasePerformanceMonitoring.Current.NewTrace("test_custom_trace_contract"); - var started = false; + try { + sut.IsDataCollectionEnabled = false; + PerformanceAssertions.DataCollectionEnabled(sut, false); - try { - Assert.Equal("test_custom_trace_contract", trace.Name); - - trace.PutAttribute(AttributeName, AttributeValue); - Assert.Equal(AttributeValue, trace.GetAttribute(AttributeName)); - Assert.True(trace.Attributes.ContainsKey(AttributeName)); - Assert.Equal(AttributeValue, trace.Attributes[AttributeName]); + sut.IsDataCollectionEnabled = true; + PerformanceAssertions.DataCollectionEnabled(sut, true); + } + finally { + sut.IsDataCollectionEnabled = originalValue; + PerformanceAssertions.DataCollectionEnabled(sut, originalValue); + } + } - trace.Start(); - started = true; + [Fact] + public void records_custom_trace_attributes_and_metrics() + { + var trace = CrossFirebasePerformanceMonitoring.Current.NewTrace("test_custom_trace_contract"); + var started = false; - trace.PutMetric(MetricName, 1); - trace.IncrementMetric(MetricName, 1); - Assert.Equal(2, trace.GetLongMetric(MetricName)); + try { + PerformanceAssertions.TraceName(trace, "test_custom_trace_contract"); - trace.RemoveAttribute(AttributeName); - Assert.Null(trace.GetAttribute(AttributeName)); - Assert.False(trace.Attributes.ContainsKey(AttributeName)); - } - finally { - if(started) { - trace.Stop(); - } - } - } + trace.PutAttribute(PerformanceAssertions.AttributeName, PerformanceAssertions.AttributeValue); + PerformanceAssertions.TraceAttribute(trace, PerformanceAssertions.AttributeValue); - [Fact] - public void records_started_custom_trace_metrics() - { - var trace = CrossFirebasePerformanceMonitoring.Current.StartTrace("test_started_trace_contract"); + trace.Start(); + started = true; - try { - Assert.Equal("test_started_trace_contract", trace.Name); + trace.PutMetric(PerformanceAssertions.MetricName, 1); + trace.IncrementMetric(PerformanceAssertions.MetricName, 1); + PerformanceAssertions.TraceMetric(trace, 2); - trace.PutMetric(MetricName, 3); - trace.IncrementMetric(MetricName, 4); - Assert.Equal(7, trace.GetLongMetric(MetricName)); - } - finally { + trace.RemoveAttribute(PerformanceAssertions.AttributeName); + PerformanceAssertions.TraceAttributeRemoved(trace); + } + finally { + if(started) { trace.Stop(); } } + } - [Theory] - [MemberData(nameof(HttpMethods))] - public void records_string_http_metric_for_each_method(HttpMethod method) - { - var metric = CrossFirebasePerformanceMonitoring.Current.NewHttpMetric( - GetHttpMetricUrl("string", method), - method - ); - - AssertHttpMetricContract(metric); - } - - [Theory] - [MemberData(nameof(HttpMethods))] - public void records_uri_http_metric_for_each_method(HttpMethod method) - { - var metric = CrossFirebasePerformanceMonitoring.Current.NewHttpMetric( - new Uri(GetHttpMetricUrl("uri", method)), - method - ); - - AssertHttpMetricContract(metric); - } + [Fact] + public void records_started_custom_trace_metrics() + { + var trace = CrossFirebasePerformanceMonitoring.Current.StartTrace("test_started_trace_contract"); - [Fact] - public void throws_for_null_http_metric_uri() - { - var exception = Assert.Throws( - () => CrossFirebasePerformanceMonitoring.Current.NewHttpMetric( - (Uri) null!, - HttpMethod.Get - ) - ); - Assert.Equal("url", exception.ParamName); - } + try { + PerformanceAssertions.TraceName(trace, "test_started_trace_contract"); - [Fact] - public void throws_for_unsupported_string_http_metric_method() - { - var exception = Assert.Throws( - () => CrossFirebasePerformanceMonitoring.Current.NewHttpMetric( - "https://example.com/performance-monitoring/unsupported-string-method", - new HttpMethod("BREW") - ) - ); - Assert.Equal("httpMethod", exception.ParamName); + trace.PutMetric(PerformanceAssertions.MetricName, 3); + trace.IncrementMetric(PerformanceAssertions.MetricName, 4); + PerformanceAssertions.TraceMetric(trace, 7); } - - [Fact] - public void throws_for_unsupported_uri_http_metric_method() - { - var exception = Assert.Throws( - () => CrossFirebasePerformanceMonitoring.Current.NewHttpMetric( - new Uri("https://example.com/performance-monitoring/unsupported-uri-method"), - new HttpMethod("BREW") - ) - ); - Assert.Equal("httpMethod", exception.ParamName); + finally { + trace.Stop(); } + } - [RealFirebaseFact] - public void real_backend_accepts_custom_trace() - { - var trace = CrossFirebasePerformanceMonitoring.Current.NewTrace("test_real_backend_trace"); - var started = false; - - try { - trace.PutAttribute(AttributeName, "real_backend"); - trace.Start(); - started = true; - trace.PutMetric(MetricName, 1); - trace.IncrementMetric(MetricName, 1); - Assert.Equal(2, trace.GetLongMetric(MetricName)); - } - finally { - if(started) { - trace.Stop(); - } - } - } + [Theory] + [MemberData(nameof(HttpMethods))] + public void records_string_http_metric_for_each_method(HttpMethod method) + { + var metric = CrossFirebasePerformanceMonitoring.Current.NewHttpMetric( + GetHttpMetricUrl("string", method), + method + ); - [RealFirebaseFact] - public void real_backend_accepts_custom_http_metric() - { - var metric = CrossFirebasePerformanceMonitoring.Current.NewHttpMetric( - "https://example.com/performance-monitoring/real-backend", - HttpMethod.Get - ); + PerformanceAssertions.HttpMetricContract(metric); + } - AssertHttpMetricContract(metric); - } + [Theory] + [MemberData(nameof(HttpMethods))] + public void records_uri_http_metric_for_each_method(HttpMethod method) + { + var metric = CrossFirebasePerformanceMonitoring.Current.NewHttpMetric( + new Uri(GetHttpMetricUrl("uri", method)), + method + ); - private static void AssertHttpMetricContract(IFirebasePerformanceHttpMetric metric) - { - var started = false; + PerformanceAssertions.HttpMetricContract(metric); + } - try { - metric.PutAttribute(AttributeName, AttributeValue); - Assert.Equal(AttributeValue, metric.GetAttribute(AttributeName)); - Assert.True(metric.Attributes.ContainsKey(AttributeName)); - Assert.Equal(AttributeValue, metric.Attributes[AttributeName]); + [Fact] + public void throws_for_null_http_metric_uri() + { + PerformanceAssertions.NullHttpMetricUriThrows(CrossFirebasePerformanceMonitoring.Current); + } - metric.Start(); - started = true; + [Fact] + public void throws_for_unsupported_string_http_metric_method() + { + PerformanceAssertions.UnsupportedStringHttpMetricMethodThrows(CrossFirebasePerformanceMonitoring.Current); + } - metric.SetHttpResponseCode(200); - metric.SetRequestPayloadSize(10); - metric.SetResponsePayloadSize(20); - metric.SetResponseContentType("text/plain"); + [Fact] + public void throws_for_unsupported_uri_http_metric_method() + { + PerformanceAssertions.UnsupportedUriHttpMetricMethodThrows(CrossFirebasePerformanceMonitoring.Current); + } - metric.RemoveAttribute(AttributeName); - Assert.Null(metric.GetAttribute(AttributeName)); - Assert.False(metric.Attributes.ContainsKey(AttributeName)); - } - finally { - if(started) { - metric.Stop(); - } + [RealFirebaseFact] + public void real_backend_accepts_custom_trace() + { + var trace = CrossFirebasePerformanceMonitoring.Current.NewTrace("test_real_backend_trace"); + var started = false; + + try { + trace.PutAttribute(PerformanceAssertions.AttributeName, "real_backend"); + trace.Start(); + started = true; + trace.PutMetric(PerformanceAssertions.MetricName, 1); + trace.IncrementMetric(PerformanceAssertions.MetricName, 1); + PerformanceAssertions.TraceMetric(trace, 2); + } + finally { + if(started) { + trace.Stop(); } } + } - private static string GetHttpMetricUrl(string overloadName, HttpMethod method) - { - return $"https://example.com/performance-monitoring/{overloadName}/{method.Method.ToLowerInvariant()}"; - } + [RealFirebaseFact] + public void real_backend_accepts_custom_http_metric() + { + var metric = CrossFirebasePerformanceMonitoring.Current.NewHttpMetric( + "https://example.com/performance-monitoring/real-backend", + HttpMethod.Get + ); + + PerformanceAssertions.HttpMetricContract(metric); + } + + private static string GetHttpMetricUrl(string overloadName, HttpMethod method) + { + return $"https://example.com/performance-monitoring/{overloadName}/{method.Method.ToLowerInvariant()}"; } -} +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Platforms/Android/AnalyticsAndroidFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Platforms/Android/AnalyticsAndroidFixture.cs new file mode 100644 index 00000000..0d362cae --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Platforms/Android/AnalyticsAndroidFixture.cs @@ -0,0 +1,54 @@ +using Plugin.Firebase.Analytics; + +namespace Plugin.Firebase.IntegrationTests.Analytics; + +[Collection("Sequential")] +[TestLogging] +[IntegrationTestFixture(IntegrationTestPackage.Analytics)] +[Preserve(AllMembers = true)] +public sealed class AnalyticsAndroidFixture +{ + [Fact] + public void throws_actionable_exception_when_android_analytics_is_not_initialized() + { + var firebaseAnalyticsField = typeof(FirebaseAnalyticsImplementation).GetField( + "_firebaseAnalytics", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static + ); + Assert.NotNull(firebaseAnalyticsField); + + var originalFirebaseAnalytics = firebaseAnalyticsField.GetValue(null); + try { + firebaseAnalyticsField.SetValue(null, null); + + var logEventException = Assert.Throws( + () => CrossFirebaseAnalytics.Current.LogEvent("test_uninitialized_analytics_guard") + ); + AssertAnalyticsNotInitializedException(logEventException); + + var setDefaultEventParametersException = Assert.Throws( + () => CrossFirebaseAnalytics.Current.SetDefaultEventParameters(new Dictionary { + { "default_string", "some_value" } + }) + ); + AssertAnalyticsNotInitializedException(setDefaultEventParametersException); + + var setConsentException = Assert.Throws( + () => CrossFirebaseAnalytics.Current.SetConsent(new Dictionary { + { ConsentType.AnalyticsStorage, ConsentStatus.Granted } + }) + ); + AssertAnalyticsNotInitializedException(setConsentException); + } + finally { + firebaseAnalyticsField.SetValue(null, originalFirebaseAnalytics); + } + } + + private static void AssertAnalyticsNotInitializedException(InvalidOperationException exception) + { + Assert.Contains("Firebase Analytics has not been initialized on Android", exception.Message); + Assert.Contains("FirebaseAnalyticsImplementation.Initialize(activity)", exception.Message); + Assert.Contains("isAnalyticsEnabled: true", exception.Message); + } +} diff --git a/tests/Plugin.Firebase.IntegrationTests/Platforms/Android/CloudMessagingAndroidFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Platforms/Android/CloudMessagingAndroidFixture.cs new file mode 100644 index 00000000..43dccdce --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Platforms/Android/CloudMessagingAndroidFixture.cs @@ -0,0 +1,32 @@ +using Plugin.Firebase.CloudMessaging; + +namespace Plugin.Firebase.IntegrationTests.CloudMessaging; + +[Collection("Sequential")] +[TestLogging] +[IntegrationTestFixture(IntegrationTestPackage.CloudMessaging)] +[Preserve(AllMembers = true)] +public sealed class CloudMessagingAndroidFixture +{ + [Fact] + public void silent_foreground_notification_suppresses_android_local_notification_action() + { + var wasInvoked = false; + FirebaseCloudMessagingImplementation.ShowLocalNotificationAction = _ => wasInvoked = true; + + try { + CrossFirebaseCloudMessaging.Current.OnNotificationReceived( + new FCMNotification( + data: new Dictionary { + { "title", "Silent" }, + { "body", "No local notification" }, + { "is_silent_in_foreground", "true" } + })); + + Assert.False(wasInvoked); + } + finally { + FirebaseCloudMessagingImplementation.ShowLocalNotificationAction = null; + } + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Plugin.Firebase.IntegrationTests.csproj b/tests/Plugin.Firebase.IntegrationTests/Plugin.Firebase.IntegrationTests.csproj index 14583b4f..1397c1b7 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Plugin.Firebase.IntegrationTests.csproj +++ b/tests/Plugin.Firebase.IntegrationTests/Plugin.Firebase.IntegrationTests.csproj @@ -10,6 +10,8 @@ 9.0.120 true enable + enable + true $(RestoreAdditionalProjectSources);https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json @@ -37,6 +39,7 @@ + diff --git a/tests/Plugin.Firebase.IntegrationTests/README.md b/tests/Plugin.Firebase.IntegrationTests/README.md new file mode 100644 index 00000000..7fca4c15 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/README.md @@ -0,0 +1,66 @@ +# Integration Test Harness + +`Plugin.Firebase.IntegrationTests` is a MAUI/xUnit device test app. The default backend is the Firebase Local Emulator Suite for Auth, Firestore, Functions, and Storage. Real Firebase project tests must stay opt-in. + +## Fact Attributes + +- Use `Fact` for tests that run on both emulator and real backends. +- Use `EmulatorBackendFact` / `EmulatorBackendTheory` when the test requires local emulators. +- Use `RealFirebaseFact` or `RealFirebaseOptInFact` when Firebase has no local emulator or the test needs a configured real project. +- Use `AndroidFact`, `IosFact`, or `IosDeviceFact` for platform-only behavior instead of returning early from the test body. +- Use `OptInFact` for destructive, paid, external-delivery, or manually coordinated tests. +- Use `IntegrationTestCase` only when a plain xUnit attribute needs explicit backend/platform/opt-in metadata. + +## Coverage Metadata + +- Put `IntegrationTestFixture(IntegrationTestPackage.X)` on the root declaration for every fixture that maps to package acceptance coverage. +- Use `IntegrationTestCoverageIgnore` only for harness-only fixtures, and include the reason in the attribute. +- Keep `ACCEPTANCE_COVERAGE.md` as the source of package coverage expectations. Dynamic Links is intentionally excluded. +- Run `scripts/check-integration-coverage.rb` after adding, renaming, or moving fixtures. + +## Resource Cleanup + +- Prefer explicit scopes over fixture-wide cleanup when a test creates backend state. +- Use `AuthTestUserScope` for temporary Auth users. +- Use `FirestoreTestCollectionScope` for unique Firestore collections. +- Use `StorageTestPathScope` for temporary Storage files. +- Use `IntegrationTestResourceScope` when one test owns multiple async cleanup resources; add resources as soon as they are created. +- Cleanup should log failures with `TestLog` and avoid deleting shared seed data. +- Shared seeded Auth users, custom-claims users, and destructive opt-in flows must stay explicit and should not be hidden inside broad cleanup. +- Fixture-level Auth cleanup is a defensive fallback. New temporary Auth flows should own their user through `AuthTestUserScope`. + +## C# Test Layout + +- Keep `PackageFixture.cs` for lifecycle, setup, shared resource accessors, and package-level state. +- Keep `PackageFixture.Behavior.cs` files focused on test methods and visible Firebase API calls. +- Put DTOs and Firestore documents in `PackagePayloads.cs` files, data construction in `PackageFactories.cs`, and reusable checks in `PackageAssertions.cs`. +- Keep fixture files under roughly 200 lines unless a behavior area genuinely needs more room. + +## Probes and Helpers + +- Use `CallbackProbe` for callback/listener completion instead of ad hoc `TaskCompletionSource`. +- Use `EventProbe` for .NET events that should unsubscribe reliably at the end of the test. +- Keep timeout names explicit at the wait site so failures identify the operation. +- Put repeated wire field names beside the payload/model type they belong to; leave one-off field names inline when that is clearer. +- Helper extraction must preserve test behavior, xUnit skip attributes, and coverage metadata. + +## Seed Data + +- Auth emulator seed data lives in `tests/cloud-functions/scripts/seed-auth-emulator.js`. +- Functions emulator behavior lives in `tests/cloud-functions/functions/src/index.ts`. +- Storage tests create emulator seed files through the test harness and should only assume real-backend seed files documented in `docs/BUILDING.md`. + +## Adding Coverage + +- Keep tests close to native Firebase behavior and avoid app-specific policy. +- Add new public API coverage to `ACCEPTANCE_COVERAGE.md`. +- Keep real-backend requirements documented in `docs/BUILDING.md`. +- Prefer `WaitForTestAsync` and `EventuallyAsync` for device callbacks so failures name the operation that timed out. +- Keep fixture files behavior-focused. For broad fixtures, split files by operation family such as credentials, listeners, payload serialization, dictionary values, nullability, uploads, downloads, metadata, and cleanup. +- Move reusable Firestore document/payload types into internal files under `Firestore/` instead of nesting them in arrange/act/assert flows. + +## Running Locally + +- Use `scripts/check-integration-environment.sh android|ios` before a device run to verify CLIs, built app output, function build output, emulator ports, and target availability. +- `scripts/run-integration-emulators.sh android|ios` calls preflight automatically. Set `SKIP_INTEGRATION_PREFLIGHT=1` only for CI edge cases where another step has already guaranteed the environment. +- GitHub summaries from `scripts/write-xunit-github-summary.rb` include totals, failures, skips, slowest tests, and recent `[TEST START]` breadcrumbs when logs are present. diff --git a/tests/Plugin.Firebase.IntegrationTests/RemoteConfig/RemoteConfigFixture.cs b/tests/Plugin.Firebase.IntegrationTests/RemoteConfig/RemoteConfigFixture.cs index 5b99fe15..5e17ce03 100644 --- a/tests/Plugin.Firebase.IntegrationTests/RemoteConfig/RemoteConfigFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/RemoteConfig/RemoteConfigFixture.cs @@ -10,212 +10,238 @@ using NSStringSet = Foundation.NSSet; #endif -namespace Plugin.Firebase.IntegrationTests.RemoteConfig +namespace Plugin.Firebase.IntegrationTests.RemoteConfig; + +[Collection("Sequential")] +[TestLogging] +[IntegrationTestFixture(IntegrationTestPackage.RemoteConfig)] +[Preserve(AllMembers = true)] +public sealed class RemoteConfigFixture { - [Collection("Sequential")] - [TestLogging] - [Preserve(AllMembers = true)] - public sealed class RemoteConfigFixture + [RealFirebaseFact] + public async Task ensures_it_is_initialized() { - [RealFirebaseFact] - public async Task ensures_it_is_initialized() - { - await CrossFirebaseRemoteConfig.Current.EnsureInitializedAsync(); - } + await CrossFirebaseRemoteConfig.Current.EnsureInitializedAsync(); + } - [RealFirebaseFact] - public async Task sets_defaults_via_tuples() - { - var sut = CrossFirebaseRemoteConfig.Current; - var millis = DateTimeOffset.Now.ToUnixTimeMilliseconds(); - - await sut.SetDefaultsAsync( - ("some_string", millis.ToString()), - ("some_long", millis), - ("some_double", millis), - ("some_bool", millis % 2 == 0)); - - Assert.Equal(millis.ToString(), sut.GetString("some_string")); - Assert.Equal(millis, sut.GetLong("some_long")); - Assert.Equal(millis, sut.GetDouble("some_double")); - Assert.Equal(millis % 2 == 0, sut.GetBoolean("some_bool")); - } + [RealFirebaseFact] + public async Task sets_defaults_via_tuples() + { + var sut = CrossFirebaseRemoteConfig.Current; + var millis = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + + await sut.SetDefaultsAsync( + ("some_string", millis.ToString()), + ("some_long", millis), + ("some_double", millis), + ("some_bool", millis % 2 == 0)); + + Assert.Equal(millis.ToString(), sut.GetString("some_string")); + Assert.Equal(millis, sut.GetLong("some_long")); + Assert.Equal(millis, sut.GetDouble("some_double")); + Assert.Equal(millis % 2 == 0, sut.GetBoolean("some_bool")); + } - [RealFirebaseFact] - public async Task sets_defaults_via_dictionary() - { - var sut = CrossFirebaseRemoteConfig.Current; - var millis = DateTimeOffset.Now.ToUnixTimeMilliseconds(); - - await sut.SetDefaultsAsync(new Dictionary { - { "some_string", millis.ToString() }, - { "some_long", millis }, - { "some_double", millis }, - { "some_bool", millis%2 == 0 } - }); - - Assert.Equal(millis.ToString(), sut.GetString("some_string")); - Assert.Equal(millis, sut.GetLong("some_long")); - Assert.Equal(millis, sut.GetDouble("some_double")); - Assert.Equal(millis % 2 == 0, sut.GetBoolean("some_bool")); - } + [RealFirebaseFact] + public async Task sets_defaults_via_dictionary() + { + var sut = CrossFirebaseRemoteConfig.Current; + var millis = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + + await sut.SetDefaultsAsync(new Dictionary { + { "some_string", millis.ToString() }, + { "some_long", millis }, + { "some_double", millis }, + { "some_bool", millis%2 == 0 } + }); + + Assert.Equal(millis.ToString(), sut.GetString("some_string")); + Assert.Equal(millis, sut.GetLong("some_long")); + Assert.Equal(millis, sut.GetDouble("some_double")); + Assert.Equal(millis % 2 == 0, sut.GetBoolean("some_bool")); + } - [RealFirebaseFact] - public async Task fetches_and_activates_remote_config_at_once() - { - var sut = CrossFirebaseRemoteConfig.Current; + [RealFirebaseFact] + public async Task fetches_and_activates_remote_config_at_once() + { + var sut = CrossFirebaseRemoteConfig.Current; - await sut.SetDefaultsAsync( - ("remote_string", "default_value"), - ("remote_long", 123L), - ("remote_double", 12.3), - ("remote_bool", false)); + await sut.SetDefaultsAsync( + ("remote_string", "default_value"), + ("remote_long", 123L), + ("remote_double", 12.3), + ("remote_bool", false)); - await sut.SetRemoteConfigSettingsAsync(new RemoteConfigSettings(minimumFetchInterval: TimeSpan.Zero)); - await sut.FetchAndActivateAsync(); + await sut.SetRemoteConfigSettingsAsync(new RemoteConfigSettings(minimumFetchInterval: TimeSpan.Zero)); + await sut.FetchAndActivateAsync(); - Assert.Equal("remote_value", sut.GetString("remote_string")); - Assert.Equal(1337L, sut.GetLong("remote_long")); - Assert.Equal(13.37, sut.GetDouble("remote_double")); - Assert.True(sut.GetBoolean("remote_bool")); - } + Assert.Equal("remote_value", sut.GetString("remote_string")); + Assert.Equal(1337L, sut.GetLong("remote_long")); + Assert.Equal(13.37, sut.GetDouble("remote_double")); + Assert.True(sut.GetBoolean("remote_bool")); + } - [RealFirebaseFact] - public async Task fetches_and_activates_remote_config_separately() - { - var sut = CrossFirebaseRemoteConfig.Current; - - await sut.SetDefaultsAsync( - ("remote_string", "default_value"), - ("remote_long", 123L), - ("remote_double", 12.3), - ("remote_bool", false)); - - try { - await sut.FetchAsync(0); - await sut.ActivateAsync(); - } catch(Exception e) { - Assert.Equal(GetExpectedActivationErrorMessage(), e.Message); - } - - Assert.Equal("remote_value", sut.GetString("remote_string")); - Assert.Equal(1337L, sut.GetLong("remote_long")); - Assert.Equal(13.37, sut.GetDouble("remote_double")); - Assert.True(sut.GetBoolean("remote_bool")); + [RealFirebaseFact] + public async Task fetches_and_activates_remote_config_separately() + { + var sut = CrossFirebaseRemoteConfig.Current; + + await sut.SetDefaultsAsync( + ("remote_string", "default_value"), + ("remote_long", 123L), + ("remote_double", 12.3), + ("remote_bool", false)); + + try { + await sut.FetchAsync(0); + await sut.ActivateAsync(); + } catch(Exception e) { + Assert.Equal(GetExpectedActivationErrorMessage(), e.Message); } - [RealFirebaseFact] - public void registers_and_disposes_config_update_listener() - { - IDisposable registration = null; - - try { - registration = CrossFirebaseRemoteConfig.Current.AddOnConfigUpdateListener( - _ => { }, - _ => { }); - - Assert.NotNull(registration); - } - finally { - registration?.Dispose(); - } + Assert.Equal("remote_value", sut.GetString("remote_string")); + Assert.Equal(1337L, sut.GetLong("remote_long")); + Assert.Equal(13.37, sut.GetDouble("remote_double")); + Assert.True(sut.GetBoolean("remote_bool")); + } + + [RealFirebaseFact] + public void registers_and_disposes_config_update_listener() + { + IDisposable? registration = null; + + try { + registration = CrossFirebaseRemoteConfig.Current.AddOnConfigUpdateListener( + _ => { }, + _ => { }); + + Assert.NotNull(registration); + } + finally { + registration?.Dispose(); } + } #if ANDROID - [Fact] - public void android_config_update_listener_maps_updated_keys() - { - RemoteConfigUpdate update = null; - Exception error = null; - var listener = new ConfigUpdateListener( - x => update = x, - x => error = x); - var updatedKeys = new[] { "remote_string", "remote_bool" }; - - listener.OnUpdate(ConfigUpdate.Create(updatedKeys)); - - Assert.Null(error); - Assert.NotNull(update); - Assert.Equal( - new[] { "remote_bool", "remote_string" }, - update.UpdatedKeys.OrderBy(x => x).ToArray()); - } + [Fact] + public void android_config_update_listener_maps_updated_keys() + { + RemoteConfigUpdate? update = null; + Exception? error = null; + var listener = new ConfigUpdateListener( + x => update = x, + x => error = x); + var updatedKeys = new[] { "remote_string", "remote_bool" }; + + listener.OnUpdate(ConfigUpdate.Create(updatedKeys)); + + Assert.Null(error); + Assert.NotNull(update); + Assert.Equal( + new[] { "remote_bool", "remote_string" }, + update!.UpdatedKeys.OrderBy(x => x).ToArray()); + } - [Fact] - public void android_config_update_listener_maps_errors() - { - RemoteConfigUpdate update = null; - Exception error = null; - var listener = new ConfigUpdateListener( - x => update = x, - x => error = x); - using var nativeError = new FirebaseRemoteConfigException( - "real-time update failed", - FirebaseRemoteConfigException.Code.ConfigUpdateUnavailable); - - listener.OnError(nativeError); - - Assert.Null(update); - var firebaseException = Assert.IsType(error); - Assert.Equal("real-time update failed", firebaseException.Message); - } + [Fact] + public void android_config_update_listener_maps_errors() + { + RemoteConfigUpdate? update = null; + Exception? error = null; + var listener = new ConfigUpdateListener( + x => update = x, + x => error = x); + using var nativeError = new FirebaseRemoteConfigException( + "real-time update failed", + FirebaseRemoteConfigException.Code.ConfigUpdateUnavailable!); + + listener.OnError(nativeError); + + Assert.Null(update); + var firebaseException = Assert.IsType(error); + Assert.Equal("real-time update failed", firebaseException.Message); + } #elif IOS - [Fact] - public void ios_config_update_listener_maps_updated_keys() - { - using var firstKey = new NSString("remote_string"); - using var secondKey = new NSString("remote_bool"); - using var updatedKeys = new NSStringSet(new[] { firstKey, secondKey }); - - var update = ConfigUpdateListener.ToAbstract(updatedKeys); - - Assert.Equal( - new[] { "remote_bool", "remote_string" }, - update.UpdatedKeys.OrderBy(x => x).ToArray()); - } + [Fact] + public void ios_config_update_listener_maps_updated_keys() + { + using var firstKey = new NSString("remote_string"); + using var secondKey = new NSString("remote_bool"); + using var updatedKeys = new NSStringSet(new[] { firstKey, secondKey }); - [Fact] - public void ios_config_update_listener_maps_errors() - { - using var domain = new NSString("remote.config.test"); - using var nativeError = new NSError(domain, new IntPtr(7)); + var update = ConfigUpdateListener.ToAbstract(updatedKeys); - var firebaseException = ConfigUpdateListener.ToAbstract(nativeError); + Assert.Equal( + new[] { "remote_bool", "remote_string" }, + update.UpdatedKeys.OrderBy(x => x).ToArray()); + } - Assert.Equal(nativeError.LocalizedDescription, firebaseException.Message); - } + [Fact] + public void ios_config_update_listener_maps_errors() + { + using var domain = new NSString("remote.config.test"); + using var nativeError = new NSError(domain, new IntPtr(7)); + + var firebaseException = ConfigUpdateListener.ToAbstract(nativeError); + + Assert.Equal(nativeError.LocalizedDescription, firebaseException.Message); + } #endif - private static string GetExpectedActivationErrorMessage() - { - return DeviceInfo.Platform == DevicePlatform.iOS - ? "Error Domain=com.google.remoteconfig.ErrorDomain Code=8003 \"(null)\" UserInfo={ActivationFailureReason=Most recently fetched config already activated}" - : "Android shouldn't throw an exception"; - } + [RealFirebaseFact] + public void gets_keys_by_prefix() + { + var keys = CrossFirebaseRemoteConfig.Current.GetKeysByPrefix("remote").ToList(); + Assert.Equal(4, keys.Count); + Assert.Contains("remote_string", keys); + Assert.Contains("remote_long", keys); + Assert.Contains("remote_double", keys); + Assert.Contains("remote_bool", keys); + } - [RealFirebaseFact] - public void gets_keys_by_prefix() - { - var keys = CrossFirebaseRemoteConfig.Current.GetKeysByPrefix("remote").ToList(); - Assert.Equal(4, keys.Count()); - Assert.Contains("remote_string", keys); - Assert.Contains("remote_long", keys); - Assert.Contains("remote_double", keys); - Assert.Contains("remote_bool", keys); - } + [RealFirebaseFact] + public async Task gets_keys_for_empty_prefix() + { + var sut = CrossFirebaseRemoteConfig.Current; + var key = IntegrationTestData.UniqueId("acceptance_empty_prefix").Replace('-', '_'); - [RealFirebaseFact] - public async Task gets_info() - { - var sut = CrossFirebaseRemoteConfig.Current; - var configSettings = new RemoteConfigSettings(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)); + await sut.SetDefaultsAsync((key, "default")); - await sut.SetRemoteConfigSettingsAsync(configSettings); - await sut.FetchAsync(); + var keys = sut.GetKeysByPrefix("").ToList(); - var info = sut.Info; - Assert.Equal(configSettings, info.ConfigSettings); - Assert.Equal(RemoteConfigFetchStatus.Success, info.LastFetchStatus); - } + Assert.Contains(key, keys); + } + + [RealFirebaseFact] + public void returns_default_values_for_missing_keys() + { + var sut = CrossFirebaseRemoteConfig.Current; + var key = IntegrationTestData.UniqueId("missing").Replace('-', '_'); + + Assert.False(sut.GetBoolean(key)); + Assert.Equal("", sut.GetString(key)); + Assert.Equal(0L, sut.GetLong(key)); + Assert.Equal(0d, sut.GetDouble(key)); + } + + [RealFirebaseFact] + public async Task gets_info() + { + var sut = CrossFirebaseRemoteConfig.Current; + var configSettings = new RemoteConfigSettings(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)); + + await sut.SetRemoteConfigSettingsAsync(configSettings); + await sut.FetchAsync(); + + var info = sut.Info; + Assert.Equal(configSettings, info.ConfigSettings); + Assert.Equal(RemoteConfigFetchStatus.Success, info.LastFetchStatus); + Assert.NotEqual(default, info.LastFetchTime); + } + + private static string GetExpectedActivationErrorMessage() + { + return DeviceInfo.Platform == DevicePlatform.iOS + ? "Error Domain=com.google.remoteconfig.ErrorDomain Code=8003 \"(null)\" UserInfo={ActivationFailureReason=Most recently fetched config already activated}" + : "Android shouldn't throw an exception"; } -} \ No newline at end of file +} diff --git a/tests/Plugin.Firebase.IntegrationTests/Storage/StorageAssertions.cs b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageAssertions.cs new file mode 100644 index 00000000..e76be3c8 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageAssertions.cs @@ -0,0 +1,39 @@ +using System.Net; +using Plugin.Firebase.Storage; + +namespace Plugin.Firebase.IntegrationTests.Storage; + +internal static class StorageAssertions +{ + public static string ExpectedBucket() + { + return CrossFirebaseStorage.Current.GetRootReference().Bucket; + } + + public static void DownloadUrl(string pathToFile, string downloadUrl) + { + var bucket = ExpectedBucket(); + var decodedUrl = WebUtility.UrlDecode(downloadUrl); + if(IntegrationTestEnvironment.ShouldUseStorageEmulator) { + var uri = new Uri(decodedUrl); + var expectedEndpoint = IntegrationTestEnvironment.StorageEmulatorEndpoint; + + Assert.Equal("http", uri.Scheme); + Assert.True( + string.Equals(uri.Host, expectedEndpoint.Host, StringComparison.OrdinalIgnoreCase) + || (string.Equals(expectedEndpoint.Host, "localhost", StringComparison.OrdinalIgnoreCase) + && string.Equals(uri.Host, "127.0.0.1", StringComparison.OrdinalIgnoreCase)), + $"Expected storage emulator host '{expectedEndpoint.Host}' but got '{uri.Host}'."); + Assert.Equal(expectedEndpoint.Port, uri.Port); + Assert.StartsWith($"/v0/b/{bucket}/o/{pathToFile}", uri.AbsolutePath); + Assert.Contains("alt=media", uri.Query, StringComparison.Ordinal); + Assert.Contains("token=", uri.Query, StringComparison.Ordinal); + return; + } + + var port = DeviceInfo.Platform == DevicePlatform.iOS ? ":443" : ""; + Assert.StartsWith( + $"https://firebasestorage.googleapis.com{port}/v0/b/{bucket}/o/{pathToFile}?alt=media&token=", + decodedUrl); + } +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Downloads.cs b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Downloads.cs new file mode 100644 index 00000000..705d0ada --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Downloads.cs @@ -0,0 +1,129 @@ +using System.Net; +using System.Text; +using Plugin.Firebase.Storage; + +namespace Plugin.Firebase.IntegrationTests.Storage; + +public sealed partial class StorageFixture +{ + [Fact] + public async Task gets_data_as_stream() + { + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath("files_to_keep/text_1.txt"); + + var stream = await reference.GetStreamAsync(1 * 1024 * 1024); + Assert.NotNull(stream); + } + + + [Fact] + public async Task gets_data_as_bytes() + { + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath("files_to_keep/text_1.txt"); + + var bytes = await reference.GetBytesAsync(1 * 1024 * 1024); + Assert.NotNull(bytes); + Assert.Equal(34, bytes.Length); + } + + + [Fact] + public async Task fails_when_download_exceeds_max_byte_size() + { + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath("files_to_keep/text_1.txt"); + + await Assert.ThrowsAnyAsync(() => reference.GetBytesAsync(1)); + } + + + [Fact] + public async Task downloads_file() + { + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath("files_to_keep/text_1.txt"); + + await reference.DownloadFile($"{FileSystem.CacheDirectory}/test.txt").AwaitAsync(); + } + + + [Fact] + public async Task observes_download_success_snapshot() + { + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath("files_to_keep/text_1.txt"); + var destinationFilePath = Path.Combine( + FileSystem.CacheDirectory, + IntegrationTestData.UniqueFileName("downloaded", ".txt")); + var transferTask = reference.DownloadFile(destinationFilePath); + var completion = new CallbackProbe(); + Action observer = snapshot => completion.TrySetResult(snapshot); + transferTask.AddObserver(StorageTaskStatus.Success, observer); + + try { + await transferTask.AwaitAsync(); + var snapshot = await completion.WaitAsync( + IntegrationTestTimeouts.Callback, + "storage download success snapshot"); + + Assert.NotNull(snapshot); + Assert.True(File.Exists(destinationFilePath)); + } + finally { + transferTask.RemoveObserver(observer); + } + } + + + [Fact] + public async Task observes_missing_download_failure_snapshot() + { + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath($"missing/{IntegrationTestData.UniqueFileName("missing", ".txt")}"); + var destinationFilePath = Path.Combine( + FileSystem.CacheDirectory, + IntegrationTestData.UniqueFileName("missing", ".txt")); + var transferTask = reference.DownloadFile(destinationFilePath); + var failure = new CallbackProbe(); + Action observer = snapshot => failure.TrySetResult(snapshot); + transferTask.AddObserver(StorageTaskStatus.Failure, observer); + + try { + await Assert.ThrowsAnyAsync( + () => transferTask.AwaitAsync().WaitForTestAsync( + IntegrationTestTimeouts.LongCallback, + "missing storage download failure")); + var snapshot = await failure.WaitAsync( + IntegrationTestTimeouts.Callback, + "storage download failure snapshot"); + + Assert.NotNull(snapshot); + } + finally { + transferTask.RemoveObserver(observer); + } + } + + + [Fact] + public void can_manage_files_download() + { + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath("files_to_keep/text_1.txt"); + + var transferTask = reference.DownloadFile($"{FileSystem.CacheDirectory}/managed.txt"); + transferTask.Pause(); + transferTask.Resume(); + transferTask.Cancel(); + } + +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Listing.cs b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Listing.cs new file mode 100644 index 00000000..c571405f --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Listing.cs @@ -0,0 +1,76 @@ +using System.Net; +using System.Text; +using Plugin.Firebase.Storage; + +namespace Plugin.Firebase.IntegrationTests.Storage; + +public sealed partial class StorageFixture +{ + [Fact] + public async Task lists_files_with_limit() + { + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath("files_to_keep"); + + var result = await reference.ListAsync(2); + Assert.Equal(2, result.Items.Count()); + Assert.False(string.IsNullOrWhiteSpace(result.PageToken)); + } + + + [Fact] + public async Task lists_all_files() + { + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath("files_to_keep"); + + var result = await reference.ListAllAsync(); + Assert.Equal(3, result.Items.Count()); + Assert.Empty(result.Prefixes); + Assert.Null(result.PageToken); + } + + + [Fact] + public async Task lists_prefixes_for_nested_files() + { + var root = CrossFirebaseStorage.Current.GetRootReference(); + var parent = root.GetChild("prefix_listing"); + var firstFile = parent.GetChild("folder_a/first.txt"); + var secondFile = parent.GetChild("folder_b/second.txt"); + + try { + await firstFile.PutBytes("first"u8.ToArray()).AwaitAsync(); + await secondFile.PutBytes("second"u8.ToArray()).AwaitAsync(); + + var result = await parent.ListAllAsync(); + var prefixes = result.Prefixes.Select(x => x.FullPath).OrderBy(x => x).ToList(); + + Assert.Empty(result.Items); + Assert.Equal(Expected, prefixes); + } + finally { + await StorageTestPathScope.DeleteIfExistsAsync(firstFile); + await StorageTestPathScope.DeleteIfExistsAsync(secondFile); + } + } + + + [Fact] + public async Task deletes_file() + { + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath("files_to_delete"); + + Assert.Empty((await reference.ListAllAsync()).Items); + await reference.GetChild("text.txt").PutBytes("This file should get deleted"u8.ToArray()).AwaitAsync(); + Assert.Single((await reference.ListAllAsync()).Items); + + await reference.GetChild("text.txt").DeleteAsync(); + Assert.Empty((await reference.ListAllAsync()).Items); + } + +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Metadata.cs b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Metadata.cs new file mode 100644 index 00000000..aa87c945 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Metadata.cs @@ -0,0 +1,44 @@ +using System.Net; +using System.Text; +using Plugin.Firebase.Storage; + +namespace Plugin.Firebase.IntegrationTests.Storage; + +public sealed partial class StorageFixture +{ + [Fact] + public async Task metadata_exposes_reference_and_timestamps() + { + const string path = "texts/metadata_properties.txt"; + var reference = CrossFirebaseStorage.Current.GetReferenceFromPath(path); + + var metadataToUpload = new StorageMetadata( + cacheControl: "public,max-age=60", + contentDisposition: "inline", + contentEncoding: "identity", + contentLanguage: "en", + contentType: "text/plain"); + + await reference.PutBytes("metadata properties"u8.ToArray(), metadataToUpload).AwaitAsync(); + var metadata = await reference.GetMetadataAsync(); + + Assert.Equal(StorageAssertions.ExpectedBucket(), metadata.Bucket); + Assert.Equal("metadata_properties.txt", metadata.Name); + Assert.Equal(path, metadata.Path); + if(OperatingSystem.IsIOS() && IntegrationTestEnvironment.UsesEmulatorBackend) { + Assert.Null(metadata.CacheControl); + } else { + Assert.Equal("public,max-age=60", metadata.CacheControl); + } + Assert.Equal("inline", metadata.ContentDisposition); + Assert.Equal("identity", metadata.ContentEncoding); + Assert.Equal("en", metadata.ContentLanguage); + Assert.Equal("text/plain", metadata.ContentType); + if(metadata.StorageReference != null) { + Assert.Equal(reference.FullPath, metadata.StorageReference.FullPath); + } + Assert.NotEqual(default, metadata.CreationTime); + Assert.NotEqual(default, metadata.UpdatedTime); + } + +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.References.cs b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.References.cs new file mode 100644 index 00000000..f8f0dc8b --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.References.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Text; +using Plugin.Firebase.Storage; + +namespace Plugin.Firebase.IntegrationTests.Storage; + +public sealed partial class StorageFixture +{ + [Fact] + public void gets_root_reference() + { + var reference = CrossFirebaseStorage.Current.GetRootReference(); + + Assert.NotNull(reference); + Assert.Null(reference.Parent); + Assert.Equal("/", reference.FullPath); + Assert.Equal("", reference.Name); + Assert.Equal(StorageAssertions.ExpectedBucket(), reference.Bucket); + } + + + [Fact] + public void gets_reference_from_url() + { + var bucket = StorageAssertions.ExpectedBucket(); + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromUrl($"gs://{bucket}/files_to_keep/text_1.txt"); + + Assert.NotNull(reference.Root); + Assert.NotNull(reference.Parent); + Assert.Equal("/files_to_keep/text_1.txt", reference.FullPath); + Assert.Equal("text_1.txt", reference.Name); + Assert.Equal(bucket, reference.Bucket); + } + + + [Fact] + public void gets_reference_from_path() + { + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath("files_to_keep/text_1.txt"); + + Assert.NotNull(reference.Root); + Assert.NotNull(reference.Parent); + Assert.Equal("/files_to_keep/text_1.txt", reference.FullPath); + Assert.Equal("text_1.txt", reference.Name); + Assert.Equal(StorageAssertions.ExpectedBucket(), reference.Bucket); + } + + + [Fact] + public void gets_child_reference() + { + var reference = CrossFirebaseStorage + .Current + .GetRootReference().GetChild("files_to_keep/text_1.txt"); + + Assert.NotNull(reference.Root); + Assert.NotNull(reference.Parent); + Assert.Equal("/files_to_keep/text_1.txt", reference.FullPath); + Assert.Equal("text_1.txt", reference.Name); + Assert.Equal(StorageAssertions.ExpectedBucket(), reference.Bucket); + } + + + [Fact] + public void normalizes_nested_child_reference_paths() + { + var reference = CrossFirebaseStorage + .Current + .GetRootReference() + .GetChild("/nested//folder///file.txt/"); + + Assert.Equal("/nested/folder/file.txt", reference.FullPath); + Assert.Equal("file.txt", reference.Name); + Assert.Equal("/nested/folder", reference.Parent.FullPath); + } + + + [Fact] + public async Task gets_download_url() + { + const string path = "files_to_keep/text_1.txt"; + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath(path); + + var downloadUrl = await reference.GetDownloadUrlAsync(); + StorageAssertions.DownloadUrl(path, downloadUrl); + } + +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Uploads.cs b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Uploads.cs new file mode 100644 index 00000000..fa5630e2 --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.Uploads.cs @@ -0,0 +1,119 @@ +using System.Net; +using System.Text; +using Plugin.Firebase.Storage; + +namespace Plugin.Firebase.IntegrationTests.Storage; + +public sealed partial class StorageFixture +{ + [Fact] + public async Task uploads_via_byte_array() + { + const string path = "texts/via_bytes.txt"; + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath(path); + + await reference.PutBytes("Some test text"u8.ToArray()).AwaitAsync(); + var downloadUrl = await reference.GetDownloadUrlAsync(); + StorageAssertions.DownloadUrl(path, downloadUrl); + } + + + [Fact] + public async Task uploads_via_stream() + { + const string path = "texts/via_stream.txt"; + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath(path); + + await using var stream = await CreateTextStreamAsync("Some text via stream"); + await reference.PutStream(stream).AwaitAsync(); + var downloadUrl = await reference.GetDownloadUrlAsync(); + StorageAssertions.DownloadUrl(path, downloadUrl); + } + + + [Fact] + public async Task uploads_via_file_path() + { + const string path = "texts/via_file.txt"; + const string contents = "Some text via file"; + var filePath = await CreateTempTextFileAsync("via_file.txt", contents); + var reference = CrossFirebaseStorage.Current.GetReferenceFromPath(path); + + await reference.PutFile(filePath).AwaitAsync(); + + var bytes = await reference.GetBytesAsync(1 * 1024 * 1024); + Assert.Equal(contents, Encoding.UTF8.GetString(bytes)); + } + + + [Fact] + public async Task uploads_stream_with_meta_data() + { + const string path = "texts/via_stream_with_metadata.txt"; + var metadata = new StorageMetadata(contentType: "text/plain"); + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath(path); + + await reference.PutBytes("Some test text"u8.ToArray(), metadata).AwaitAsync(); + var uploadedMetadata = await reference.GetMetadataAsync(); + + Assert.Equal(path, uploadedMetadata.Path); + Assert.Equal("text/plain", uploadedMetadata.ContentType); + Assert.Equal(14, uploadedMetadata.Size); + + var customData = new Dictionary { { "some_key", "some_value" } }; + var updatedMetadata = await reference.UpdateMetadataAsync(new StorageMetadata(contentType: "text/html", customMetadata: customData)); + + Assert.Equal(path, updatedMetadata.Path); + Assert.Equal("text/html", updatedMetadata.ContentType); + Assert.Equal(customData, updatedMetadata.CustomMetadata); + } + + + [Fact] + public async Task observes_upload_success_snapshot() + { + const string path = "texts/upload_success_snapshot.txt"; + var reference = CrossFirebaseStorage.Current.GetReferenceFromPath(path); + var transferTask = reference.PutBytes("Observe upload success"u8.ToArray()); + var completion = new CallbackProbe(); + Action observer = snapshot => completion.TrySetResult(snapshot); + transferTask.AddObserver(StorageTaskStatus.Success, observer); + + try { + await transferTask.AwaitAsync(); + var snapshot = await completion.WaitAsync( + IntegrationTestTimeouts.Callback, + "storage upload success snapshot"); + + Assert.NotNull(snapshot); + Assert.NotNull(snapshot.Metadata); + Assert.True(snapshot.TransferredUnitCount > 0); + Assert.InRange(snapshot.TransferredFraction, 0.99, 1.01); + } + finally { + transferTask.RemoveObserver(observer); + } + } + + + [Fact] + public void can_manage_files_upload() + { + const string path = "texts/managed.txt"; + var reference = CrossFirebaseStorage + .Current + .GetReferenceFromPath(path); + + var transferTask = reference.PutBytes("Some test text"u8.ToArray()); + transferTask.Pause(); + transferTask.Resume(); + transferTask.Cancel(); + } + +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.cs b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.cs index ca300d34..8c479db9 100644 --- a/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.cs +++ b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageFixture.cs @@ -1,443 +1,95 @@ -using System.Net; using System.Text; -using Plugin.Firebase.IntegrationTests; using Plugin.Firebase.Storage; -namespace Plugin.Firebase.IntegrationTests.Storage -{ - [Collection("Sequential")] - [TestLogging] - [Preserve(AllMembers = true)] - public sealed class StorageFixture : IAsyncLifetime - { - private static readonly SemaphoreSlim SeedLock = new(1, 1); - private static bool _storageEmulatorSeeded; - - public async Task InitializeAsync() - { - await EnsureStorageEmulatorSeedDataAsync(); - } - - [Fact] - public void gets_root_reference() - { - var reference = CrossFirebaseStorage.Current.GetRootReference(); - - Assert.NotNull(reference); - Assert.Null(reference.Parent); - Assert.Equal("/", reference.FullPath); - Assert.Equal("", reference.Name); - Assert.Equal(GetExpectedBucket(), reference.Bucket); - } +namespace Plugin.Firebase.IntegrationTests.Storage; - [Fact] - public void gets_reference_from_url() - { - var bucket = GetExpectedBucket(); - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromUrl($"gs://{bucket}/files_to_keep/text_1.txt"); - - Assert.NotNull(reference.Root); - Assert.NotNull(reference.Parent); - Assert.Equal("/files_to_keep/text_1.txt", reference.FullPath); - Assert.Equal("text_1.txt", reference.Name); - Assert.Equal(bucket, reference.Bucket); - } +[Collection("Sequential")] +[TestLogging] +[IntegrationTestFixture(IntegrationTestPackage.Storage)] +[Preserve(AllMembers = true)] +public sealed partial class StorageFixture : IAsyncLifetime +{ + private static readonly SemaphoreSlim SeedLock = new(1, 1); + private static bool _storageEmulatorSeeded; + private static readonly string[] Expected = ["/prefix_listing/folder_a", "/prefix_listing/folder_b"]; - [Fact] - public void gets_reference_from_path() - { - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath("files_to_keep/text_1.txt"); + public async Task InitializeAsync() + { + await EnsureStorageEmulatorSeedDataAsync(); + } - Assert.NotNull(reference.Root); - Assert.NotNull(reference.Parent); - Assert.Equal("/files_to_keep/text_1.txt", reference.FullPath); - Assert.Equal("text_1.txt", reference.Name); - Assert.Equal(GetExpectedBucket(), reference.Bucket); - } + private static bool UsesStorageEmulator() + { + return IntegrationTestEnvironment.ShouldUseStorageEmulator; + } - [Fact] - public void gets_child_reference() - { - var reference = CrossFirebaseStorage - .Current - .GetRootReference().GetChild("files_to_keep/text_1.txt"); + private static async Task CreateTextStreamAsync(string text) + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + await writer.WriteAsync(text); + await writer.FlushAsync(); + return stream; + } - Assert.NotNull(reference.Root); - Assert.NotNull(reference.Parent); - Assert.Equal("/files_to_keep/text_1.txt", reference.FullPath); - Assert.Equal("text_1.txt", reference.Name); - Assert.Equal(GetExpectedBucket(), reference.Bucket); - } + private static async Task CreateTempTextFileAsync(string fileName, string text) + { + var filePath = Path.Combine(FileSystem.CacheDirectory, fileName); + await File.WriteAllTextAsync(filePath, text); + return filePath; + } - [Fact] - public async Task gets_download_url() - { - var path = $"files_to_keep/text_1.txt"; - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath(path); + public async Task DisposeAsync() + { + TestLog.Write("[STORAGE CLEANUP START]"); + var rootReference = CrossFirebaseStorage.Current.GetRootReference(); + await StorageTestPathScope.DeleteChildrenIfExistsAsync(rootReference.GetChild("files_to_delete")); + await StorageTestPathScope.DeleteChildrenIfExistsAsync(rootReference.GetChild("texts")); + TestLog.Write("[STORAGE CLEANUP END]"); + } - var downloadUrl = await reference.GetDownloadUrlAsync(); - AssertDownloadUrl(path, downloadUrl); + private static async Task EnsureStorageEmulatorSeedDataAsync() + { + if(!UsesStorageEmulator() || _storageEmulatorSeeded) { + return; } - private static void AssertDownloadUrl(string pathToFile, string downloadUrl) - { - var bucket = GetExpectedBucket(); - var decodedUrl = WebUtility.UrlDecode(downloadUrl); - if(UsesStorageEmulator()) { - var uri = new Uri(decodedUrl); - var expectedEndpoint = IntegrationTestEnvironment.StorageEmulatorEndpoint; - - Assert.Equal("http", uri.Scheme); - Assert.True( - string.Equals(uri.Host, expectedEndpoint.Host, StringComparison.OrdinalIgnoreCase) - || (string.Equals(expectedEndpoint.Host, "localhost", StringComparison.OrdinalIgnoreCase) - && string.Equals(uri.Host, "127.0.0.1", StringComparison.OrdinalIgnoreCase)), - $"Expected storage emulator host '{expectedEndpoint.Host}' but got '{uri.Host}'."); - Assert.Equal(expectedEndpoint.Port, uri.Port); - Assert.StartsWith($"/v0/b/{bucket}/o/{pathToFile}", uri.AbsolutePath); - Assert.Contains("alt=media", uri.Query, StringComparison.Ordinal); - Assert.Contains("token=", uri.Query, StringComparison.Ordinal); + await SeedLock.WaitAsync(); + try { + if(_storageEmulatorSeeded) { return; } - var port = DeviceInfo.Platform == DevicePlatform.iOS ? ":443" : ""; - Assert.StartsWith( - $"https://firebasestorage.googleapis.com{port}/v0/b/{bucket}/o/{pathToFile}?alt=media&token=", - decodedUrl); - } - - private static string GetExpectedBucket() - { - return CrossFirebaseStorage.Current.GetRootReference().Bucket; - } - - private static bool UsesStorageEmulator() - { - return IntegrationTestEnvironment.ShouldUseStorageEmulator; - } - - [Fact] - public async Task uploads_via_byte_array() - { - var path = $"texts/via_bytes.txt"; - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath(path); - - await reference.PutBytes(Encoding.UTF8.GetBytes("Some test text")).AwaitAsync(); - var downloadUrl = await reference.GetDownloadUrlAsync(); - AssertDownloadUrl(path, downloadUrl); - } - - [Fact] - public async Task uploads_via_stream() - { - var path = $"texts/via_stream.txt"; - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath(path); - - using(var stream = await CreateTextStreamAsync("Some text via stream")) { - await reference.PutStream(stream).AwaitAsync(); - var downloadUrl = await reference.GetDownloadUrlAsync(); - AssertDownloadUrl(path, downloadUrl); - } - } - - [Fact] - public async Task uploads_via_file_path() - { - var path = "texts/via_file.txt"; - var contents = "Some text via file"; - var filePath = await CreateTempTextFileAsync("via_file.txt", contents); - var reference = CrossFirebaseStorage.Current.GetReferenceFromPath(path); - - await reference.PutFile(filePath).AwaitAsync(); - - var bytes = await reference.GetBytesAsync(1 * 1024 * 1024); - Assert.Equal(contents, Encoding.UTF8.GetString(bytes)); - } - - private static async Task CreateTextStreamAsync(string text) - { - var stream = new MemoryStream(); - var writer = new StreamWriter(stream); - await writer.WriteAsync(text); - await writer.FlushAsync(); - return stream; - } - - private static async Task CreateTempTextFileAsync(string fileName, string text) - { - var filePath = Path.Combine(FileSystem.CacheDirectory, fileName); - await File.WriteAllTextAsync(filePath, text); - return filePath; - } - - [Fact] - public async Task uploads_stream_with_meta_data() - { - var path = $"texts/via_stream_with_metadata.txt"; - var metadata = new StorageMetadata(contentType: "text/plain"); - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath(path); - - await reference.PutBytes("Some test text"u8.ToArray(), metadata).AwaitAsync(); - var uploadedMetadata = await reference.GetMetadataAsync(); - - Assert.Equal(path, uploadedMetadata.Path); - Assert.Equal("text/plain", uploadedMetadata.ContentType); - Assert.Equal(14, uploadedMetadata.Size); - - var customData = new Dictionary { { "some_key", "some_value" } }; - var updatedMetadata = await reference.UpdateMetadataAsync(new StorageMetadata(contentType: "text/html", customMetadata: customData)); - - Assert.Equal(path, updatedMetadata.Path); - Assert.Equal("text/html", updatedMetadata.ContentType); - Assert.Equal(customData, updatedMetadata.CustomMetadata); - } - - [Fact] - public async Task observes_upload_success_snapshot() - { - var path = "texts/upload_success_snapshot.txt"; - var reference = CrossFirebaseStorage.Current.GetReferenceFromPath(path); - var transferTask = reference.PutBytes(Encoding.UTF8.GetBytes("Observe upload success")); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Action observer = snapshot => completion.TrySetResult(snapshot); - transferTask.AddObserver(StorageTaskStatus.Success, observer); - - try { - await transferTask.AwaitAsync(); - var snapshot = await completion.Task.WaitAsync(TimeSpan.FromSeconds(10)); - - Assert.NotNull(snapshot); - Assert.NotNull(snapshot.Metadata); - Assert.True(snapshot.TransferredUnitCount > 0); - Assert.InRange(snapshot.TransferredFraction, 0.99, 1.01); - } finally { - transferTask.RemoveObserver(observer); - } - } - - [Fact] - public async Task lists_files_with_limit() - { - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath("files_to_keep"); - - var result = await reference.ListAsync(2); - Assert.Equal(2, result.Items.Count()); - } - - [Fact] - public async Task lists_all_files() - { - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath("files_to_keep"); - - var result = await reference.ListAllAsync(); - Assert.Equal(3, result.Items.Count()); - } - - [Fact] - public async Task gets_data_as_stream() - { - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath("files_to_keep/text_1.txt"); - - var stream = await reference.GetStreamAsync(1 * 1024 * 1024); - Assert.NotNull(stream); - } - - [Fact] - public async Task gets_data_as_bytes() - { - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath("files_to_keep/text_1.txt"); - - var bytes = await reference.GetBytesAsync(1 * 1024 * 1024); - Assert.NotNull(bytes); - Assert.Equal(34, bytes.Length); - } - - [Fact] - public async Task downloads_file() - { - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath("files_to_keep/text_1.txt"); - - await reference.DownloadFile($"{FileSystem.CacheDirectory}/test.txt").AwaitAsync(); - } - - [Fact] - public async Task observes_download_success_snapshot() - { - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath("files_to_keep/text_1.txt"); - var destinationFilePath = Path.Combine(FileSystem.CacheDirectory, $"downloaded-{Guid.NewGuid():N}.txt"); - var transferTask = reference.DownloadFile(destinationFilePath); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Action observer = snapshot => completion.TrySetResult(snapshot); - transferTask.AddObserver(StorageTaskStatus.Success, observer); - - try { - await transferTask.AwaitAsync(); - var snapshot = await completion.Task.WaitAsync(TimeSpan.FromSeconds(10)); - - Assert.NotNull(snapshot); - Assert.True(File.Exists(destinationFilePath)); - } finally { - transferTask.RemoveObserver(observer); - } - } - - [Fact] - public void can_manage_files_upload() - { - var path = $"texts/managed.txt"; - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath(path); - - var transferTask = reference.PutBytes(Encoding.UTF8.GetBytes("Some test text")); - transferTask.Pause(); - transferTask.Resume(); - transferTask.Cancel(); - } - - [Fact] - public void can_manage_files_download() - { - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath("files_to_keep/text_1.txt"); - - var transferTask = reference.DownloadFile($"{FileSystem.CacheDirectory}/managed.txt"); - transferTask.Pause(); - transferTask.Resume(); - transferTask.Cancel(); - } - - [Fact] - public async Task metadata_exposes_reference_and_timestamps() - { - var path = "texts/metadata_properties.txt"; - var reference = CrossFirebaseStorage.Current.GetReferenceFromPath(path); - - await reference.PutBytes(Encoding.UTF8.GetBytes("metadata properties")).AwaitAsync(); - var metadata = await reference.GetMetadataAsync(); - - Assert.Equal(GetExpectedBucket(), metadata.Bucket); - Assert.Equal("metadata_properties.txt", metadata.Name); - Assert.Equal(path, metadata.Path); - if(metadata.StorageReference != null) { - Assert.Equal(reference.FullPath, metadata.StorageReference.FullPath); - } - Assert.NotEqual(default, metadata.CreationTime); - Assert.NotEqual(default, metadata.UpdatedTime); - } - - [Fact] - public async Task deletes_file() - { - var reference = CrossFirebaseStorage - .Current - .GetReferenceFromPath("files_to_delete"); - - Assert.Empty((await reference.ListAllAsync()).Items); - await reference.GetChild("text.txt").PutBytes(Encoding.UTF8.GetBytes("This file should get deleted")).AwaitAsync(); - Assert.Single((await reference.ListAllAsync()).Items); - - await reference.GetChild("text.txt").DeleteAsync(); - Assert.Empty((await reference.ListAllAsync()).Items); - } - - public async Task DisposeAsync() - { - TestLog.Write("[STORAGE CLEANUP START]"); var rootReference = CrossFirebaseStorage.Current.GetRootReference(); - var filesToDelete = await ListItemsIfExistsAsync(rootReference.GetChild("files_to_delete")); - var texts = await ListItemsIfExistsAsync(rootReference.GetChild("texts")); - await Task.WhenAll(filesToDelete.Select(TryDeleteAsync).Concat(texts.Select(TryDeleteAsync))); - TestLog.Write("[STORAGE CLEANUP END]"); - } - - private static async Task> ListItemsIfExistsAsync(IStorageReference reference) - { - try { - return (await reference.ListAllAsync()).Items; - } catch(Exception e) when(e.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase)) { - TestLog.Write($"[STORAGE CLEANUP SKIP] {reference.FullPath}: {e.Message}"); - return Array.Empty(); - } - } - - private static async Task TryDeleteAsync(IStorageReference reference) - { - try { - await reference.DeleteAsync(); - } catch(Exception e) { - TestLog.Write($"[STORAGE CLEANUP ERROR] {reference.FullPath}: {e}"); - } - } - - private static async Task EnsureStorageEmulatorSeedDataAsync() - { - if(!UsesStorageEmulator() || _storageEmulatorSeeded) { - return; - } - - await SeedLock.WaitAsync(); - try { - if(_storageEmulatorSeeded) { - return; - } - - var rootReference = CrossFirebaseStorage.Current.GetRootReference(); - var filesToKeep = rootReference.GetChild("files_to_keep"); - var existingItems = await ListItemsIfExistsAsync(filesToKeep); - await Task.WhenAll(existingItems.Select(x => x.DeleteAsync())); - - await EnsureSeedFileAsync( - filesToKeep, - "text_1.txt", - "0123456789012345678901234567890123"u8.ToArray()); - await EnsureSeedFileAsync( - filesToKeep, - "text_2.txt", - Encoding.UTF8.GetBytes("text-file-two")); - await EnsureSeedFileAsync( - filesToKeep, - "text_3.txt", - Encoding.UTF8.GetBytes("text-file-three")); - - _storageEmulatorSeeded = true; - } - finally { - SeedLock.Release(); - } + var filesToKeep = rootReference.GetChild("files_to_keep"); + var existingItems = await StorageTestPathScope.ListItemsIfExistsAsync(filesToKeep); + await Task.WhenAll(existingItems.Select(x => x.DeleteAsync())); + + await EnsureSeedFileAsync( + filesToKeep, + "text_1.txt", + "0123456789012345678901234567890123"u8.ToArray()); + await EnsureSeedFileAsync( + filesToKeep, + "text_2.txt", + "text-file-two"u8.ToArray()); + await EnsureSeedFileAsync( + filesToKeep, + "text_3.txt", + "text-file-three"u8.ToArray()); + + _storageEmulatorSeeded = true; + } + finally { + SeedLock.Release(); } + } - private static async Task EnsureSeedFileAsync( - IStorageReference parentReference, - string fileName, - byte[] contents) - { - await parentReference.GetChild(fileName).PutBytes(contents).AwaitAsync(); - } + private static async Task EnsureSeedFileAsync( + IStorageReference parentReference, + string fileName, + byte[] contents) + { + await parentReference.GetChild(fileName).PutBytes(contents).AwaitAsync(); } -} +} \ No newline at end of file diff --git a/tests/Plugin.Firebase.IntegrationTests/Storage/StorageTestPathScope.cs b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageTestPathScope.cs new file mode 100644 index 00000000..54a4641e --- /dev/null +++ b/tests/Plugin.Firebase.IntegrationTests/Storage/StorageTestPathScope.cs @@ -0,0 +1,69 @@ +using Plugin.Firebase.Storage; + +namespace Plugin.Firebase.IntegrationTests.Storage; + +internal sealed class StorageTestPathScope : IAsyncDisposable +{ + private readonly List _references = []; + private bool _isDisposed; + + private StorageTestPathScope(IStorageReference rootReference) + { + RootReference = rootReference; + } + + public IStorageReference RootReference { get; } + + public static StorageTestPathScope FromPath(string path) + { + return new StorageTestPathScope(CrossFirebaseStorage.Current.GetReferenceFromPath(path)); + } + + public IStorageReference Track(IStorageReference reference) + { + _references.Add(reference); + return reference; + } + + public IStorageReference Child(string path) + { + return Track(RootReference.GetChild(path)); + } + + public async ValueTask DisposeAsync() + { + if(_isDisposed) { + return; + } + + _isDisposed = true; + foreach(var reference in _references.AsEnumerable().Reverse()) { + await DeleteIfExistsAsync(reference); + } + } + + public static async Task DeleteChildrenIfExistsAsync(IStorageReference reference) + { + var children = await ListItemsIfExistsAsync(reference); + await Task.WhenAll(children.Select(DeleteIfExistsAsync)); + } + + internal static async Task> ListItemsIfExistsAsync(IStorageReference reference) + { + try { + return (await reference.ListAllAsync()).Items; + } catch(Exception e) when(e.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase)) { + TestLog.Write($"[STORAGE CLEANUP SKIP] {reference.FullPath}: {e.Message}"); + return []; + } + } + + internal static async Task DeleteIfExistsAsync(IStorageReference reference) + { + try { + await reference.DeleteAsync(); + } catch(Exception e) { + TestLog.Write($"[STORAGE CLEANUP ERROR] {reference.FullPath}: {e}"); + } + } +} \ No newline at end of file diff --git a/tests/cloud-functions/functions/src/index.ts b/tests/cloud-functions/functions/src/index.ts index 95b08618..9f52a924 100644 --- a/tests/cloud-functions/functions/src/index.ts +++ b/tests/cloud-functions/functions/src/index.ts @@ -3,6 +3,35 @@ import * as functions from 'firebase-functions/v1'; admin.initializeApp(); +function encodeBase64Url(value: object): string { + return Buffer + .from(JSON.stringify(value)) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function createUnsignedEmulatorCustomToken(uid: string, claims: object): string { + const now = Math.floor(Date.now() / 1000); + const serviceAccount = 'firebase-adminsdk@demo-pluginfirebase-integrationtests.iam.gserviceaccount.com'; + const header = { + alg: 'none', + typ: 'JWT', + }; + const payload = { + iss: serviceAccount, + sub: serviceAccount, + aud: 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit', + iat: now, + exp: now + 3600, + uid, + claims, + }; + + return `${encodeBase64Url(header)}.${encodeBase64Url(payload)}.`; +} + exports.addMessage = functions.https.onRequest(async (req, res) => { const original = req.query.text; const writeResult = await admin.firestore().collection('messages').add( { original: original }); @@ -93,6 +122,52 @@ exports.returnNullPayload = functions.https.onCall(async (data, context) => { return null; }); +exports.createCustomToken = functions.https.onCall(async (data, context) => { + functions.logger.log('[+] createCustomToken:', data); + if (process.env.FUNCTIONS_EMULATOR !== 'true') { + throw new functions.https.HttpsError( + 'failed-precondition', + 'createCustomToken is only available when running in the Firebase Functions emulator.' + ); + } + + const uid = data?.uid ?? `acceptance-${Date.now()}`; + const claims = data?.claims ?? {}; + let token: string; + try { + token = await admin.auth().createCustomToken(uid, claims); + } catch (error) { + functions.logger.warn('[!] createCustomToken falling back to unsigned emulator token:', error); + token = createUnsignedEmulatorCustomToken(uid, claims); + } + return { + uid, + token, + }; +}); + +exports.echoAuthContext = functions.https.onCall(async (data, context) => { + functions.logger.log('[+] echoAuthContext:', { + data, + uid: context.auth?.uid, + }); + return { + has_auth: !!context.auth, + uid: context.auth?.uid ?? null, + token_email: context.auth?.token?.email ?? null, + input_value: data?.input_value ?? null, + }; +}); + +exports.throwStructuredError = functions.https.onCall(async () => { + functions.logger.log('[+] throwStructuredError'); + throw new functions.https.HttpsError( + 'failed-precondition', + 'Structured acceptance-test failure', + { reason: 'acceptance-test' } + ); +}); + exports.echo = functions.https.onRequest(async (request, response) => { functions.logger.log(`[+] echo: headers = ${JSON.stringify(request.headers)}`); response.send(request.body);