Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 319 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

706 changes: 664 additions & 42 deletions README.md

Large diffs are not rendered by default.

1,070 changes: 1,060 additions & 10 deletions dist/index.cjs

Large diffs are not rendered by default.

14,268 changes: 11,036 additions & 3,232 deletions dist/index.d.cts

Large diffs are not rendered by default.

14,268 changes: 11,036 additions & 3,232 deletions dist/index.d.ts

Large diffs are not rendered by default.

1,066 changes: 1,056 additions & 10 deletions dist/index.js

Large diffs are not rendered by default.

21,412 changes: 15,964 additions & 5,448 deletions openapi.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sendly-sdk",
"version": "1.0.0",
"version": "1.1.0",
"description": "Official Sendly TypeScript SDK",
"license": "MIT",
"type": "module",
Expand Down
98 changes: 97 additions & 1 deletion src/__tests__/campaigns.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { describe, expect, test } from "vitest";
import { SendlyConflictError, SendlyNotFoundError } from "../index";
import type { CampaignV1 } from "../types";
import type { CampaignFailureV1, CampaignV1 } from "../types";
import { cursorPage, getCall, getCallBody, jsonResponse, makeClient, problemResponse, rejection } from "./helpers";

function campaign(id: string): CampaignV1 {
// eslint-disable-next-line sendly/no-unknown-cast-laundering -- minimal fixture; only the fields under assertion matter
return { id, name: `Campaign ${id}`, status: "DRAFT" } as unknown as CampaignV1;
}

function failure(id: string): CampaignFailureV1 {
return {
id,
contact_id: `ct_${id}`,
email: `${id}@example.com`,
reason: "HARD_BOUNCE",
failed_at: "2026-09-01T00:00:00.000Z",
};
}

