@follow-app/client-sdk
Version:
TypeScript client SDK for Follow RSS Server API
396 lines (333 loc) • 11.4 kB
text/typescript
/* eslint-disable @typescript-eslint/no-empty-object-type */
import { describe, expect, it } from "vitest"
import type { InferParams, ModuleAPI, RouteFunction } from "./define-module"
import { defineModule, defineRoute } from "./define-module"
describe("define-module", () => {
describe("defineRoute", () => {
it("should define a simple GET route", () => {
const route = defineRoute("GET", "/users")
expect(route).toEqual({
method: "GET",
path: "/users",
params: undefined,
query: undefined,
body: undefined,
input: undefined,
response: undefined,
requestType: undefined,
responseType: undefined,
})
})
it("should extract params from path", () => {
const route = defineRoute("GET", "/users/{userId}/posts/{postId}")
expect(route.params).toEqual(["userId", "postId"])
})
it("should define route with query and body options", () => {
const route = defineRoute("POST", "/users/{userId}", {
query: ["include", "fields"] as const,
body: ["name", "email"] as const,
requestType: "json",
responseType: "json",
})
expect(route).toEqual({
method: "POST",
path: "/users/{userId}",
params: ["userId"],
query: ["include", "fields"],
body: ["name", "email"],
input: undefined,
response: undefined,
requestType: "json",
responseType: "json",
})
})
it("should handle path with no params", () => {
const route = defineRoute("GET", "/health")
expect(route.params).toBeUndefined()
})
it("should handle complex path patterns", () => {
const route = defineRoute(
"GET",
"/api/v1/users/{userId}/posts/{postId}/comments",
)
expect(route.params).toEqual(["userId", "postId"])
})
})
describe("defineModule", () => {
it("should define a simple module", () => {
const routes = {
getUsers: defineRoute("GET", "/users"),
createUser: defineRoute("POST", "/users"),
}
const module = defineModule({
name: "users",
prefix: "/api/v1",
routes,
})
expect(module.name).toBe("users")
expect(module.prefix).toBe("/api/v1")
expect(module.routes).toBe(routes)
expect(module.api).toEqual({})
})
it("should define module with nested routes", () => {
const routes = {
users: {
list: defineRoute("GET", "/users"),
create: defineRoute("POST", "/users"),
byId: {
get: defineRoute("GET", "/users/{userId}"),
update: defineRoute("PUT", "/users/{userId}"),
posts: {
list: defineRoute("GET", "/users/{userId}/posts"),
create: defineRoute("POST", "/users/{userId}/posts"),
},
},
},
}
const module = defineModule({
name: "api",
routes,
})
expect(module.routes).toBe(routes)
})
})
describe("Type inference", () => {
it("should infer params from path string", () => {
type UserPath = "/users/{userId}/posts/{postId}"
type Params = InferParams<UserPath>
const params: Params = {
userId: "123",
postId: "456",
}
expect(params.userId).toBe("123")
expect(params.postId).toBe("456")
})
it("should handle path with no params", () => {
type SimplePath = "/users"
type Params = InferParams<SimplePath>
const params: Params = {}
expect(params).toEqual({})
})
})
describe("RouteFunction types", () => {
it("should create route function with required args", async () => {
interface UserInput {
name: string
email: string
}
interface UserResponse {
id: string
name: string
email: string
}
type CreateUserFn = RouteFunction<UserInput, UserResponse>
// This should compile - required args
const createUser: CreateUserFn = async (args, options) => {
expect(args.name).toBeDefined()
expect(args.email).toBeDefined()
expect(options?.headers).toBeUndefined() // Optional second param
return {
id: "123",
name: args.name,
email: args.email,
}
}
// Test the function call
const result = createUser({ name: "John", email: "john@example.com" })
await expect(result).resolves.toEqual({
id: "123",
name: "John",
email: "john@example.com",
})
})
it("should create route function with optional args", async () => {
interface EmptyInput {}
interface ListResponse {
users: string[]
}
type ListUsersFn = RouteFunction<EmptyInput, ListResponse>
// This should compile - optional args
const listUsers: ListUsersFn = async (args, options) => {
// When called without options, should be undefined
// When called with options, should have the values
if (options) {
expect(options.timeout).toBe(5000)
}
return {
users: ["user1", "user2"],
}
}
// Both calls should work
const result1 = listUsers()
const result2 = listUsers({}, { timeout: 5000 })
await expect(result1).resolves.toEqual({ users: ["user1", "user2"] })
await expect(result2).resolves.toEqual({ users: ["user1", "user2"] })
})
it("should separate input args from fetch options", async () => {
interface LoginInput {
username: string
password: string
}
interface LoginResponse {
token: string
}
type LoginFn = RouteFunction<LoginInput, LoginResponse>
const login: LoginFn = async (args, options) => {
// First parameter: route input
expect(args.username).toBe("testuser")
expect(args.password).toBe("testpass")
// Second parameter: fetch options
expect(options?.headers?.["Authorization"]).toBe(
"Bearer existing-token",
)
expect(options?.timeout).toBe(10000)
expect(options?.signal).toBeInstanceOf(AbortSignal)
return { token: "new-token" }
}
// Test with fetch options
const controller = new AbortController()
const result = login(
{ username: "testuser", password: "testpass" },
{
headers: { Authorization: "Bearer existing-token" },
timeout: 10000,
signal: controller.signal,
},
)
await expect(result).resolves.toEqual({ token: "new-token" })
})
})
describe("ModuleAPI types", () => {
it("should generate correct API types from route definitions", () => {
const _routes = {
getUser: defineRoute("GET", "/users/{userId}", {
input: {} as { userId: string },
response: {} as { id: string, name: string },
}),
createUser: defineRoute("POST", "/users", {
input: {} as { name: string, email: string },
response: {} as { id: string, name: string, email: string },
}),
listUsers: defineRoute("GET", "/users", {
input: {} as {},
response: {} as { users: Array<{ id: string, name: string }> },
}),
}
type API = ModuleAPI<typeof _routes>
// This test ensures the types compile correctly
const mockApi: API = {
getUser: async (args) => {
expect(args.userId).toBeDefined()
return { id: args.userId, name: "Test User" }
},
createUser: async (args) => {
expect(args.name).toBeDefined()
expect(args.email).toBeDefined()
return { id: "123", name: args.name, email: args.email }
},
listUsers: async () => {
return { users: [{ id: "1", name: "User 1" }] }
},
}
expect(mockApi.getUser).toBeDefined()
expect(mockApi.createUser).toBeDefined()
expect(mockApi.listUsers).toBeDefined()
})
it("should handle nested route structures", () => {
const _routes = {
users: {
get: defineRoute("GET", "/users/{userId}", {
input: {} as { userId: string },
response: {} as { id: string, name: string },
}),
posts: {
list: defineRoute("GET", "/users/{userId}/posts", {
input: {} as { userId: string },
response: {} as { posts: Array<{ id: string, title: string }> },
}),
},
},
}
type API = ModuleAPI<typeof _routes>
const mockApi: API = {
users: {
get: async (args) => {
return { id: args.userId, name: "Test User" }
},
posts: {
list: async () => {
return { posts: [{ id: "1", title: "Post 1" }] }
},
},
},
}
expect(mockApi.users.get).toBeDefined()
expect(mockApi.users.posts.list).toBeDefined()
})
})
describe("FetchOptions separation", () => {
it("should handle fetch options independently from route input", async () => {
interface TestInput {
id: string
data: { name: string }
}
interface TestResponse {
success: boolean
}
type TestFn = RouteFunction<TestInput, TestResponse>
const testFunction: TestFn = async (args, options) => {
// Route input should not contain fetch options
expect(args).toEqual({
id: "123",
data: { name: "test" },
})
// Fetch options should be separate
expect(options).toEqual({
headers: { "Content-Type": "application/json" },
timeout: 5000,
})
return { success: true }
}
const result = testFunction(
{ id: "123", data: { name: "test" } },
{ headers: { "Content-Type": "application/json" }, timeout: 5000 },
)
await expect(result).resolves.toEqual({ success: true })
})
it("should allow omitting fetch options", async () => {
interface TestInput {
message: string
}
interface TestResponse {
echo: string
}
type TestFn = RouteFunction<TestInput, TestResponse>
const testFunction: TestFn = async (args, options) => {
expect(args.message).toBe("hello")
expect(options).toBeUndefined()
return { echo: args.message }
}
const result = testFunction({ message: "hello" })
await expect(result).resolves.toEqual({ echo: "hello" })
})
})
describe("Legacy compatibility", () => {
it("should maintain LegacyRouteArgs interface", () => {
// This test ensures the legacy interface still exists
const legacyArgs = {
params: { userId: "123" },
query: { include: "posts" },
body: { name: "John" },
headers: { Authorization: "Bearer token" },
timeout: 5000,
signal: new AbortController().signal,
}
expect(legacyArgs.params?.userId).toBe("123")
expect(legacyArgs.query?.include).toBe("posts")
expect(legacyArgs.body).toEqual({ name: "John" })
expect(legacyArgs.headers?.Authorization).toBe("Bearer token")
expect(legacyArgs.timeout).toBe(5000)
expect(legacyArgs.signal).toBeInstanceOf(AbortSignal)
})
})
})