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
28 changes: 28 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: SDK Tests

on:
pull_request:
paths:
- "packages/scrawn/**"
- ".github/workflows/tests.yml"

jobs:
sdk-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/scrawn
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.3.2"

- name: Install dependencies
run: bun install

- name: Run SDK tests
run: bun run test
1 change: 1 addition & 0 deletions examples/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"build": "./clean_build.sh",
"rebuild": "cd packages/scrawn && bun run build",
"clean": "cd packages/scrawn && bun run clean",
"install": "bun install && cd packages/scrawn && bun install && cd ../.. && cd examples && bun install"
"install:all": "bun install && cd packages/scrawn && bun install && cd ../.. && cd examples && bun install"
},
"devDependencies": {
"@types/node": "^24.10.0",
Expand Down
309 changes: 309 additions & 0 deletions packages/scrawn/bun.lock

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions packages/scrawn/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
"scripts": {
"build": "tsc",
"clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
"gen": "cd proto && bunx buf generate"
"gen": "cd proto && bunx buf generate",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@bufbuild/protobuf": "1.7.2",
Expand All @@ -29,7 +32,9 @@
"zod": "^4.1.12"
},
"devDependencies": {
"@types/node": "^24.10.0"
"@types/node": "^24.10.0",
"@vitest/coverage-v8": "1.6.1",
"vitest": "1.6.1"
},
"files": [
"dist"
Expand Down
39 changes: 39 additions & 0 deletions packages/scrawn/tests/mocks/mockTransport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { MethodInfo, ServiceType } from "@bufbuild/protobuf";
import type { Transport, UnaryResponse } from "@connectrpc/connect";
import { Headers } from "undici";

type HeaderInit = Record<string, string> | string[][] | Headers | undefined;

export type UnaryHandler = (request: {
service: ServiceType;
method: MethodInfo;
input: unknown;
headers: HeaderInit;
}) => Promise<unknown> | unknown;

export function createMockTransport(handlers: {
unary: UnaryHandler;
}): Transport {
return {
async unary(service, method, _signal, _timeoutMs, header, input): Promise<UnaryResponse> {
const message = await handlers.unary({
service,
method,
input,
headers: header as HeaderInit,
});

return {
stream: false,
service,
method,
header: new Headers(header as HeaderInit),
trailer: new Headers(),
message,
} as UnaryResponse;
},
async stream(): Promise<never> {
throw new Error("Streaming not supported in mock transport");
},
};
}
12 changes: 12 additions & 0 deletions packages/scrawn/tests/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { afterEach, beforeEach, vi } from "vitest";

const originalEnv = { ...process.env };

beforeEach(() => {
process.env = { ...originalEnv, SCRAWN_DEBUG: "" };
vi.restoreAllMocks();
});

afterEach(() => {
process.env = { ...originalEnv };
});
21 changes: 21 additions & 0 deletions packages/scrawn/tests/unit/auth/apiKeyAuth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { ApiKeyAuth, isValidApiKey, validateApiKey } from "../../../src/core/auth/apiKeyAuth.js";
import { ScrawnValidationError } from "../../../src/core/errors/index.js";

const validKey = "scrn_1234567890abcdef1234567890abcdef";

describe("apiKeyAuth", () => {
it("validates api key format", () => {
expect(isValidApiKey(validKey)).toBe(true);
expect(isValidApiKey("scrn_invalid")).toBe(false);
});

it("throws a validation error for invalid api keys", () => {
expect(() => validateApiKey("scrn_invalid")).toThrow(ScrawnValidationError);
});

it("returns validated credentials", async () => {
const auth = new ApiKeyAuth(validKey);
await expect(auth.getCreds()).resolves.toEqual({ apiKey: validKey });
});
});
73 changes: 73 additions & 0 deletions packages/scrawn/tests/unit/errors/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
ScrawnAPIError,
ScrawnAuthenticationError,
ScrawnConfigError,
ScrawnNetworkError,
ScrawnRateLimitError,
ScrawnValidationError,
convertGrpcError,
isRetryableError,
isScrawnError,
} from "../../../src/core/errors/index.js";

describe("Scrawn errors", () => {
it("creates typed errors with metadata", () => {
const error = new ScrawnValidationError("Invalid payload", {
details: { field: "userId" },
});

expect(error.code).toBe("VALIDATION_ERROR");
expect(error.statusCode).toBe(400);
expect(error.details).toEqual({ field: "userId" });
});

it("converts grpc errors to auth errors", () => {
const grpcError = { code: 16, message: "Unauthenticated" };
const converted = convertGrpcError(grpcError);

expect(converted).toBeInstanceOf(ScrawnAuthenticationError);
expect(converted.statusCode).toBe(401);
});

it("converts grpc errors to validation errors", () => {
const grpcError = { code: 3, message: "Invalid" };
const converted = convertGrpcError(grpcError);

expect(converted).toBeInstanceOf(ScrawnValidationError);
expect(converted.statusCode).toBe(400);
});

it("converts grpc errors to rate limit errors", () => {
const grpcError = { code: 8, message: "Rate limit" };
const converted = convertGrpcError(grpcError);

expect(converted).toBeInstanceOf(ScrawnRateLimitError);
expect(converted.retryable).toBe(true);
});

it("converts grpc errors to network errors", () => {
const grpcError = { code: 14, message: "Unavailable" };
const converted = convertGrpcError(grpcError);

expect(converted).toBeInstanceOf(ScrawnNetworkError);
expect(converted.retryable).toBe(true);
});

it("converts grpc errors to api errors", () => {
const grpcError = { code: 13, message: "Internal" };
const converted = convertGrpcError(grpcError);

expect(converted).toBeInstanceOf(ScrawnAPIError);
expect(converted.statusCode).toBe(500);
});

it("marks retryable errors", () => {
const retryable = new ScrawnNetworkError("Timeout");
const nonRetryable = new ScrawnConfigError("Bad config");

expect(isScrawnError(retryable)).toBe(true);
expect(isRetryableError(retryable)).toBe(true);
expect(isRetryableError(nonRetryable)).toBe(false);
});
});
22 changes: 22 additions & 0 deletions packages/scrawn/tests/unit/grpc/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { GrpcClient } from "../../../src/core/grpc/client.js";
import { EventService } from "../../../src/gen/event/v1/event_connect.js";
import { createMockTransport } from "../../mocks/mockTransport.js";
import { RegisterEventResponse } from "../../../src/gen/event/v1/event_pb.js";

const mockTransport = createMockTransport({
unary: () => new RegisterEventResponse({ random: "ok" }),
});

vi.mock("@connectrpc/connect-node", () => ({
createConnectTransport: () => mockTransport,
}));

describe("GrpcClient", () => {
it("creates request builders with the configured base URL", () => {
const client = new GrpcClient("https://api.example");

expect(client.getBaseURL()).toBe("https://api.example");
expect(() => client.newCall(EventService, "registerEvent")).not.toThrow();
});
});
49 changes: 49 additions & 0 deletions packages/scrawn/tests/unit/grpc/requestBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { RequestBuilder } from "../../../src/core/grpc/requestBuilder.js";
import { createMockTransport } from "../../mocks/mockTransport.js";
import { PaymentService } from "../../../src/gen/payment/v1/payment_connect.js";
import { CreateCheckoutLinkResponse } from "../../../src/gen/payment/v1/payment_pb.js";

describe("RequestBuilder", () => {
it("builds a request with headers and payload", async () => {
const transport = createMockTransport({
unary: ({ input, headers }) => {
expect(headers).toEqual({ Authorization: "Bearer token" });
expect(input).toEqual({ userId: "user_1" });
return new CreateCheckoutLinkResponse({
checkoutLink: "https://checkout.example",
});
},
});

const builder = new RequestBuilder(transport, PaymentService, "createCheckoutLink");
const response = await builder
.addHeader("Authorization", "Bearer token")
.addPayload({ userId: "user_1" })
.request();

expect(response.checkoutLink).toBe("https://checkout.example");
});

it("throws when payload is missing", async () => {
const transport = createMockTransport({
unary: () => new CreateCheckoutLinkResponse({ checkoutLink: "" }),
});

const builder = new RequestBuilder(transport, PaymentService, "createCheckoutLink");
await expect(builder.request()).rejects.toThrow("addPayload");
});

it("prevents payload from being set twice", () => {
const transport = createMockTransport({
unary: () => new CreateCheckoutLinkResponse({ checkoutLink: "" }),
});

const builder = new RequestBuilder(transport, PaymentService, "createCheckoutLink");
builder.addPayload({ userId: "user_1" });

expect(() => builder.addPayload({ userId: "user_2" })).toThrow(
"Payload has already been set"
);
});
});
75 changes: 75 additions & 0 deletions packages/scrawn/tests/unit/scrawn/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Scrawn } from "../../../src/core/scrawn.js";
import { createMockTransport } from "../../mocks/mockTransport.js";
import { EventService } from "../../../src/gen/event/v1/event_connect.js";
import { RegisterEventResponse } from "../../../src/gen/event/v1/event_pb.js";