describe("campaigns resource (/api/v1)", () => {
test("create POSTs /api/v1/campaigns and resolves the bare body — no envelope unwrap", async () => {
const { client, fetchMock } = makeClient();
Expand Down Expand Up @@ -227,4 +237,90 @@ describe("campaigns resource (/api/v1)", () => {
expect(error).toBeInstanceOf(SendlyConflictError);
expect(error.errorCode).toBe("conflict");
});

test("listFailures GETs the failures sub-path and keeps the `total` this list uniquely carries", async () => {
const { client, fetchMock } = makeClient();
fetchMock.mockResolvedValue(
jsonResponse(200, {
data: [
{
id: "fail_1",
contact_id: "ct_1",
email: "bounced@example.com",
reason: "HARD_BOUNCE",
failed_at: "2026-09-01T00:00:00.000Z",
},
],
has_more: false,
next_cursor: null,
total: 4211,
}),
);

const page = await client.campaigns.listFailures("cmp_1");

const { url, init } = getCall(fetchMock);
expect(url).toBe("http://localhost/api/v1/campaigns/cmp_1/failures");
expect(init.method).toBe("GET");
// Bare v1 body: no `{ success, data }` unwrap happened, and `total` survives.
expect(page.total).toBe(4211);
expect(page.data[0]?.reason).toBe("HARD_BOUNCE");
});

test("listFailures serializes the cursor query params", async () => {
const { client, fetchMock } = makeClient();
fetchMock.mockResolvedValue(jsonResponse(200, { data: [], has_more: false, next_cursor: null, total: 0 }));

await client.campaigns.listFailures("cmp_1", { limit: 50, after: "fail_9" });

const { url } = getCall(fetchMock);
expect(url).toContain("limit=50");
expect(url).toContain("after=fail_9");
});

test("listFailuresAll walks two pages, threads the cursor, and stops on the last one", async () => {
const { client, fetchMock } = makeClient();
fetchMock
.mockResolvedValueOnce(cursorPage([failure("fail_1")], "fail_1"))
.mockResolvedValueOnce(cursorPage([failure("fail_2"), failure("fail_3")], null));

const seen: string[] = [];
for await (const row of client.campaigns.listFailuresAll("cmp_1")) seen.push(row.id);

expect(seen).toEqual(["fail_1", "fail_2", "fail_3"]);
expect(fetchMock.mock.calls).toHaveLength(2);
expect(getCall(fetchMock, 0).url).not.toContain("after=");
expect(getCall(fetchMock, 1).url).toContain("/campaigns/cmp_1/failures");
expect(getCall(fetchMock, 1).url).toContain("after=fail_1");
});

test("retryFailed POSTs retry-failed with no body and resolves the queued count", async () => {
const { client, fetchMock } = makeClient();
fetchMock.mockResolvedValue(jsonResponse(200, { id: "cmp_1", queued: 12 }));

const ack = await client.campaigns.retryFailed("cmp_1");

const { url, init } = getCall(fetchMock);
expect(url).toBe("http://localhost/api/v1/campaigns/cmp_1/retry-failed");
expect(init.method).toBe("POST");
// The route takes no body — sending one would be a contract change.
expect(init.body).toBeUndefined();
expect(ack).toEqual({ id: "cmp_1", queued: 12 });
});

test("retrying a campaign whose retry is already running surfaces the conflict code", async () => {
const { client, fetchMock } = makeClient();
fetchMock.mockResolvedValue(
problemResponse(409, {
type: "https://docs.sendly.now/errors/conflict",
title: "Conflict",
detail: "A retry is already running for this campaign.",
code: "conflict",
}),
);

const error = await rejection<SendlyConflictError>(client.campaigns.retryFailed("cmp_1"));
expect(error).toBeInstanceOf(SendlyConflictError);
expect(error.errorCode).toBe("conflict");
});
});
133 changes: 132 additions & 1 deletion src/__tests__/contacts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, test } from "vitest";
import { SendlyNotFoundError } from "../index";
import { getCall, getCallBody, jsonResponse, makeClient } from "./helpers";
import type { ContactV1 } from "../types";
import { cursorPage, getCall, getCallBody, jsonResponse, makeClient } from "./helpers";

function contactV1(id: string): ContactV1 {
// eslint-disable-next-line sendly/no-unknown-cast-laundering -- minimal fixture; only the fields under assertion matter
return { id, email: `${id}@example.com`, subscribed: true } as unknown as ContactV1;
}

describe("contacts resource", () => {
test("create POSTs /api/contacts and unwraps data", async () => {
Expand Down Expand Up @@ -72,3 +78,128 @@ describe("contacts resource", () => {
expect(init.method).toBe("DELETE");
});
});

describe("contacts resource (/api/v1)", () => {
test("createV1 POSTs /api/v1/contacts and resolves the bare body, unwrapping nothing", async () => {
const { client, fetchMock } = makeClient();
const body = {
id: "con_1",
email: "x@y.com",
subscribed: true,
custom_fields: { plan: "pro" },
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
};
fetchMock.mockResolvedValue(jsonResponse(201, body));

const created = await client.contacts.createV1({ email: "x@y.com", custom_fields: { plan: "pro" } });

const { url, init } = getCall(fetchMock);
expect(url).toBe("http://localhost/api/v1/contacts");
expect(init.method).toBe("POST");
expect(getCallBody(fetchMock)).toEqual({ email: "x@y.com", custom_fields: { plan: "pro" } });
// v1 answers a bare body: the whole document reaches the caller, `data` and all.
expect(created).toEqual(body);
});

test("createV1 accepts just an email — `subscribed` defaults server-side", async () => {
const { client, fetchMock } = makeClient();
fetchMock.mockResolvedValue(jsonResponse(201, contactV1("con_1")));
await client.contacts.createV1({ email: "x@y.com" });
expect(getCallBody(fetchMock)).toEqual({ email: "x@y.com" });
});

test("getV1, updateV1 and deleteV1 build the right verb and path", async () => {
const { client, fetchMock } = makeClient();
fetchMock.mockResolvedValue(jsonResponse(200, { id: "con_1", deleted: true }));

await client.contacts.getV1("con_1");
expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/contacts/con_1");
expect(getCall(fetchMock).init.method).toBe("GET");

fetchMock.mockClear();
await client.contacts.updateV1("con_1", { custom_fields: { plan: "enterprise" } });
expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/contacts/con_1");
expect(getCall(fetchMock).init.method).toBe("PATCH");
expect(getCallBody(fetchMock)).toEqual({ custom_fields: { plan: "enterprise" } });

fetchMock.mockClear();
const deleted = await client.contacts.deleteV1("con_1");
expect(getCall(fetchMock).init.method).toBe("DELETE");
// Unlike the legacy delete, the acknowledgement is resolved rather than discarded.
expect(deleted).toEqual({ id: "con_1", deleted: true });
});

test("listV1 serializes the search, subscribed and cursor params", async () => {
const { client, fetchMock } = makeClient();
fetchMock.mockResolvedValue(cursorPage([], null));

await client.contacts.listV1({ limit: 10, after: "cur_con", search: "ada", subscribed: "false" });

const { url } = getCall(fetchMock);
expect(url).toContain("/api/v1/contacts?");
expect(url).toContain("limit=10");
expect(url).toContain("after=cur_con");
expect(url).toContain("search=ada");
expect(url).toContain("subscribed=false");
});

test("listV1 resolves the cursor envelope itself, not just its rows", async () => {
const { client, fetchMock } = makeClient();
fetchMock.mockResolvedValue(cursorPage([contactV1("con_1")], "cur_2"));

const page = await client.contacts.listV1();

expect(page.has_more).toBe(true);
expect(page.next_cursor).toBe("cur_2");
expect(page.data[0]?.email).toBe("con_1@example.com");
});

test("listAllV1 walks every page and yields individual contacts", async () => {
const { client, fetchMock } = makeClient();
fetchMock
.mockResolvedValueOnce(cursorPage([contactV1("con_1")], "cur_2"))
.mockResolvedValueOnce(cursorPage([contactV1("con_2"), contactV1("con_3")], null));

const seen: string[] = [];
for await (const contact of client.contacts.listAllV1({ search: "ada" })) seen.push(contact.id);

expect(seen).toEqual(["con_1", "con_2", "con_3"]);
expect(fetchMock.mock.calls).toHaveLength(2);
// The filter is carried forward with the cursor — the cursor encodes it.
expect(getCall(fetchMock, 1).url).toContain("after=cur_2");
expect(getCall(fetchMock, 1).url).toContain("search=ada");
});

test("topicPreferences GETs the contact's topics sub-path", async () => {
const { client, fetchMock } = makeClient();
const preferences = {
contact_id: "con_1",
subscribed: false,
topics: [{ topic_id: "top_1", key: "product-news", name: "Product news", subscribed: true, pending: false }],
};
fetchMock.mockResolvedValue(jsonResponse(200, preferences));

const result = await client.contacts.topicPreferences("con_1");

const { url, init } = getCall(fetchMock);
expect(url).toBe("http://localhost/api/v1/contacts/con_1/topics");
expect(init.method).toBe("GET");
// The global opt-out outranks the per-topic answers; both must survive the trip.
expect(result.subscribed).toBe(false);
expect(result.topics[0]?.key).toBe("product-news");
});

test("contact ids are URL-encoded into every v1 path", async () => {
const { client, fetchMock } = makeClient();
fetchMock.mockResolvedValue(jsonResponse(200, contactV1("a/b")));
await client.contacts.getV1("a/b");
expect(getCall(fetchMock).url).toBe("http://localhost/api/v1/contacts/a%2Fb");
});

test("getV1 surfaces a 404 problem document as SendlyNotFoundError", async () => {
const { client, fetchMock } = makeClient();
fetchMock.mockResolvedValue(jsonResponse(404, { error: { message: "no such contact", code: "not_found" } }));
await expect(client.contacts.getV1("con_missing")).rejects.toBeInstanceOf(SendlyNotFoundError);
});
});
Loading