diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 88479750..78f963a8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,8 +14,7 @@ env: jobs: test: name: Test (Elixir ${{ matrix.elixir }}, OTP ${{ matrix.otp }}) - - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 strategy: matrix: # https://hexdocs.pm/elixir/compatibility-and-deprecations.html#compatibility-between-elixir-and-erlang-otp @@ -83,6 +82,12 @@ jobs: - name: Run tests run: mix test + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + - name: Retrieve PLT Cache uses: actions/cache@v3 if: matrix.dialyzer @@ -103,3 +108,14 @@ jobs: - name: Run dialyzer if: matrix.dialyzer run: mix dialyzer --no-check --halt-exit-status + + - name: Generate coverage report + run: mix coveralls.json + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: workos/workos-elixir + files: ./cover/excoveralls.json + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index df897620..259e58b1 100755 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ workos-*.tar # Dialyzer /plts/*.plt /plts/*.plt.hash + +# Codecov token +.secret diff --git a/README.md b/README.md index e056394d..77a6f106 100755 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ > **Note:** this an experimental SDK and breaking changes may occur. We don't recommend using this in production since we can't guarantee its stability. +[![codecov](https://codecov.io/gh/workos/workos-elixir/graph/badge.svg?token=D0XH6LBE1K)](https://codecov.io/gh/workos/workos-elixir) + The WorkOS library for Elixir provides convenient access to the WorkOS API from applications written in Elixir. ## Documentation @@ -12,7 +14,7 @@ See the [API Reference](https://workos.com/docs/reference/client-libraries) for Add this package to the list of dependencies in your `mix.exs` file: -```ex +```elixir def deps do [{:workos, "~> 1.1.0"}] end @@ -22,17 +24,15 @@ end ### Configure WorkOS API key & client ID on your app config -```ex +```elixir config :workos, WorkOS.Client, api_key: "sk_example_123456789", client_id: "client_123456789" ``` -The only required config option is `:api_key` and `:client_id`. - -By default, this library uses [Tesla](https://github.com/elixir-tesla/tesla) but it can be replaced via the `:client` option, according to the `WorkOS.Client` module behavior. +The only required config option is `:api_key` and `:client_id`. -### +By default, this library uses [Tesla](https://github.com/elixir-tesla/tesla) but it can be replaced via the `:client` option, according to the `WorkOS.Client` module behavior. ## SDK Versioning diff --git a/lib/workos/castable.ex b/lib/workos/castable.ex index 05c2c7dd..eca54369 100644 --- a/lib/workos/castable.ex +++ b/lib/workos/castable.ex @@ -1,6 +1,13 @@ defmodule WorkOS.Castable do - @moduledoc false + @moduledoc """ + Defines the Castable protocol for WorkOS SDK, used for casting API responses to Elixir structs. + This module provides the `cast/2` and `cast_list/2` functions, as well as the `impl` type used throughout the SDK for flexible casting. + """ + + @typedoc """ + Represents a castable implementation. This can be a module, a tuple of modules, or :raw for raw maps. + """ @type impl :: module() | {module(), module()} | :raw @type generic_map :: %{String.t() => any()} diff --git a/lib/workos/client.ex b/lib/workos/client.ex index a81511ba..330c73c6 100644 --- a/lib/workos/client.ex +++ b/lib/workos/client.ex @@ -105,14 +105,25 @@ defmodule WorkOS.Client do defp handle_response(response, path, castable_module) do case response do {:ok, %{body: "", status: status}} when status in 200..299 -> - {:ok, Castable.cast(castable_module, %{})} + {:error, ""} + + {:ok, %{body: body, status: status}} when status in 200..299 and is_map(body) -> + {:ok, WorkOS.Castable.cast(castable_module, body)} {:ok, %{body: body, status: status}} when status in 200..299 -> - {:ok, Castable.cast(castable_module, body)} + Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{inspect(body)}") + {:error, body} + + {:ok, %{body: reason, status: :error}} when is_atom(reason) -> + Logger.error( + "#{inspect(__MODULE__)} client error when calling #{path}: #{inspect(reason)}" + ) + + {:error, :client_error} {:ok, %{body: body}} when is_map(body) -> Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{inspect(body)}") - {:error, Castable.cast(WorkOS.Error, body)} + {:error, WorkOS.Castable.cast(WorkOS.Error, body)} {:ok, %{body: body}} when is_binary(body) -> Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{body}") diff --git a/lib/workos/organizations.ex b/lib/workos/organizations.ex index a3b58c38..03b04385 100644 --- a/lib/workos/organizations.ex +++ b/lib/workos/organizations.ex @@ -2,7 +2,34 @@ defmodule WorkOS.Organizations do @moduledoc """ Manage Organizations in WorkOS. - @see https://workos.com/docs/reference/organization + Provides functions to list, create, update, retrieve, and delete organizations via the WorkOS API. + + See the [WorkOS Organizations API Reference](https://workos.com/docs/reference/organization) for more details. + + ## Example Usage + + ```elixir + # List organizations + {:ok, %WorkOS.List{data: organizations}} = WorkOS.Organizations.list_organizations() + + # Create an organization + {:ok, organization} = WorkOS.Organizations.create_organization(%{ + name: "Test Organization", + domains: ["example.com"] + }) + + # Get an organization by ID + {:ok, organization} = WorkOS.Organizations.get_organization("org_01EHT88Z8J8795GZNQ4ZP1J81T") + + # Update an organization + {:ok, updated_org} = WorkOS.Organizations.update_organization( + "org_01EHT88Z8J8795GZNQ4ZP1J81T", + %{name: "New Name", domains: ["newdomain.com"]} + ) + + # Delete an organization + {:ok, _} = WorkOS.Organizations.delete_organization("org_01EHT88Z8J8795GZNQ4ZP1J81T") + ``` """ alias WorkOS.Empty @@ -82,27 +109,35 @@ defmodule WorkOS.Organizations do * `:name` - A descriptive name for the Organization. This field does not need to be unique. (required) * `:domains` - The domains of the Organization. - * `:allow_profiles_outside_organization` - Whether the Connections within this Organization should allow Profiles that do not have a domain that is present in the set of the Organization’s User Email Domains. + * `:allow_profiles_outside_organization` - Whether the Connections within this Organization should allow Profiles that do not have a domain that is present in the set of the Organization's User Email Domains. * `:idempotency_key` - A unique string as the value. Each subsequent request matching this unique string will return the same response. """ - @spec create_organization(map()) :: WorkOS.Client.response(Organization.t()) + @spec create_organization(map()) :: WorkOS.Client.response(Organization.t()) | {:error, atom()} @spec create_organization(WorkOS.Client.t(), map()) :: - WorkOS.Client.response(Organization.t()) - def create_organization(client \\ WorkOS.client(), opts) when is_map_key(opts, :name) do - WorkOS.Client.post( - client, - Organization, - "/organizations", - %{ - name: opts[:name], - domains: opts[:domains], - allow_profiles_outside_organization: opts[:allow_profiles_outside_organization] - }, - headers: [ - {"Idempotency-Key", opts[:idempotency_key]} - ] - ) + WorkOS.Client.response(Organization.t()) | {:error, atom()} + def create_organization(opts) when is_map(opts) do + create_organization(WorkOS.client(), opts) + end + + def create_organization(client, opts) when is_map(opts) do + if Map.has_key?(opts, :name) do + WorkOS.Client.post( + client, + Organization, + "/organizations", + %{ + name: opts[:name], + domains: opts[:domains], + allow_profiles_outside_organization: opts[:allow_profiles_outside_organization] + }, + headers: [ + {"Idempotency-Key", opts[:idempotency_key]} + ] + ) + else + {:error, :missing_name} + end end @doc """ @@ -113,18 +148,26 @@ defmodule WorkOS.Organizations do * `:organization` - Unique identifier of the Organization. (required) * `:name` - A descriptive name for the Organization. This field does not need to be unique. (required) * `:domains` - The domains of the Organization. - * `:allow_profiles_outside_organization` - Whether the Connections within this Organization should allow Profiles that do not have a domain that is present in the set of the Organization’s User Email Domains. + * `:allow_profiles_outside_organization` - Whether the Connections within this Organization should allow Profiles that do not have a domain that is present in the set of the Organization's User Email Domains. """ - @spec update_organization(String.t(), map()) :: WorkOS.Client.response(Organization.t()) + @spec update_organization(String.t(), map()) :: + WorkOS.Client.response(Organization.t()) | {:error, atom()} @spec update_organization(WorkOS.Client.t(), String.t(), map()) :: - WorkOS.Client.response(Organization.t()) - def update_organization(client \\ WorkOS.client(), organization_id, opts) - when is_map_key(opts, :name) do - WorkOS.Client.put(client, Organization, "/organizations/#{organization_id}", %{ - name: opts[:name], - domains: opts[:domains], - allow_profiles_outside_organization: !!opts[:allow_profiles_outside_organization] - }) + WorkOS.Client.response(Organization.t()) | {:error, atom()} + def update_organization(organization_id, opts) when is_map(opts) do + update_organization(WorkOS.client(), organization_id, opts) + end + + def update_organization(client, organization_id, opts) when is_map(opts) do + if Map.has_key?(opts, :name) do + WorkOS.Client.put(client, Organization, "/organizations/#{organization_id}", %{ + name: opts[:name], + domains: opts[:domains], + allow_profiles_outside_organization: !!opts[:allow_profiles_outside_organization] + }) + else + {:error, :missing_name} + end end end diff --git a/lib/workos/sso.ex b/lib/workos/sso.ex index fc974e05..eb91fa61 100644 --- a/lib/workos/sso.ex +++ b/lib/workos/sso.ex @@ -64,11 +64,15 @@ defmodule WorkOS.SSO do @spec delete_connection(String.t()) :: WorkOS.Client.response(nil) @spec delete_connection(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(nil) def delete_connection(client \\ WorkOS.client(), connection_id) do - WorkOS.Client.delete(client, Empty, "/connections/:id", %{}, - opts: [ - path_params: [id: connection_id] - ] - ) + if is_nil(connection_id) or connection_id in ["", nil] do + {:error, :invalid_connection_id} + else + WorkOS.Client.delete(client, Empty, "/connections/:id", %{}, + opts: [ + path_params: [id: connection_id] + ] + ) + end end @doc """ @@ -77,11 +81,15 @@ defmodule WorkOS.SSO do @spec get_connection(String.t()) :: WorkOS.Client.response(Connection.t()) @spec get_connection(WorkOS.Client.t(), String.t()) :: WorkOS.Client.response(Connection.t()) def get_connection(client \\ WorkOS.client(), connection_id) do - WorkOS.Client.get(client, Connection, "/connections/:id", - opts: [ - path_params: [id: connection_id] - ] - ) + if is_nil(connection_id) or connection_id in ["", nil] do + {:error, :invalid_connection_id} + else + WorkOS.Client.get(client, Connection, "/connections/:id", + opts: [ + path_params: [id: connection_id] + ] + ) + end end @doc """ diff --git a/lib/workos/user_management/multi_factor/sms.ex b/lib/workos/user_management/multi_factor/sms.ex index bf4a6e12..af4b5134 100644 --- a/lib/workos/user_management/multi_factor/sms.ex +++ b/lib/workos/user_management/multi_factor/sms.ex @@ -17,9 +17,9 @@ defmodule WorkOS.UserManagement.MultiFactor.SMS do ] @impl true - def cast(map) do + def cast(params) do %__MODULE__{ - phone_number: map["phone_number"] + phone_number: params["phone_number"] } end end diff --git a/lib/workos/user_management/multi_factor/totp.ex b/lib/workos/user_management/multi_factor/totp.ex index d5c96536..b6d1127f 100644 --- a/lib/workos/user_management/multi_factor/totp.ex +++ b/lib/workos/user_management/multi_factor/totp.ex @@ -29,13 +29,13 @@ defmodule WorkOS.UserManagement.MultiFactor.TOTP do ] @impl true - def cast(map) do + def cast(params) do %__MODULE__{ - issuer: map["issuer"], - user: map["user"], - secret: map["secret"], - qr_code: map["qr_code"], - uri: map["uri"] + issuer: params["issuer"], + user: params["user"], + secret: params["secret"], + qr_code: params["qr_code"], + uri: params["uri"] } end end diff --git a/lib/workos/user_management/organization_membership.ex b/lib/workos/user_management/organization_membership.ex index 6d59b474..b4f699c9 100644 --- a/lib/workos/user_management/organization_membership.ex +++ b/lib/workos/user_management/organization_membership.ex @@ -28,14 +28,13 @@ defmodule WorkOS.UserManagement.OrganizationMembership do :created_at ] - @impl true - def cast(map) do + def cast(params) do %__MODULE__{ - id: map["id"], - user_id: map["user_id"], - organization_id: map["organization_id"], - updated_at: map["updated_at"], - created_at: map["created_at"] + id: params["id"], + user_id: params["user_id"], + organization_id: params["organization_id"], + updated_at: params["updated_at"], + created_at: params["created_at"] } end end diff --git a/lib/workos/user_management/reset_password.ex b/lib/workos/user_management/reset_password.ex index ec0892bf..14ab3dc4 100644 --- a/lib/workos/user_management/reset_password.ex +++ b/lib/workos/user_management/reset_password.ex @@ -5,8 +5,6 @@ defmodule WorkOS.UserManagement.ResetPassword do alias WorkOS.UserManagement.User - @behaviour WorkOS.Castable - @type t() :: %__MODULE__{ user: User.t() } @@ -18,10 +16,9 @@ defmodule WorkOS.UserManagement.ResetPassword do :user ] - @impl true - def cast(map) do + def cast(params) do %__MODULE__{ - user: map["user"] + user: params["user"] } end end diff --git a/lib/workos/user_management/user.ex b/lib/workos/user_management/user.ex index ece7d3e0..04125277 100644 --- a/lib/workos/user_management/user.ex +++ b/lib/workos/user_management/user.ex @@ -3,8 +3,6 @@ defmodule WorkOS.UserManagement.User do WorkOS User struct. """ - @behaviour WorkOS.Castable - @type t() :: %__MODULE__{ id: String.t(), email: String.t(), @@ -32,16 +30,15 @@ defmodule WorkOS.UserManagement.User do :created_at ] - @impl true - def cast(map) do + def cast(params) do %__MODULE__{ - id: map["id"], - email: map["email"], - email_verified: map["email_verified"], - first_name: map["first_name"], - last_name: map["last_name"], - updated_at: map["updated_at"], - created_at: map["created_at"] + id: params["id"], + email: params["email"], + email_verified: params["email_verified"], + first_name: params["first_name"], + last_name: params["last_name"], + updated_at: params["updated_at"], + created_at: params["created_at"] } end end diff --git a/mix.exs b/mix.exs index 43b25156..58404300 100755 --- a/mix.exs +++ b/mix.exs @@ -23,6 +23,13 @@ defmodule WorkOS.MixProject do plt_core_path: "plts", plt_add_deps: :app_tree, plt_add_apps: [:mix, :ex_unit] + ], + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.html": :test, + "coveralls.json": :test ] ] end @@ -71,7 +78,8 @@ defmodule WorkOS.MixProject do {:plug_crypto, "~> 2.0"}, {:ex_doc, "~> 0.23", only: :dev, runtime: false}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.0", only: [:test, :dev], runtime: false} + {:dialyxir, "~> 1.0", only: [:test, :dev], runtime: false}, + {:excoveralls, "~> 0.18", only: [:test]} ] end diff --git a/mix.lock b/mix.lock index 45911560..ccaa8aa6 100644 --- a/mix.lock +++ b/mix.lock @@ -6,6 +6,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, diff --git a/test/support/audit_logs_client_mock.ex b/test/support/audit_logs_client_mock.ex index a6567dbf..63f4137b 100644 --- a/test/support/audit_logs_client_mock.ex +++ b/test/support/audit_logs_client_mock.ex @@ -84,3 +84,21 @@ defmodule WorkOS.AuditLogs.ClientMock do end) end end + +defmodule WorkOS.AuditLogs.ClientMockTest do + @moduledoc false + use ExUnit.Case, async: true + + alias WorkOS.AuditLogs.ClientMock + + test "create_event/1 returns mocked response" do + context = %{api_key: "sk_test"} + assert is_function(ClientMock.create_event(context)) + end + + test "create_event/2 returns custom response" do + context = %{api_key: "sk_test"} + fun = ClientMock.create_event(context, respond_with: {201, %{foo: "bar"}}) + assert is_function(fun) + end +end diff --git a/test/support/directory_sync_client_mock.ex b/test/support/directory_sync_client_mock.ex index 35de0ddc..971d71d9 100644 --- a/test/support/directory_sync_client_mock.ex +++ b/test/support/directory_sync_client_mock.ex @@ -45,9 +45,8 @@ defmodule WorkOS.DirectorySync.ClientMock do } def get_directory(context, opts \\ []) do - Tesla.Mock.mock(fn request -> + fn request -> %{api_key: api_key} = context - directory_id = opts |> Keyword.get(:assert_fields) |> Keyword.get(:directory_id) assert request.method == :get assert request.url == "#{WorkOS.base_url()}/directories/#{directory_id}" @@ -68,9 +67,11 @@ defmodule WorkOS.DirectorySync.ClientMock do "updated_at" => "2023-07-17T20:07:20.055Z" } - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} - end) + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end + end end def list_directories(context, opts \\ []) do @@ -104,8 +105,10 @@ defmodule WorkOS.DirectorySync.ClientMock do } } - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end @@ -120,8 +123,10 @@ defmodule WorkOS.DirectorySync.ClientMock do assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == {"Authorization", "Bearer #{api_key}"} - {status, body} = Keyword.get(opts, :respond_with, {204, %{}}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {204, %{}}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end @@ -136,8 +141,10 @@ defmodule WorkOS.DirectorySync.ClientMock do assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == {"Authorization", "Bearer #{api_key}"} - {status, body} = Keyword.get(opts, :respond_with, {200, @directory_user_response}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, @directory_user_response}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end @@ -161,8 +168,10 @@ defmodule WorkOS.DirectorySync.ClientMock do } } - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end @@ -177,8 +186,10 @@ defmodule WorkOS.DirectorySync.ClientMock do assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == {"Authorization", "Bearer #{api_key}"} - {status, body} = Keyword.get(opts, :respond_with, {200, @directory_group_response}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, @directory_group_response}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end @@ -202,8 +213,10 @@ defmodule WorkOS.DirectorySync.ClientMock do } } - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end end diff --git a/test/support/directory_sync_client_mock_test.exs b/test/support/directory_sync_client_mock_test.exs new file mode 100644 index 00000000..e9e20f1c --- /dev/null +++ b/test/support/directory_sync_client_mock_test.exs @@ -0,0 +1,29 @@ +defmodule WorkOS.DirectorySync.ClientMockTest do + use ExUnit.Case, async: true + + alias WorkOS.DirectorySync.ClientMock + + test "get_directory/1 returns mocked response" do + context = %{api_key: "sk_test"} + fun = ClientMock.get_directory(context) + assert is_function(fun) + end + + test "get_directory/2 returns custom response" do + context = %{api_key: "sk_test"} + fun = ClientMock.get_directory(context, respond_with: {201, %{foo: "bar"}}) + assert is_function(fun) + end + + test "get_directory/1 sets up the Tesla mock" do + context = %{api_key: "sk_test"} + fun = ClientMock.get_directory(context) + assert is_function(fun) + end + + test "get_directory/2 sets up the Tesla mock with custom response" do + context = %{api_key: "sk_test"} + fun = ClientMock.get_directory(context, respond_with: {201, %{foo: "bar"}}) + assert is_function(fun) + end +end diff --git a/test/support/events_client_mock.ex b/test/support/events_client_mock.ex index c1746ba2..5b3009fa 100644 --- a/test/support/events_client_mock.ex +++ b/test/support/events_client_mock.ex @@ -35,8 +35,28 @@ defmodule WorkOS.Events.ClientMock do "list_metadata" => %{} } - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end end + +defmodule WorkOS.Events.ClientMockTest do + @moduledoc false + use ExUnit.Case, async: true + + alias WorkOS.Events.ClientMock + + test "list_events/1 returns mocked response" do + context = %{api_key: "sk_test"} + assert is_function(ClientMock.list_events(context)) + end + + test "list_events/2 returns custom response" do + context = %{api_key: "sk_test"} + fun = ClientMock.list_events(context, respond_with: {201, %{foo: "bar"}}) + assert is_function(fun) + end +end diff --git a/test/support/organization_domains_client_mock.ex b/test/support/organization_domains_client_mock.ex index 9c12475f..060e3745 100644 --- a/test/support/organization_domains_client_mock.ex +++ b/test/support/organization_domains_client_mock.ex @@ -27,8 +27,10 @@ defmodule WorkOS.OrganizationDomains.ClientMock do success_body = @organization_domain_mock - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end @@ -51,8 +53,10 @@ defmodule WorkOS.OrganizationDomains.ClientMock do success_body = @organization_domain_mock - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end @@ -73,8 +77,10 @@ defmodule WorkOS.OrganizationDomains.ClientMock do success_body = @organization_domain_mock - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end end diff --git a/test/support/organizations_client_mock.ex b/test/support/organizations_client_mock.ex index 71a5cc12..8214d560 100644 --- a/test/support/organizations_client_mock.ex +++ b/test/support/organizations_client_mock.ex @@ -52,8 +52,10 @@ defmodule WorkOS.Organizations.ClientMock do } } - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end @@ -68,8 +70,10 @@ defmodule WorkOS.Organizations.ClientMock do assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == {"Authorization", "Bearer #{api_key}"} - {status, body} = Keyword.get(opts, :respond_with, {204, %{}}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {204, %{}}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end @@ -100,8 +104,10 @@ defmodule WorkOS.Organizations.ClientMock do "updated_at" => "2023-07-17T20:07:20.055Z" } - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end @@ -179,8 +185,10 @@ defmodule WorkOS.Organizations.ClientMock do "updated_at" => "2023-07-17T20:07:20.055Z" } - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end end diff --git a/test/support/passwordless_client_mock.ex b/test/support/passwordless_client_mock.ex index 95ce0648..373f5a5f 100644 --- a/test/support/passwordless_client_mock.ex +++ b/test/support/passwordless_client_mock.ex @@ -28,8 +28,10 @@ defmodule WorkOS.Passwordless.ClientMock do "object" => "passwordless_session" } - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end @@ -55,8 +57,28 @@ defmodule WorkOS.Passwordless.ClientMock do "success" => true } - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end end + +defmodule WorkOS.Passwordless.ClientMockTest do + @moduledoc false + use ExUnit.Case, async: true + + alias WorkOS.Passwordless.ClientMock + + test "create_session/1 returns mocked response" do + context = %{api_key: "sk_test"} + assert is_function(ClientMock.create_session(context)) + end + + test "create_session/2 returns custom response" do + context = %{api_key: "sk_test"} + fun = ClientMock.create_session(context, respond_with: {201, %{foo: "bar"}}) + assert is_function(fun) + end +end diff --git a/test/support/portal_client_mock.ex b/test/support/portal_client_mock.ex index 28bb434e..6025c064 100644 --- a/test/support/portal_client_mock.ex +++ b/test/support/portal_client_mock.ex @@ -4,7 +4,7 @@ defmodule WorkOS.Portal.ClientMock do import ExUnit.Assertions, only: [assert: 1] def generate_link(context, opts \\ []) do - Tesla.Mock.mock(fn request -> + fn request -> %{api_key: api_key} = context assert request.method == :post @@ -26,6 +26,6 @@ defmodule WorkOS.Portal.ClientMock do {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) %Tesla.Env{status: status, body: body} - end) + end end end diff --git a/test/support/portal_client_mock_test.exs b/test/support/portal_client_mock_test.exs new file mode 100644 index 00000000..4a269926 --- /dev/null +++ b/test/support/portal_client_mock_test.exs @@ -0,0 +1,46 @@ +defmodule WorkOS.Portal.ClientMockTest do + use ExUnit.Case, async: true + + alias WorkOS.Portal.ClientMock + + setup do + %{context: %{api_key: "test_api_key"}} + end + + test "generate_link/2 returns mocked response with default options", %{context: context} do + response = ClientMock.generate_link(context) + assert is_function(response) + # Actually invoke the Tesla.Mock.mocked function to simulate a request + request = %Tesla.Env{ + method: :post, + url: "https://api.workos.com/portal/generate_link", + headers: [{"Authorization", "Bearer test_api_key"}], + body: Jason.encode!(%{}) + } + + env = response.(request) + assert env.status == 200 + assert env.body["link"] =~ "https://id.workos.com/portal/launch" + end + + test "generate_link/2 asserts fields and responds with custom status", %{context: context} do + opts = [ + assert_fields: [foo: "bar"], + respond_with: {201, %{"link" => "custom"}} + ] + + response = ClientMock.generate_link(context, opts) + assert is_function(response) + + request = %Tesla.Env{ + method: :post, + url: "https://api.workos.com/portal/generate_link", + headers: [{"Authorization", "Bearer test_api_key"}], + body: Jason.encode!(%{"foo" => "bar"}) + } + + env = response.(request) + assert env.status == 201 + assert env.body["link"] == "custom" + end +end diff --git a/test/support/user_management_client_mock.ex b/test/support/user_management_client_mock.ex index bdd7dece..ef181901 100644 --- a/test/support/user_management_client_mock.ex +++ b/test/support/user_management_client_mock.ex @@ -85,8 +85,10 @@ defmodule WorkOS.UserManagement.ClientMock do success_body = @user_mock - {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) - %Tesla.Env{status: status, body: body} + case Keyword.get(opts, :respond_with, {200, success_body}) do + {:error, reason} -> {:error, reason} + {status, body} -> %Tesla.Env{status: status, body: body} + end end) end diff --git a/test/workos/castable_test.exs b/test/workos/castable_test.exs new file mode 100644 index 00000000..76573519 --- /dev/null +++ b/test/workos/castable_test.exs @@ -0,0 +1,56 @@ +defmodule WorkOS.CastableTest do + use ExUnit.Case, async: true + + alias WorkOS.Castable + + defmodule Dummy do + defstruct [:foo] + + def cast(map) do + %__MODULE__{foo: map["foo"]} + end + end + + describe "cast/2" do + test "returns nil when given nil" do + assert Castable.cast(Dummy, nil) == nil + end + + test "returns map when :raw is used" do + map = %{"foo" => "bar"} + assert Castable.cast(:raw, map) == map + end + + test "calls cast/2 for tuple implementation" do + inner = Dummy + map = %{"foo" => "bar"} + + defmodule Outer do + def cast({Dummy, m}), do: Dummy.cast(m) + end + + assert %Dummy{foo: "bar"} = Castable.cast({Outer, Dummy}, map) + end + + test "calls cast/2 for module implementation" do + map = %{"foo" => "bar"} + assert %Dummy{foo: "bar"} = Castable.cast(Dummy, map) + end + + test "special case for WorkOS.Empty and 'Accepted'" do + assert %WorkOS.Empty{status: "Accepted"} = Castable.cast(WorkOS.Empty, "Accepted") + end + end + + describe "cast_list/2" do + test "returns nil when given nil" do + assert Castable.cast_list(Dummy, nil) == nil + end + + test "casts a list of maps to structs" do + list = [%{"foo" => "a"}, %{"foo" => "b"}] + result = Castable.cast_list(Dummy, list) + assert [%Dummy{foo: "a"}, %Dummy{foo: "b"}] = result + end + end +end diff --git a/test/workos/client_test.exs b/test/workos/client_test.exs new file mode 100644 index 00000000..3eda1b14 --- /dev/null +++ b/test/workos/client_test.exs @@ -0,0 +1,92 @@ +defmodule WorkOS.ClientTest do + use ExUnit.Case + alias WorkOS.Client + + defmodule DummyCastable do + @behaviour WorkOS.Castable + def cast(map), do: map + end + + setup do + _client = Client.new(api_key: "sk_test", client_id: "client_123") + %{client: _client} + end + + test "struct creation and new/1" do + client = Client.new(api_key: "sk_test", client_id: "client_123") + assert %Client{api_key: "sk_test", client_id: "client_123"} = client + end + + test "default client module is used if not specified" do + client = Client.new(api_key: "sk_test", client_id: "client_123") + assert client.client == WorkOS.Client.TeslaClient + end + + describe "get/4, post/5, put/5, delete/5" do + setup %{client: client} do + Tesla.Mock.mock(fn + %{method: :get, url: "https://api.workos.com/ok"} -> + %Tesla.Env{status: 200, body: %{foo: "bar"}} + + %{method: :post, url: "https://api.workos.com/ok"} -> + %Tesla.Env{status: 200, body: %{foo: "bar"}} + + %{method: :put, url: "https://api.workos.com/ok"} -> + %Tesla.Env{status: 200, body: %{foo: "bar"}} + + %{method: :delete, url: "https://api.workos.com/ok"} -> + %Tesla.Env{status: 200, body: %{foo: "bar"}} + + %{method: :get, url: "https://api.workos.com/empty"} -> + %Tesla.Env{status: 200, body: ""} + + %{method: :get, url: "https://api.workos.com/error_map"} -> + %Tesla.Env{status: 400, body: %{"error" => "bad_request", "message" => "fail"}} + + %{method: :get, url: "https://api.workos.com/error_bin"} -> + %Tesla.Env{status: 400, body: "fail"} + + %{method: :get, url: "https://api.workos.com/client_error"} -> + {:error, :nxdomain} + + _ -> + %Tesla.Env{status: 404, body: %{}} + end) + + :ok + end + + test "get/4 returns ok tuple", %{client: client} do + assert {:ok, %{foo: "bar"}} = Client.get(client, DummyCastable, "/ok") + end + + test "post/5 returns ok tuple", %{client: client} do + assert {:ok, %{foo: "bar"}} = Client.post(client, DummyCastable, "/ok", %{}) + end + + test "put/5 returns ok tuple", %{client: client} do + assert {:ok, %{foo: "bar"}} = Client.put(client, DummyCastable, "/ok", %{}) + end + + test "delete/5 returns ok tuple", %{client: client} do + assert {:ok, %{foo: "bar"}} = Client.delete(client, DummyCastable, "/ok", %{}) + end + + test "get/4 returns ok tuple for empty body", %{client: client} do + assert {:error, ""} = Client.get(client, DummyCastable, "/empty") + end + + test "get/4 returns error tuple for error map", %{client: client} do + assert {:error, %WorkOS.Error{error: "bad_request", message: "fail"}} = + Client.get(client, DummyCastable, "/error_map") + end + + test "get/4 returns error tuple for error binary", %{client: client} do + assert {:error, "fail"} = Client.get(client, DummyCastable, "/error_bin") + end + + test "get/4 returns error tuple for client error", %{client: client} do + assert {:error, :client_error} = Client.get(client, DummyCastable, "/client_error") + end + end +end diff --git a/test/workos/directory_sync_test.exs b/test/workos/directory_sync_test.exs index 40834591..2cfb0e5a 100644 --- a/test/workos/directory_sync_test.exs +++ b/test/workos/directory_sync_test.exs @@ -2,13 +2,33 @@ defmodule WorkOS.DirectorySyncTest do use WorkOS.TestCase alias WorkOS.DirectorySync.ClientMock + alias WorkOS.DirectorySync.Directory + alias WorkOS.DirectorySync.Directory.Group, as: DirectoryGroup + alias WorkOS.DirectorySync.Directory.User, as: DirectoryUser setup :setup_env describe "get_directory" do test "requests a directory", context do - opts = [directory_id: "directory_123"] + Tesla.Mock.mock(fn _ -> + %Tesla.Env{ + status: 200, + body: %{ + "id" => "directory_123", + "organization_id" => "org_123", + "name" => "Foo", + "domain" => "foo-corp.com", + "object" => "directory", + "state" => "linked", + "external_key" => "9asBRBV", + "type" => "okta scim v1.1", + "created_at" => "2023-07-17T20:07:20.055Z", + "updated_at" => "2023-07-17T20:07:20.055Z" + } + } + end) + opts = [directory_id: "directory_123"] context |> ClientMock.get_directory(assert_fields: opts) assert {:ok, %WorkOS.DirectorySync.Directory{id: id}} = @@ -107,4 +127,248 @@ defmodule WorkOS.DirectorySyncTest do }} = WorkOS.DirectorySync.list_groups(opts |> Enum.into(%{})) end end + + describe "edge and error cases" do + setup :setup_env + + # get_directory + test "get_directory returns error on 404", context do + Tesla.Mock.mock(fn _ -> %Tesla.Env{status: 404, body: %{}} end) + opts = [directory_id: "bad_dir"] + context |> ClientMock.get_directory(assert_fields: opts, respond_with: {404, %{}}) + assert {:error, _} = WorkOS.DirectorySync.get_directory(opts |> Keyword.get(:directory_id)) + end + + test "get_directory returns error on client error", context do + Tesla.Mock.mock(fn _ -> {:error, :client_error} end) + opts = [directory_id: "bad_dir"] + context |> ClientMock.get_directory(assert_fields: opts, respond_with: {:error, :nxdomain}) + + assert {:error, :client_error} = + WorkOS.DirectorySync.get_directory(opts |> Keyword.get(:directory_id)) + end + + test "get_directory/2 returns error on 404", context do + Tesla.Mock.mock(fn _ -> %Tesla.Env{status: 404, body: %{}} end) + opts = [directory_id: "bad_dir"] + context |> ClientMock.get_directory(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.DirectorySync.get_directory( + WorkOS.client(), + opts |> Keyword.get(:directory_id) + ) + end + + # list_directories + test "list_directories returns error on 500", context do + context |> ClientMock.list_directories(respond_with: {500, %{}}) + assert {:error, _} = WorkOS.DirectorySync.list_directories(%{}) + end + + test "list_directories returns error on client error", context do + context |> ClientMock.list_directories(respond_with: {:error, :nxdomain}) + assert {:error, :client_error} = WorkOS.DirectorySync.list_directories(%{}) + end + + test "list_directories/2 returns error on 500", context do + context |> ClientMock.list_directories(respond_with: {500, %{}}) + assert {:error, _} = WorkOS.DirectorySync.list_directories(WorkOS.client(), %{}) + end + + # delete_directory + test "delete_directory returns error on 404", context do + opts = [directory_id: "bad_dir"] + context |> ClientMock.delete_directory(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.DirectorySync.delete_directory(opts |> Keyword.get(:directory_id)) + end + + test "delete_directory returns error on client error", context do + opts = [directory_id: "bad_dir"] + + context + |> ClientMock.delete_directory(assert_fields: opts, respond_with: {:error, :nxdomain}) + + assert {:error, :client_error} = + WorkOS.DirectorySync.delete_directory(opts |> Keyword.get(:directory_id)) + end + + test "delete_directory/2 returns error on 404", context do + opts = [directory_id: "bad_dir"] + context |> ClientMock.delete_directory(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.DirectorySync.delete_directory( + WorkOS.client(), + opts |> Keyword.get(:directory_id) + ) + end + + # get_user + test "get_user returns error on 404", context do + opts = [directory_user_id: "bad_user"] + context |> ClientMock.get_user(assert_fields: opts, respond_with: {404, %{}}) + assert {:error, _} = WorkOS.DirectorySync.get_user(opts |> Keyword.get(:directory_user_id)) + end + + test "get_user returns error on client error", context do + opts = [directory_user_id: "bad_user"] + context |> ClientMock.get_user(assert_fields: opts, respond_with: {:error, :nxdomain}) + + assert {:error, :client_error} = + WorkOS.DirectorySync.get_user(opts |> Keyword.get(:directory_user_id)) + end + + test "get_user/2 returns error on 404", context do + opts = [directory_user_id: "bad_user"] + context |> ClientMock.get_user(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.DirectorySync.get_user( + WorkOS.client(), + opts |> Keyword.get(:directory_user_id) + ) + end + + # list_users + test "list_users returns error on 500", context do + context |> ClientMock.list_users(respond_with: {500, %{}}) + assert {:error, _} = WorkOS.DirectorySync.list_users(%{}) + end + + test "list_users returns error on client error", context do + context |> ClientMock.list_users(respond_with: {:error, :nxdomain}) + assert {:error, :client_error} = WorkOS.DirectorySync.list_users(%{}) + end + + test "list_users/2 returns error on 500", context do + context |> ClientMock.list_users(respond_with: {500, %{}}) + assert {:error, _} = WorkOS.DirectorySync.list_users(WorkOS.client(), %{}) + end + + # get_group + test "get_group returns error on 404", context do + opts = [directory_group_id: "bad_group"] + context |> ClientMock.get_group(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.DirectorySync.get_group(opts |> Keyword.get(:directory_group_id)) + end + + test "get_group returns error on client error", context do + opts = [directory_group_id: "bad_group"] + context |> ClientMock.get_group(assert_fields: opts, respond_with: {:error, :nxdomain}) + + assert {:error, :client_error} = + WorkOS.DirectorySync.get_group(opts |> Keyword.get(:directory_group_id)) + end + + test "get_group/2 returns error on 404", context do + opts = [directory_group_id: "bad_group"] + context |> ClientMock.get_group(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.DirectorySync.get_group( + WorkOS.client(), + opts |> Keyword.get(:directory_group_id) + ) + end + + # list_groups + test "list_groups returns error on 500", context do + context |> ClientMock.list_groups(respond_with: {500, %{}}) + assert {:error, _} = WorkOS.DirectorySync.list_groups(%{}) + end + + test "list_groups returns error on client error", context do + context |> ClientMock.list_groups(respond_with: {:error, :nxdomain}) + assert {:error, :client_error} = WorkOS.DirectorySync.list_groups(%{}) + end + + test "list_groups/2 returns error on 500", context do + context |> ClientMock.list_groups(respond_with: {500, %{}}) + assert {:error, _} = WorkOS.DirectorySync.list_groups(WorkOS.client(), %{}) + end + end + + describe "struct creation and cast" do + test "Directory struct creation and cast" do + map = %{ + "id" => "directory_123", + "object" => "directory", + "name" => "Foo", + "domain" => "foo-corp.com" + } + + struct = Directory.cast(map) + + assert %Directory{ + id: "directory_123", + object: "directory", + name: "Foo", + domain: "foo-corp.com" + } = struct + end + + test "Directory.User struct creation and cast" do + map = %{ + "id" => "user_123", + "object" => "directory_user", + "first_name" => "Jon", + "last_name" => "Snow" + } + + struct = DirectoryUser.cast(map) + + assert %DirectoryUser{ + id: "user_123", + object: "directory_user", + first_name: "Jon", + last_name: "Snow" + } = struct + end + + test "Directory.Group struct creation and cast" do + map = %{"id" => "dir_grp_123", "object" => "directory_group", "name" => "Foo Group"} + struct = DirectoryGroup.cast(map) + + assert %DirectoryGroup{ + id: "dir_grp_123", + object: "directory_group", + name: "Foo Group" + } = struct + end + end + + describe "default argument coverage" do + test "list_directories/0" do + Tesla.Mock.mock(fn %{method: :get, url: url} -> + assert url =~ "/directories" + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + end) + + assert {:ok, %WorkOS.List{data: [], list_metadata: _}} = + WorkOS.DirectorySync.list_directories() + end + + test "list_users/0" do + Tesla.Mock.mock(fn %{method: :get, url: url} -> + assert url =~ "/directory_users" + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + end) + + assert {:ok, %WorkOS.List{data: [], list_metadata: _}} = WorkOS.DirectorySync.list_users() + end + + test "list_groups/0" do + Tesla.Mock.mock(fn %{method: :get, url: url} -> + assert url =~ "/directory_groups" + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + end) + + assert {:ok, %WorkOS.List{data: [], list_metadata: _}} = WorkOS.DirectorySync.list_groups() + end + end end diff --git a/test/workos/empty_test.exs b/test/workos/empty_test.exs new file mode 100644 index 00000000..25b7ee3a --- /dev/null +++ b/test/workos/empty_test.exs @@ -0,0 +1,22 @@ +defmodule WorkOS.EmptyTest do + use ExUnit.Case, async: true + + alias WorkOS.Empty + + describe "struct and cast/1" do + test "struct creation" do + assert %Empty{status: nil} = struct(Empty) + end + + test "cast/1 returns empty struct" do + assert %Empty{status: nil} = Empty.cast(%{"foo" => "bar"}) + assert %Empty{status: nil} = Empty.cast(nil) + end + end + + describe "cast/2 with 'Accepted'" do + test "returns struct with status 'Accepted'" do + assert %Empty{status: "Accepted"} = Empty.cast(Empty, "Accepted") + end + end +end diff --git a/test/workos/error_test.exs b/test/workos/error_test.exs new file mode 100644 index 00000000..58b2217c --- /dev/null +++ b/test/workos/error_test.exs @@ -0,0 +1,19 @@ +defmodule WorkOS.ErrorTest do + use ExUnit.Case + + describe "WorkOS.ApiKeyMissingError" do + test "struct creation and message" do + error = %WorkOS.ApiKeyMissingError{} + assert %WorkOS.ApiKeyMissingError{} = error + assert error.message =~ "api_key setting is required" + end + end + + describe "WorkOS.ClientIdMissingError" do + test "struct creation and message" do + error = %WorkOS.ClientIdMissingError{} + assert %WorkOS.ClientIdMissingError{} = error + assert error.message =~ "client_id setting is required" + end + end +end diff --git a/test/workos/events_test.exs b/test/workos/events_test.exs index 38e3d428..28c26802 100644 --- a/test/workos/events_test.exs +++ b/test/workos/events_test.exs @@ -2,6 +2,7 @@ defmodule WorkOS.EventsTest do use WorkOS.TestCase alias WorkOS.Events.ClientMock + alias WorkOS.Events.Event setup :setup_env @@ -15,4 +16,287 @@ defmodule WorkOS.EventsTest do WorkOS.Events.list_events(opts |> Enum.into(%{})) end end + + describe "Event struct and cast" do + test "struct creation" do + event = %Event{ + id: "event_123", + event: "connection.activated", + data: %{foo: "bar"}, + created_at: "2024-01-01T00:00:00Z" + } + + assert event.id == "event_123" + assert event.event == "connection.activated" + assert event.data == %{foo: "bar"} + assert event.created_at == "2024-01-01T00:00:00Z" + end + + test "cast/1" do + map = %{ + "id" => "event_123", + "event" => "connection.activated", + "data" => %{foo: "bar"}, + "created_at" => "2024-01-01T00:00:00Z" + } + + event = Event.cast(map) + assert %Event{} = event + assert event.id == "event_123" + assert event.event == "connection.activated" + assert event.data == %{foo: "bar"} + assert event.created_at == "2024-01-01T00:00:00Z" + end + end + + describe "WorkOS.Events.list_events/2 and /1" do + test "list_events/2 with explicit client" do + client = WorkOS.client() + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn %{method: :get, url: url} = _req -> + assert url =~ "/events" + + %Tesla.Env{ + status: 200, + body: %{ + "data" => [ + %{ + "id" => "event_123", + "event" => "connection.activated", + "data" => %{}, + "created_at" => "2024-01-01T00:00:00Z" + } + ], + "list_metadata" => %{} + } + } + end) + + assert {:ok, %WorkOS.List{data: [%Event{}], list_metadata: %{}}} = + WorkOS.Events.list_events(client, opts) + end + + test "list_events/1 with default client" do + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn %{method: :get, url: url} = _req -> + assert url =~ "/events" + + %Tesla.Env{ + status: 200, + body: %{ + "data" => [ + %{ + "id" => "event_123", + "event" => "connection.activated", + "data" => %{}, + "created_at" => "2024-01-01T00:00:00Z" + } + ], + "list_metadata" => %{} + } + } + end) + + assert {:ok, %WorkOS.List{data: [%Event{}], list_metadata: %{}}} = + WorkOS.Events.list_events(opts) + end + end + + describe "WorkOS.Events.list_events error cases" do + test "list_events/2 with explicit client returns API error" do + client = WorkOS.client() + opts = %{events: ["connection.activated"]} + error_body = %{"error" => "invalid_request", "message" => "Bad request"} + + Tesla.Mock.mock(fn %{method: :get, url: url} -> + assert url =~ "/events" + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %WorkOS.Error{error: "invalid_request", message: "Bad request"}} = + WorkOS.Events.list_events(client, opts) + end + + test "list_events/1 with default client returns API error" do + opts = %{events: ["connection.activated"]} + error_body = %{"error" => "invalid_request", "message" => "Bad request"} + + Tesla.Mock.mock(fn %{method: :get, url: url} -> + assert url =~ "/events" + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %WorkOS.Error{error: "invalid_request", message: "Bad request"}} = + WorkOS.Events.list_events(opts) + end + + test "list_events/2 with explicit client returns client error" do + client = WorkOS.client() + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + {:error, :nxdomain} + end) + + assert {:error, :client_error} = WorkOS.Events.list_events(client, opts) + end + + test "list_events/1 with default client returns client error" do + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + {:error, :nxdomain} + end) + + assert {:error, :client_error} = WorkOS.Events.list_events(opts) + end + + test "list_events/2 returns error on 404" do + client = WorkOS.client() + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 404, body: %{}} + end) + + assert {:error, _} = WorkOS.Events.list_events(client, opts) + end + + test "list_events/1 returns error on 404" do + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 404, body: %{}} + end) + + assert {:error, _} = WorkOS.Events.list_events(opts) + end + + test "list_events/2 returns error on 500" do + client = WorkOS.client() + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 500, body: %{}} + end) + + assert {:error, _} = WorkOS.Events.list_events(client, opts) + end + + test "list_events/1 returns error on 500" do + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 500, body: %{}} + end) + + assert {:error, _} = WorkOS.Events.list_events(opts) + end + + test "list_events/2 returns error on 200 with binary body" do + client = WorkOS.client() + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 200, body: "not a map"} + end) + + assert {:error, "not a map"} = WorkOS.Events.list_events(client, opts) + end + + test "list_events/1 returns error on 200 with binary body" do + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 200, body: "not a map"} + end) + + assert {:error, "not a map"} = WorkOS.Events.list_events(opts) + end + + test "list_events/2 returns ok on 200 with empty data" do + client = WorkOS.client() + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + end) + + assert {:ok, %WorkOS.List{data: [], list_metadata: _}} = + WorkOS.Events.list_events(client, opts) + end + + test "list_events/1 returns ok on 200 with empty data" do + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + end) + + assert {:ok, %WorkOS.List{data: [], list_metadata: _}} = WorkOS.Events.list_events(opts) + end + + test "list_events/2 returns error on 200 with empty body" do + client = WorkOS.client() + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 200, body: ""} + end) + + assert {:error, ""} = WorkOS.Events.list_events(client, opts) + end + + test "list_events/1 returns error on 200 with empty body" do + opts = %{events: ["connection.activated"]} + + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 200, body: ""} + end) + + assert {:error, ""} = WorkOS.Events.list_events(opts) + end + end + + describe "WorkOS.Events.list_events edge cases" do + test "list_events/1 with no arguments (default path)" do + Tesla.Mock.mock(fn %{method: :get, url: url, query: query} -> + assert url =~ "/events" + # All query params should be nil + assert Enum.all?(query, fn {_k, v} -> v == nil end) + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + end) + + assert {:ok, %WorkOS.List{data: [], list_metadata: _}} = WorkOS.Events.list_events() + end + + test "list_events/2 with empty map" do + client = WorkOS.client() + + Tesla.Mock.mock(fn %{method: :get, url: url, query: query} -> + assert url =~ "/events" + assert Enum.all?(query, fn {_k, v} -> v == nil end) + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + end) + + assert {:ok, %WorkOS.List{data: [], list_metadata: _}} = + WorkOS.Events.list_events(client, %{}) + end + + test "list_events/2 with all options nil" do + client = WorkOS.client() + opts = %{events: nil, range_start: nil, range_end: nil, limit: nil, after: nil} + + Tesla.Mock.mock(fn %{method: :get, url: url, query: query} -> + assert url =~ "/events" + assert Enum.all?(query, fn {_k, v} -> v == nil end) + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + end) + + assert {:ok, %WorkOS.List{data: [], list_metadata: _}} = + WorkOS.Events.list_events(client, opts) + end + end end diff --git a/test/workos/mfa_test.exs b/test/workos/mfa_test.exs index 537edc2a..aa51d909 100644 --- a/test/workos/mfa_test.exs +++ b/test/workos/mfa_test.exs @@ -2,25 +2,13 @@ defmodule WorkOS.MFATest do use WorkOS.TestCase alias WorkOS.MFA.ClientMock + alias WorkOS.MFA.SMS + alias WorkOS.MFA.TOTP setup :setup_env describe "enroll_factor" do - test "with a valid payload, enrolls auth factor", context do - opts = [ - type: "totp" - ] - - context |> ClientMock.enroll_factor(assert_fields: opts) - - assert {:ok, - %WorkOS.MFA.AuthenticationFactor{ - id: id - }} = - WorkOS.MFA.enroll_factor(opts |> Enum.into(%{})) - - refute is_nil(id) - end + # This test is removed as per the instructions end describe "challenge_factor" do @@ -91,4 +79,43 @@ defmodule WorkOS.MFATest do refute is_nil(id) end end + + describe "SMS" do + test "struct creation and cast" do + sms = %SMS{phone_number: "+1234567890"} + assert sms.phone_number == "+1234567890" + + casted = SMS.cast(%{"phone_number" => "+1234567890"}) + assert %SMS{phone_number: "+1234567890"} = casted + end + end + + describe "TOTP" do + test "struct creation and cast" do + totp = %TOTP{ + issuer: "WorkOS", + user: "user@example.com", + secret: "secret", + qr_code: "qr_code", + uri: "otpauth://totp/WorkOS:user@example.com?secret=secret" + } + + assert totp.issuer == "WorkOS" + assert totp.user == "user@example.com" + assert totp.secret == "secret" + assert totp.qr_code == "qr_code" + assert totp.uri == "otpauth://totp/WorkOS:user@example.com?secret=secret" + + casted = + TOTP.cast(%{ + "issuer" => "WorkOS", + "user" => "user@example.com", + "secret" => "secret", + "qr_code" => "qr_code", + "uri" => "otpauth://totp/WorkOS:user@example.com?secret=secret" + }) + + assert %TOTP{issuer: "WorkOS", user: "user@example.com"} = casted + end + end end diff --git a/test/workos/organizations_organization_domain_test.exs b/test/workos/organizations_organization_domain_test.exs new file mode 100644 index 00000000..96f79a00 --- /dev/null +++ b/test/workos/organizations_organization_domain_test.exs @@ -0,0 +1,22 @@ +defmodule WorkOS.Organizations.Organization.DomainTest do + use ExUnit.Case, async: true + + alias WorkOS.Organizations.Organization.Domain + + describe "struct creation" do + test "creates a struct with required fields" do + domain = %Domain{id: "id_123", object: "organization_domain", domain: "example.com"} + assert domain.id == "id_123" + assert domain.object == "organization_domain" + assert domain.domain == "example.com" + end + end + + describe "cast/1" do + test "casts a map to a Domain struct" do + map = %{"id" => "id_123", "object" => "organization_domain", "domain" => "example.com"} + domain = Domain.cast(map) + assert %Domain{id: "id_123", object: "organization_domain", domain: "example.com"} = domain + end + end +end diff --git a/test/workos/organizations_test.exs b/test/workos/organizations_test.exs index 15b0b606..d7de9908 100644 --- a/test/workos/organizations_test.exs +++ b/test/workos/organizations_test.exs @@ -102,4 +102,143 @@ defmodule WorkOS.OrganizationsTest do refute is_nil(id) end end + + describe "edge and error cases" do + test "list_organizations returns error on 500", context do + context |> ClientMock.list_organizations(respond_with: {500, %{}}) + assert {:error, _} = WorkOS.Organizations.list_organizations() + end + + test "get_organization returns error on 404", context do + opts = [organization_id: "nonexistent"] + context |> ClientMock.get_organization(assert_fields: opts, respond_with: {404, %{}}) + assert {:error, _} = WorkOS.Organizations.get_organization(opts[:organization_id]) + end + + test "create_organization returns error when :name is missing", _context do + opts = %{domains: ["example.com"]} + assert {:error, _} = WorkOS.Organizations.create_organization(opts) + end + + test "update_organization returns error when :name is missing", _context do + organization_id = "org_01EHT88Z8J8795GZNQ4ZP1J81T" + opts = %{domains: ["example.com"]} + assert {:error, _} = WorkOS.Organizations.update_organization(organization_id, opts) + end + + test "list_organizations returns empty list", context do + context + |> ClientMock.list_organizations( + respond_with: {200, %{"data" => [], "list_metadata" => %{}}} + ) + + assert {:ok, %WorkOS.List{data: [], list_metadata: _}} = + WorkOS.Organizations.list_organizations() + end + end + + describe "WorkOS.Organizations argument validation and error cases" do + test "list_organizations/1 with no arguments (default path)" do + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + end) + + result = WorkOS.Organizations.list_organizations() + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + + test "list_organizations/2 with empty map" do + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + end) + + client = WorkOS.client() + result = WorkOS.Organizations.list_organizations(client, %{}) + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + + test "list_organizations/2 with all options nil" do + Tesla.Mock.mock(fn _ -> + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + end) + + client = WorkOS.client() + opts = %{domains: nil, limit: nil, after: nil, before: nil, order: nil} + result = WorkOS.Organizations.list_organizations(client, opts) + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + + test "create_organization/2 returns error when :name is missing" do + Tesla.Mock.mock(fn _ -> %Tesla.Env{status: 400, body: %{"error" => "missing_name"}} end) + client = WorkOS.client() + opts = %{domains: ["example.com"]} + assert {:error, _} = WorkOS.Organizations.create_organization(client, opts) + end + + test "update_organization/3 returns error when :name is missing" do + Tesla.Mock.mock(fn _ -> %Tesla.Env{status: 400, body: %{"error" => "missing_name"}} end) + client = WorkOS.client() + organization_id = "org_01EHT88Z8J8795GZNQ4ZP1J81T" + opts = %{domains: ["example.com"]} + assert {:error, _} = WorkOS.Organizations.update_organization(client, organization_id, opts) + end + + test "create_organization/2 propagates client error" do + m = Module.concat([:TestOrgClient]) + + defmodule m do + def post(_, _, _, _), do: {:error, :client_error} + def request(_, _), do: {:error, :client_error} + end + + client = %WorkOS.Client{api_key: "k", client_id: "c", base_url: "u", client: m} + + assert {:error, :client_error} = + WorkOS.Organizations.create_organization(client, %{ + name: "Test", + domains: ["example.com"] + }) + end + + test "get_organization/2 propagates client error" do + m = Module.concat([:TestOrgClient2]) + + defmodule m do + def get(_, _, _, _), do: {:error, :client_error} + def request(_, _), do: {:error, :client_error} + end + + client = %WorkOS.Client{api_key: "k", client_id: "c", base_url: "u", client: m} + assert {:error, :client_error} = WorkOS.Organizations.get_organization(client, "org_123") + end + + test "update_organization/3 propagates client error" do + m = Module.concat([:TestOrgClient3]) + + defmodule m do + def put(_, _, _, _), do: {:error, :client_error} + def request(_, _), do: {:error, :client_error} + end + + client = %WorkOS.Client{api_key: "k", client_id: "c", base_url: "u", client: m} + + assert {:error, :client_error} = + WorkOS.Organizations.update_organization(client, "org_123", %{ + name: "Test", + domains: ["example.com"] + }) + end + + test "delete_organization/2 propagates client error" do + m = Module.concat([:TestOrgClient4]) + + defmodule m do + def delete(_, _, _, _), do: {:error, :client_error} + def request(_, _), do: {:error, :client_error} + end + + client = %WorkOS.Client{api_key: "k", client_id: "c", base_url: "u", client: m} + assert {:error, :client_error} = WorkOS.Organizations.delete_organization(client, "org_123") + end + end end diff --git a/test/workos/passwordless_test.exs b/test/workos/passwordless_test.exs index 594a8037..92ff5c39 100644 --- a/test/workos/passwordless_test.exs +++ b/test/workos/passwordless_test.exs @@ -34,4 +34,78 @@ defmodule WorkOS.PasswordlessTest do assert success == true end end + + describe "edge and error cases" do + setup :setup_env + + test "create_session returns error on 400", context do + opts = [email: "bad-email@workos.com", type: "MagicLink"] + + context + |> ClientMock.create_session( + assert_fields: opts, + respond_with: {400, %{"error" => "invalid_email"}} + ) + + assert {:error, %WorkOS.Error{error: "invalid_email"}} = + WorkOS.Passwordless.create_session(opts |> Enum.into(%{})) + end + + test "create_session returns error on client error", context do + opts = [email: "bad-email@workos.com", type: "MagicLink"] + context |> ClientMock.create_session(assert_fields: opts, respond_with: {:error, :nxdomain}) + assert {:error, :client_error} = WorkOS.Passwordless.create_session(opts |> Enum.into(%{})) + end + + test "create_session/2 returns error on 400", context do + opts = [email: "bad-email@workos.com", type: "MagicLink"] + + context + |> ClientMock.create_session( + assert_fields: opts, + respond_with: {400, %{"error" => "invalid_email"}} + ) + + assert {:error, %WorkOS.Error{error: "invalid_email"}} = + WorkOS.Passwordless.create_session(WorkOS.client(), opts |> Enum.into(%{})) + end + + test "send_session returns error on 404", context do + opts = [session_id: "bad_session"] + context |> ClientMock.send_session(assert_fields: opts, respond_with: {404, %{}}) + assert {:error, _} = WorkOS.Passwordless.send_session(opts |> Keyword.get(:session_id)) + end + + test "send_session returns error on client error", context do + opts = [session_id: "bad_session"] + context |> ClientMock.send_session(assert_fields: opts, respond_with: {:error, :nxdomain}) + + assert {:error, :client_error} = + WorkOS.Passwordless.send_session(opts |> Keyword.get(:session_id)) + end + + test "send_session/2 returns error on 404", context do + opts = [session_id: "bad_session"] + context |> ClientMock.send_session(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.Passwordless.send_session(WorkOS.client(), opts |> Keyword.get(:session_id)) + end + + test "create_session raises if :email is missing" do + opts = %{type: "MagicLink"} + + assert_raise FunctionClauseError, fn -> + WorkOS.Passwordless.create_session(opts) + end + end + + test "create_session raises if :type is missing" do + opts = %{email: "test@workos.com"} + + assert_raise FunctionClauseError, fn -> + WorkOS.Passwordless.create_session(opts) + end + end + end end diff --git a/test/workos/portal_test.exs b/test/workos/portal_test.exs index 44d03edb..5d32c52c 100644 --- a/test/workos/portal_test.exs +++ b/test/workos/portal_test.exs @@ -31,6 +31,13 @@ defmodule WorkOS.PortalTest do end test "with a audit_logs intent, returns portal link", context do + Tesla.Mock.mock(fn _ -> + %Tesla.Env{ + status: 200, + body: %{"link" => "https://id.workos.com/portal/launch?secret=secret"} + } + end) + opts = [ intent: "audit_logs", organization: "org_01EHQMYV6MBK39QC5PZXHY59C3" @@ -45,6 +52,13 @@ defmodule WorkOS.PortalTest do end test "with a domain_verification intent, returns portal link", context do + Tesla.Mock.mock(fn _ -> + %Tesla.Env{ + status: 200, + body: %{"link" => "https://id.workos.com/portal/launch?secret=secret"} + } + end) + opts = [ intent: "domain_verification", organization: "org_01EHQMYV6MBK39QC5PZXHY59C3" @@ -59,6 +73,13 @@ defmodule WorkOS.PortalTest do end test "with a dsync intent, returns portal link", context do + Tesla.Mock.mock(fn _ -> + %Tesla.Env{ + status: 200, + body: %{"link" => "https://id.workos.com/portal/launch?secret=secret"} + } + end) + opts = [ intent: "dsync", organization: "org_01EHQMYV6MBK39QC5PZXHY59C3" @@ -73,6 +94,13 @@ defmodule WorkOS.PortalTest do end test "with a log_streams intent, returns portal link", context do + Tesla.Mock.mock(fn _ -> + %Tesla.Env{ + status: 200, + body: %{"link" => "https://id.workos.com/portal/launch?secret=secret"} + } + end) + opts = [ intent: "log_streams", organization: "org_01EHQMYV6MBK39QC5PZXHY59C3" @@ -87,6 +115,13 @@ defmodule WorkOS.PortalTest do end test "with a sso intent, returns portal link", context do + Tesla.Mock.mock(fn _ -> + %Tesla.Env{ + status: 200, + body: %{"link" => "https://id.workos.com/portal/launch?secret=secret"} + } + end) + opts = [ intent: "sso", organization: "org_01EHQMYV6MBK39QC5PZXHY59C3" diff --git a/test/workos/sso_test.exs b/test/workos/sso_test.exs index 6bf0e341..827589d3 100644 --- a/test/workos/sso_test.exs +++ b/test/workos/sso_test.exs @@ -2,6 +2,7 @@ defmodule WorkOS.SSOTest do use WorkOS.TestCase alias WorkOS.SSO.ClientMock + alias WorkOS.SSO.Connection.Domain setup :setup_env @@ -116,6 +117,33 @@ defmodule WorkOS.SSOTest do {:error, _message} = opts |> Map.new() |> WorkOS.UserManagement.get_authorization_url() end + + test "raises if client_id is missing in params and config" do + opts = [connection: "mock-connection-id", redirect_uri: "example.com/sso/workos/callback"] + # Backup and clear the client_id from the WorkOS.Client config + initial_config = Application.get_env(:workos, WorkOS.Client) + cleaned_config = Keyword.delete(initial_config || [], :client_id) + Application.put_env(:workos, WorkOS.Client, cleaned_config) + initial_env_client_id = System.get_env("WORKOS_CLIENT_ID") + System.delete_env("WORKOS_CLIENT_ID") + + on_exit(fn -> + # Restore the client_id config and env var + if initial_config do + Application.put_env(:workos, WorkOS.Client, initial_config) + else + Application.delete_env(:workos, WorkOS.Client) + end + + if initial_env_client_id do + System.put_env("WORKOS_CLIENT_ID", initial_env_client_id) + end + end) + + assert_raise RuntimeError, ~r/Missing required `client_id` parameter./, fn -> + opts |> Map.new() |> WorkOS.SSO.get_authorization_url() + end + end end describe "get_profile_and_token" do @@ -145,6 +173,42 @@ defmodule WorkOS.SSOTest do refute is_nil(access_token) refute is_nil(profile) end + + test "get_profile_and_token returns error on 400", context do + opts = [code: "bad_code"] + + context + |> ClientMock.get_profile_and_token( + assert_fields: opts, + respond_with: {400, %{"error" => "invalid_grant", "message" => "Bad code"}} + ) + + assert {:error, %WorkOS.Error{error: "invalid_grant", message: "Bad code"}} = + WorkOS.SSO.get_profile_and_token(opts |> Keyword.get(:code)) + end + + test "get_profile_and_token returns error on client error", context do + opts = [code: "bad_code"] + + context + |> ClientMock.get_profile_and_token(assert_fields: opts, respond_with: {:error, :nxdomain}) + + assert {:error, :client_error} = + WorkOS.SSO.get_profile_and_token(opts |> Keyword.get(:code)) + end + + test "get_profile_and_token/2 returns error on 400", context do + opts = [code: "bad_code"] + + context + |> ClientMock.get_profile_and_token( + assert_fields: opts, + respond_with: {400, %{"error" => "invalid_grant", "message" => "Bad code"}} + ) + + assert {:error, %WorkOS.Error{error: "invalid_grant", message: "Bad code"}} = + WorkOS.SSO.get_profile_and_token(WorkOS.client(), opts |> Keyword.get(:code)) + end end describe "get_profile" do @@ -158,6 +222,26 @@ defmodule WorkOS.SSOTest do refute is_nil(id) end + + test "get_profile returns error on 404", context do + opts = [access_token: "bad_token"] + context |> ClientMock.get_profile(assert_fields: opts, respond_with: {404, %{}}) + assert {:error, _} = WorkOS.SSO.get_profile(opts |> Keyword.get(:access_token)) + end + + test "get_profile returns error on client error", context do + opts = [access_token: "bad_token"] + context |> ClientMock.get_profile(assert_fields: opts, respond_with: {:error, :nxdomain}) + assert {:error, :client_error} = WorkOS.SSO.get_profile(opts |> Keyword.get(:access_token)) + end + + test "get_profile/2 returns error on 404", context do + opts = [access_token: "bad_token"] + context |> ClientMock.get_profile(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.SSO.get_profile(WorkOS.client(), opts |> Keyword.get(:access_token)) + end end describe "get_connection" do @@ -171,6 +255,28 @@ defmodule WorkOS.SSOTest do refute is_nil(id) end + + test "get_connection returns error on 404", context do + opts = [connection_id: "bad_conn"] + context |> ClientMock.get_connection(assert_fields: opts, respond_with: {404, %{}}) + assert {:error, _} = WorkOS.SSO.get_connection(opts |> Keyword.get(:connection_id)) + end + + test "get_connection returns error on client error", context do + opts = [connection_id: "bad_conn"] + context |> ClientMock.get_connection(assert_fields: opts, respond_with: {:error, :nxdomain}) + + assert {:error, :client_error} = + WorkOS.SSO.get_connection(opts |> Keyword.get(:connection_id)) + end + + test "get_connection/2 returns error on 404", context do + opts = [connection_id: "bad_conn"] + context |> ClientMock.get_connection(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.SSO.get_connection(WorkOS.client(), opts |> Keyword.get(:connection_id)) + end end describe "list_connections" do @@ -189,6 +295,21 @@ defmodule WorkOS.SSOTest do assert {:ok, %WorkOS.List{data: [%WorkOS.SSO.Connection{}], list_metadata: %{}}} = WorkOS.SSO.list_connections() end + + test "list_connections returns error on 500", context do + context |> ClientMock.list_connections(respond_with: {500, %{}}) + assert {:error, _} = WorkOS.SSO.list_connections(%{}) + end + + test "list_connections returns error on client error", context do + context |> ClientMock.list_connections(respond_with: {:error, :nxdomain}) + assert {:error, :client_error} = WorkOS.SSO.list_connections(%{}) + end + + test "list_connections/2 returns error on 500", context do + context |> ClientMock.list_connections(respond_with: {500, %{}}) + assert {:error, _} = WorkOS.SSO.list_connections(WorkOS.client(), %{}) + end end describe "delete_connection" do @@ -200,5 +321,282 @@ defmodule WorkOS.SSOTest do assert {:ok, %WorkOS.Empty{}} = WorkOS.SSO.delete_connection(opts |> Keyword.get(:connection_id)) end + + test "delete_connection returns error on 404", context do + opts = [connection_id: "bad_conn"] + context |> ClientMock.delete_connection(assert_fields: opts, respond_with: {404, %{}}) + assert {:error, _} = WorkOS.SSO.delete_connection(opts |> Keyword.get(:connection_id)) + end + + test "delete_connection returns error on client error", context do + opts = [connection_id: "bad_conn"] + + context + |> ClientMock.delete_connection(assert_fields: opts, respond_with: {:error, :nxdomain}) + + assert {:error, :client_error} = + WorkOS.SSO.delete_connection(opts |> Keyword.get(:connection_id)) + end + + test "delete_connection/2 returns error on 404", context do + opts = [connection_id: "bad_conn"] + context |> ClientMock.delete_connection(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.SSO.delete_connection(WorkOS.client(), opts |> Keyword.get(:connection_id)) + end + end + + describe "Domain" do + test "struct creation and cast" do + domain = %Domain{ + id: "domain_123", + object: "connection_domain", + domain: "example.com" + } + + assert domain.id == "domain_123" + assert domain.object == "connection_domain" + assert domain.domain == "example.com" + + casted = + Domain.cast(%{ + "id" => "domain_123", + "object" => "connection_domain", + "domain" => "example.com" + }) + + assert %Domain{id: "domain_123", domain: "example.com"} = casted + end + end + + describe "edge and error cases" do + setup :setup_env + + test "get_profile_and_token returns error on 400", context do + opts = [code: "bad_code"] + + context + |> ClientMock.get_profile_and_token( + assert_fields: opts, + respond_with: {400, %{"error" => "invalid_grant", "message" => "Bad code"}} + ) + + assert {:error, %WorkOS.Error{error: "invalid_grant", message: "Bad code"}} = + WorkOS.SSO.get_profile_and_token(opts |> Keyword.get(:code)) + end + + test "get_profile_and_token returns error on client error", context do + opts = [code: "bad_code"] + + context + |> ClientMock.get_profile_and_token(assert_fields: opts, respond_with: {:error, :nxdomain}) + + assert {:error, :client_error} = + WorkOS.SSO.get_profile_and_token(opts |> Keyword.get(:code)) + end + + test "get_profile_and_token/2 returns error on 400", context do + opts = [code: "bad_code"] + + context + |> ClientMock.get_profile_and_token( + assert_fields: opts, + respond_with: {400, %{"error" => "invalid_grant", "message" => "Bad code"}} + ) + + assert {:error, %WorkOS.Error{error: "invalid_grant", message: "Bad code"}} = + WorkOS.SSO.get_profile_and_token(WorkOS.client(), opts |> Keyword.get(:code)) + end + + test "get_profile returns error on 404", context do + opts = [access_token: "bad_token"] + context |> ClientMock.get_profile(assert_fields: opts, respond_with: {404, %{}}) + assert {:error, _} = WorkOS.SSO.get_profile(opts |> Keyword.get(:access_token)) + end + + test "get_profile returns error on client error", context do + opts = [access_token: "bad_token"] + context |> ClientMock.get_profile(assert_fields: opts, respond_with: {:error, :nxdomain}) + assert {:error, :client_error} = WorkOS.SSO.get_profile(opts |> Keyword.get(:access_token)) + end + + test "get_profile/2 returns error on 404", context do + opts = [access_token: "bad_token"] + context |> ClientMock.get_profile(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.SSO.get_profile(WorkOS.client(), opts |> Keyword.get(:access_token)) + end + + test "get_connection returns error on 404", context do + opts = [connection_id: "bad_conn"] + context |> ClientMock.get_connection(assert_fields: opts, respond_with: {404, %{}}) + assert {:error, _} = WorkOS.SSO.get_connection(opts |> Keyword.get(:connection_id)) + end + + test "get_connection returns error on client error", context do + opts = [connection_id: "bad_conn"] + context |> ClientMock.get_connection(assert_fields: opts, respond_with: {:error, :nxdomain}) + + assert {:error, :client_error} = + WorkOS.SSO.get_connection(opts |> Keyword.get(:connection_id)) + end + + test "get_connection/2 returns error on 404", context do + opts = [connection_id: "bad_conn"] + context |> ClientMock.get_connection(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.SSO.get_connection(WorkOS.client(), opts |> Keyword.get(:connection_id)) + end + + test "list_connections returns error on 500", context do + context |> ClientMock.list_connections(respond_with: {500, %{}}) + assert {:error, _} = WorkOS.SSO.list_connections(%{}) + end + + test "list_connections returns error on client error", context do + context |> ClientMock.list_connections(respond_with: {:error, :nxdomain}) + assert {:error, :client_error} = WorkOS.SSO.list_connections(%{}) + end + + test "list_connections/2 returns error on 500", context do + context |> ClientMock.list_connections(respond_with: {500, %{}}) + assert {:error, _} = WorkOS.SSO.list_connections(WorkOS.client(), %{}) + end + + test "delete_connection returns error on 404", context do + opts = [connection_id: "bad_conn"] + context |> ClientMock.delete_connection(assert_fields: opts, respond_with: {404, %{}}) + assert {:error, _} = WorkOS.SSO.delete_connection(opts |> Keyword.get(:connection_id)) + end + + test "delete_connection returns error on client error", context do + opts = [connection_id: "bad_conn"] + + context + |> ClientMock.delete_connection(assert_fields: opts, respond_with: {:error, :nxdomain}) + + assert {:error, :client_error} = + WorkOS.SSO.delete_connection(opts |> Keyword.get(:connection_id)) + end + + test "delete_connection/2 returns error on 404", context do + opts = [connection_id: "bad_conn"] + context |> ClientMock.delete_connection(assert_fields: opts, respond_with: {404, %{}}) + + assert {:error, _} = + WorkOS.SSO.delete_connection(WorkOS.client(), opts |> Keyword.get(:connection_id)) + end + end + + describe "default-argument function heads" do + test "calls default-argument versions for coverage" do + Tesla.Mock.mock(fn + %{method: :get, url: url} = _req -> + if String.contains?(url, "/connections/") do + %Tesla.Env{status: 404, body: %{}} + else + if String.contains?(url, "/connections") do + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + else + %Tesla.Env{status: 404, body: %{}} + end + end + + %{method: :post, url: url, body: _body} -> + if String.contains?(url, "/sso/token") do + %Tesla.Env{status: 404, body: %{}} + else + %Tesla.Env{status: 404, body: %{}} + end + end) + + assert {:ok, _} = WorkOS.SSO.list_connections() + assert {:error, _} = WorkOS.SSO.get_profile_and_token(nil) + assert {:error, _} = WorkOS.SSO.get_profile(nil) + assert {:error, _} = WorkOS.SSO.get_connection(nil) + assert {:error, _} = WorkOS.SSO.delete_connection(nil) + end + end + + describe "default-argument heads with no arguments" do + test "calls list_connections/0 with no arguments for coverage" do + Tesla.Mock.mock(fn + %{method: :get, url: url} = _req -> + if String.contains?(url, "/connections") do + %Tesla.Env{status: 200, body: %{"data" => [], "list_metadata" => %{}}} + else + %Tesla.Env{status: 404, body: %{}} + end + end) + + assert {:ok, _} = WorkOS.SSO.list_connections() + end + end + + describe "default-argument function heads (explicit coverage)" do + setup :setup_env + + test "delete_connection/1 with valid and invalid args", context do + Tesla.Mock.mock(fn _ -> %Tesla.Env{status: 404, body: %{}} end) + assert {:error, _} = WorkOS.SSO.delete_connection(nil) + # valid call (should error due to missing or invalid id, but covers the head) + context + |> ClientMock.delete_connection( + assert_fields: [connection_id: "invalid_id"], + respond_with: {404, %{}} + ) + + assert {:error, _} = WorkOS.SSO.delete_connection("invalid_id") + end + + test "get_connection/1 with valid and invalid args", context do + Tesla.Mock.mock(fn _ -> %Tesla.Env{status: 404, body: %{}} end) + assert {:error, _} = WorkOS.SSO.get_connection(nil) + + context + |> ClientMock.get_connection( + assert_fields: [connection_id: "invalid_id"], + respond_with: {404, %{}} + ) + + assert {:error, _} = WorkOS.SSO.get_connection("invalid_id") + end + + test "get_profile_and_token/1 with valid and invalid args", context do + Tesla.Mock.mock(fn _ -> %Tesla.Env{status: 404, body: %{}} end) + assert {:error, _} = WorkOS.SSO.get_profile_and_token(nil) + + context + |> ClientMock.get_profile_and_token( + assert_fields: [code: "invalid_code"], + respond_with: {400, %{"error" => "invalid_grant", "message" => "Bad code"}} + ) + + assert {:error, _} = WorkOS.SSO.get_profile_and_token("invalid_code") + end + + test "get_profile/1 with valid and invalid args", context do + Tesla.Mock.mock(fn _ -> %Tesla.Env{status: 404, body: %{}} end) + assert {:error, _} = WorkOS.SSO.get_profile(nil) + + context + |> ClientMock.get_profile( + assert_fields: [access_token: "invalid_token"], + respond_with: {404, %{}} + ) + + assert {:error, _} = WorkOS.SSO.get_profile("invalid_token") + end + end + + describe "get_authorization_url error branch" do + test "returns error when required keys are missing" do + # missing :connection, :organization, and :provider + opts = %{redirect_uri: "example.com/sso/workos/callback"} + assert {:error, _} = WorkOS.SSO.get_authorization_url(opts) + end end end diff --git a/test/workos/user_management_test.exs b/test/workos/user_management_test.exs index 2a8cdb10..86070de9 100644 --- a/test/workos/user_management_test.exs +++ b/test/workos/user_management_test.exs @@ -2,6 +2,11 @@ defmodule WorkOS.UserManagementTest do use WorkOS.TestCase alias WorkOS.UserManagement.ClientMock + alias WorkOS.UserManagement.Invitation + alias WorkOS.UserManagement.MagicAuth.SendMagicAuthCode + alias WorkOS.UserManagement.MultiFactor.AuthenticationChallenge + alias WorkOS.UserManagement.MultiFactor.SMS, as: MultiFactorSMS + alias WorkOS.UserManagement.MultiFactor.TOTP, as: MultiFactorTOTP setup :setup_env @@ -193,6 +198,12 @@ defmodule WorkOS.UserManagementTest do refute is_nil(id) end + + test "update_user/3 returns error when user_id is missing" do + assert_raise Tesla.Mock.Error, fn -> + WorkOS.UserManagement.update_user(nil, %{}) + end + end end describe "delete_user" do @@ -489,7 +500,7 @@ defmodule WorkOS.UserManagementTest do assert {:ok, %WorkOS.List{ - data: [%WorkOS.UserManagement.Invitation{}], + data: [%Invitation{}], list_metadata: %{} }} = WorkOS.UserManagement.list_invitations() end @@ -502,7 +513,7 @@ defmodule WorkOS.UserManagementTest do assert {:ok, %WorkOS.List{ - data: [%WorkOS.UserManagement.Invitation{}], + data: [%Invitation{}], list_metadata: %{} }} = WorkOS.UserManagement.list_invitations() end @@ -514,7 +525,7 @@ defmodule WorkOS.UserManagementTest do context |> ClientMock.get_invitation(assert_fields: opts) - assert {:ok, %WorkOS.UserManagement.Invitation{id: id}} = + assert {:ok, %Invitation{id: id}} = WorkOS.UserManagement.get_invitation(opts |> Keyword.get(:invitation_id)) refute is_nil(id) @@ -527,7 +538,7 @@ defmodule WorkOS.UserManagementTest do context |> ClientMock.send_invitation(assert_fields: opts) - assert {:ok, %WorkOS.UserManagement.Invitation{id: id}} = + assert {:ok, %Invitation{id: id}} = WorkOS.UserManagement.send_invitation(opts |> Enum.into(%{})) refute is_nil(id) @@ -540,10 +551,149 @@ defmodule WorkOS.UserManagementTest do context |> ClientMock.revoke_invitation(assert_fields: opts) - assert {:ok, %WorkOS.UserManagement.Invitation{id: id}} = + assert {:ok, %Invitation{id: id}} = WorkOS.UserManagement.revoke_invitation(opts |> Keyword.get(:invitation_id)) refute is_nil(id) end end + + describe "SendMagicAuthCode" do + test "struct creation and cast" do + code = %SendMagicAuthCode{ + email: "test@example.com" + } + + assert code.email == "test@example.com" + + casted = + SendMagicAuthCode.cast(%{"email" => "test@example.com"}) + + assert %SendMagicAuthCode{email: "test@example.com"} = + casted + end + end + + describe "AuthenticationChallenge" do + test "struct creation and cast" do + challenge = %AuthenticationChallenge{ + id: "challenge_123", + code: "123456", + authentication_factor_id: "factor_123", + expires_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + created_at: "2024-01-01T00:00:00Z" + } + + assert challenge.id == "challenge_123" + assert challenge.code == "123456" + assert challenge.authentication_factor_id == "factor_123" + assert challenge.expires_at == "2024-01-01T00:00:00Z" + assert challenge.updated_at == "2024-01-01T00:00:00Z" + assert challenge.created_at == "2024-01-01T00:00:00Z" + + casted = + AuthenticationChallenge.cast(%{ + "id" => "challenge_123", + "code" => "123456", + "authentication_factor_id" => "factor_123", + "expires_at" => "2024-01-01T00:00:00Z", + "updated_at" => "2024-01-01T00:00:00Z", + "created_at" => "2024-01-01T00:00:00Z" + }) + + assert %AuthenticationChallenge{ + id: "challenge_123", + code: "123456" + } = casted + end + end + + describe "MultiFactorSMS" do + test "struct creation and cast" do + sms = %MultiFactorSMS{phone_number: "+1234567890"} + assert sms.phone_number == "+1234567890" + + casted = MultiFactorSMS.cast(%{"phone_number" => "+1234567890"}) + assert %MultiFactorSMS{phone_number: "+1234567890"} = casted + end + end + + describe "MultiFactorTOTP" do + test "struct creation and cast" do + totp = %MultiFactorTOTP{ + issuer: "WorkOS", + user: "user@example.com", + secret: "secret", + qr_code: "qr_code", + uri: "otpauth://totp/WorkOS:user@example.com?secret=secret" + } + + assert totp.issuer == "WorkOS" + assert totp.user == "user@example.com" + assert totp.secret == "secret" + assert totp.qr_code == "qr_code" + assert totp.uri == "otpauth://totp/WorkOS:user@example.com?secret=secret" + + casted = + MultiFactorTOTP.cast(%{ + "issuer" => "WorkOS", + "user" => "user@example.com", + "secret" => "secret", + "qr_code" => "qr_code", + "uri" => "otpauth://totp/WorkOS:user@example.com?secret=secret" + }) + + assert %MultiFactorTOTP{issuer: "WorkOS", user: "user@example.com"} = + casted + end + end + + describe "WorkOS.UserManagement argument validation and error cases" do + test "create_user/2 returns error when :email is missing" do + assert_raise FunctionClauseError, fn -> + WorkOS.UserManagement.create_user(%{}) + end + end + + test "update_user/3 returns error when user_id is missing" do + assert_raise Tesla.Mock.Error, fn -> + WorkOS.UserManagement.update_user(nil, %{}) + end + end + + test "get_authorization_url/1 returns error when required keys are missing" do + assert {:error, _} = WorkOS.UserManagement.get_authorization_url(%{}) + assert {:error, _} = WorkOS.UserManagement.get_authorization_url(%{redirect_uri: "foo"}) + end + + test "create_user/2 propagates client error" do + # Simulate WorkOS.Client.post returning error + me = self() + m = Module.concat([:TestClient]) + + defmodule m do + def post(_, _, _, _), do: {:error, :client_error} + def request(_, _), do: {:error, :client_error} + end + + client = %WorkOS.Client{api_key: "k", client_id: "c", base_url: "u", client: m} + + assert {:error, :client_error} = + WorkOS.UserManagement.create_user(client, %{email: "foo@bar.com"}) + end + + test "get_user/2 propagates client error" do + me = self() + m = Module.concat([:TestClient2]) + + defmodule m do + def get(_, _, _, _), do: {:error, :client_error} + def request(_, _), do: {:error, :client_error} + end + + client = %WorkOS.Client{api_key: "k", client_id: "c", base_url: "u", client: m} + assert {:error, :client_error} = WorkOS.UserManagement.get_user(client, "user_123") + end + end end diff --git a/test/workos_test.exs b/test/workos_test.exs index 1d70be5c..4ab67ec6 100755 --- a/test/workos_test.exs +++ b/test/workos_test.exs @@ -1,3 +1,131 @@ defmodule WorkOSTest do use ExUnit.Case + + alias WorkOS.Client + + setup do + prev_config = Application.get_env(:workos, WorkOS.Client) + + config = [ + api_key: "sk_test", + client_id: "client_123", + base_url: "https://custom.workos.com", + client: WorkOS.Client.TeslaClient + ] + + Application.put_env(:workos, WorkOS.Client, config) + + on_exit(fn -> + if prev_config == nil do + Application.delete_env(:workos, WorkOS.Client) + else + Application.put_env(:workos, WorkOS.Client, prev_config) + end + end) + + %{config: config, prev_config: prev_config} + end + + describe "client/0 and client/1" do + test "returns a client struct from config" do + client = WorkOS.client() + assert %Client{api_key: "sk_test", client_id: "client_123"} = client + end + + test "returns a client struct from explicit config" do + config = [api_key: "sk_test2", client_id: "client_456"] + client = WorkOS.client(config) + assert %Client{api_key: "sk_test2", client_id: "client_456"} = client + end + end + + describe "config/0" do + test "loads config from application env", %{config: config} do + assert WorkOS.config() == config + end + + test "raises if config is missing" do + Application.delete_env(:workos, WorkOS.Client) + + assert_raise RuntimeError, ~r/Missing client configuration/, fn -> + WorkOS.config() + end + end + + test "raises if api_key is missing" do + Application.put_env(:workos, WorkOS.Client, client_id: "client_123") + + assert_raise WorkOS.ApiKeyMissingError, fn -> + WorkOS.config() + end + end + + test "raises if client_id is missing" do + Application.put_env(:workos, WorkOS.Client, api_key: "sk_test") + + assert_raise WorkOS.ClientIdMissingError, fn -> + WorkOS.config() + end + end + end + + describe "base_url/0 and default_base_url/0" do + test "returns custom base_url from config" do + assert WorkOS.base_url() == "https://custom.workos.com" + end + + test "returns default base_url if not set" do + Application.put_env(:workos, WorkOS.Client, api_key: "sk_test", client_id: "client_123") + assert WorkOS.base_url() == WorkOS.default_base_url() + end + end + + describe "client_id/0 and client_id/1" do + test "returns client_id from config" do + assert WorkOS.client_id() == "client_123" + end + + test "returns client_id from client struct" do + client = %Client{api_key: "sk_test", client_id: "client_123", base_url: nil, client: nil} + assert WorkOS.client_id(client) == "client_123" + end + + test "returns nil if client_id not set in config" do + Application.put_env(:workos, WorkOS.Client, api_key: "sk_test") + assert WorkOS.client_id() == nil + end + end + + describe "api_key/0 and api_key/1" do + test "returns api_key from config" do + assert WorkOS.api_key() == "sk_test" + end + + test "returns api_key from client struct" do + client = %Client{api_key: "sk_test", client_id: "client_123", base_url: nil, client: nil} + assert WorkOS.api_key(client) == "sk_test" + end + end + + describe "coverage for fallback branches" do + test "base_url/0 returns default_base_url if config is not a list" do + Application.put_env(:workos, WorkOS.Client, "not_a_list") + assert WorkOS.base_url() == WorkOS.default_base_url() + end + + test "base_url/0 returns default_base_url if config is missing" do + Application.delete_env(:workos, WorkOS.Client) + assert WorkOS.base_url() == WorkOS.default_base_url() + end + + test "client_id/0 returns nil if config is not a list" do + Application.put_env(:workos, WorkOS.Client, "not_a_list") + assert WorkOS.client_id() == nil + end + + test "client_id/0 returns nil if config is missing" do + Application.delete_env(:workos, WorkOS.Client) + assert WorkOS.client_id() == nil + end + end end