const validKey = "scrn_1234567890abcdef1234567890abcdef";

const unaryHandler = vi.fn(({ service, input }) => {
if (service.typeName === EventService.typeName) {
const payload = input as { userId: string };
expect(payload.userId).toBe("user_1");
return new RegisterEventResponse({ random: "ok" });
}

throw new Error("Unexpected call");
});

const transport = createMockTransport({
unary: unaryHandler,
});

vi.mock("@connectrpc/connect-node", () => ({
createConnectTransport: () => transport,
}));

describe("middlewareEventConsumer", () => {
afterEach(() => {
unaryHandler.mockClear();
});

it("tracks events for matching paths", async () => {
const scrawn = new Scrawn({ apiKey: validKey, baseURL: "https://api.example" });
const middleware = scrawn.middlewareEventConsumer({
extractor: () => ({ userId: "user_1", debitAmount: 2 }),
whitelist: ["/api/**"],
});

const next = vi.fn();
await middleware({ path: "/api/users" }, {}, next);

expect(next).toHaveBeenCalled();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unaryHandler).toHaveBeenCalledTimes(1);
});

it("skips events for non-whitelisted paths", async () => {
const scrawn = new Scrawn({ apiKey: validKey, baseURL: "https://api.example" });
const middleware = scrawn.middlewareEventConsumer({
extractor: () => ({ userId: "user_1", debitAmount: 2 }),
whitelist: ["/billing/**"],
});

const next = vi.fn();
await middleware({ path: "/api/users" }, {}, next);

expect(next).toHaveBeenCalled();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unaryHandler).toHaveBeenCalledTimes(0);
});

it("skips events when extractor returns null", async () => {
const scrawn = new Scrawn({ apiKey: validKey, baseURL: "https://api.example" });
const middleware = scrawn.middlewareEventConsumer({
extractor: () => null,
});

const next = vi.fn();
await middleware({ path: "/api/users" }, {}, next);

expect(next).toHaveBeenCalled();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unaryHandler).toHaveBeenCalledTimes(0);
});
});
Loading
Loading