@follow-app/client-sdk
Version:
TypeScript client SDK for Follow RSS Server API
756 lines (627 loc) • 22.5 kB
text/typescript
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { ModuleDefinition } from "../shared/define-module"
import type { HttpClient } from "./base"
import type { RouteArgs, RouteDefinition } from "./proxy"
import { APIProxyHandler, createAPIProxy } from "./proxy"
describe("APIProxyHandler", () => {
let mockClient: HttpClient
let routes: Map<string, RouteDefinition>
let handler: APIProxyHandler
beforeEach(() => {
mockClient = {
request: vi.fn(),
} as any
routes = new Map([
["get", { method: "GET", path: "/test" }],
["create", { method: "POST", path: "/test" }],
["update", { method: "PUT", path: "/test/{id}", params: ["id"] }],
["nested.get", { method: "GET", path: "/test/nested" }],
[
"nested.create",
{ method: "POST", path: "/test/nested/{id}", params: ["id"] },
],
[
"deeply.nested.action",
{ method: "POST", path: "/test/deeply/nested/action" },
],
// Routes with explicit parameter classification
[
"withClassification",
{
method: "POST",
path: "/test/{id}",
params: ["id"],
query: ["includeMetadata", "format"],
body: ["title", "content"],
},
],
[
"getWithQuery",
{
method: "GET",
path: "/test/search",
query: ["keyword", "limit", "offset"],
},
],
])
handler = new APIProxyHandler(mockClient, routes)
})
describe("Route Function Creation", () => {
it("should create a route function for basic GET request", async () => {
const mockResponse = { data: "test" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "get")
expect(typeof routeFunction).toBe("function")
const result = await routeFunction()
expect(mockClient.request).toHaveBeenCalledWith("/test", {
method: "GET",
headers: undefined,
timeout: undefined,
signal: undefined,
})
expect(result).toBe(mockResponse)
})
it("should create a route function for POST request with body", async () => {
const mockResponse = { data: "created" }
const requestBody = { name: "test", value: 123 }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "create")
const result = await routeFunction(requestBody)
expect(mockClient.request).toHaveBeenCalledWith("/test", {
method: "POST",
headers: undefined,
timeout: undefined,
signal: undefined,
body: requestBody,
})
expect(result).toBe(mockResponse)
})
it("should create a route function with parameters", async () => {
const mockResponse = { data: "updated" }
const requestBody = { name: "updated" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "update")
const result = await routeFunction({
id: "123",
...requestBody,
})
expect(mockClient.request).toHaveBeenCalledWith("/test/123", {
method: "PUT",
headers: undefined,
timeout: undefined,
signal: undefined,
body: requestBody,
})
expect(result).toBe(mockResponse)
})
it("should handle query parameters", async () => {
const mockResponse = { data: "test" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "get")
const result = await routeFunction({
limit: 10,
offset: 0,
active: true,
})
expect(mockClient.request).toHaveBeenCalledWith("/test", {
method: "GET",
headers: undefined,
timeout: undefined,
signal: undefined,
query: { limit: 10, offset: 0, active: true },
})
expect(result).toBe(mockResponse)
})
it("should handle all route arguments", async () => {
const mockResponse = { data: "test" }
const controller = new AbortController()
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "update")
const args: RouteArgs = {
id: "456", // path param
include: "metadata", // will go to query for GET, body for PUT
name: "test", // will go to body for PUT
headers: { "X-Custom": "header" },
timeout: 5000,
signal: controller.signal,
}
const result = await routeFunction(args)
expect(mockClient.request).toHaveBeenCalledWith("/test/456", {
method: "PUT",
headers: { "X-Custom": "header" },
timeout: 5000,
signal: controller.signal,
body: { include: "metadata", name: "test" },
})
expect(result).toBe(mockResponse)
})
})
describe("Nested Routes", () => {
it("should handle nested routes", async () => {
const mockResponse = { data: "nested" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const nestedProxy = handler.get(target, "nested")
expect(typeof nestedProxy).toBe("object")
expect(typeof nestedProxy.get).toBe("function")
const result = await nestedProxy.get()
expect(mockClient.request).toHaveBeenCalledWith("/test/nested", {
method: "GET",
headers: undefined,
timeout: undefined,
signal: undefined,
})
expect(result).toBe(mockResponse)
})
it("should handle nested routes with parameters", async () => {
const mockResponse = { data: "nested created" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const nestedProxy = handler.get(target, "nested")
const result = await nestedProxy.create({
id: "nested-123",
data: "nested",
})
expect(mockClient.request).toHaveBeenCalledWith(
"/test/nested/nested-123",
{
method: "POST",
headers: undefined,
timeout: undefined,
signal: undefined,
body: { data: "nested" },
},
)
expect(result).toBe(mockResponse)
})
it("should handle deeply nested routes", async () => {
const mockResponse = { data: "deeply nested" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const deeplyProxy = handler.get(target, "deeply")
const nestedProxy = deeplyProxy.nested
expect(typeof nestedProxy).toBe("object")
expect(typeof nestedProxy.action).toBe("function")
const result = await nestedProxy.action({ test: true })
expect(mockClient.request).toHaveBeenCalledWith(
"/test/deeply/nested/action",
{
method: "POST",
headers: undefined,
timeout: undefined,
signal: undefined,
body: { test: true },
},
)
expect(result).toBe(mockResponse)
})
})
describe("Error Handling", () => {
it("should throw error for non-existent route", () => {
const target = {}
expect(() => {
handler.get(target, "nonexistent")
}).toThrow("Route 'nonexistent' not found")
})
it("should throw error for nested route that doesn't exist", () => {
const target = {}
const nestedProxy = handler.get(target, "nested")
expect(() => {
void nestedProxy.nonexistent
}).toThrow("Route 'nonexistent' not found")
})
it("should handle client request errors", async () => {
const error = new Error("Network error")
mockClient.request = vi.fn().mockRejectedValue(error)
const target = {}
const routeFunction = handler.get(target, "get")
await expect(routeFunction()).rejects.toThrow("Network error")
})
})
describe("Special Properties", () => {
it("should handle constructor property", () => {
const target = { constructor: Function }
const result = handler.get(target, "constructor")
expect(result).toBe(Function)
})
it("should handle prototype property", () => {
const target = { prototype: {} }
const result = handler.get(target, "prototype")
expect(result).toBe(target.prototype)
})
})
describe("Parameter Classification", () => {
it("should classify parameters according to route definition", async () => {
const mockResponse = { data: "classified" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "withClassification")
const result = await routeFunction({
id: "123", // path param
includeMetadata: true, // query param
format: "json", // query param
title: "Test Title", // body param
content: "Test Content", // body param
extra: "should be in body", // remaining field → body (POST)
})
expect(mockClient.request).toHaveBeenCalledWith("/test/123", {
method: "POST",
headers: undefined,
timeout: undefined,
signal: undefined,
query: { includeMetadata: true, format: "json" },
body: { title: "Test Title", content: "Test Content", extra: "should be in body" },
})
expect(result).toBe(mockResponse)
})
it("should classify GET route parameters correctly", async () => {
const mockResponse = { data: "search results" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "getWithQuery")
const result = await routeFunction({
keyword: "typescript", // query param
limit: 10, // query param
offset: 0, // query param
extra: "should be in query", // remaining field → query (GET)
})
expect(mockClient.request).toHaveBeenCalledWith("/test/search", {
method: "GET",
headers: undefined,
timeout: undefined,
signal: undefined,
query: { keyword: "typescript", limit: 10, offset: 0, extra: "should be in query" },
})
expect(result).toBe(mockResponse)
})
it("should handle missing optional classified fields", async () => {
const mockResponse = { data: "partial" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "withClassification")
const result = await routeFunction({
id: "456", // path param
title: "Only Title", // body param
// Missing: includeMetadata, format, content
})
expect(mockClient.request).toHaveBeenCalledWith("/test/456", {
method: "POST",
headers: undefined,
timeout: undefined,
signal: undefined,
body: { title: "Only Title" },
})
expect(result).toBe(mockResponse)
})
it("should work with flattened API style", async () => {
const mockResponse = { data: "flattened" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "withClassification")
// Simulate flattened input - user doesn't need to know about params/query/body
const result = await routeFunction({
id: "789",
includeMetadata: false,
title: "Flattened Title",
content: "Flattened Content",
format: "xml",
})
expect(mockClient.request).toHaveBeenCalledWith("/test/789", {
method: "POST",
headers: undefined,
timeout: undefined,
signal: undefined,
query: { includeMetadata: false, format: "xml" },
body: { title: "Flattened Title", content: "Flattened Content" },
})
expect(result).toBe(mockResponse)
})
it("should handle empty body correctly", async () => {
const mockResponse = { data: "no body" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "withClassification")
const result = await routeFunction({
id: "empty",
includeMetadata: true,
// No body fields provided
})
expect(mockClient.request).toHaveBeenCalledWith("/test/empty", {
method: "POST",
headers: undefined,
timeout: undefined,
signal: undefined,
query: { includeMetadata: true },
})
expect(result).toBe(mockResponse)
})
it("should preserve backward compatibility for unclassified routes", async () => {
const mockResponse = { data: "backward compatible" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const getFunction = handler.get(target, "get")
const createFunction = handler.get(target, "create")
// GET route - all fields should go to query
await getFunction({
search: "test",
limit: 10,
active: true,
})
expect(mockClient.request).toHaveBeenCalledWith("/test", {
method: "GET",
headers: undefined,
timeout: undefined,
signal: undefined,
query: { search: "test", limit: 10, active: true },
})
// POST route - all fields should go to body
await createFunction({
name: "test",
value: 123,
active: false,
})
expect(mockClient.request).toHaveBeenCalledWith("/test", {
method: "POST",
headers: undefined,
timeout: undefined,
signal: undefined,
body: { name: "test", value: 123, active: false },
})
})
})
describe("Parameter Encoding", () => {
it("should encode URL parameters", async () => {
const mockResponse = { data: "test" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "update")
await routeFunction({
id: "test/with/slashes",
})
expect(mockClient.request).toHaveBeenCalledWith(
"/test/test%2Fwith%2Fslashes",
{
method: "PUT",
headers: undefined,
timeout: undefined,
signal: undefined,
},
)
})
it("should handle undefined parameters", async () => {
const mockResponse = { data: "test" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const target = {}
const routeFunction = handler.get(target, "update")
await routeFunction({
id: undefined,
})
expect(mockClient.request).toHaveBeenCalledWith("/test/{id}", {
method: "PUT",
headers: undefined,
timeout: undefined,
signal: undefined,
})
})
})
})
describe("createAPIProxy", () => {
let mockClient: HttpClient
let moduleDefinition: ModuleDefinition<any>
beforeEach(() => {
mockClient = {
request: vi.fn(),
} as any
moduleDefinition = {
api: {},
name: "test",
prefix: "/api/v1",
routes: {
get: { method: "GET", path: "/" },
create: { method: "POST", path: "/" },
update: { method: "PUT", path: "/{id}", params: ["id"] },
nested: {
get: { method: "GET", path: "/nested" },
create: { method: "POST", path: "/nested/{id}", params: ["id"] },
},
},
}
// Mock the RouteResolver
vi.mock("../shared/route-resolver", () => ({
RouteResolver: {
flattenRoutes: vi.fn().mockReturnValue({
"get": { method: "GET", path: "/api/v1/" },
"create": { method: "POST", path: "/api/v1/" },
"update": { method: "PUT", path: "/api/v1/{id}", params: ["id"] },
"nested.get": { method: "GET", path: "/api/v1/nested" },
"nested.create": {
method: "POST",
path: "/api/v1/nested/{id}",
params: ["id"],
},
}),
},
}))
})
it("should create a typed API proxy", async () => {
const mockResponse = { data: "test" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const api = createAPIProxy(mockClient, moduleDefinition)
expect(typeof api).toBe("object")
expect(typeof api.get).toBe("function")
expect(typeof api.create).toBe("function")
expect(typeof api.update).toBe("function")
expect(typeof api.nested).toBe("object")
expect(typeof api.nested.get).toBe("function")
expect(typeof api.nested.create).toBe("function")
const result = await api.get()
expect(mockClient.request).toHaveBeenCalledWith("/api/v1/", {
method: "GET",
headers: undefined,
timeout: undefined,
signal: undefined,
})
expect(result).toBe(mockResponse)
})
it("should handle nested routes in created proxy", async () => {
const mockResponse = { data: "nested" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const api = createAPIProxy(mockClient, moduleDefinition)
const result = await api.nested.get()
expect(mockClient.request).toHaveBeenCalledWith("/api/v1/nested", {
method: "GET",
headers: undefined,
timeout: undefined,
signal: undefined,
})
expect(result).toBe(mockResponse)
})
it("should handle nested routes with parameters", async () => {
const mockResponse = { data: "nested created" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const api = createAPIProxy(mockClient, moduleDefinition)
const result = await api.nested.create({
id: "123",
name: "test",
})
expect(mockClient.request).toHaveBeenCalledWith("/api/v1/nested/123", {
method: "POST",
headers: undefined,
timeout: undefined,
signal: undefined,
body: { name: "test" },
})
expect(result).toBe(mockResponse)
})
it("should handle route with parameters", async () => {
const mockResponse = { data: "updated" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const api = createAPIProxy(mockClient, moduleDefinition)
const result = await api.update({
id: "456",
name: "updated",
})
expect(mockClient.request).toHaveBeenCalledWith("/api/v1/456", {
method: "PUT",
headers: undefined,
timeout: undefined,
signal: undefined,
body: { name: "updated" },
})
expect(result).toBe(mockResponse)
})
})
describe("Proxy Integration", () => {
let mockClient: HttpClient
beforeEach(() => {
mockClient = {
request: vi.fn(),
} as any
})
it("should work with complex nested structure", async () => {
const routes = new Map<string, RouteDefinition>([
["admin.users.list", { method: "GET", path: "/admin/users" }],
["admin.users.create", { method: "POST", path: "/admin/users" }],
[
"admin.users.get",
{ method: "GET", path: "/admin/users/{id}", params: ["id"] },
],
[
"admin.users.update",
{ method: "PUT", path: "/admin/users/{id}", params: ["id"] },
],
[
"admin.users.delete",
{ method: "DELETE", path: "/admin/users/{id}", params: ["id"] },
],
["admin.settings.get", { method: "GET", path: "/admin/settings" }],
["admin.settings.update", { method: "PUT", path: "/admin/settings" }],
])
const handler = new APIProxyHandler(mockClient, routes)
const target = {}
const mockResponse = { data: "success" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
// Test nested admin.users.list
const adminProxy = handler.get(target, "admin")
const usersProxy = adminProxy.users
await usersProxy.list()
expect(mockClient.request).toHaveBeenCalledWith("/admin/users", {
method: "GET",
headers: undefined,
timeout: undefined,
signal: undefined,
})
// Test nested admin.users.get with params
await usersProxy.get({ id: "123" })
expect(mockClient.request).toHaveBeenCalledWith("/admin/users/123", {
method: "GET",
headers: undefined,
timeout: undefined,
signal: undefined,
})
// Test nested admin.settings.get
const settingsProxy = adminProxy.settings
await settingsProxy.get()
expect(mockClient.request).toHaveBeenCalledWith("/admin/settings", {
method: "GET",
headers: undefined,
timeout: undefined,
signal: undefined,
})
})
it("should handle multiple levels of nesting", async () => {
const routes = new Map<string, RouteDefinition>([
["a.b.c.d.action", { method: "POST", path: "/a/b/c/d/action" }],
])
const handler = new APIProxyHandler(mockClient, routes)
const target = {}
const mockResponse = { data: "deep" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const result = await handler.get(target, "a").b.c.d.action()
expect(mockClient.request).toHaveBeenCalledWith("/a/b/c/d/action", {
method: "POST",
headers: undefined,
timeout: undefined,
signal: undefined,
})
expect(result).toBe(mockResponse)
})
it("should handle concurrent requests", async () => {
const routes = new Map<string, RouteDefinition>([
["get", { method: "GET", path: "/test" }],
["post", { method: "POST", path: "/test" }],
])
const handler = new APIProxyHandler(mockClient, routes)
const target = {}
const mockResponse = { data: "concurrent" }
mockClient.request = vi.fn().mockResolvedValue(mockResponse)
const getFunction = handler.get(target, "get")
const postFunction = handler.get(target, "post")
const promises = [
getFunction(),
postFunction({ data: "test" }),
getFunction(),
]
const results = await Promise.all(promises)
expect(results).toHaveLength(3)
expect(mockClient.request).toHaveBeenCalledTimes(3)
expect(mockClient.request).toHaveBeenCalledWith("/test", {
method: "GET",
headers: undefined,
timeout: undefined,
signal: undefined,
})
expect(mockClient.request).toHaveBeenCalledWith("/test", {
method: "POST",
headers: undefined,
timeout: undefined,
signal: undefined,
body: { data: "test" },
})
})
})