diff --git a/test/ash_typescript/rpc/map_field_name_rpc_test.exs b/test/ash_typescript/rpc/map_field_name_rpc_test.exs new file mode 100644 index 0000000..68bd187 --- /dev/null +++ b/test/ash_typescript/rpc/map_field_name_rpc_test.exs @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Rpc.MapFieldNameRpcTest do + @moduledoc """ + Tests for map field name handling in RPC run phase. + + Verifies that: + 1. RPC accepts camelCase field names (matching TypeScript types) + 2. RPC accepts snake_case field names (matching Elixir definition) + 3. The output is always camelCase (for TypeScript consistency) + """ + use ExUnit.Case, async: false + alias AshTypescript.Rpc + alias AshTypescript.Test.TestHelpers + + setup do + conn = TestHelpers.build_rpc_conn() + {:ok, conn: conn} + end + + describe "RPC should accept camelCase field names for map return types" do + test "get_metrics action accepts camelCase field names", %{conn: conn} do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "get_metrics", + "fields" => ["total", "lastWeek", "lastMonth", "lastYear"] + }) + + assert result["success"] == true, + "RPC rejected camelCase field names. Expected success but got: #{inspect(result)}" + + data = result["data"] + + assert Map.has_key?(data, "total"), "Expected 'total' in output" + assert Map.has_key?(data, "lastWeek"), "Expected 'lastWeek' in output" + assert Map.has_key?(data, "lastMonth"), "Expected 'lastMonth' in output" + assert Map.has_key?(data, "lastYear"), "Expected 'lastYear' in output" + end + + test "get_metrics action also accepts snake_case field names for backwards compatibility", %{ + conn: conn + } do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "get_metrics", + "fields" => ["total", "last_week", "last_month", "last_year"] + }) + + assert result["success"] == true, + "snake_case field names should work. Got: #{inspect(result)}" + + data = result["data"] + + assert Map.has_key?(data, "total"), "Expected 'total' in output" + assert Map.has_key?(data, "lastWeek"), "Expected 'lastWeek' in output" + assert Map.has_key?(data, "lastMonth"), "Expected 'lastMonth' in output" + assert Map.has_key?(data, "lastYear"), "Expected 'lastYear' in output" + end + + test "get_nested_stats action accepts camelCase for top-level and nested field selection", %{ + conn: conn + } do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "get_nested_stats", + "fields" => [ + %{ + "userStats" => ["activeUsers", "newSignups", "churnRate"] + }, + %{ + "contentStats" => ["totalPosts", "postsThisWeek", "avgEngagementRate"] + } + ] + }) + + assert result["success"] == true, + "RPC rejected camelCase nested field names. Expected success but got: #{inspect(result)}" + + data = result["data"] + + assert Map.has_key?(data, "userStats"), "Expected 'userStats' in output" + assert Map.has_key?(data, "contentStats"), "Expected 'contentStats' in output" + + user_stats = data["userStats"] + assert Map.has_key?(user_stats, "activeUsers"), "Expected 'activeUsers' in userStats" + assert Map.has_key?(user_stats, "newSignups"), "Expected 'newSignups' in userStats" + assert Map.has_key?(user_stats, "churnRate"), "Expected 'churnRate' in userStats" + + content_stats = data["contentStats"] + assert Map.has_key?(content_stats, "totalPosts"), "Expected 'totalPosts' in contentStats" + + assert Map.has_key?(content_stats, "postsThisWeek"), + "Expected 'postsThisWeek' in contentStats" + + assert Map.has_key?(content_stats, "avgEngagementRate"), + "Expected 'avgEngagementRate' in contentStats" + end + end + + describe "list_users_map action with nested {:array, :map}" do + test "list_users_map action accepts camelCase for top-level and nested array fields", %{ + conn: conn + } do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "list_users_map", + "fields" => [ + "totalCount", + %{ + "results" => ["id", "email", "firstName", "lastName", "isAdmin"] + } + ] + }) + + assert result["success"] == true, + "RPC rejected camelCase for nested array fields. Expected success but got: #{inspect(result)}" + + data = result["data"] + + assert Map.has_key?(data, "totalCount"), "Expected 'totalCount' in output" + assert Map.has_key?(data, "results"), "Expected 'results' in output" + + [first_result | _] = data["results"] + assert Map.has_key?(first_result, "id"), "Expected 'id' in result item" + assert Map.has_key?(first_result, "email"), "Expected 'email' in result item" + assert Map.has_key?(first_result, "firstName"), "Expected 'firstName' in result item" + assert Map.has_key?(first_result, "lastName"), "Expected 'lastName' in result item" + assert Map.has_key?(first_result, "isAdmin"), "Expected 'isAdmin' in result item" + end + end + + describe "output field formatting consistency" do + test "output fields are always camelCase regardless of input format", %{conn: conn} do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "get_metrics", + "fields" => ["total", "last_week", "last_month", "last_year"] + }) + + assert result["success"] == true + + data = result["data"] + + refute Map.has_key?(data, "last_week"), + "Output should use camelCase 'lastWeek', not snake_case 'last_week'" + + refute Map.has_key?(data, "last_month"), + "Output should use camelCase 'lastMonth', not snake_case 'last_month'" + + refute Map.has_key?(data, "last_year"), + "Output should use camelCase 'lastYear', not snake_case 'last_year'" + end + end +end diff --git a/test/ash_typescript/rpc/nested_map_field_formatting_test.exs b/test/ash_typescript/rpc/nested_map_field_formatting_test.exs new file mode 100644 index 0000000..da2202c --- /dev/null +++ b/test/ash_typescript/rpc/nested_map_field_formatting_test.exs @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Rpc.NestedMapFieldFormattingTest do + @moduledoc """ + Tests for nested map field formatting in TypeScript generation. + + Verifies that: + 1. Nested map fields in {:array, :map} constraints are properly camelCased + 2. Deeply nested map fields are properly camelCased + """ + use ExUnit.Case, async: true + + setup_all do + {:ok, generated_content} = + AshTypescript.Rpc.Codegen.generate_typescript_types(:ash_typescript) + + {:ok, generated: generated_content} + end + + describe "nested map fields in {:array, :map} should be camelCased" do + test "list_users_map action generates camelCase field names for nested array fields", %{ + generated: generated + } do + # Check that the output type uses camelCase for nested fields + assert generated =~ "firstName", + "Expected 'firstName' in generated TypeScript but got snake_case" + + assert generated =~ "lastName", + "Expected 'lastName' in generated TypeScript but got snake_case" + + assert generated =~ "isAdmin", + "Expected 'isAdmin' in generated TypeScript but got snake_case" + + assert generated =~ "confirmedAt", + "Expected 'confirmedAt' in generated TypeScript but got snake_case" + + assert generated =~ "insertedAt", + "Expected 'insertedAt' in generated TypeScript but got snake_case" + + # The top-level field should also be camelCase + assert generated =~ "totalCount", + "Expected 'totalCount' in generated TypeScript but got snake_case" + end + + test "nested map fields should NOT contain snake_case versions", %{generated: generated} do + # Look for the NestedMapResource types specifically to avoid false positives + nested_map_types = + generated + |> String.split("\n") + |> Enum.filter(&String.contains?(&1, "NestedMapResource")) + |> Enum.join("\n") + + # Check that the generated types don't contain snake_case field names + refute nested_map_types =~ ~r/first_name.*:/, + "Found 'first_name' in NestedMapResource types - should be 'firstName'" + + refute nested_map_types =~ ~r/last_name.*:/, + "Found 'last_name' in NestedMapResource types - should be 'lastName'" + + refute nested_map_types =~ ~r/is_admin.*:/, + "Found 'is_admin' in NestedMapResource types - should be 'isAdmin'" + + refute nested_map_types =~ ~r/confirmed_at.*:/, + "Found 'confirmed_at' in NestedMapResource types - should be 'confirmedAt'" + + refute nested_map_types =~ ~r/inserted_at.*:/, + "Found 'inserted_at' in NestedMapResource types - should be 'insertedAt'" + + refute nested_map_types =~ ~r/total_count.*:/, + "Found 'total_count' in NestedMapResource types - should be 'totalCount'" + end + end + + describe "deeply nested map fields should be camelCased" do + test "get_nested_stats action generates camelCase for deeply nested map fields", %{ + generated: generated + } do + # Check top-level nested fields are camelCased + assert generated =~ "userStats", + "Expected 'userStats' in generated TypeScript but got snake_case" + + assert generated =~ "contentStats", + "Expected 'contentStats' in generated TypeScript but got snake_case" + + # Check deeply nested fields are camelCased + assert generated =~ "activeUsers", + "Expected 'activeUsers' in generated TypeScript but got snake_case" + + assert generated =~ "newSignups", + "Expected 'newSignups' in generated TypeScript but got snake_case" + + assert generated =~ "churnRate", + "Expected 'churnRate' in generated TypeScript but got snake_case" + + assert generated =~ "totalPosts", + "Expected 'totalPosts' in generated TypeScript but got snake_case" + + assert generated =~ "postsThisWeek", + "Expected 'postsThisWeek' in generated TypeScript but got snake_case" + + assert generated =~ "avgEngagementRate", + "Expected 'avgEngagementRate' in generated TypeScript but got snake_case" + end + end +end diff --git a/test/support/domain.ex b/test/support/domain.ex index d6a54b2..0ae8fdd 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -211,6 +211,13 @@ defmodule AshTypescript.Test.Domain do rpc_action :destroy_tenant_setting, :destroy end + # Test resource for nested map field formatting bugs + resource AshTypescript.Test.NestedMapResource do + rpc_action :list_users_map, :list_users_map + rpc_action :get_metrics, :get_metrics + rpc_action :get_nested_stats, :get_nested_stats + end + # Input parsing stress test resource - covers all edge cases for input formatting resource AshTypescript.Test.InputParsing.Resource do rpc_action :list_input_parsing, :read @@ -243,6 +250,7 @@ defmodule AshTypescript.Test.Domain do resource AshTypescript.Test.Article resource AshTypescript.Test.Subscription resource AshTypescript.Test.TenantSetting + resource AshTypescript.Test.NestedMapResource resource AshTypescript.Test.InputParsing.Resource end end diff --git a/test/support/resources/nested_map_resource.ex b/test/support/resources/nested_map_resource.ex new file mode 100644 index 0000000..de74573 --- /dev/null +++ b/test/support/resources/nested_map_resource.ex @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Test.NestedMapResource do + @moduledoc """ + Test resource for nested map field formatting. + + Tests: + 1. Nested map fields in array constraints being camelCased in TypeScript generation + 2. Field name consistency between TypeScript types (camelCase) and RPC validation + """ + use Ash.Resource, + domain: AshTypescript.Test.Domain, + data_layer: Ash.DataLayer.Ets, + extensions: [AshTypescript.Resource] + + typescript do + type_name "NestedMapResource" + end + + ets do + private? true + end + + attributes do + uuid_primary_key :id + end + + actions do + defaults [:read, :destroy] + + action :list_users_map, :map do + constraints fields: [ + results: [ + type: {:array, :map}, + constraints: [ + fields: [ + id: [type: :string], + email: [type: :string], + first_name: [type: :string, allow_nil?: true], + last_name: [type: :string, allow_nil?: true], + phone: [type: :string, allow_nil?: true], + is_admin: [type: :boolean, allow_nil?: true], + confirmed_at: [type: :utc_datetime_usec, allow_nil?: true], + inserted_at: [type: :utc_datetime_usec] + ] + ] + ], + total_count: [type: :integer] + ] + + run fn _input, _context -> + {:ok, + %{ + results: [ + %{ + id: "user-1", + email: "test@example.com", + first_name: "Test", + last_name: "User", + phone: nil, + is_admin: false, + confirmed_at: nil, + inserted_at: ~U[2025-01-01 00:00:00Z] + } + ], + total_count: 1 + }} + end + end + + action :get_metrics, :map do + constraints fields: [ + total: [type: :integer], + last_week: [type: :integer], + last_month: [type: :integer], + last_year: [type: :integer] + ] + + run fn _input, _context -> + {:ok, + %{ + total: 100, + last_week: 10, + last_month: 40, + last_year: 100 + }} + end + end + + # Deeply nested map for additional testing + action :get_nested_stats, :map do + constraints fields: [ + user_stats: [ + type: :map, + constraints: [ + fields: [ + active_users: [type: :integer], + new_signups: [type: :integer], + churn_rate: [type: :float] + ] + ] + ], + content_stats: [ + type: :map, + constraints: [ + fields: [ + total_posts: [type: :integer], + posts_this_week: [type: :integer], + avg_engagement_rate: [type: :float] + ] + ] + ] + ] + + run fn _input, _context -> + {:ok, + %{ + user_stats: %{ + active_users: 1000, + new_signups: 50, + churn_rate: 0.05 + }, + content_stats: %{ + total_posts: 5000, + posts_this_week: 100, + avg_engagement_rate: 0.15 + } + }} + end + end + end +end