UNPKG

@follow-app/client-sdk

Version:

TypeScript client SDK for Follow RSS Server API

756 lines (627 loc) 22.5 kB
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" }, }) }) })