diff --git a/lib/resend/audiences.ex b/lib/resend/audiences.ex new file mode 100644 index 0000000..4603105 --- /dev/null +++ b/lib/resend/audiences.ex @@ -0,0 +1,117 @@ +defmodule Resend.Audiences do + @moduledoc """ + Manage audiences in Resend. + + Audiences are groups of contacts that you can send broadcasts to. This module + provides functions to create, list, retrieve, and delete audiences. + + ## Examples + + # Create a new audience + {:ok, audience} = Resend.Audiences.create(name: "Newsletter Subscribers") + + # List all audiences + {:ok, audiences} = Resend.Audiences.list() + + # Get a specific audience + {:ok, audience} = Resend.Audiences.get("78261eea-8f8b-4381-83c6-79fa7120f1cf") + + # Remove an audience + {:ok, removed_audience} = Resend.Audiences.remove("78261eea-8f8b-4381-83c6-79fa7120f1cf") + """ + + alias Resend.Audiences.Audience + + @doc """ + Creates a new audience. + + ## Parameters + + * `:name` - The name of the audience you want to create (required) + + ## Examples + + iex> Resend.Audiences.create(name: "Newsletter Subscribers") + {:ok, %Resend.Audiences.Audience{id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", name: "Newsletter Subscribers"}} + + iex> Resend.Audiences.create(name: "") + {:error, %Resend.Error{message: "Audience name is required"}} + + """ + @spec create(Keyword.t()) :: Resend.Client.response(Audience.t()) + @spec create(Resend.Client.t(), Keyword.t()) :: Resend.Client.response(Audience.t()) + def create(client \\ Resend.client(), opts) do + Resend.Client.post(client, Audience, "/audiences", %{ + name: opts[:name] + }) + end + + @doc """ + Lists all audiences in your account. + + ## Examples + + iex> Resend.Audiences.list() + {:ok, %Resend.List{data: [%Resend.Audiences.Audience{id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", name: "Newsletter Subscribers"}]}} + + """ + @spec list() :: Resend.Client.response(Resend.List.t(Audience.t())) + @spec list(Resend.Client.t()) :: Resend.Client.response(Resend.List.t(Audience.t())) + def list(client \\ Resend.client()) do + Resend.Client.get(client, Resend.List.of(Audience), "/audiences") + end + + @doc """ + Gets details of a specific audience by its ID. + + ## Parameters + + * `audience_id` - The Audience ID (required) + + ## Examples + + iex> Resend.Audiences.get("78261eea-8f8b-4381-83c6-79fa7120f1cf") + {:ok, %Resend.Audiences.Audience{id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", name: "Newsletter Subscribers"}} + + iex> Resend.Audiences.get("non-existent-id") + {:error, %Resend.Error{message: "Audience not found"}} + + """ + @spec get(String.t()) :: Resend.Client.response(Audience.t()) + @spec get(Resend.Client.t(), String.t()) :: Resend.Client.response(Audience.t()) + def get(client \\ Resend.client(), audience_id) do + Resend.Client.get(client, Audience, "/audiences/:id", + opts: [ + path_params: [id: audience_id] + ] + ) + end + + @doc """ + Removes an audience permanently. This action cannot be undone. + + Deleting an audience will also delete all associated contacts. + + ## Parameters + + * `audience_id` - The Audience ID (required) + + ## Examples + + iex> Resend.Audiences.remove("78261eea-8f8b-4381-83c6-79fa7120f1cf") + {:ok, %Resend.Audiences.Audience{id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", deleted: true}} + + iex> Resend.Audiences.remove("non-existent-id") + {:error, %Resend.Error{message: "Audience not found"}} + + """ + @spec remove(String.t()) :: Resend.Client.response(Audience.t()) + @spec remove(Resend.Client.t(), String.t()) :: Resend.Client.response(Audience.t()) + def remove(client \\ Resend.client(), audience_id) do + Resend.Client.delete(client, Audience, "/audiences/:id", %{}, + opts: [ + path_params: [id: audience_id] + ] + ) + end +end diff --git a/lib/resend/audiences/audience.ex b/lib/resend/audiences/audience.ex new file mode 100644 index 0000000..09f80cf --- /dev/null +++ b/lib/resend/audiences/audience.ex @@ -0,0 +1,44 @@ +defmodule Resend.Audiences.Audience do + @moduledoc """ + Resend Audience struct. + + Represents an audience, which is a group of contacts that you can send broadcasts to. + Each audience has a unique ID that is used to reference it in other API calls. + """ + + alias Resend.Util + + @behaviour Resend.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + name: String.t() | nil, + created_at: DateTime.t() | nil, + deleted: boolean() | nil + } + + @enforce_keys [:id] + defstruct [ + :id, + :name, + :created_at, + :deleted + ] + + @doc """ + Casts a raw map from the API response into an Audience struct. + + This function is used internally by the Castable protocol to convert + JSON responses from the Resend API into properly typed Elixir structs. + """ + @impl true + @spec cast(map()) :: t() + def cast(map) do + %__MODULE__{ + id: map["id"], + name: map["name"], + created_at: Util.parse_iso8601(map["created_at"]), + deleted: map["deleted"] + } + end +end diff --git a/lib/resend/broadcasts.ex b/lib/resend/broadcasts.ex new file mode 100644 index 0000000..8f77fbf --- /dev/null +++ b/lib/resend/broadcasts.ex @@ -0,0 +1,278 @@ +defmodule Resend.Broadcasts do + @moduledoc """ + Manage broadcasts in Resend. + + Broadcasts are email campaigns that can be sent to an entire audience. This module + provides functions to create, list, retrieve, update, send, and delete broadcasts. + + ## Examples + + # Create a new broadcast + {:ok, broadcast} = Resend.Broadcasts.create( + audience_id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", + name: "Monthly Newsletter", + from: "newsletter@example.com", + subject: "Our Monthly Update" + ) + + # List all broadcasts + {:ok, broadcasts} = Resend.Broadcasts.list() + + # Get a specific broadcast + {:ok, broadcast} = Resend.Broadcasts.get("b6d24b8e-af0b-4c3c-be0c-359bbd97381e") + + # Update a broadcast + {:ok, broadcast} = Resend.Broadcasts.update("b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + subject: "Updated Subject Line" + ) + + # Send a broadcast immediately + {:ok, broadcast} = Resend.Broadcasts.send("b6d24b8e-af0b-4c3c-be0c-359bbd97381e") + + # Schedule a broadcast for later + {:ok, broadcast} = Resend.Broadcasts.send("b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + scheduled_at: ~U[2024-12-25 09:00:00Z] + ) + + # Remove a broadcast + {:ok, removed_broadcast} = Resend.Broadcasts.remove("b6d24b8e-af0b-4c3c-be0c-359bbd97381e") + """ + + alias Resend.Broadcasts.Broadcast + + @doc """ + Creates a new broadcast. + + ## Parameters + + * `:audience_id` - The ID of the audience to send to (required) + * `:name` - The name of the broadcast (required) + * `:from` - The sender email address (required) + * `:subject` - The subject line of the broadcast (required) + * `:reply_to` - The reply-to email address(es). Can be a single string or list of strings (optional) + * `:preview_text` - The preview text that appears in email clients (optional) + * `:html` - The HTML content of the broadcast (optional) + * `:text` - The plain text content of the broadcast (optional) + * `:headers` - Map of custom headers to include (optional) + * `:attachments` - List of attachments to include (optional) + * `:tags` - List of tags for analytics (optional) + + ## Examples + + iex> Resend.Broadcasts.create( + ...> audience_id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", + ...> name: "Welcome Campaign", + ...> from: "hello@example.com", + ...> subject: "Welcome to our newsletter!", + ...> html: "
Thank you for subscribing!
" + ...> ) + {:ok, %Resend.Broadcasts.Broadcast{id: "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", status: "draft"}} + + iex> Resend.Broadcasts.create( + ...> audience_id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", + ...> name: "Newsletter", + ...> from: "news@example.com", + ...> subject: "Monthly Update", + ...> reply_to: ["support@example.com", "help@example.com"], + ...> preview_text: "Check out our latest updates...", + ...> html: "Content here
" + text = "Newsletter\n\nContent here" + headers = %{"X-Custom" => "value"} + attachments = [%{filename: "report.pdf", content: "base64content"}] + tags = [%{name: "category", value: "newsletter"}] + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["preview_text"] == preview_text + assert body["html"] == html + assert body["text"] == text + assert body["headers"] == headers + # Attachments are sent as atoms but returned as strings in JSON + assert body["attachments"] == [ + %{"filename" => "report.pdf", "content" => "base64content"} + ] + + # Tags are sent as atoms but returned as strings in JSON + assert body["tags"] == [%{"name" => "category", "value" => "newsletter"}] + + success_body = %{ + "id" => broadcast_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.create( + audience_id: audience_id, + name: "Full Broadcast", + from: "test@example.com", + subject: "Test Subject", + preview_text: preview_text, + html: html, + text: text, + headers: headers, + attachments: attachments, + tags: tags + ) + end + + test "lists all broadcasts", context do + broadcast_id_1 = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + broadcast_id_2 = "a5d24b8e-af0b-4c3c-be0c-359bbd97382f" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "list", + "data" => [ + %{ + "id" => broadcast_id_1, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Monthly Newsletter", + "from" => "news@example.com", + "subject" => "October Update", + "status" => "sent", + "created_at" => "2023-10-06T22:59:55.977Z", + "sent_at" => "2023-10-07T09:00:00.000Z" + }, + %{ + "id" => broadcast_id_2, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Weekly Digest", + "from" => "digest@example.com", + "subject" => "This Week's Highlights", + "status" => "draft", + "created_at" => "2023-10-08T10:30:00.000Z" + } + ] + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, + %Resend.List{ + data: [ + %Broadcast{id: ^broadcast_id_1, status: "sent"}, + %Broadcast{id: ^broadcast_id_2, status: "draft"} + ] + }} = Resend.Broadcasts.list() + end + + test "gets a broadcast by ID", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + name = "Monthly Newsletter" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => audience_id, + "name" => name, + "from" => "news@example.com", + "subject" => "October Update", + "status" => "sent", + "created_at" => "2023-10-06T22:59:55.977Z", + "sent_at" => "2023-10-07T09:00:00.000Z" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, + %Broadcast{ + id: ^broadcast_id, + audience_id: ^audience_id, + name: ^name, + status: "sent" + }} = Resend.Broadcasts.get(broadcast_id) + end + + test "updates a broadcast", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + new_subject = "Updated Newsletter Subject" + new_preview_text = "New preview text" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :patch + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["subject"] == new_subject + assert body["preview_text"] == new_preview_text + # Should not include nil values + refute Map.has_key?(body, "name") + refute Map.has_key?(body, "from") + + success_body = %{ + "id" => broadcast_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, + %Broadcast{ + id: ^broadcast_id + }} = + Resend.Broadcasts.update(broadcast_id, + subject: new_subject, + preview_text: new_preview_text + ) + end + + test "updates a broadcast with reply_to", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + new_reply_to = ["noreply@example.com", "support@example.com"] + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :patch + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["reply_to"] == new_reply_to + assert Map.keys(body) == ["reply_to"] + + success_body = %{ + "id" => broadcast_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.update(broadcast_id, reply_to: new_reply_to) + end + + test "sends a broadcast immediately", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body == %{} + + success_body = %{ + "id" => broadcast_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.send(broadcast_id) + end + + test "schedules a broadcast for later", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + scheduled_at = ~U[2024-12-25 09:00:00Z] + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["scheduled_at"] == DateTime.to_iso8601(scheduled_at) + + success_body = %{ + "id" => broadcast_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + # Fix: The function signature is send(client \\ Resend.client(), broadcast_id, opts \\ []) + # When called with 2 args, the second arg is interpreted as broadcast_id + # So we need to use the default client and pass broadcast_id and opts + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.send(Resend.client(), broadcast_id, scheduled_at: scheduled_at) + end + + test "cancels a scheduled broadcast", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["scheduled_at"] == nil + + success_body = %{ + "id" => broadcast_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + # Fix: Same issue here, need to use proper argument order + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.send(Resend.client(), broadcast_id, scheduled_at: nil) + end + + test "removes a broadcast by ID", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :delete + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "deleted" => true + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id, deleted: true}} = + Resend.Broadcasts.remove(broadcast_id) + end + + if not live_mode?() do + test "returns an error when creating broadcast fails", context do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Audience ID is required", + "name" => "validation_error", + "statusCode" => 400 + } + + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %Resend.Error{name: "validation_error", status_code: 400}} = + Resend.Broadcasts.create( + audience_id: nil, + name: "Test", + from: "test@example.com", + subject: "Test" + ) + end + + test "returns an error when broadcast not found", context do + broadcast_id = "non-existent-id" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Broadcast not found", + "name" => "not_found", + "statusCode" => 404 + } + + %Tesla.Env{status: 404, body: error_body} + end) + + assert {:error, %Resend.Error{name: "not_found", status_code: 404}} = + Resend.Broadcasts.get(broadcast_id) + end + + test "returns an error when updating a sent broadcast", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :patch + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Cannot update a broadcast that has been sent", + "name" => "invalid_request_error", + "statusCode" => 400 + } + + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %Resend.Error{name: "invalid_request_error", status_code: 400}} = + Resend.Broadcasts.update(broadcast_id, subject: "New subject") + end + + test "returns an error when sending a broadcast that's already sent", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Broadcast has already been sent", + "name" => "invalid_request_error", + "statusCode" => 400 + } + + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %Resend.Error{name: "invalid_request_error", status_code: 400}} = + Resend.Broadcasts.send(broadcast_id) + end + + test "returns an error when removing a sent broadcast", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :delete + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Cannot delete a broadcast that has been sent", + "name" => "invalid_request_error", + "statusCode" => 400 + } + + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %Resend.Error{name: "invalid_request_error", status_code: 400}} = + Resend.Broadcasts.remove(broadcast_id) + end + end + end + + describe "with custom client" do + test "creates a broadcast with custom client", _context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + body = Jason.decode!(request.body) + assert body["audience_id"] == audience_id + + success_body = %{ + "id" => broadcast_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.create(custom_client, + audience_id: audience_id, + name: "Test Broadcast", + from: "test@example.com", + subject: "Test Subject" + ) + end + + test "lists broadcasts with custom client", _context do + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :get + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + success_body = %{ + "object" => "list", + "data" => [] + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Resend.List{data: []}} = Resend.Broadcasts.list(custom_client) + end + + test "gets a broadcast with custom client", _context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :get + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Test Broadcast", + "from" => "test@example.com", + "subject" => "Test Subject", + "status" => "draft" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.get(custom_client, broadcast_id) + end + + test "updates a broadcast with custom client", _context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :patch + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + success_body = %{ + "id" => broadcast_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.update(custom_client, broadcast_id, subject: "Updated Subject") + end + + test "sends a broadcast with custom client", _context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + success_body = %{ + "id" => broadcast_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.send(custom_client, broadcast_id) + end + + test "removes a broadcast with custom client", _context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :delete + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "deleted" => true + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id, deleted: true}} = + Resend.Broadcasts.remove(custom_client, broadcast_id) + end + end +end diff --git a/test/resend/contacts/contact_test.exs b/test/resend/contacts/contact_test.exs new file mode 100644 index 0000000..d122961 --- /dev/null +++ b/test/resend/contacts/contact_test.exs @@ -0,0 +1,119 @@ +defmodule Resend.Contacts.ContactTest do + use ExUnit.Case, async: true + + alias Resend.Contacts.Contact + + describe "cast/1" do + test "casts a valid contact map to struct" do + contact_map = %{ + "id" => "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email" => "steve.wozniak@gmail.com", + "first_name" => "Steve", + "last_name" => "Wozniak", + "created_at" => "2023-10-06T23:47:56.678Z", + "unsubscribed" => false + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "e169aa45-1ecf-4183-9955-b1499d5701d3", + email: "steve.wozniak@gmail.com", + first_name: "Steve", + last_name: "Wozniak", + created_at: ~U[2023-10-06 23:47:56.678Z], + unsubscribed: false, + deleted: nil + } = result + end + + test "casts a contact map with deleted flag" do + contact_map = %{ + "id" => "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email" => "steve.wozniak@gmail.com", + "deleted" => true + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "e169aa45-1ecf-4183-9955-b1499d5701d3", + email: "steve.wozniak@gmail.com", + first_name: nil, + last_name: nil, + created_at: nil, + unsubscribed: nil, + deleted: true + } = result + end + + test "casts a minimal contact map" do + contact_map = %{ + "id" => "e169aa45-1ecf-4183-9955-b1499d5701d3" + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "e169aa45-1ecf-4183-9955-b1499d5701d3", + email: nil, + first_name: nil, + last_name: nil, + created_at: nil, + unsubscribed: nil, + deleted: nil + } = result + end + + test "handles contact field in delete response" do + contact_map = %{ + "contact" => "520784e2-887d-4c25-b53c-4ad46ad38100", + "deleted" => true + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "520784e2-887d-4c25-b53c-4ad46ad38100", + email: nil, + first_name: nil, + last_name: nil, + created_at: nil, + unsubscribed: nil, + deleted: true + } = result + end + + test "handles nil datetime" do + contact_map = %{ + "id" => "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email" => "test@example.com", + "created_at" => nil + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "e169aa45-1ecf-4183-9955-b1499d5701d3", + email: "test@example.com", + created_at: nil + } = result + end + + test "handles unsubscribed status" do + contact_map = %{ + "id" => "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email" => "test@example.com", + "unsubscribed" => true + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "e169aa45-1ecf-4183-9955-b1499d5701d3", + email: "test@example.com", + unsubscribed: true + } = result + end + end +end diff --git a/test/resend/contacts_test.exs b/test/resend/contacts_test.exs new file mode 100644 index 0000000..ee4ea75 --- /dev/null +++ b/test/resend/contacts_test.exs @@ -0,0 +1,362 @@ +defmodule Resend.ContactsTest do + use Resend.TestCase, async: true + + alias Resend.Contacts.Contact + + setup :setup_env + + describe "/audiences/:audience_id/contacts" do + test "creates a contact", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "479e3145-dd38-476b-932c-529ceb705947" + email = "steve.wozniak@gmail.com" + first_name = "Steve" + last_name = "Wozniak" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/audiences/#{audience_id}/contacts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["email"] == email + assert body["first_name"] == first_name + assert body["last_name"] == last_name + assert body["unsubscribed"] == false + + success_body = %{ + "object" => "contact", + "id" => contact_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id}} = + Resend.Contacts.create(audience_id, + email: email, + first_name: first_name, + last_name: last_name, + unsubscribed: false + ) + end + + test "lists all contacts", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "e169aa45-1ecf-4183-9955-b1499d5701d3" + email = "steve.wozniak@gmail.com" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "https://api.resend.com/audiences/#{audience_id}/contacts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "list", + "data" => [ + %{ + "id" => contact_id, + "email" => email, + "first_name" => "Steve", + "last_name" => "Wozniak", + "created_at" => "2023-10-06T23:47:56.678Z", + "unsubscribed" => false + } + ] + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Resend.List{data: [%Contact{id: ^contact_id, email: ^email}]}} = + Resend.Contacts.list(audience_id) + end + + test "gets a contact by ID", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "e169aa45-1ecf-4183-9955-b1499d5701d3" + email = "steve.wozniak@gmail.com" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{contact_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "contact", + "id" => contact_id, + "email" => email, + "first_name" => "Steve", + "last_name" => "Wozniak", + "created_at" => "2023-10-06T23:47:56.678Z", + "unsubscribed" => false + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id, email: ^email}} = + Resend.Contacts.get(audience_id, id: contact_id) + end + + test "gets a contact by email", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "e169aa45-1ecf-4183-9955-b1499d5701d3" + email = "steve.wozniak@gmail.com" + encoded_email = URI.encode_www_form(email) + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{encoded_email}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "contact", + "id" => contact_id, + "email" => email, + "first_name" => "Steve", + "last_name" => "Wozniak", + "created_at" => "2023-10-06T23:47:56.678Z", + "unsubscribed" => false + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id, email: ^email}} = + Resend.Contacts.get(audience_id, email: email) + end + + test "updates a contact by ID", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "e169aa45-1ecf-4183-9955-b1499d5701d3" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :patch + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{contact_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["unsubscribed"] == true + assert Map.keys(body) == ["unsubscribed"] + + success_body = %{ + "object" => "contact", + "id" => contact_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id}} = + Resend.Contacts.update(audience_id, id: contact_id, unsubscribed: true) + end + + test "updates a contact by email", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "e169aa45-1ecf-4183-9955-b1499d5701d3" + email = "steve@example.com" + encoded_email = URI.encode_www_form(email) + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :patch + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{encoded_email}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["first_name"] == "Steven" + assert body["last_name"] == "Jobs" + assert Map.keys(body) |> Enum.sort() == ["first_name", "last_name"] + + success_body = %{ + "object" => "contact", + "id" => contact_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id}} = + Resend.Contacts.update(audience_id, + email: email, + first_name: "Steven", + last_name: "Jobs" + ) + end + + test "removes a contact by ID", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "520784e2-887d-4c25-b53c-4ad46ad38100" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :delete + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{contact_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "contact", + "contact" => contact_id, + "deleted" => true + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id, deleted: true}} = + Resend.Contacts.remove(audience_id, id: contact_id) + end + + test "removes a contact by email", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "520784e2-887d-4c25-b53c-4ad46ad38100" + email = "acme@example.com" + encoded_email = URI.encode_www_form(email) + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :delete + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{encoded_email}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "contact", + "contact" => contact_id, + "deleted" => true + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id, deleted: true}} = + Resend.Contacts.remove(audience_id, email: email) + end + + if not live_mode?() do + test "returns an error when creating contact fails", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/audiences/#{audience_id}/contacts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Email is required", + "name" => "validation_error", + "statusCode" => 400 + } + + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %Resend.Error{name: "validation_error", status_code: 400}} = + Resend.Contacts.create(audience_id, email: nil) + end + + test "returns an error when contact not found", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "non-existent-id" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{contact_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Contact not found", + "name" => "not_found", + "statusCode" => 404 + } + + %Tesla.Env{status: 404, body: error_body} + end) + + assert {:error, %Resend.Error{name: "not_found", status_code: 404}} = + Resend.Contacts.get(audience_id, id: contact_id) + end + end + end + + describe "with custom client" do + test "creates a contact with custom client", _context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "479e3145-dd38-476b-932c-529ceb705947" + email = "test@example.com" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :post + assert request.url == "https://api.resend.com/audiences/#{audience_id}/contacts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + body = Jason.decode!(request.body) + assert body["email"] == email + + success_body = %{ + "object" => "contact", + "id" => contact_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id}} = + Resend.Contacts.create(custom_client, audience_id, email: email) + end + end +end