diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b679d1f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run test suite + run: pnpm test diff --git a/CLAUDE.md b/CLAUDE.md index c6bbb45..f796d10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ pnpm publish-prod ### Module Structure -The SDK exposes **14 modules** bound on `SublayClient` (camelCase accessor in +The SDK exposes **15 modules** bound on `SublayClient` (camelCase accessor in parentheses). Every endpoint is reached with a **service key**; operations that act on behalf of a user take an explicit `userId` (or `actingUserId` on the nested `users` follow/connection routes and the `chat` target routes), since a @@ -47,6 +47,7 @@ src/ │ # Collection, Connection, Follow, Report, Space, …) ├── modules/ │ ├── entities/ # client.entities +│ ├── events/ # client.events │ ├── comments/ # client.comments │ ├── users/ # client.users (incl. nested follow/connection actions) │ ├── spaces/ # client.spaces @@ -65,8 +66,7 @@ src/ > **Not bound (unmounted):** `oauth` only. It's a browser redirect flow with no > meaningful server-to-server contract — the directory stays on disk but is not -> exposed on `SublayClient`. (`chat` and `storage` were previously deferred here; -> both are now bound — see §13 and §14 below and `plan-chat-node-sdk-parity.md`.) +> exposed on `SublayClient`. > > The space-scoped chat endpoints (`getSpaceConversation`, `moderateSpaceChatMessage`, > `handleSpaceChatReport`) live on **`client.spaces`** (they're `/spaces/...` routes), @@ -101,7 +101,7 @@ const client = await SublayClient.init({ ## API Modules & Features -`SublayClient` binds **14 modules**. The source of truth is `src/index.ts` (the `bindModule` calls) and each module's `index.ts`. The full public surface and per-function props/returns are documented in `docs/v7/node-sdk/`. +`SublayClient` binds **15 modules**. The source of truth is `src/index.ts` (the `bindModule` calls) and each module's `index.ts`. The full public surface and per-function props/returns are documented in `docs/v7/node-sdk/`. **Acting on behalf of a user**: the SDK authenticates as the project (service key), not as an end user. So user-scoped functions take an explicit `userId` — the user the operation is performed as. A few routes act on one user *toward another* and take the actor as `actingUserId` while `userId` is the target: the nested follow/connection routes on the `users` module, and `chat.createDirectConversation` / `chat.addMember` / `chat.removeMember` / `chat.changeMemberRole`. @@ -115,13 +115,21 @@ Core content objects (posts, articles, products, listings, etc.). `fetchManyEntities` supports rich filtering via object-shaped filters: `keywordsFilters`, `metadataFilters` (`includes`/`includesAny`/`doesNotInclude`/`exists`/`doesNotExist`), `titleFilters`, `contentFilters`, `attachmentsFilters`, `locationFilters`; plus `sortBy` (new/hot/top/controversial or `metadata.`), `sortDir`, `sortType`, `sortByReaction`, `timeFrame`, `userId`/`followedOnly`, `include`, pagination. -### 2. Comments Module (10 functions) +### 2. Events Module (14 functions) + +Event lifecycle, RSVPs, hosts, and invites. + +`createEvent`, `fetchEvent`, `fetchManyEvents`, `updateEvent`, `cancelEvent`, `deleteEvent`, `setRsvp`, `withdrawRsvp`, `addHost`, `removeHost`, `addInvite`, `removeInvite`, `fetchInvitees`, `fetchEventRsvps` + +`EventType` is `online`/`physical`/`hybrid`; `EventVisibility` is `public`/`members`/`invite`; `RsvpStatus` is `going`/`maybe`/`not_going`. `fetchManyEvents` filters: `timeWindow` (upcoming/ongoing/past) or an explicit `startsAfter`/`startsBefore` range, `spaceId`, `hostId`, `type`, `status` (defaults to `active`), `myRsvp` (requires `userId`), `locationFilters`, `titleFilters`/`descriptionFilters`. `fetchInvitees` (the guest list) is host-only; `fetchEventRsvps` is visible to hosts, to any viewer when `guestListVisible` is true, or to service/master keys. + +### 3. Comments Module (10 functions) `createComment`, `fetchComment`, `fetchCommentByForeignId`, `updateComment`, `deleteComment`, `fetchManyComments`, `addReaction`, `removeReaction`, `fetchReactions`, `getUserReaction` Threaded replies (`parentId`), quote-replies (`referencedCommentId`), GIFs, mentions, reactions, foreign IDs, soft deletes. `fetchManyComments` sorts by `sortBy` (new/old/top/controversial). -### 3. Users Module (18 functions) +### 4. Users Module (18 functions) Read/update profiles and query + act on the follow/connection graph. @@ -131,25 +139,25 @@ Graph queries (by target user ID): `fetchFollowersByUserId`, `fetchFollowersCoun Graph actions (nested routes, take `actingUserId` + path `userId`): `createFollow`, `deleteFollow`, `fetchFollowStatus`, `requestConnection`, `fetchConnectionStatus`, `removeConnectionByUserId` -### 4. Collections Module (8 functions) +### 5. Collections Module (8 functions) A user's saved-content collections (replaces the old "lists"). All take `userId`. `fetchRootCollection`, `fetchSubCollections`, `createNewCollection`, `fetchCollectionEntities`, `addEntityToCollection`, `removeEntityFromCollection`, `updateCollection`, `deleteCollection` -### 5. Follows Module (5 functions) +### 6. Follows Module (5 functions) Read a user's follow graph and remove follows by record ID. All take `userId`. `fetchFollowing`, `fetchFollowers`, `fetchFollowingCount`, `fetchFollowersCount`, `deleteFollow` -### 6. Connections Module (7 functions) +### 7. Connections Module (7 functions) A user's mutual connections and pending requests. All take `userId`. `fetchConnections`, `fetchConnectionsCount`, `fetchSentPendingConnections`, `fetchReceivedPendingConnections`, `acceptConnection`, `declineConnection`, `removeConnection` -### 7. Spaces Module (33 functions) +### 8. Spaces Module (33 functions) Space lifecycle, membership, moderation, rules, and digest config — documented across three pages (`spaces`, `spaces-members`, `spaces-moderation`). @@ -159,27 +167,27 @@ Members: `joinSpace`, `leaveSpace`, `checkMyMembership`, `fetchSpaceMembers`, `f Moderation/rules/digest: `handleEntityReport`, `handleCommentReport` (both take an `actions` array), `moderateSpaceEntity`, `moderateSpaceComment`, `fetchManyRules`, `fetchRule`, `createRule`, `updateRule`, `deleteRule`, `reorderRules`, `fetchDigestConfig`, `updateDigestConfig` -### 8. Search Module (4 functions) +### 9. Search Module (4 functions) `searchContent`, `searchUsers`, `searchSpaces`, `askContent` (AI Q&A). Content/ask take `query`, `sourceTypes`, `spaceId`, `conversationId`, `limit`. -### 9. Reports Module (2 functions) +### 10. Reports Module (2 functions) `createReport` (reporter is `userId`), `fetchModeratedReports` (moderator is `userId`). `ReportStatus`: `pending` | `on-hold` | `escalated` | `dismissed` | `actioned`. -### 10. App Notifications Module (4 functions) +### 11. App Notifications Module (4 functions) `fetchNotifications`, `countUnreadNotifications`, `markNotificationAsRead`, `markAllNotificationsAsRead`. All take `userId`. -### 11. Auth Module (10 functions) +### 12. Auth Module (10 functions) `signUp`, `signIn`, `signOut`, `requestNewAccessToken`, `verifyExternalUser`, `requestPasswordReset`, `resetPassword`, `verifyEmail`, `sendVerificationEmail`, `changePassword` -### 12. Hosted Apps Module (1 function) +### 13. Hosted Apps Module (1 function) `fetchHostedApp` — fetches hosted app configuration (uses the internal axios instance). -### 13. Storage Module (4 functions) +### 14. Storage Module (4 functions) File and image uploads, plus read/delete. `uploadFile`, `uploadImage`, `getFile`, `deleteFile`. @@ -195,7 +203,7 @@ File and image uploads, plus read/delete. `uploadFile`, `uploadImage`, `getFile` owner-or-service** — a user token may only delete files it owns, service/master keys may delete any. Both enforced server-side in `server/src/{v7,v7-schema}/controllers/storage/`. -### 14. Chat Module (20 functions) +### 15. Chat Module (20 functions) Conversations, messages, members, reactions, read state, and reporting. diff --git a/__tests__/app-notifications.test.ts b/__tests__/app-notifications.test.ts new file mode 100644 index 0000000..6630ee3 --- /dev/null +++ b/__tests__/app-notifications.test.ts @@ -0,0 +1,78 @@ +import { + countUnreadNotifications, + fetchNotifications, + markAllNotificationsAsRead, + markNotificationAsRead, +} from "../src/modules/app-notifications"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk appNotifications — request shaping", () => { + it("fetchNotifications hits /app-notifications with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchNotifications(client, { userId: "u1", page: 1 }); + expect(projectInstance.get).toHaveBeenCalledWith("/app-notifications", { + params: { userId: "u1", page: 1 }, + }); + }); + + it("countUnreadNotifications hits /app-notifications/count with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await countUnreadNotifications(client, { userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/app-notifications/count", { + params: { userId: "u1" }, + }); + }); + + it("markNotificationAsRead patches the mark-as-read route with userId in the body", async () => { + const { client, projectInstance } = makeClient(); + await markNotificationAsRead(client, { notificationId: "n1", userId: "u1" }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/app-notifications/n1/mark-as-read", + { userId: "u1" }, + ); + }); + + it("markAllNotificationsAsRead patches the mark-all-as-read route with userId in the body", async () => { + const { client, projectInstance } = makeClient(); + await markAllNotificationsAsRead(client, { userId: "u1" }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/app-notifications/mark-all-as-read", + { userId: "u1" }, + ); + }); +}); + +describe("node-sdk appNotifications — response mapping", () => { + it("fetchNotifications returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "n1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(fetchNotifications(client, { userId: "u1" })).resolves.toEqual( + envelope, + ); + }); + + it("countUnreadNotifications returns response.data as a raw number", async () => { + const { client, projectInstance } = makeClient(); + projectInstance.get.mockResolvedValueOnce({ data: 3 }); + await expect( + countUnreadNotifications(client, { userId: "u1" }), + ).resolves.toBe(3); + }); + + it("markNotificationAsRead resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + markNotificationAsRead(client, { notificationId: "n1", userId: "u1" }), + ).resolves.toBeUndefined(); + }); + + it("markAllNotificationsAsRead returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { markedAsRead: 5 }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + markAllNotificationsAsRead(client, { userId: "u1" }), + ).resolves.toEqual(result); + }); +}); diff --git a/__tests__/auth.test.ts b/__tests__/auth.test.ts new file mode 100644 index 0000000..1f9a67b --- /dev/null +++ b/__tests__/auth.test.ts @@ -0,0 +1,184 @@ +import { + changePassword, + requestNewAccessToken, + requestPasswordReset, + resetPassword, + sendVerificationEmail, + signIn, + signOut, + signUp, + verifyEmail, + verifyExternalUser, +} from "../src/modules/auth"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk auth — request shaping", () => { + it("signUp posts the full body to /auth/sign-up", async () => { + const { client, projectInstance } = makeClient(); + await signUp(client, { email: "a@b.com", password: "pw" }); + expect(projectInstance.post).toHaveBeenCalledWith("/auth/sign-up", { + email: "a@b.com", + password: "pw", + }); + }); + + it("signIn posts the full body to /auth/sign-in", async () => { + const { client, projectInstance } = makeClient(); + await signIn(client, { email: "a@b.com", password: "pw" }); + expect(projectInstance.post).toHaveBeenCalledWith("/auth/sign-in", { + email: "a@b.com", + password: "pw", + }); + }); + + it("signOut posts the full body to /auth/sign-out", async () => { + const { client, projectInstance } = makeClient(); + await signOut(client, { refreshToken: "rt1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/auth/sign-out", { + refreshToken: "rt1", + }); + }); + + it("requestNewAccessToken posts the full body to /auth/request-new-access-token", async () => { + const { client, projectInstance } = makeClient(); + await requestNewAccessToken(client, { refreshToken: "rt1" }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/auth/request-new-access-token", + { refreshToken: "rt1" }, + ); + }); + + it("verifyExternalUser posts the full body to /auth/verify-external-user", async () => { + const { client, projectInstance } = makeClient(); + await verifyExternalUser(client, { userJwt: "jwt1" }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/auth/verify-external-user", + { userJwt: "jwt1" }, + ); + }); + + it("requestPasswordReset posts the full body to /auth/request-password-reset", async () => { + const { client, projectInstance } = makeClient(); + await requestPasswordReset(client, { email: "a@b.com" }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/auth/request-password-reset", + { email: "a@b.com" }, + ); + }); + + it("resetPassword posts the full body to /auth/reset-password", async () => { + const { client, projectInstance } = makeClient(); + await resetPassword(client, { token: "tok1", newPassword: "new-pw" }); + expect(projectInstance.post).toHaveBeenCalledWith("/auth/reset-password", { + token: "tok1", + newPassword: "new-pw", + }); + }); + + it("changePassword posts the full body to /auth/change-password", async () => { + const { client, projectInstance } = makeClient(); + await changePassword(client, { userId: "u1", password: "old-pw", newPassword: "new-pw" }); + expect(projectInstance.post).toHaveBeenCalledWith("/auth/change-password", { + userId: "u1", + password: "old-pw", + newPassword: "new-pw", + }); + }); + + it("verifyEmail posts the full body to /auth/verify-email", async () => { + const { client, projectInstance } = makeClient(); + await verifyEmail(client, { token: "tok1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/auth/verify-email", { + token: "tok1", + }); + }); + + it("sendVerificationEmail posts the full body to /auth/send-verification-email", async () => { + const { client, projectInstance } = makeClient(); + await sendVerificationEmail(client, { userId: "u1", mode: "link" }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/auth/send-verification-email", + { userId: "u1", mode: "link" }, + ); + }); +}); + +describe("node-sdk auth — response mapping", () => { + it("signUp returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { user: { id: "u1" }, accessToken: "at1", refreshToken: "rt1" }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + signUp(client, { email: "a@b.com", password: "pw" }), + ).resolves.toEqual(result); + }); + + it("signIn returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { user: { id: "u1" }, accessToken: "at1", refreshToken: "rt1" }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + signIn(client, { email: "a@b.com", password: "pw" }), + ).resolves.toEqual(result); + }); + + it("signOut resolves to undefined", async () => { + const { client } = makeClient(); + await expect(signOut(client, { refreshToken: "rt1" })).resolves.toBeUndefined(); + }); + + it("requestNewAccessToken returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { accessToken: "at2" }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + requestNewAccessToken(client, { refreshToken: "rt1" }), + ).resolves.toEqual(result); + }); + + it("verifyExternalUser returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { user: { id: "u1" }, accessToken: "at1", refreshToken: "rt1" }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + verifyExternalUser(client, { userJwt: "jwt1" }), + ).resolves.toEqual(result); + }); + + it("requestPasswordReset resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + requestPasswordReset(client, { email: "a@b.com" }), + ).resolves.toBeUndefined(); + }); + + it("resetPassword resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + resetPassword(client, { token: "tok1", newPassword: "new-pw" }), + ).resolves.toBeUndefined(); + }); + + it("changePassword returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { success: true, message: "Password changed" }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + changePassword(client, { userId: "u1", password: "old-pw", newPassword: "new-pw" }), + ).resolves.toEqual(result); + }); + + it("verifyEmail resolves to undefined", async () => { + const { client } = makeClient(); + await expect(verifyEmail(client, { token: "tok1" })).resolves.toBeUndefined(); + }); + + it("sendVerificationEmail returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { success: true }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + sendVerificationEmail(client, { userId: "u1" }), + ).resolves.toEqual(result); + }); +}); diff --git a/__tests__/chat-conversations.test.ts b/__tests__/chat-conversations.test.ts new file mode 100644 index 0000000..75c7ac2 --- /dev/null +++ b/__tests__/chat-conversations.test.ts @@ -0,0 +1,233 @@ +import { + addMember, + changeMemberRole, + createDirectConversation, + createGroupConversation, + deleteConversation, + getConversation, + getUnreadCount, + leaveConversation, + listConversations, + listMembers, + removeMember, + updateConversation, +} from "../src/modules/chat"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk chat (conversations) — request shaping", () => { + it("listConversations hits /chat/conversations with the full body as params (cursor pagination)", async () => { + const { client, projectInstance } = makeClient(); + await listConversations(client, { userId: "u1", cursor: "c1", cursorCreatedAt: "t1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/chat/conversations", { + params: { userId: "u1", cursor: "c1", cursorCreatedAt: "t1" }, + }); + }); + + it("createDirectConversation posts the target userId + actingUserId to /chat/conversations/direct", async () => { + const { client, projectInstance } = makeClient(); + await createDirectConversation(client, { userId: "target1", actingUserId: "acting1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/chat/conversations/direct", { + userId: "target1", + actingUserId: "acting1", + }); + }); + + it("createGroupConversation posts type: 'group' plus the rest of the body", async () => { + const { client, projectInstance } = makeClient(); + await createGroupConversation(client, { userId: "u1", name: "Team chat" }); + expect(projectInstance.post).toHaveBeenCalledWith("/chat/conversations", { + type: "group", + userId: "u1", + name: "Team chat", + }); + }); + + it("getConversation strips conversationId into the path, sends userId as a param", async () => { + const { client, projectInstance } = makeClient(); + await getConversation(client, { conversationId: "conv1", userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/chat/conversations/conv1", { + params: { userId: "u1" }, + }); + }); + + it("updateConversation strips conversationId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await updateConversation(client, { conversationId: "conv1", userId: "u1", name: "New name" }); + expect(projectInstance.patch).toHaveBeenCalledWith("/chat/conversations/conv1", { + userId: "u1", + name: "New name", + }); + }); + + it("deleteConversation sends userId as a param", async () => { + const { client, projectInstance } = makeClient(); + await deleteConversation(client, { conversationId: "conv1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/chat/conversations/conv1", { + params: { userId: "u1" }, + }); + }); + + it("getUnreadCount hits the unread-count route with userId as a param", async () => { + const { client, projectInstance } = makeClient(); + await getUnreadCount(client, { userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith( + "/chat/conversations/unread-count", + { params: { userId: "u1" } }, + ); + }); + + it("listMembers strips conversationId into the path and passes the rest as params (offset pagination)", async () => { + const { client, projectInstance } = makeClient(); + await listMembers(client, { conversationId: "conv1", userId: "u1", page: 2, limit: 10 }); + expect(projectInstance.get).toHaveBeenCalledWith( + "/chat/conversations/conv1/members", + { params: { userId: "u1", page: 2, limit: 10 } }, + ); + }); + + it("addMember posts the target userId + actingUserId to the members route", async () => { + const { client, projectInstance } = makeClient(); + await addMember(client, { conversationId: "conv1", userId: "target1", actingUserId: "admin1" }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/chat/conversations/conv1/members", + { userId: "target1", actingUserId: "admin1" }, + ); + }); + + it("removeMember puts the target userId in the path, actingUserId as a param", async () => { + const { client, projectInstance } = makeClient(); + await removeMember(client, { conversationId: "conv1", userId: "target1", actingUserId: "admin1" }); + expect(projectInstance.delete).toHaveBeenCalledWith( + "/chat/conversations/conv1/members/target1", + { params: { actingUserId: "admin1" } }, + ); + }); + + it("leaveConversation sends userId as a param to the leave route", async () => { + const { client, projectInstance } = makeClient(); + await leaveConversation(client, { conversationId: "conv1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith( + "/chat/conversations/conv1/leave", + { params: { userId: "u1" } }, + ); + }); + + it("changeMemberRole puts the target userId in the path, role + actingUserId in the body", async () => { + const { client, projectInstance } = makeClient(); + await changeMemberRole(client, { + conversationId: "conv1", + userId: "target1", + role: "admin", + actingUserId: "admin1", + }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/chat/conversations/conv1/members/target1/role", + { role: "admin", actingUserId: "admin1" }, + ); + }); +}); + +describe("node-sdk chat (conversations) — response mapping", () => { + it("listConversations returns the raw { conversations, hasMore } shape, not a PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const result = { conversations: [{ id: "conv1" }], hasMore: true }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect(listConversations(client, { userId: "u1" })).resolves.toEqual(result); + }); + + it("createDirectConversation returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const conversation = { id: "conv1", type: "direct" }; + projectInstance.post.mockResolvedValueOnce({ data: conversation }); + await expect( + createDirectConversation(client, { userId: "target1", actingUserId: "acting1" }), + ).resolves.toEqual(conversation); + }); + + it("createGroupConversation returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const conversation = { id: "conv1", type: "group" }; + projectInstance.post.mockResolvedValueOnce({ data: conversation }); + await expect( + createGroupConversation(client, { userId: "u1" }), + ).resolves.toEqual(conversation); + }); + + it("getConversation returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const conversation = { id: "conv1" }; + projectInstance.get.mockResolvedValueOnce({ data: conversation }); + await expect( + getConversation(client, { conversationId: "conv1", userId: "u1" }), + ).resolves.toEqual(conversation); + }); + + it("updateConversation returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const conversation = { id: "conv1", name: "New name" }; + projectInstance.patch.mockResolvedValueOnce({ data: conversation }); + await expect( + updateConversation(client, { conversationId: "conv1", userId: "u1", name: "New name" }), + ).resolves.toEqual(conversation); + }); + + it("deleteConversation resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + deleteConversation(client, { conversationId: "conv1", userId: "u1" }), + ).resolves.toBeUndefined(); + }); + + it("getUnreadCount returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { totalUnread: 3, unreadConversationCount: 2 }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect(getUnreadCount(client, { userId: "u1" })).resolves.toEqual(result); + }); + + it("listMembers returns the full PaginatedResponse envelope (offset pagination, distinct from cursor pagination above)", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ userId: "u2" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect( + listMembers(client, { conversationId: "conv1", userId: "u1" }), + ).resolves.toEqual(envelope); + }); + + it("addMember returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const member = { userId: "target1", role: "member" }; + projectInstance.post.mockResolvedValueOnce({ data: member }); + await expect( + addMember(client, { conversationId: "conv1", userId: "target1", actingUserId: "admin1" }), + ).resolves.toEqual(member); + }); + + it("removeMember resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + removeMember(client, { conversationId: "conv1", userId: "target1", actingUserId: "admin1" }), + ).resolves.toBeUndefined(); + }); + + it("leaveConversation resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + leaveConversation(client, { conversationId: "conv1", userId: "u1" }), + ).resolves.toBeUndefined(); + }); + + it("changeMemberRole returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const member = { userId: "target1", role: "admin" }; + projectInstance.patch.mockResolvedValueOnce({ data: member }); + await expect( + changeMemberRole(client, { + conversationId: "conv1", + userId: "target1", + role: "admin", + actingUserId: "admin1", + }), + ).resolves.toEqual(member); + }); +}); diff --git a/__tests__/chat-messages.test.ts b/__tests__/chat-messages.test.ts new file mode 100644 index 0000000..e0d5d8f --- /dev/null +++ b/__tests__/chat-messages.test.ts @@ -0,0 +1,234 @@ +import { + deleteMessage, + editMessage, + getMessage, + listMessages, + listReactions, + markAsRead, + reportMessage, + sendMessage, + toggleReaction, +} from "../src/modules/chat"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk chat (messages) — request shaping", () => { + it("listMessages strips conversationId into the path and passes the rest as params (before/after cursor pagination)", async () => { + const { client, projectInstance } = makeClient(); + await listMessages(client, { conversationId: "conv1", userId: "u1", before: "t1", limit: 20 }); + expect(projectInstance.get).toHaveBeenCalledWith( + "/chat/conversations/conv1/messages", + { params: { userId: "u1", before: "t1", limit: 20 } }, + ); + }); + + it("sendMessage sends a plain JSON body when there are no files", async () => { + const { client, projectInstance } = makeClient(); + await sendMessage(client, { conversationId: "conv1", userId: "u1", content: "hi" }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/chat/conversations/conv1/messages", + { userId: "u1", content: "hi" }, + { params: { spaceReputationId: undefined, spaceReputationDescendants: undefined } }, + ); + }); + + it("sendMessage sends multipart FormData when files are attached", async () => { + const { client, projectInstance } = makeClient(); + await sendMessage(client, { + conversationId: "conv1", + userId: "u1", + content: "see attached", + files: [{ file: new Uint8Array([1, 2, 3]), filename: "photo.png", mimeType: "image/png" }], + }); + + expect(projectInstance.post).toHaveBeenCalledTimes(1); + const [path, formData, config] = projectInstance.post.mock.calls[0]; + expect(path).toBe("/chat/conversations/conv1/messages"); + expect(formData).toBeInstanceOf(FormData); + expect(config).toEqual({ + params: { spaceReputationId: undefined, spaceReputationDescendants: undefined }, + }); + + const fd = formData as FormData; + expect(fd.get("userId")).toBe("u1"); + expect(fd.get("content")).toBe("see attached"); + expect(fd.get("files")).toBeInstanceOf(Blob); + expect((fd.get("files") as File).name).toBe("photo.png"); + }); + + it("sendMessage sends spaceReputation params via params, never in the JSON body", async () => { + const { client, projectInstance } = makeClient(); + await sendMessage(client, { + conversationId: "conv1", + userId: "u1", + content: "hi", + spaceReputationId: "rep1", + spaceReputationDescendants: true, + }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/chat/conversations/conv1/messages", + { userId: "u1", content: "hi" }, + { params: { spaceReputationId: "rep1", spaceReputationDescendants: true } }, + ); + }); + + it("getMessage strips conversationId/messageId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await getMessage(client, { conversationId: "conv1", messageId: "msg1", userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith( + "/chat/conversations/conv1/messages/msg1", + { params: { userId: "u1" } }, + ); + }); + + it("editMessage strips conversationId/messageId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await editMessage(client, { conversationId: "conv1", messageId: "msg1", userId: "u1", content: "edited" }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/chat/conversations/conv1/messages/msg1", + { userId: "u1", content: "edited" }, + ); + }); + + it("deleteMessage sends userId as a param", async () => { + const { client, projectInstance } = makeClient(); + await deleteMessage(client, { conversationId: "conv1", messageId: "msg1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith( + "/chat/conversations/conv1/messages/msg1", + { params: { userId: "u1" } }, + ); + }); + + it("reportMessage strips conversationId/messageId into the path and posts the rest", async () => { + const { client, projectInstance } = makeClient(); + await reportMessage(client, { conversationId: "conv1", messageId: "msg1", userId: "u1", reason: "spam" }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/chat/conversations/conv1/messages/msg1/report", + { userId: "u1", reason: "spam" }, + ); + }); + + it("toggleReaction posts emoji + userId to the reactions route", async () => { + const { client, projectInstance } = makeClient(); + await toggleReaction(client, { conversationId: "conv1", messageId: "msg1", emoji: "👍", userId: "u1" }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/chat/conversations/conv1/messages/msg1/reactions", + { emoji: "👍", userId: "u1" }, + ); + }); + + it("listReactions strips conversationId/messageId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await listReactions(client, { conversationId: "conv1", messageId: "msg1", emoji: "👍", userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith( + "/chat/conversations/conv1/messages/msg1/reactions", + { params: { emoji: "👍", userId: "u1" } }, + ); + }); + + it("markAsRead posts messageId + userId to the read route", async () => { + const { client, projectInstance } = makeClient(); + await markAsRead(client, { conversationId: "conv1", messageId: "msg1", userId: "u1" }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/chat/conversations/conv1/read", + { messageId: "msg1", userId: "u1" }, + ); + }); +}); + +describe("node-sdk chat (messages) — response mapping", () => { + it("listMessages returns the raw { messages, hasMore, ... } shape, not a PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const result = { + messages: [{ id: "msg1" }], + hasMore: false, + oldestCreatedAt: "t0", + newestCreatedAt: "t1", + }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + listMessages(client, { conversationId: "conv1", userId: "u1" }), + ).resolves.toEqual(result); + }); + + it("sendMessage (JSON path) returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const message = { id: "msg1", content: "hi" }; + projectInstance.post.mockResolvedValueOnce({ data: message }); + await expect( + sendMessage(client, { conversationId: "conv1", userId: "u1", content: "hi" }), + ).resolves.toEqual(message); + }); + + it("sendMessage (multipart path) returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const message = { id: "msg2", content: "see attached" }; + projectInstance.post.mockResolvedValueOnce({ data: message }); + await expect( + sendMessage(client, { + conversationId: "conv1", + userId: "u1", + content: "see attached", + files: [{ file: new Uint8Array([1]) }], + }), + ).resolves.toEqual(message); + }); + + it("getMessage returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const message = { id: "msg1" }; + projectInstance.get.mockResolvedValueOnce({ data: message }); + await expect( + getMessage(client, { conversationId: "conv1", messageId: "msg1", userId: "u1" }), + ).resolves.toEqual(message); + }); + + it("editMessage returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const message = { id: "msg1", content: "edited" }; + projectInstance.patch.mockResolvedValueOnce({ data: message }); + await expect( + editMessage(client, { conversationId: "conv1", messageId: "msg1", userId: "u1", content: "edited" }), + ).resolves.toEqual(message); + }); + + it("deleteMessage resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + deleteMessage(client, { conversationId: "conv1", messageId: "msg1", userId: "u1" }), + ).resolves.toBeUndefined(); + }); + + it("reportMessage returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "reported", code: "ok" }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + reportMessage(client, { conversationId: "conv1", messageId: "msg1", userId: "u1", reason: "spam" }), + ).resolves.toEqual(result); + }); + + it("toggleReaction returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { reactionCounts: { "👍": 1 }, userReactions: ["👍"], delta: 1 as const }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + toggleReaction(client, { conversationId: "conv1", messageId: "msg1", emoji: "👍", userId: "u1" }), + ).resolves.toEqual(result); + }); + + it("listReactions returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { data: [{ emoji: "👍", user: { id: "u2" } }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + listReactions(client, { conversationId: "conv1", messageId: "msg1", emoji: "👍", userId: "u1" }), + ).resolves.toEqual(result); + }); + + it("markAsRead resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + markAsRead(client, { conversationId: "conv1", messageId: "msg1", userId: "u1" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts new file mode 100644 index 0000000..7580de0 --- /dev/null +++ b/__tests__/client.test.ts @@ -0,0 +1,105 @@ +import axios from "axios"; +import { SublayHttpClient } from "../src/core/client"; +import { SublayClient } from "../src/index"; + +jest.mock("axios"); + +const mockedCreate = axios.create as jest.Mock; + +describe("SublayHttpClient construction", () => { + it("configures projectInstance with the v7 project-scoped baseURL and auth headers", () => { + new SublayHttpClient({ projectId: "proj1", apiKey: "key1" }); + + expect(mockedCreate).toHaveBeenNthCalledWith(1, { + baseURL: "https://api.sublay.io/v7/proj1", + headers: { + Authorization: "Bearer key1", + "X-Sublay-Project-ID": "proj1", + }, + }); + }); + + it("configures internalInstance with the /internal baseURL and the same auth headers", () => { + new SublayHttpClient({ projectId: "proj1", apiKey: "key1" }); + + expect(mockedCreate).toHaveBeenNthCalledWith(2, { + baseURL: "https://api.sublay.io/internal", + headers: { + Authorization: "Bearer key1", + "X-Sublay-Project-ID": "proj1", + }, + }); + }); + + it("configures baseInstance with the bare API root and no auth headers", () => { + new SublayHttpClient({ projectId: "proj1", apiKey: "key1" }); + + expect(mockedCreate).toHaveBeenNthCalledWith(3, { + baseURL: "https://api.sublay.io", + }); + }); + + it("adds the X-Sublay-Internal header to projectInstance/internalInstance when isInternal is true", () => { + new SublayHttpClient({ projectId: "proj1", apiKey: "key1", isInternal: true }); + + expect(mockedCreate).toHaveBeenNthCalledWith(1, { + baseURL: "https://api.sublay.io/v7/proj1", + headers: { + Authorization: "Bearer key1", + "X-Sublay-Project-ID": "proj1", + "X-Sublay-Internal": "true", + }, + }); + expect(mockedCreate).toHaveBeenNthCalledWith(2, { + baseURL: "https://api.sublay.io/internal", + headers: { + Authorization: "Bearer key1", + "X-Sublay-Project-ID": "proj1", + "X-Sublay-Internal": "true", + }, + }); + }); + + it("omits X-Sublay-Internal when isInternal is false/omitted", () => { + new SublayHttpClient({ projectId: "proj1", apiKey: "key1", isInternal: false }); + + const [projectConfig] = mockedCreate.mock.calls[0]; + expect(projectConfig.headers).not.toHaveProperty("X-Sublay-Internal"); + }); +}); + +/** Makes `axios.create` return a distinct mocked instance per baseURL, so the + * `/internal` instance's `get` can be controlled independently of the others. */ +function mockAxiosCreate(internalGet: jest.Mock) { + mockedCreate.mockImplementation((config?: { baseURL?: string }) => { + const isInternalInstance = config?.baseURL?.endsWith("/internal"); + return { + get: isInternalInstance ? internalGet : jest.fn().mockResolvedValue({ data: {} }), + post: jest.fn().mockResolvedValue({ data: {} }), + patch: jest.fn().mockResolvedValue({ data: {} }), + delete: jest.fn().mockResolvedValue({ data: {} }), + }; + }); +} + +describe("SublayClient.init / verifyClient", () => { + it("resolves with a working, fully bound client when /service/verify succeeds", async () => { + const internalGet = jest.fn().mockResolvedValue({ data: { ok: true } }); + mockAxiosCreate(internalGet); + + const client = await SublayClient.init({ projectId: "p1", apiKey: "k1" }); + + expect(internalGet).toHaveBeenCalledWith("/service/verify"); + expect(typeof client.entities.fetchEntity).toBe("function"); + expect(typeof client.table("Events").find).toBe("function"); + }); + + it("rejects with a descriptive error when /service/verify fails", async () => { + const internalGet = jest.fn().mockRejectedValue(new Error("network fail")); + mockAxiosCreate(internalGet); + + await expect( + SublayClient.init({ projectId: "p1", apiKey: "k1" }), + ).rejects.toThrow("[Sublay] Invalid API key or project ID."); + }); +}); diff --git a/__tests__/collections.test.ts b/__tests__/collections.test.ts new file mode 100644 index 0000000..90e05cf --- /dev/null +++ b/__tests__/collections.test.ts @@ -0,0 +1,176 @@ +import { + addEntityToCollection, + createNewCollection, + deleteCollection, + fetchCollectionEntities, + fetchRootCollection, + fetchSubCollections, + removeEntityFromCollection, + updateCollection, +} from "../src/modules/collections"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk collections — request shaping", () => { + it("fetchRootCollection hits /collections/root with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchRootCollection(client, { userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/collections/root", { + params: { userId: "u1" }, + }); + }); + + it("fetchSubCollections strips collectionId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchSubCollections(client, { collectionId: "c1", userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith( + "/collections/c1/sub-collections", + { params: { userId: "u1" } }, + ); + }); + + it("createNewCollection strips collectionId into the path and posts the rest", async () => { + const { client, projectInstance } = makeClient(); + await createNewCollection(client, { + collectionId: "c1", + collectionName: "Favorites", + userId: "u1", + }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/collections/c1/sub-collections", + { collectionName: "Favorites", userId: "u1" }, + ); + }); + + it("fetchCollectionEntities strips collectionId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchCollectionEntities(client, { collectionId: "c1", userId: "u1", page: 2 }); + expect(projectInstance.get).toHaveBeenCalledWith("/collections/c1/entities", { + params: { userId: "u1", page: 2 }, + }); + }); + + it("addEntityToCollection strips collectionId into the path and posts the rest", async () => { + const { client, projectInstance } = makeClient(); + await addEntityToCollection(client, { + collectionId: "c1", + entityId: "e1", + userId: "u1", + }); + expect(projectInstance.post).toHaveBeenCalledWith("/collections/c1/entities", { + entityId: "e1", + userId: "u1", + }); + }); + + it("removeEntityFromCollection deletes /collections/:id/entities/:entityId with userId as a param", async () => { + const { client, projectInstance } = makeClient(); + await removeEntityFromCollection(client, { + collectionId: "c1", + entityId: "e1", + userId: "u1", + }); + expect(projectInstance.delete).toHaveBeenCalledWith( + "/collections/c1/entities/e1", + { params: { userId: "u1" } }, + ); + }); + + it("updateCollection strips collectionId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await updateCollection(client, { collectionId: "c1", userId: "u1", name: "New" }); + expect(projectInstance.patch).toHaveBeenCalledWith("/collections/c1", { + userId: "u1", + name: "New", + }); + }); + + it("deleteCollection deletes /collections/:id with userId as a param", async () => { + const { client, projectInstance } = makeClient(); + await deleteCollection(client, { collectionId: "c1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/collections/c1", { + params: { userId: "u1" }, + }); + }); +}); + +describe("node-sdk collections — response mapping", () => { + it("fetchRootCollection returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const collection = { id: "c1", name: "root" }; + projectInstance.get.mockResolvedValueOnce({ data: collection }); + await expect(fetchRootCollection(client, { userId: "u1" })).resolves.toEqual( + collection, + ); + }); + + it("fetchSubCollections returns response.data as an array", async () => { + const { client, projectInstance } = makeClient(); + const collections = [{ id: "c2" }, { id: "c3" }]; + projectInstance.get.mockResolvedValueOnce({ data: collections }); + await expect( + fetchSubCollections(client, { collectionId: "c1", userId: "u1" }), + ).resolves.toEqual(collections); + }); + + it("createNewCollection returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const collection = { id: "c2", name: "Favorites" }; + projectInstance.post.mockResolvedValueOnce({ data: collection }); + await expect( + createNewCollection(client, { + collectionId: "c1", + collectionName: "Favorites", + userId: "u1", + }), + ).resolves.toEqual(collection); + }); + + it("fetchCollectionEntities returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "e1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect( + fetchCollectionEntities(client, { collectionId: "c1", userId: "u1" }), + ).resolves.toEqual(envelope); + }); + + it("addEntityToCollection returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const collection = { id: "c1" }; + projectInstance.post.mockResolvedValueOnce({ data: collection }); + await expect( + addEntityToCollection(client, { + collectionId: "c1", + entityId: "e1", + userId: "u1", + }), + ).resolves.toEqual(collection); + }); + + it("removeEntityFromCollection resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + removeEntityFromCollection(client, { + collectionId: "c1", + entityId: "e1", + userId: "u1", + }), + ).resolves.toBeUndefined(); + }); + + it("updateCollection returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const collection = { id: "c1", name: "New" }; + projectInstance.patch.mockResolvedValueOnce({ data: collection }); + await expect( + updateCollection(client, { collectionId: "c1", userId: "u1", name: "New" }), + ).resolves.toEqual(collection); + }); + + it("deleteCollection resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + deleteCollection(client, { collectionId: "c1", userId: "u1" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/__tests__/comments.test.ts b/__tests__/comments.test.ts new file mode 100644 index 0000000..6b1b70d --- /dev/null +++ b/__tests__/comments.test.ts @@ -0,0 +1,177 @@ +import { + addReaction, + createComment, + deleteComment, + fetchComment, + fetchCommentByForeignId, + fetchManyComments, + fetchReactions, + getUserReaction, + removeReaction, + updateComment, +} from "../src/modules/comments"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk comments — request shaping", () => { + it("createComment posts the full body to /comments", async () => { + const { client, projectInstance } = makeClient(); + await createComment(client, { userId: "u1", entityId: "e1", content: "hi" }); + expect(projectInstance.post).toHaveBeenCalledWith("/comments", { + userId: "u1", + entityId: "e1", + content: "hi", + }); + }); + + it("fetchComment strips commentId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchComment(client, { commentId: "c1", include: "user" }); + expect(projectInstance.get).toHaveBeenCalledWith("/comments/c1", { + params: { include: "user" }, + }); + }); + + it("fetchCommentByForeignId hits /comments/by-foreign-id with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchCommentByForeignId(client, { foreignId: "f1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/comments/by-foreign-id", { + params: { foreignId: "f1" }, + }); + }); + + it("updateComment strips commentId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await updateComment(client, { commentId: "c1", content: "edited" }); + expect(projectInstance.patch).toHaveBeenCalledWith("/comments/c1", { + content: "edited", + }); + }); + + it("deleteComment deletes /comments/:id", async () => { + const { client, projectInstance } = makeClient(); + await deleteComment(client, { commentId: "c1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/comments/c1"); + }); + + it("fetchManyComments hits /comments with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchManyComments(client, { entityId: "e1", sortBy: "top" }); + expect(projectInstance.get).toHaveBeenCalledWith("/comments", { + params: { entityId: "e1", sortBy: "top" }, + }); + }); + + it("addReaction posts reactionType and userId to /comments/:id/reactions", async () => { + const { client, projectInstance } = makeClient(); + await addReaction(client, { commentId: "c1", reactionType: "like", userId: "u1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/comments/c1/reactions", { + reactionType: "like", + userId: "u1", + }); + }); + + it("removeReaction sends userId via the data option", async () => { + const { client, projectInstance } = makeClient(); + await removeReaction(client, { commentId: "c1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/comments/c1/reactions", { + data: { userId: "u1" }, + }); + }); + + it("fetchReactions strips commentId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchReactions(client, { commentId: "c1", reactionType: "like" }); + expect(projectInstance.get).toHaveBeenCalledWith("/comments/c1/reactions", { + params: { reactionType: "like" }, + }); + }); + + it("getUserReaction hits the /reactions/me route with userId as a param", async () => { + const { client, projectInstance } = makeClient(); + await getUserReaction(client, { commentId: "c1", userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/comments/c1/reactions/me", { + params: { userId: "u1" }, + }); + }); +}); + +describe("node-sdk comments — response mapping", () => { + it("createComment returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const comment = { id: "c1", content: "hi" }; + projectInstance.post.mockResolvedValueOnce({ data: comment }); + await expect( + createComment(client, { userId: "u1", entityId: "e1" }), + ).resolves.toEqual(comment); + }); + + it("fetchComment returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const comment = { id: "c1" }; + projectInstance.get.mockResolvedValueOnce({ data: comment }); + await expect(fetchComment(client, { commentId: "c1" })).resolves.toEqual(comment); + }); + + it("fetchCommentByForeignId returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const comment = { id: "c1" }; + projectInstance.get.mockResolvedValueOnce({ data: comment }); + await expect( + fetchCommentByForeignId(client, { foreignId: "f1" }), + ).resolves.toEqual(comment); + }); + + it("updateComment returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const comment = { id: "c1", content: "edited" }; + projectInstance.patch.mockResolvedValueOnce({ data: comment }); + await expect( + updateComment(client, { commentId: "c1", content: "edited" }), + ).resolves.toEqual(comment); + }); + + it("deleteComment resolves with response.data (void)", async () => { + const { client, projectInstance } = makeClient(); + projectInstance.delete.mockResolvedValueOnce({ data: undefined }); + await expect(deleteComment(client, { commentId: "c1" })).resolves.toBeUndefined(); + }); + + it("fetchManyComments returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "c1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(fetchManyComments(client, {})).resolves.toEqual(envelope); + }); + + it("addReaction returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const reaction = { id: "r1", reactionType: "like" }; + projectInstance.post.mockResolvedValueOnce({ data: reaction }); + await expect( + addReaction(client, { commentId: "c1", reactionType: "like", userId: "u1" }), + ).resolves.toEqual(reaction); + }); + + it("removeReaction resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + removeReaction(client, { commentId: "c1", userId: "u1" }), + ).resolves.toBeUndefined(); + }); + + it("fetchReactions returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "r1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(fetchReactions(client, { commentId: "c1" })).resolves.toEqual(envelope); + }); + + it("getUserReaction returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { reactionType: "like" as const }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + getUserReaction(client, { commentId: "c1", userId: "u1" }), + ).resolves.toEqual(result); + }); +}); diff --git a/__tests__/connections.test.ts b/__tests__/connections.test.ts new file mode 100644 index 0000000..ff88337 --- /dev/null +++ b/__tests__/connections.test.ts @@ -0,0 +1,132 @@ +import { + acceptConnection, + declineConnection, + fetchConnections, + fetchConnectionsCount, + fetchReceivedPendingConnections, + fetchSentPendingConnections, + removeConnection, +} from "../src/modules/connections"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk connections — request shaping", () => { + it("fetchConnections hits /connections with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchConnections(client, { userId: "u1", page: 1 }); + expect(projectInstance.get).toHaveBeenCalledWith("/connections", { + params: { userId: "u1", page: 1 }, + }); + }); + + it("fetchConnectionsCount hits /connections/count with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchConnectionsCount(client, { userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/connections/count", { + params: { userId: "u1" }, + }); + }); + + it("fetchSentPendingConnections hits /connections/pending/sent with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchSentPendingConnections(client, { userId: "u1", limit: 5 }); + expect(projectInstance.get).toHaveBeenCalledWith("/connections/pending/sent", { + params: { userId: "u1", limit: 5 }, + }); + }); + + it("fetchReceivedPendingConnections hits /connections/pending/received with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchReceivedPendingConnections(client, { userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith( + "/connections/pending/received", + { params: { userId: "u1" } }, + ); + }); + + it("acceptConnection patches /connections/:id/accept with userId in the body", async () => { + const { client, projectInstance } = makeClient(); + await acceptConnection(client, { connectionId: "conn1", userId: "u1" }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/connections/conn1/accept", + { userId: "u1" }, + ); + }); + + it("declineConnection patches /connections/:id/decline with userId in the body", async () => { + const { client, projectInstance } = makeClient(); + await declineConnection(client, { connectionId: "conn1", userId: "u1" }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/connections/conn1/decline", + { userId: "u1" }, + ); + }); + + it("removeConnection deletes /connections/:id with userId as a param", async () => { + const { client, projectInstance } = makeClient(); + await removeConnection(client, { connectionId: "conn1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/connections/conn1", { + params: { userId: "u1" }, + }); + }); +}); + +describe("node-sdk connections — response mapping", () => { + it("fetchConnections returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "c1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(fetchConnections(client, { userId: "u1" })).resolves.toEqual(envelope); + }); + + it("fetchConnectionsCount returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { count: 6 }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect(fetchConnectionsCount(client, { userId: "u1" })).resolves.toEqual( + result, + ); + }); + + it("fetchSentPendingConnections returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "p1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect( + fetchSentPendingConnections(client, { userId: "u1" }), + ).resolves.toEqual(envelope); + }); + + it("fetchReceivedPendingConnections returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "p2" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect( + fetchReceivedPendingConnections(client, { userId: "u1" }), + ).resolves.toEqual(envelope); + }); + + it("acceptConnection returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { id: "conn1", status: "accepted" }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + acceptConnection(client, { connectionId: "conn1", userId: "u1" }), + ).resolves.toEqual(result); + }); + + it("declineConnection returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { id: "conn1", status: "declined" }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + declineConnection(client, { connectionId: "conn1", userId: "u1" }), + ).resolves.toEqual(result); + }); + + it("removeConnection resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + removeConnection(client, { connectionId: "conn1", userId: "u1" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/__tests__/entities.test.ts b/__tests__/entities.test.ts new file mode 100644 index 0000000..a76fe5f --- /dev/null +++ b/__tests__/entities.test.ts @@ -0,0 +1,277 @@ +import { + addReaction, + createEntity, + deleteEntity, + fetchDrafts, + fetchEntity, + fetchEntityByForeignId, + fetchEntityByShortId, + fetchManyEntities, + fetchReactions, + fetchTopComment, + getUserReaction, + incrementEntityViews, + isEntitySaved, + publishDraft, + removeReaction, + updateEntity, +} from "../src/modules/entities"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk entities — request shaping", () => { + it("createEntity posts the full body to /entities", async () => { + const { client, projectInstance } = makeClient(); + await createEntity(client, { title: "x", userId: "u1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/entities", { + title: "x", + userId: "u1", + }); + }); + + it("fetchEntity strips entityId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchEntity(client, { entityId: "e1", include: "user" }); + expect(projectInstance.get).toHaveBeenCalledWith("/entities/e1", { + params: { include: "user" }, + }); + }); + + it("fetchEntityByForeignId hits /entities/by-foreign-id with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchEntityByForeignId(client, { foreignId: "f1", createIfNotFound: true }); + expect(projectInstance.get).toHaveBeenCalledWith("/entities/by-foreign-id", { + params: { foreignId: "f1", createIfNotFound: true }, + }); + }); + + it("fetchEntityByShortId hits /entities/by-short-id with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchEntityByShortId(client, { shortId: "s1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/entities/by-short-id", { + params: { shortId: "s1" }, + }); + }); + + it("fetchManyEntities passes keywordsFilters through as a raw params object", async () => { + const { client, projectInstance } = makeClient(); + const data = { keywordsFilters: { includes: ["nodejs"] }, limit: 10 }; + await fetchManyEntities(client, data); + expect(projectInstance.get).toHaveBeenCalledWith("/entities", { params: data }); + }); + + it("fetchManyEntities passes metadataFilters through as a raw params object", async () => { + const { client, projectInstance } = makeClient(); + const data = { metadataFilters: { exists: ["category"] }, sortBy: "hot" as const }; + await fetchManyEntities(client, data); + expect(projectInstance.get).toHaveBeenCalledWith("/entities", { params: data }); + }); + + it("updateEntity strips entityId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await updateEntity(client, { entityId: "e1", title: "new" }); + expect(projectInstance.patch).toHaveBeenCalledWith("/entities/e1", { + title: "new", + }); + }); + + it("incrementEntityViews strips entityId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await incrementEntityViews(client, { entityId: "e1", count: 3 }); + expect(projectInstance.patch).toHaveBeenCalledWith("/entities/e1/increment-views", { + count: 3, + }); + }); + + it("deleteEntity deletes /entities/:id", async () => { + const { client, projectInstance } = makeClient(); + await deleteEntity(client, { entityId: "e1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/entities/e1"); + }); + + it("fetchDrafts hits /entities/drafts with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchDrafts(client, { userId: "u1", page: 2 }); + expect(projectInstance.get).toHaveBeenCalledWith("/entities/drafts", { + params: { userId: "u1", page: 2 }, + }); + }); + + it("publishDraft patches the publish route with no body", async () => { + const { client, projectInstance } = makeClient(); + await publishDraft(client, { entityId: "e1" }); + expect(projectInstance.patch).toHaveBeenCalledWith("/entities/e1/publish"); + }); + + it("fetchTopComment hits the top-comment route", async () => { + const { client, projectInstance } = makeClient(); + await fetchTopComment(client, { entityId: "e1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/entities/e1/top-comment"); + }); + + it("addReaction posts reactionType and userId to /entities/:id/reactions", async () => { + const { client, projectInstance } = makeClient(); + await addReaction(client, { entityId: "e1", reactionType: "like", userId: "u1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/entities/e1/reactions", { + reactionType: "like", + userId: "u1", + }); + }); + + it("removeReaction sends userId via the data option", async () => { + const { client, projectInstance } = makeClient(); + await removeReaction(client, { entityId: "e1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/entities/e1/reactions", { + data: { userId: "u1" }, + }); + }); + + it("fetchReactions strips entityId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchReactions(client, { entityId: "e1", reactionType: "like", page: 1 }); + expect(projectInstance.get).toHaveBeenCalledWith("/entities/e1/reactions", { + params: { reactionType: "like", page: 1 }, + }); + }); + + it("getUserReaction hits the /reactions/me route with userId as a param", async () => { + const { client, projectInstance } = makeClient(); + await getUserReaction(client, { entityId: "e1", userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/entities/e1/reactions/me", { + params: { userId: "u1" }, + }); + }); + + it("isEntitySaved hits /entities/is-entity-saved with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await isEntitySaved(client, { entityId: "e1", userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/entities/is-entity-saved", { + params: { entityId: "e1", userId: "u1" }, + }); + }); +}); + +describe("node-sdk entities — response mapping", () => { + it("createEntity returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const entity = { id: "e1", title: "x" }; + projectInstance.post.mockResolvedValueOnce({ data: entity }); + await expect(createEntity(client, { title: "x" })).resolves.toEqual(entity); + }); + + it("fetchEntity returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const entity = { id: "e1" }; + projectInstance.get.mockResolvedValueOnce({ data: entity }); + await expect(fetchEntity(client, { entityId: "e1" })).resolves.toEqual(entity); + }); + + it("fetchEntityByForeignId returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const entity = { id: "e1" }; + projectInstance.get.mockResolvedValueOnce({ data: entity }); + await expect( + fetchEntityByForeignId(client, { foreignId: "f1" }), + ).resolves.toEqual(entity); + }); + + it("fetchEntityByShortId returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const entity = { id: "e1" }; + projectInstance.get.mockResolvedValueOnce({ data: entity }); + await expect( + fetchEntityByShortId(client, { shortId: "s1" }), + ).resolves.toEqual(entity); + }); + + it("fetchManyEntities returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "e1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(fetchManyEntities(client, {})).resolves.toEqual(envelope); + }); + + it("updateEntity returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const entity = { id: "e1", title: "new" }; + projectInstance.patch.mockResolvedValueOnce({ data: entity }); + await expect( + updateEntity(client, { entityId: "e1", title: "new" }), + ).resolves.toEqual(entity); + }); + + it("incrementEntityViews returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const entity = { id: "e1", views: 4 }; + projectInstance.patch.mockResolvedValueOnce({ data: entity }); + await expect( + incrementEntityViews(client, { entityId: "e1" }), + ).resolves.toEqual(entity); + }); + + it("deleteEntity resolves with response.data (void)", async () => { + const { client, projectInstance } = makeClient(); + projectInstance.delete.mockResolvedValueOnce({ data: undefined }); + await expect(deleteEntity(client, { entityId: "e1" })).resolves.toBeUndefined(); + }); + + it("fetchDrafts returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "e1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(fetchDrafts(client, { userId: "u1" })).resolves.toEqual(envelope); + }); + + it("publishDraft returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const entity = { id: "e1", isDraft: false }; + projectInstance.patch.mockResolvedValueOnce({ data: entity }); + await expect(publishDraft(client, { entityId: "e1" })).resolves.toEqual(entity); + }); + + it("fetchTopComment returns response.data, including null", async () => { + const { client, projectInstance } = makeClient(); + projectInstance.get.mockResolvedValueOnce({ data: null }); + await expect(fetchTopComment(client, { entityId: "e1" })).resolves.toBeNull(); + }); + + it("addReaction returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const reaction = { id: "r1", reactionType: "like" }; + projectInstance.post.mockResolvedValueOnce({ data: reaction }); + await expect( + addReaction(client, { entityId: "e1", reactionType: "like", userId: "u1" }), + ).resolves.toEqual(reaction); + }); + + it("removeReaction resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + removeReaction(client, { entityId: "e1", userId: "u1" }), + ).resolves.toBeUndefined(); + }); + + it("fetchReactions returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "r1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(fetchReactions(client, { entityId: "e1" })).resolves.toEqual(envelope); + }); + + it("getUserReaction returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { reactionType: "like" as const }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + getUserReaction(client, { entityId: "e1", userId: "u1" }), + ).resolves.toEqual(result); + }); + + it("isEntitySaved returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { isSaved: true }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + isEntitySaved(client, { entityId: "e1", userId: "u1" }), + ).resolves.toEqual(result); + }); +}); diff --git a/__tests__/events.test.ts b/__tests__/events.test.ts new file mode 100644 index 0000000..7685028 --- /dev/null +++ b/__tests__/events.test.ts @@ -0,0 +1,256 @@ +import { + addHost, + addInvite, + cancelEvent, + createEvent, + deleteEvent, + fetchEvent, + fetchEventRsvps, + fetchInvitees, + fetchManyEvents, + removeHost, + removeInvite, + setRsvp, + updateEvent, + withdrawRsvp, +} from "../src/modules/events"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk events — request shaping", () => { + it("createEvent posts the full body to /events", async () => { + const { client, projectInstance } = makeClient(); + await createEvent(client, { + title: "Launch party", + startTime: "2026-07-01T18:00:00Z", + type: "physical", + }); + expect(projectInstance.post).toHaveBeenCalledWith("/events", { + title: "Launch party", + startTime: "2026-07-01T18:00:00Z", + type: "physical", + }); + }); + + it("fetchEvent strips eventId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchEvent(client, { eventId: "ev1", include: "user,space" }); + expect(projectInstance.get).toHaveBeenCalledWith("/events/ev1", { + params: { include: "user,space" }, + }); + }); + + it("fetchManyEvents hits /events with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchManyEvents(client, { timeWindow: "upcoming", spaceId: "s1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/events", { + params: { timeWindow: "upcoming", spaceId: "s1" }, + }); + }); + + it("fetchManyEvents defaults to an empty params object when called with no args", async () => { + const { client, projectInstance } = makeClient(); + await fetchManyEvents(client); + expect(projectInstance.get).toHaveBeenCalledWith("/events", { params: {} }); + }); + + it("updateEvent strips eventId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await updateEvent(client, { eventId: "ev1", title: "New title" }); + expect(projectInstance.patch).toHaveBeenCalledWith("/events/ev1", { + title: "New title", + }); + }); + + it("cancelEvent posts the cancel route with no body", async () => { + const { client, projectInstance } = makeClient(); + await cancelEvent(client, { eventId: "ev1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/events/ev1/cancel"); + }); + + it("deleteEvent deletes /events/:id", async () => { + const { client, projectInstance } = makeClient(); + await deleteEvent(client, { eventId: "ev1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/events/ev1"); + }); + + it("setRsvp strips eventId into the path and posts the rest", async () => { + const { client, projectInstance } = makeClient(); + await setRsvp(client, { eventId: "ev1", status: "going", userId: "u1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/events/ev1/rsvp", { + status: "going", + userId: "u1", + }); + }); + + it("withdrawRsvp sends the rest of the body via the data option", async () => { + const { client, projectInstance } = makeClient(); + await withdrawRsvp(client, { eventId: "ev1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/events/ev1/rsvp", { + data: { userId: "u1" }, + }); + }); + + it("addHost posts userId to /events/:id/hosts", async () => { + const { client, projectInstance } = makeClient(); + await addHost(client, { eventId: "ev1", userId: "u1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/events/ev1/hosts", { + userId: "u1", + }); + }); + + it("removeHost sends userId via the data option", async () => { + const { client, projectInstance } = makeClient(); + await removeHost(client, { eventId: "ev1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/events/ev1/hosts", { + data: { userId: "u1" }, + }); + }); + + it("addInvite posts userId to /events/:id/invites", async () => { + const { client, projectInstance } = makeClient(); + await addInvite(client, { eventId: "ev1", userId: "u1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/events/ev1/invites", { + userId: "u1", + }); + }); + + it("removeInvite sends userId via the data option", async () => { + const { client, projectInstance } = makeClient(); + await removeInvite(client, { eventId: "ev1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/events/ev1/invites", { + data: { userId: "u1" }, + }); + }); + + it("fetchInvitees strips eventId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchInvitees(client, { eventId: "ev1", page: 2 }); + expect(projectInstance.get).toHaveBeenCalledWith("/events/ev1/invites", { + params: { page: 2 }, + }); + }); + + it("fetchEventRsvps strips eventId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchEventRsvps(client, { eventId: "ev1", status: "going,maybe" }); + expect(projectInstance.get).toHaveBeenCalledWith("/events/ev1/rsvps", { + params: { status: "going,maybe" }, + }); + }); +}); + +describe("node-sdk events — response mapping", () => { + it("createEvent returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const event = { id: "ev1", title: "Launch party" }; + projectInstance.post.mockResolvedValueOnce({ data: event }); + await expect( + createEvent(client, { title: "Launch party", startTime: "2026-07-01T18:00:00Z", type: "physical" }), + ).resolves.toEqual(event); + }); + + it("fetchEvent returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const event = { id: "ev1" }; + projectInstance.get.mockResolvedValueOnce({ data: event }); + await expect(fetchEvent(client, { eventId: "ev1" })).resolves.toEqual(event); + }); + + it("fetchManyEvents returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "ev1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(fetchManyEvents(client)).resolves.toEqual(envelope); + }); + + it("updateEvent returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const event = { id: "ev1", title: "New title" }; + projectInstance.patch.mockResolvedValueOnce({ data: event }); + await expect( + updateEvent(client, { eventId: "ev1", title: "New title" }), + ).resolves.toEqual(event); + }); + + it("cancelEvent returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const event = { id: "ev1", status: "cancelled" }; + projectInstance.post.mockResolvedValueOnce({ data: event }); + await expect(cancelEvent(client, { eventId: "ev1" })).resolves.toEqual(event); + }); + + it("deleteEvent resolves to undefined", async () => { + const { client } = makeClient(); + await expect(deleteEvent(client, { eventId: "ev1" })).resolves.toBeUndefined(); + }); + + it("setRsvp returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const event = { id: "ev1", rsvpCounts: { going: 1 } }; + projectInstance.post.mockResolvedValueOnce({ data: event }); + await expect( + setRsvp(client, { eventId: "ev1", status: "going" }), + ).resolves.toEqual(event); + }); + + it("withdrawRsvp returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const event = { id: "ev1", rsvpCounts: { going: 0 } }; + projectInstance.delete.mockResolvedValueOnce({ data: event }); + await expect(withdrawRsvp(client, { eventId: "ev1" })).resolves.toEqual(event); + }); + + it("addHost returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const event = { id: "ev1", hostIds: ["u1"] }; + projectInstance.post.mockResolvedValueOnce({ data: event }); + await expect( + addHost(client, { eventId: "ev1", userId: "u1" }), + ).resolves.toEqual(event); + }); + + it("removeHost returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const event = { id: "ev1", hostIds: [] }; + projectInstance.delete.mockResolvedValueOnce({ data: event }); + await expect( + removeHost(client, { eventId: "ev1", userId: "u1" }), + ).resolves.toEqual(event); + }); + + it("addInvite returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const event = { id: "ev1" }; + projectInstance.post.mockResolvedValueOnce({ data: event }); + await expect( + addInvite(client, { eventId: "ev1", userId: "u1" }), + ).resolves.toEqual(event); + }); + + it("removeInvite returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const event = { id: "ev1" }; + projectInstance.delete.mockResolvedValueOnce({ data: event }); + await expect( + removeInvite(client, { eventId: "ev1", userId: "u1" }), + ).resolves.toEqual(event); + }); + + it("fetchInvitees returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ userId: "u1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect( + fetchInvitees(client, { eventId: "ev1" }), + ).resolves.toEqual(envelope); + }); + + it("fetchEventRsvps returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ userId: "u1", status: "going" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect( + fetchEventRsvps(client, { eventId: "ev1" }), + ).resolves.toEqual(envelope); + }); +}); diff --git a/__tests__/follows.test.ts b/__tests__/follows.test.ts new file mode 100644 index 0000000..b70ab5a --- /dev/null +++ b/__tests__/follows.test.ts @@ -0,0 +1,87 @@ +import { + deleteFollow, + fetchFollowers, + fetchFollowersCount, + fetchFollowing, + fetchFollowingCount, +} from "../src/modules/follows"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk follows — request shaping", () => { + it("fetchFollowing hits /follows/following with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchFollowing(client, { userId: "u1", page: 1 }); + expect(projectInstance.get).toHaveBeenCalledWith("/follows/following", { + params: { userId: "u1", page: 1 }, + }); + }); + + it("fetchFollowers hits /follows/followers with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchFollowers(client, { userId: "u1", limit: 5 }); + expect(projectInstance.get).toHaveBeenCalledWith("/follows/followers", { + params: { userId: "u1", limit: 5 }, + }); + }); + + it("fetchFollowingCount hits /follows/following-count with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchFollowingCount(client, { userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/follows/following-count", { + params: { userId: "u1" }, + }); + }); + + it("fetchFollowersCount hits /follows/followers-count with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchFollowersCount(client, { userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/follows/followers-count", { + params: { userId: "u1" }, + }); + }); + + it("deleteFollow (by record id) deletes /follows/:followId with userId as a param", async () => { + const { client, projectInstance } = makeClient(); + await deleteFollow(client, { followId: "fol1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/follows/fol1", { + params: { userId: "u1" }, + }); + }); +}); + +describe("node-sdk follows — response mapping", () => { + it("fetchFollowing returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "f1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(fetchFollowing(client, { userId: "u1" })).resolves.toEqual(envelope); + }); + + it("fetchFollowers returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "f2" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(fetchFollowers(client, { userId: "u1" })).resolves.toEqual(envelope); + }); + + it("fetchFollowingCount returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { count: 4 }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect(fetchFollowingCount(client, { userId: "u1" })).resolves.toEqual(result); + }); + + it("fetchFollowersCount returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { count: 9 }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect(fetchFollowersCount(client, { userId: "u1" })).resolves.toEqual(result); + }); + + it("deleteFollow resolves to undefined", async () => { + const { client } = makeClient(); + await expect( + deleteFollow(client, { followId: "fol1", userId: "u1" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/__tests__/helpers/mockClient.ts b/__tests__/helpers/mockClient.ts new file mode 100644 index 0000000..663c138 --- /dev/null +++ b/__tests__/helpers/mockClient.ts @@ -0,0 +1,48 @@ +import { SublayHttpClient } from "../../src/core/client"; + +export interface MockAxiosInstance { + get: jest.Mock; + post: jest.Mock; + patch: jest.Mock; + delete: jest.Mock; +} + +export interface MockClient { + client: SublayHttpClient; + projectInstance: MockAxiosInstance; + internalInstance: MockAxiosInstance; + baseInstance: MockAxiosInstance; +} + +function makeMockAxiosInstance(): MockAxiosInstance { + return { + get: jest.fn().mockResolvedValue({ data: {} }), + post: jest.fn().mockResolvedValue({ data: {} }), + patch: jest.fn().mockResolvedValue({ data: {} }), + delete: jest.fn().mockResolvedValue({ data: {} }), + }; +} + +/** + * Builds a `SublayHttpClient`-shaped object with all three axios instances + * (`projectInstance`/`internalInstance`/`baseInstance`) mocked. Each method + * defaults to resolving `{ data: {} }`; override per test with + * `projectInstance.get.mockResolvedValueOnce(...)` (or `mockRejectedValueOnce` + * for failure-path tests). + */ +export function makeClient(): MockClient { + const projectInstance = makeMockAxiosInstance(); + const internalInstance = makeMockAxiosInstance(); + const baseInstance = makeMockAxiosInstance(); + + return { + client: { + projectInstance, + internalInstance, + baseInstance, + } as unknown as SublayHttpClient, + projectInstance, + internalInstance, + baseInstance, + }; +} diff --git a/__tests__/hosted-apps-reports.test.ts b/__tests__/hosted-apps-reports.test.ts new file mode 100644 index 0000000..e70f52c --- /dev/null +++ b/__tests__/hosted-apps-reports.test.ts @@ -0,0 +1,72 @@ +import { fetchHostedApp } from "../src/modules/hosted-apps"; +import { createReport, fetchModeratedReports } from "../src/modules/reports"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk hostedApps — request shaping", () => { + it("fetchHostedApp hits the internal axios instance, not projectInstance", async () => { + const { client, internalInstance, projectInstance } = makeClient(); + await fetchHostedApp(client, { appId: "app1" }); + expect(internalInstance.get).toHaveBeenCalledWith("/hosted-apps/app1"); + expect(projectInstance.get).not.toHaveBeenCalled(); + }); +}); + +describe("node-sdk hostedApps — response mapping", () => { + it("fetchHostedApp returns response.data", async () => { + const { client, internalInstance } = makeClient(); + const app = { id: "app1", name: "My App" }; + internalInstance.get.mockResolvedValueOnce({ data: app }); + await expect(fetchHostedApp(client, { appId: "app1" })).resolves.toEqual(app); + }); +}); + +describe("node-sdk reports — request shaping", () => { + it("createReport posts the full body to /reports", async () => { + const { client, projectInstance } = makeClient(); + await createReport(client, { + userId: "u1", + targetType: "entity", + targetId: "e1", + reason: "spam", + }); + expect(projectInstance.post).toHaveBeenCalledWith("/reports", { + userId: "u1", + targetType: "entity", + targetId: "e1", + reason: "spam", + }); + }); + + it("fetchModeratedReports hits /reports/moderated with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchModeratedReports(client, { userId: "u1", status: "pending" }); + expect(projectInstance.get).toHaveBeenCalledWith("/reports/moderated", { + params: { userId: "u1", status: "pending" }, + }); + }); +}); + +describe("node-sdk reports — response mapping", () => { + it("createReport returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { id: "rep1", status: "pending" }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + createReport(client, { + userId: "u1", + targetType: "entity", + targetId: "e1", + reason: "spam", + }), + ).resolves.toEqual(result); + }); + + it("fetchModeratedReports returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "rep1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect( + fetchModeratedReports(client, { userId: "u1" }), + ).resolves.toEqual(envelope); + }); +}); diff --git a/__tests__/search.test.ts b/__tests__/search.test.ts new file mode 100644 index 0000000..4a8c3c6 --- /dev/null +++ b/__tests__/search.test.ts @@ -0,0 +1,84 @@ +import { askContent, searchContent, searchSpaces, searchUsers } from "../src/modules/search"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk search — request shaping", () => { + it("searchContent posts the body minus space-reputation fields, which go in params instead", async () => { + const { client, projectInstance } = makeClient(); + await searchContent(client, { + query: "hello", + sourceTypes: ["entity"], + spaceReputationId: "rep1", + spaceReputationDescendants: true, + }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/search/content", + { query: "hello", sourceTypes: ["entity"] }, + { params: { spaceReputationId: "rep1", spaceReputationDescendants: true } }, + ); + }); + + it("searchUsers posts the full body to /search/users", async () => { + const { client, projectInstance } = makeClient(); + await searchUsers(client, { query: "ali", limit: 5 }); + expect(projectInstance.post).toHaveBeenCalledWith("/search/users", { + query: "ali", + limit: 5, + }); + }); + + it("searchSpaces posts the full body to /search/spaces", async () => { + const { client, projectInstance } = makeClient(); + await searchSpaces(client, { query: "design", limit: 5 }); + expect(projectInstance.post).toHaveBeenCalledWith("/search/spaces", { + query: "design", + limit: 5, + }); + }); + + it("askContent posts the body minus space-reputation fields, which go in params instead (plain request, no streaming)", async () => { + const { client, projectInstance } = makeClient(); + await askContent(client, { + query: "what is this space about?", + spaceId: "s1", + spaceReputationId: "rep1", + spaceReputationDescendants: false, + }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/search/ask", + { query: "what is this space about?", spaceId: "s1" }, + { params: { spaceReputationId: "rep1", spaceReputationDescendants: false } }, + ); + }); +}); + +describe("node-sdk search — response mapping", () => { + it("searchContent returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "e1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.post.mockResolvedValueOnce({ data: envelope }); + await expect(searchContent(client, { query: "hello" })).resolves.toEqual(envelope); + }); + + it("searchUsers returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "u1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.post.mockResolvedValueOnce({ data: envelope }); + await expect(searchUsers(client, { query: "ali" })).resolves.toEqual(envelope); + }); + + it("searchSpaces returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "s1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.post.mockResolvedValueOnce({ data: envelope }); + await expect(searchSpaces(client, { query: "design" })).resolves.toEqual(envelope); + }); + + it("askContent returns response.data as a plain { answer, sources } object", async () => { + const { client, projectInstance } = makeClient(); + const result = { answer: "It's about design.", sources: [{ entityId: "e1", title: "Intro" }] }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + askContent(client, { query: "what is this space about?" }), + ).resolves.toEqual(result); + }); +}); diff --git a/__tests__/spaces-lifecycle.test.ts b/__tests__/spaces-lifecycle.test.ts new file mode 100644 index 0000000..4a5cc4a --- /dev/null +++ b/__tests__/spaces-lifecycle.test.ts @@ -0,0 +1,210 @@ +import { + checkSlugAvailability, + createSpace, + deleteSpace, + fetchChildSpaces, + fetchManySpaces, + fetchMutualSpaces, + fetchSpace, + fetchSpaceBreadcrumb, + fetchSpaceByShortId, + fetchSpaceBySlug, + fetchUserSpaces, + updateSpace, +} from "../src/modules/spaces"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk spaces (lifecycle) — request shaping", () => { + it("createSpace posts the full body to /spaces", async () => { + const { client, projectInstance } = makeClient(); + await createSpace(client, { userId: "u1", name: "My Space" }); + expect(projectInstance.post).toHaveBeenCalledWith("/spaces", { + userId: "u1", + name: "My Space", + }); + }); + + it("fetchManySpaces hits /spaces with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchManySpaces(client, { sortBy: "newest", page: 1 }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces", { + params: { sortBy: "newest", page: 1 }, + }); + }); + + it("fetchSpace hits /spaces/:id with no params", async () => { + const { client, projectInstance } = makeClient(); + await fetchSpace(client, { spaceId: "s1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/s1"); + }); + + it("fetchSpaceByShortId hits /spaces/by-short-id with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchSpaceByShortId(client, { shortId: "sh1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/by-short-id", { + params: { shortId: "sh1" }, + }); + }); + + it("fetchSpaceBySlug hits /spaces/by-slug with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchSpaceBySlug(client, { slug: "my-space" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/by-slug", { + params: { slug: "my-space" }, + }); + }); + + it("checkSlugAvailability hits /spaces/check-slug with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await checkSlugAvailability(client, { slug: "my-space" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/check-slug", { + params: { slug: "my-space" }, + }); + }); + + it("fetchUserSpaces hits /spaces/user-spaces with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchUserSpaces(client, { userId: "u1", role: "admin" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/user-spaces", { + params: { userId: "u1", role: "admin" }, + }); + }); + + it("fetchChildSpaces strips spaceId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchChildSpaces(client, { spaceId: "s1", page: 2 }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/s1/children", { + params: { page: 2 }, + }); + }); + + it("fetchSpaceBreadcrumb hits the breadcrumb route with no params", async () => { + const { client, projectInstance } = makeClient(); + await fetchSpaceBreadcrumb(client, { spaceId: "s1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/s1/breadcrumb"); + }); + + it("fetchMutualSpaces puts the target userId in the path, actingUserId as a param", async () => { + const { client, projectInstance } = makeClient(); + await fetchMutualSpaces(client, { userId: "target1", actingUserId: "acting1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/mutual/target1", { + params: { actingUserId: "acting1" }, + }); + }); + + it("updateSpace strips spaceId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await updateSpace(client, { spaceId: "s1", name: "New name" }); + expect(projectInstance.patch).toHaveBeenCalledWith("/spaces/s1", { + name: "New name", + }); + }); + + it("deleteSpace deletes /spaces/:id", async () => { + const { client, projectInstance } = makeClient(); + await deleteSpace(client, { spaceId: "s1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/spaces/s1"); + }); +}); + +describe("node-sdk spaces (lifecycle) — response mapping", () => { + it("createSpace returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const space = { id: "s1", name: "My Space" }; + projectInstance.post.mockResolvedValueOnce({ data: space }); + await expect( + createSpace(client, { userId: "u1", name: "My Space" }), + ).resolves.toEqual(space); + }); + + it("fetchManySpaces returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "s1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(fetchManySpaces(client, {})).resolves.toEqual(envelope); + }); + + it("fetchSpace returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const space = { id: "s1" }; + projectInstance.get.mockResolvedValueOnce({ data: space }); + await expect(fetchSpace(client, { spaceId: "s1" })).resolves.toEqual(space); + }); + + it("fetchSpaceByShortId returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const space = { id: "s1" }; + projectInstance.get.mockResolvedValueOnce({ data: space }); + await expect( + fetchSpaceByShortId(client, { shortId: "sh1" }), + ).resolves.toEqual(space); + }); + + it("fetchSpaceBySlug returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const space = { id: "s1" }; + projectInstance.get.mockResolvedValueOnce({ data: space }); + await expect( + fetchSpaceBySlug(client, { slug: "my-space" }), + ).resolves.toEqual(space); + }); + + it("checkSlugAvailability returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { available: true }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + checkSlugAvailability(client, { slug: "my-space" }), + ).resolves.toEqual(result); + }); + + it("fetchUserSpaces returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { data: [{ id: "s1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect(fetchUserSpaces(client, { userId: "u1" })).resolves.toEqual(result); + }); + + it("fetchChildSpaces returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "s2" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect( + fetchChildSpaces(client, { spaceId: "s1" }), + ).resolves.toEqual(envelope); + }); + + it("fetchSpaceBreadcrumb returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const breadcrumb = [{ id: "s1", name: "Root" }]; + projectInstance.get.mockResolvedValueOnce({ data: breadcrumb }); + await expect( + fetchSpaceBreadcrumb(client, { spaceId: "s1" }), + ).resolves.toEqual(breadcrumb); + }); + + it("fetchMutualSpaces returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "s3" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect( + fetchMutualSpaces(client, { userId: "target1", actingUserId: "acting1" }), + ).resolves.toEqual(envelope); + }); + + it("updateSpace returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const space = { id: "s1", name: "New name" }; + projectInstance.patch.mockResolvedValueOnce({ data: space }); + await expect( + updateSpace(client, { spaceId: "s1", name: "New name" }), + ).resolves.toEqual(space); + }); + + it("deleteSpace returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { deleted: true }; + projectInstance.delete.mockResolvedValueOnce({ data: result }); + await expect(deleteSpace(client, { spaceId: "s1" })).resolves.toEqual(result); + }); +}); diff --git a/__tests__/spaces-membership.test.ts b/__tests__/spaces-membership.test.ts new file mode 100644 index 0000000..83c0980 --- /dev/null +++ b/__tests__/spaces-membership.test.ts @@ -0,0 +1,183 @@ +import { + approveMembership, + banMember, + checkMyMembership, + declineMembership, + fetchSpaceMembers, + fetchSpaceTeam, + joinSpace, + leaveSpace, + unbanMember, + updateMemberRole, +} from "../src/modules/spaces"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk spaces (membership) — request shaping", () => { + it("joinSpace posts userId to /spaces/:id/join", async () => { + const { client, projectInstance } = makeClient(); + await joinSpace(client, { spaceId: "s1", userId: "u1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/spaces/s1/join", { + userId: "u1", + }); + }); + + it("leaveSpace sends userId as a param to /spaces/:id/leave", async () => { + const { client, projectInstance } = makeClient(); + await leaveSpace(client, { spaceId: "s1", userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/spaces/s1/leave", { + params: { userId: "u1" }, + }); + }); + + it("checkMyMembership sends userId as a param to /spaces/:id/membership/me", async () => { + const { client, projectInstance } = makeClient(); + await checkMyMembership(client, { spaceId: "s1", userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith( + "/spaces/s1/membership/me", + { params: { userId: "u1" } }, + ); + }); + + it("fetchSpaceMembers strips spaceId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchSpaceMembers(client, { spaceId: "s1", role: "admin", page: 1 }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/s1/members", { + params: { role: "admin", page: 1 }, + }); + }); + + it("fetchSpaceTeam strips spaceId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchSpaceTeam(client, { spaceId: "s1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/s1/team", { + params: {}, + }); + }); + + it("updateMemberRole patches the role route with role in the body", async () => { + const { client, projectInstance } = makeClient(); + await updateMemberRole(client, { spaceId: "s1", memberId: "m1", role: "moderator" }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/members/m1/role", + { role: "moderator" }, + ); + }); + + it("approveMembership patches the approve route with no body", async () => { + const { client, projectInstance } = makeClient(); + await approveMembership(client, { spaceId: "s1", memberId: "m1" }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/members/m1/approve", + ); + }); + + it("declineMembership patches the decline route with no body", async () => { + const { client, projectInstance } = makeClient(); + await declineMembership(client, { spaceId: "s1", memberId: "m1" }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/members/m1/decline", + ); + }); + + it("banMember patches the ban route with no body", async () => { + const { client, projectInstance } = makeClient(); + await banMember(client, { spaceId: "s1", memberId: "m1" }); + expect(projectInstance.patch).toHaveBeenCalledWith("/spaces/s1/members/m1/ban"); + }); + + it("unbanMember patches the unban route with no body", async () => { + const { client, projectInstance } = makeClient(); + await unbanMember(client, { spaceId: "s1", memberId: "m1" }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/members/m1/unban", + ); + }); +}); + +describe("node-sdk spaces (membership) — response mapping", () => { + it("joinSpace returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { membership: { id: "m1", status: "active" } }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + joinSpace(client, { spaceId: "s1", userId: "u1" }), + ).resolves.toEqual(result); + }); + + it("leaveSpace returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { left: true }; + projectInstance.delete.mockResolvedValueOnce({ data: result }); + await expect( + leaveSpace(client, { spaceId: "s1", userId: "u1" }), + ).resolves.toEqual(result); + }); + + it("checkMyMembership returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { isMember: true, role: "member" }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + checkMyMembership(client, { spaceId: "s1", userId: "u1" }), + ).resolves.toEqual(result); + }); + + it("fetchSpaceMembers returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { data: [{ id: "m1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect(fetchSpaceMembers(client, { spaceId: "s1" })).resolves.toEqual(result); + }); + + it("fetchSpaceTeam returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { admins: [], moderators: [] }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect(fetchSpaceTeam(client, { spaceId: "s1" })).resolves.toEqual(result); + }); + + it("updateMemberRole returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { membership: { id: "m1", role: "moderator" } }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + updateMemberRole(client, { spaceId: "s1", memberId: "m1", role: "moderator" }), + ).resolves.toEqual(result); + }); + + it("approveMembership returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "approved", membership: { id: "m1", status: "active" } }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + approveMembership(client, { spaceId: "s1", memberId: "m1" }), + ).resolves.toEqual(result); + }); + + it("declineMembership returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "declined", membership: { id: "m1", status: "rejected" } }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + declineMembership(client, { spaceId: "s1", memberId: "m1" }), + ).resolves.toEqual(result); + }); + + it("banMember returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "banned", membership: { id: "m1", status: "banned" as const } }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + banMember(client, { spaceId: "s1", memberId: "m1" }), + ).resolves.toEqual(result); + }); + + it("unbanMember returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "unbanned", membership: { id: "m1", status: "active" as const } }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + unbanMember(client, { spaceId: "s1", memberId: "m1" }), + ).resolves.toEqual(result); + }); +}); diff --git a/__tests__/spaces-moderation.test.ts b/__tests__/spaces-moderation.test.ts new file mode 100644 index 0000000..3ca1eed --- /dev/null +++ b/__tests__/spaces-moderation.test.ts @@ -0,0 +1,329 @@ +import { + createRule, + deleteRule, + fetchDigestConfig, + fetchManyRules, + fetchRule, + getSpaceConversation, + handleCommentReport, + handleEntityReport, + handleSpaceChatReport, + moderateSpaceChatMessage, + moderateSpaceComment, + moderateSpaceEntity, + reorderRules, + updateDigestConfig, + updateRule, +} from "../src/modules/spaces"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk spaces (moderation/reports) — request shaping", () => { + it("handleEntityReport strips spaceId/reportId into the path and patches the rest, covering multiple action types", async () => { + const { client, projectInstance } = makeClient(); + await handleEntityReport(client, { + spaceId: "s1", + reportId: "r1", + entityId: "e1", + actions: ["remove-entity", "ban-user"], + }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/reports/entity/r1", + { entityId: "e1", actions: ["remove-entity", "ban-user"] }, + ); + }); + + it("handleCommentReport strips spaceId/reportId into the path and patches the rest, covering multiple action types", async () => { + const { client, projectInstance } = makeClient(); + await handleCommentReport(client, { + spaceId: "s1", + reportId: "r1", + commentId: "c1", + actions: ["remove-comment", "dismiss"], + }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/reports/comment/r1", + { commentId: "c1", actions: ["remove-comment", "dismiss"] }, + ); + }); + + it("moderateSpaceEntity strips spaceId/entityId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await moderateSpaceEntity(client, { spaceId: "s1", entityId: "e1", action: "remove" }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/entities/e1/moderation", + { action: "remove" }, + ); + }); + + it("moderateSpaceComment strips spaceId/commentId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await moderateSpaceComment(client, { spaceId: "s1", commentId: "c1", action: "approve" }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/comments/c1/moderation", + { action: "approve" }, + ); + }); + + it("getSpaceConversation sends userId as a param", async () => { + const { client, projectInstance } = makeClient(); + await getSpaceConversation(client, { spaceId: "s1", userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/s1/conversation", { + params: { userId: "u1" }, + }); + }); + + it("moderateSpaceChatMessage sends the body as-is, including optional actingUserId, with no extra client-side gating", async () => { + const { client, projectInstance } = makeClient(); + await moderateSpaceChatMessage(client, { + spaceId: "s1", + messageId: "msg1", + moderationStatus: "removed", + actingUserId: "mod1", + }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/chat/messages/msg1/moderation", + { moderationStatus: "removed", actingUserId: "mod1" }, + ); + }); + + it("moderateSpaceChatMessage works with actingUserId omitted (backend/system action)", async () => { + const { client, projectInstance } = makeClient(); + await moderateSpaceChatMessage(client, { + spaceId: "s1", + messageId: "msg1", + moderationStatus: "removed", + }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/chat/messages/msg1/moderation", + { moderationStatus: "removed" }, + ); + }); + + it("handleSpaceChatReport sends the body as-is, including optional actingUserId, with no extra client-side gating", async () => { + const { client, projectInstance } = makeClient(); + await handleSpaceChatReport(client, { + spaceId: "s1", + reportId: "r1", + actions: ["remove-message", "ban-user"], + userId: "target1", + messageId: "msg1", + actingUserId: "mod1", + }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/chat/reports/r1", + { + actions: ["remove-message", "ban-user"], + userId: "target1", + messageId: "msg1", + actingUserId: "mod1", + }, + ); + }); + + it("createRule strips spaceId into the path and posts the rest", async () => { + const { client, projectInstance } = makeClient(); + await createRule(client, { spaceId: "s1", title: "Be nice" }); + expect(projectInstance.post).toHaveBeenCalledWith("/spaces/s1/rules", { + title: "Be nice", + }); + }); + + it("updateRule strips spaceId/ruleId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await updateRule(client, { spaceId: "s1", ruleId: "r1", title: "Updated" }); + expect(projectInstance.patch).toHaveBeenCalledWith("/spaces/s1/rules/r1", { + title: "Updated", + }); + }); + + it("deleteRule deletes /spaces/:id/rules/:ruleId", async () => { + const { client, projectInstance } = makeClient(); + await deleteRule(client, { spaceId: "s1", ruleId: "r1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/spaces/s1/rules/r1"); + }); + + it("fetchRule hits /spaces/:id/rules/:ruleId with no params", async () => { + const { client, projectInstance } = makeClient(); + await fetchRule(client, { spaceId: "s1", ruleId: "r1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/s1/rules/r1"); + }); + + it("fetchManyRules hits /spaces/:id/rules with no params", async () => { + const { client, projectInstance } = makeClient(); + await fetchManyRules(client, { spaceId: "s1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/s1/rules"); + }); + + it("reorderRules patches the reorder route with ruleIds in the body", async () => { + const { client, projectInstance } = makeClient(); + await reorderRules(client, { spaceId: "s1", ruleIds: ["r2", "r1"] }); + expect(projectInstance.patch).toHaveBeenCalledWith("/spaces/s1/rules/reorder", { + ruleIds: ["r2", "r1"], + }); + }); + + it("fetchDigestConfig hits the digest-config route with no params", async () => { + const { client, projectInstance } = makeClient(); + await fetchDigestConfig(client, { spaceId: "s1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/spaces/s1/digest-config"); + }); + + it("updateDigestConfig strips spaceId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await updateDigestConfig(client, { spaceId: "s1", digestEnabled: true }); + expect(projectInstance.patch).toHaveBeenCalledWith("/spaces/s1/digest-config", { + digestEnabled: true, + }); + }); +}); + +describe("node-sdk spaces (moderation/reports) — response mapping", () => { + it("handleEntityReport returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "handled" }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + handleEntityReport(client, { + spaceId: "s1", + reportId: "r1", + entityId: "e1", + actions: ["dismiss"], + }), + ).resolves.toEqual(result); + }); + + it("handleCommentReport returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "handled" }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + handleCommentReport(client, { + spaceId: "s1", + reportId: "r1", + commentId: "c1", + actions: ["dismiss"], + }), + ).resolves.toEqual(result); + }); + + it("moderateSpaceEntity returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "moderated" }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + moderateSpaceEntity(client, { spaceId: "s1", entityId: "e1", action: "remove" }), + ).resolves.toEqual(result); + }); + + it("moderateSpaceComment returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "moderated" }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + moderateSpaceComment(client, { spaceId: "s1", commentId: "c1", action: "approve" }), + ).resolves.toEqual(result); + }); + + it("getSpaceConversation returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const conversation = { id: "conv1" }; + projectInstance.get.mockResolvedValueOnce({ data: conversation }); + await expect( + getSpaceConversation(client, { spaceId: "s1", userId: "u1" }), + ).resolves.toEqual(conversation); + }); + + it("moderateSpaceChatMessage returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "moderated", moderationStatus: "removed" }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + moderateSpaceChatMessage(client, { + spaceId: "s1", + messageId: "msg1", + moderationStatus: "removed", + }), + ).resolves.toEqual(result); + }); + + it("handleSpaceChatReport returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "handled", code: "ok" }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + handleSpaceChatReport(client, { + spaceId: "s1", + reportId: "r1", + actions: ["dismiss"], + }), + ).resolves.toEqual(result); + }); + + it("createRule returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const rule = { id: "r1", title: "Be nice" }; + projectInstance.post.mockResolvedValueOnce({ data: rule }); + await expect( + createRule(client, { spaceId: "s1", title: "Be nice" }), + ).resolves.toEqual(rule); + }); + + it("updateRule returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const rule = { id: "r1", title: "Updated" }; + projectInstance.patch.mockResolvedValueOnce({ data: rule }); + await expect( + updateRule(client, { spaceId: "s1", ruleId: "r1", title: "Updated" }), + ).resolves.toEqual(rule); + }); + + it("deleteRule returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { deleted: true }; + projectInstance.delete.mockResolvedValueOnce({ data: result }); + await expect( + deleteRule(client, { spaceId: "s1", ruleId: "r1" }), + ).resolves.toEqual(result); + }); + + it("fetchRule returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const rule = { id: "r1", title: "Be nice" }; + projectInstance.get.mockResolvedValueOnce({ data: rule }); + await expect(fetchRule(client, { spaceId: "s1", ruleId: "r1" })).resolves.toEqual( + rule, + ); + }); + + it("fetchManyRules returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { rules: [{ id: "r1" }] }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect(fetchManyRules(client, { spaceId: "s1" })).resolves.toEqual(result); + }); + + it("reorderRules returns response.data as an array", async () => { + const { client, projectInstance } = makeClient(); + const rules = [{ id: "r2" }, { id: "r1" }]; + projectInstance.patch.mockResolvedValueOnce({ data: rules }); + await expect( + reorderRules(client, { spaceId: "s1", ruleIds: ["r2", "r1"] }), + ).resolves.toEqual(rules); + }); + + it("fetchDigestConfig returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const config = { digestEnabled: true }; + projectInstance.get.mockResolvedValueOnce({ data: config }); + await expect(fetchDigestConfig(client, { spaceId: "s1" })).resolves.toEqual(config); + }); + + it("updateDigestConfig returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const config = { digestEnabled: false }; + projectInstance.patch.mockResolvedValueOnce({ data: config }); + await expect( + updateDigestConfig(client, { spaceId: "s1", digestEnabled: false }), + ).resolves.toEqual(config); + }); +}); diff --git a/__tests__/storage.test.ts b/__tests__/storage.test.ts new file mode 100644 index 0000000..a8f995c --- /dev/null +++ b/__tests__/storage.test.ts @@ -0,0 +1,151 @@ +import { deleteFile, getFile, uploadFile, uploadImage } from "../src/modules/storage"; +import { makeClient } from "./helpers/mockClient"; + +function formDataToObject(formData: FormData): Record { + const obj: Record = {}; + for (const [key, value] of formData.entries()) { + obj[key] = value; + } + return obj; +} + +describe("node-sdk storage — uploadFile request shaping", () => { + it("posts multipart FormData to /storage with the file blob and JSON-stringified object fields", async () => { + const { client, projectInstance } = makeClient(); + await uploadFile(client, { + file: new Uint8Array([1, 2, 3]), + filename: "avatar.png", + mimeType: "image/png", + pathParts: ["avatars", "u1"], + position: 1, + metadata: { source: "upload" }, + userId: "u1", + }); + + expect(projectInstance.post).toHaveBeenCalledTimes(1); + const [path, formData] = projectInstance.post.mock.calls[0]; + expect(path).toBe("/storage"); + expect(formData).toBeInstanceOf(FormData); + + const fields = formDataToObject(formData as FormData); + expect(fields.file).toBeInstanceOf(Blob); + expect((fields.file as File).name).toBe("avatar.png"); + expect(fields.pathParts).toBe(JSON.stringify(["avatars", "u1"])); + expect(fields.position).toBe("1"); + expect(fields.metadata).toBe(JSON.stringify({ source: "upload" })); + expect(fields.userId).toBe("u1"); + }); + + it("defaults the filename to 'upload' and skips undefined fields", async () => { + const { client, projectInstance } = makeClient(); + await uploadFile(client, { + file: new Uint8Array([1]), + pathParts: ["files"], + }); + + const [, formData] = projectInstance.post.mock.calls[0]; + const fields = formDataToObject(formData as FormData); + expect((fields.file as File).name).toBe("upload"); + expect(fields.pathParts).toBe(JSON.stringify(["files"])); + expect(fields).not.toHaveProperty("userId"); + expect(fields).not.toHaveProperty("metadata"); + }); +}); + +describe("node-sdk storage — uploadImage request shaping", () => { + it("flattens exact-dimensions imageOptions as top-level multipart fields", async () => { + const { client, projectInstance } = makeClient(); + await uploadImage(client, { + file: new Uint8Array([1, 2, 3]), + imageOptions: { + mode: "exact-dimensions", + dimensions: { thumbnail: { width: 100, height: 100 } }, + fit: "cover", + }, + pathParts: ["spaces", "s1", "banner"], + spaceId: "s1", + }); + + expect(projectInstance.post).toHaveBeenCalledTimes(1); + const [path, formData] = projectInstance.post.mock.calls[0]; + expect(path).toBe("/storage/images"); + const fields = formDataToObject(formData as FormData); + expect(fields.file).toBeInstanceOf(Blob); + expect(fields.mode).toBe("exact-dimensions"); + expect(fields.dimensions).toBe( + JSON.stringify({ thumbnail: { width: 100, height: 100 } }), + ); + expect(fields.fit).toBe("cover"); + expect(fields.pathParts).toBe(JSON.stringify(["spaces", "s1", "banner"])); + expect(fields.spaceId).toBe("s1"); + }); + + it("flattens aspect-ratio-width-based imageOptions as top-level multipart fields", async () => { + const { client, projectInstance } = makeClient(); + await uploadImage(client, { + file: new Uint8Array([1, 2, 3]), + imageOptions: { + mode: "aspect-ratio-width-based", + aspectRatio: { width: 16, height: 9 }, + widths: { hero: 1200 }, + }, + entityId: "e1", + }); + + const [, formData] = projectInstance.post.mock.calls[0]; + const fields = formDataToObject(formData as FormData); + expect(fields.mode).toBe("aspect-ratio-width-based"); + expect(fields.aspectRatio).toBe(JSON.stringify({ width: 16, height: 9 })); + expect(fields.widths).toBe(JSON.stringify({ hero: 1200 })); + expect(fields.entityId).toBe("e1"); + }); +}); + +describe("node-sdk storage — getFile/deleteFile request shaping", () => { + it("getFile hits /storage/:fileId", async () => { + const { client, projectInstance } = makeClient(); + await getFile(client, { fileId: "f1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/storage/f1"); + }); + + it("deleteFile deletes /storage/:fileId", async () => { + const { client, projectInstance } = makeClient(); + await deleteFile(client, { fileId: "f1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/storage/f1"); + }); +}); + +describe("node-sdk storage — response mapping", () => { + it("uploadFile returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const file = { id: "f1", url: "https://cdn/avatar.png" }; + projectInstance.post.mockResolvedValueOnce({ data: file }); + await expect( + uploadFile(client, { file: new Uint8Array([1]), pathParts: ["avatars"] }), + ).resolves.toEqual(file); + }); + + it("uploadImage returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const file = { id: "f2", url: "https://cdn/banner.webp" }; + projectInstance.post.mockResolvedValueOnce({ data: file }); + await expect( + uploadImage(client, { + file: new Uint8Array([1]), + imageOptions: { mode: "original-aspect", sizes: { full: 2000 } }, + }), + ).resolves.toEqual(file); + }); + + it("getFile returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const file = { id: "f1", url: "https://cdn/avatar.png" }; + projectInstance.get.mockResolvedValueOnce({ data: file }); + await expect(getFile(client, { fileId: "f1" })).resolves.toEqual(file); + }); + + it("deleteFile resolves to undefined", async () => { + const { client } = makeClient(); + await expect(deleteFile(client, { fileId: "f1" })).resolves.toBeUndefined(); + }); +}); diff --git a/__tests__/tables.test.ts b/__tests__/tables.test.ts index 1aa8b1f..bd975b7 100644 --- a/__tests__/tables.test.ts +++ b/__tests__/tables.test.ts @@ -1,4 +1,3 @@ -import { SublayHttpClient } from "../src/core/client"; import { bulkCreate, bulkDelete, @@ -15,22 +14,7 @@ import { dropColumn, dropTable, } from "../src/modules/tables-management"; - -function makeClient() { - const projectInstance = { - get: jest.fn().mockResolvedValue({ - data: { data: [], pagination: {}, row: { id: "1" } }, - }), - post: jest.fn().mockResolvedValue({ - data: { row: { id: "1" }, rows: [{ id: "1" }], table: "Events", added: "x" }, - }), - patch: jest.fn().mockResolvedValue({ data: { row: { id: "1" } } }), - delete: jest.fn().mockResolvedValue({ - data: { deleted: true, soft: true, deletedCount: 2, dropped: "x" }, - }), - }; - return { client: { projectInstance } as unknown as SublayHttpClient, projectInstance }; -} +import { makeClient } from "./helpers/mockClient"; describe("node-sdk custom-table row ops — request shaping", () => { it("find serializes filters to JSON and includeDeleted to a string", async () => { @@ -106,6 +90,68 @@ describe("node-sdk custom-table row ops — request shaping", () => { }); }); +describe("node-sdk custom-table row ops — response mapping", () => { + it("find returns the full { data, pagination } envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect(find(client, "Events")).resolves.toEqual(envelope); + }); + + it("findOne unwraps response.data.row", async () => { + const { client, projectInstance } = makeClient(); + const row = { id: "abc", name: "x" }; + projectInstance.get.mockResolvedValueOnce({ data: { row } }); + await expect(findOne(client, "Events", "abc")).resolves.toEqual(row); + }); + + it("create unwraps response.data.row", async () => { + const { client, projectInstance } = makeClient(); + const row = { id: "1", name: "x" }; + projectInstance.post.mockResolvedValueOnce({ data: { row } }); + await expect(create(client, "Events", { name: "x" })).resolves.toEqual(row); + }); + + it("bulkCreate unwraps response.data.rows", async () => { + const { client, projectInstance } = makeClient(); + const rows = [{ id: "1" }, { id: "2" }]; + projectInstance.post.mockResolvedValueOnce({ data: { rows } }); + await expect( + bulkCreate(client, "Events", [{ name: "a" }, { name: "b" }]), + ).resolves.toEqual(rows); + }); + + it("update unwraps response.data.row", async () => { + const { client, projectInstance } = makeClient(); + const row = { id: "id1", name: "y" }; + projectInstance.patch.mockResolvedValueOnce({ data: { row } }); + await expect(update(client, "Events", "id1", { name: "y" })).resolves.toEqual(row); + }); + + it("deleteRow returns the full DeleteResult", async () => { + const { client, projectInstance } = makeClient(); + const result = { deleted: true, id: "id1" }; + projectInstance.delete.mockResolvedValueOnce({ data: result }); + await expect(deleteRow(client, "Events", "id1")).resolves.toEqual(result); + }); + + it("bulkDelete returns the full BulkDeleteResult", async () => { + const { client, projectInstance } = makeClient(); + const result = { deletedCount: 2 }; + projectInstance.delete.mockResolvedValueOnce({ data: result }); + await expect( + bulkDelete(client, "Events", { rowIds: ["a", "b"] }), + ).resolves.toEqual(result); + }); + + it("restore unwraps response.data.row", async () => { + const { client, projectInstance } = makeClient(); + const row = { id: "id1", deletedAt: null }; + projectInstance.post.mockResolvedValueOnce({ data: { row } }); + await expect(restore(client, "Events", "id1")).resolves.toEqual(row); + }); +}); + describe("node-sdk table management (DDL) — request shaping", () => { it("createTable posts the full body to /db/tables", async () => { const { client, projectInstance } = makeClient(); @@ -152,3 +198,49 @@ describe("node-sdk table management (DDL) — request shaping", () => { ); }); }); + +describe("node-sdk table management (DDL) — response mapping", () => { + it("createTable returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { table: "custom_Events" }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + createTable(client, { + tableName: "Events", + columns: [{ columnName: "name", dataType: "text", nullable: false }], + timestamps: true, + paranoid: false, + }), + ).resolves.toEqual(result); + }); + + it("dropTable returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { dropped: "custom_Events" }; + projectInstance.delete.mockResolvedValueOnce({ data: result }); + await expect(dropTable(client, { tableName: "Events" })).resolves.toEqual(result); + }); + + it("addColumn returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { added: "price" }; + projectInstance.post.mockResolvedValueOnce({ data: result }); + await expect( + addColumn(client, { + tableName: "Events", + columnName: "price", + dataType: "decimal", + nullable: true, + }), + ).resolves.toEqual(result); + }); + + it("dropColumn returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { dropped: "price" }; + projectInstance.delete.mockResolvedValueOnce({ data: result }); + await expect( + dropColumn(client, { tableName: "Events", columnName: "price" }), + ).resolves.toEqual(result); + }); +}); diff --git a/__tests__/users.test.ts b/__tests__/users.test.ts new file mode 100644 index 0000000..9a91cc3 --- /dev/null +++ b/__tests__/users.test.ts @@ -0,0 +1,342 @@ +import { + checkUsernameAvailability, + createFollow, + deleteFollow, + deleteUser, + fetchConnectionStatus, + fetchConnectionsByUserId, + fetchConnectionsCountByUserId, + fetchFollowStatus, + fetchFollowersByUserId, + fetchFollowersCountByUserId, + fetchFollowingByUserId, + fetchFollowingCountByUserId, + fetchUserByForeignId, + fetchUserById, + fetchUserByUsername, + fetchUserSuggestions, + removeConnectionByUserId, + requestConnection, + updateUser, +} from "../src/modules/users"; +import { makeClient } from "./helpers/mockClient"; + +describe("node-sdk users — profile — request shaping", () => { + it("fetchUserById strips userId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchUserById(client, { userId: "u1", include: "stats" }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/u1", { + params: { include: "stats" }, + }); + }); + + it("fetchUserByForeignId JSON-stringifies metadata/secureMetadata", async () => { + const { client, projectInstance } = makeClient(); + await fetchUserByForeignId(client, { + foreignId: "f1", + metadata: { plan: "pro" }, + secureMetadata: { internalScore: 5 }, + }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/by-foreign-id", { + params: { + foreignId: "f1", + createIfNotFound: undefined, + name: undefined, + username: undefined, + avatar: undefined, + bio: undefined, + metadata: JSON.stringify({ plan: "pro" }), + secureMetadata: JSON.stringify({ internalScore: 5 }), + include: undefined, + spaceReputationId: undefined, + spaceReputationDescendants: undefined, + }, + }); + }); + + it("fetchUserByUsername hits /users/by-username with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchUserByUsername(client, { username: "alice" }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/by-username", { + params: { username: "alice" }, + }); + }); + + it("fetchUserSuggestions hits /users/suggestions with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchUserSuggestions(client, { query: "ali" }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/suggestions", { + params: { query: "ali" }, + }); + }); + + it("checkUsernameAvailability hits /users/check-username with the full body as params", async () => { + const { client, projectInstance } = makeClient(); + await checkUsernameAvailability(client, { username: "alice" }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/check-username", { + params: { username: "alice" }, + }); + }); + + it("updateUser strips userId into the path and patches the rest", async () => { + const { client, projectInstance } = makeClient(); + await updateUser(client, { userId: "u1", name: "Alice" }); + expect(projectInstance.patch).toHaveBeenCalledWith("/users/u1", { + name: "Alice", + }); + }); + + it("deleteUser deletes /users/:id", async () => { + const { client, projectInstance } = makeClient(); + await deleteUser(client, { userId: "u1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/users/u1"); + }); +}); + +describe("node-sdk users — graph queries — request shaping", () => { + it("fetchFollowersByUserId strips userId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchFollowersByUserId(client, { userId: "u1", page: 2 }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/u1/followers", { + params: { page: 2 }, + }); + }); + + it("fetchFollowersCountByUserId hits the followers-count route", async () => { + const { client, projectInstance } = makeClient(); + await fetchFollowersCountByUserId(client, { userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/u1/followers-count"); + }); + + it("fetchFollowingByUserId strips userId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchFollowingByUserId(client, { userId: "u1", limit: 5 }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/u1/following", { + params: { limit: 5 }, + }); + }); + + it("fetchFollowingCountByUserId hits the following-count route", async () => { + const { client, projectInstance } = makeClient(); + await fetchFollowingCountByUserId(client, { userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/u1/following-count"); + }); + + it("fetchConnectionsByUserId strips userId into the path and passes the rest as params", async () => { + const { client, projectInstance } = makeClient(); + await fetchConnectionsByUserId(client, { userId: "u1", page: 1 }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/u1/connections", { + params: { page: 1 }, + }); + }); + + it("fetchConnectionsCountByUserId hits the connections-count route", async () => { + const { client, projectInstance } = makeClient(); + await fetchConnectionsCountByUserId(client, { userId: "u1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/u1/connections-count"); + }); +}); + +describe("node-sdk users — graph actions — request shaping (actingUserId vs path userId)", () => { + it("createFollow posts actingUserId in the body, userId (target) in the path", async () => { + const { client, projectInstance } = makeClient(); + await createFollow(client, { userId: "target1", actingUserId: "follower1" }); + expect(projectInstance.post).toHaveBeenCalledWith("/users/target1/follow", { + actingUserId: "follower1", + }); + }); + + it("deleteFollow sends actingUserId as a param, userId (target) in the path", async () => { + const { client, projectInstance } = makeClient(); + await deleteFollow(client, { userId: "target1", actingUserId: "follower1" }); + expect(projectInstance.delete).toHaveBeenCalledWith("/users/target1/follow", { + params: { actingUserId: "follower1" }, + }); + }); + + it("fetchFollowStatus sends actingUserId as a param, userId (target) in the path", async () => { + const { client, projectInstance } = makeClient(); + await fetchFollowStatus(client, { userId: "target1", actingUserId: "follower1" }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/target1/follow", { + params: { actingUserId: "follower1" }, + }); + }); + + it("requestConnection posts actingUserId (+ rest of body) to the target's path", async () => { + const { client, projectInstance } = makeClient(); + await requestConnection(client, { + userId: "target1", + actingUserId: "requester1", + message: "hi", + }); + expect(projectInstance.post).toHaveBeenCalledWith("/users/target1/connection", { + actingUserId: "requester1", + message: "hi", + }); + }); + + it("fetchConnectionStatus sends actingUserId as a param, userId (target) in the path", async () => { + const { client, projectInstance } = makeClient(); + await fetchConnectionStatus(client, { + userId: "target1", + actingUserId: "requester1", + }); + expect(projectInstance.get).toHaveBeenCalledWith("/users/target1/connection", { + params: { actingUserId: "requester1" }, + }); + }); + + it("removeConnectionByUserId sends actingUserId as a param, userId (target) in the path", async () => { + const { client, projectInstance } = makeClient(); + await removeConnectionByUserId(client, { + userId: "target1", + actingUserId: "requester1", + }); + expect(projectInstance.delete).toHaveBeenCalledWith("/users/target1/connection", { + params: { actingUserId: "requester1" }, + }); + }); +}); + +describe("node-sdk users — response mapping", () => { + it("fetchUserById returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const user = { id: "u1", username: "alice" }; + projectInstance.get.mockResolvedValueOnce({ data: user }); + await expect(fetchUserById(client, { userId: "u1" })).resolves.toEqual(user); + }); + + it("fetchUserByForeignId returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const user = { id: "u1" }; + projectInstance.get.mockResolvedValueOnce({ data: user }); + await expect( + fetchUserByForeignId(client, { foreignId: "f1" }), + ).resolves.toEqual(user); + }); + + it("fetchUserByUsername returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const user = { id: "u1" }; + projectInstance.get.mockResolvedValueOnce({ data: user }); + await expect( + fetchUserByUsername(client, { username: "alice" }), + ).resolves.toEqual(user); + }); + + it("fetchUserSuggestions returns response.data as an array", async () => { + const { client, projectInstance } = makeClient(); + const users = [{ id: "u1" }, { id: "u2" }]; + projectInstance.get.mockResolvedValueOnce({ data: users }); + await expect( + fetchUserSuggestions(client, { query: "a" }), + ).resolves.toEqual(users); + }); + + it("checkUsernameAvailability returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { available: false }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + checkUsernameAvailability(client, { username: "alice" }), + ).resolves.toEqual(result); + }); + + it("updateUser returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const user = { id: "u1", name: "Alice" }; + projectInstance.patch.mockResolvedValueOnce({ data: user }); + await expect( + updateUser(client, { userId: "u1", name: "Alice" }), + ).resolves.toEqual(user); + }); + + it("deleteUser resolves to undefined", async () => { + const { client } = makeClient(); + await expect(deleteUser(client, { userId: "u1" })).resolves.toBeUndefined(); + }); + + it("fetchFollowersByUserId returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "u2" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect( + fetchFollowersByUserId(client, { userId: "u1" }), + ).resolves.toEqual(envelope); + }); + + it("fetchFollowersCountByUserId returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { count: 7 }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + fetchFollowersCountByUserId(client, { userId: "u1" }), + ).resolves.toEqual(result); + }); + + it("fetchConnectionsByUserId returns the full PaginatedResponse envelope", async () => { + const { client, projectInstance } = makeClient(); + const envelope = { data: [{ id: "c1" }], pagination: { page: 1, limit: 10, total: 1 } }; + projectInstance.get.mockResolvedValueOnce({ data: envelope }); + await expect( + fetchConnectionsByUserId(client, { userId: "u1" }), + ).resolves.toEqual(envelope); + }); + + it("fetchConnectionsCountByUserId returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { count: 3 }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + fetchConnectionsCountByUserId(client, { userId: "u1" }), + ).resolves.toEqual(result); + }); + + it("createFollow returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const follow = { id: "fol1" }; + projectInstance.post.mockResolvedValueOnce({ data: follow }); + await expect( + createFollow(client, { userId: "target1", actingUserId: "follower1" }), + ).resolves.toEqual(follow); + }); + + it("fetchFollowStatus returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { isFollowing: true, followId: "fol1" }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + fetchFollowStatus(client, { userId: "target1", actingUserId: "follower1" }), + ).resolves.toEqual(result); + }); + + it("requestConnection returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const connection = { id: "conn1", status: "pending" }; + projectInstance.post.mockResolvedValueOnce({ data: connection }); + await expect( + requestConnection(client, { userId: "target1", actingUserId: "requester1" }), + ).resolves.toEqual(connection); + }); + + it("fetchConnectionStatus returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { status: "connected" }; + projectInstance.get.mockResolvedValueOnce({ data: result }); + await expect( + fetchConnectionStatus(client, { userId: "target1", actingUserId: "requester1" }), + ).resolves.toEqual(result); + }); + + it("removeConnectionByUserId returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { removed: true }; + projectInstance.delete.mockResolvedValueOnce({ data: result }); + await expect( + removeConnectionByUserId(client, { + userId: "target1", + actingUserId: "requester1", + }), + ).resolves.toEqual(result); + }); +}); diff --git a/package.json b/package.json index ce13283..bdefa80 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "test": "jest", "version:patch": "pnpm version patch --no-git-tag-version --no-git-checks", "version:minor": "pnpm version minor --no-git-tag-version --no-git-checks", - "publish-beta": "pnpm publish --tag beta --no-git-checks", - "publish-prod": "pnpm publish --no-git-checks", + "publish-beta": "pnpm test && pnpm publish --tag beta --no-git-checks", + "publish-prod": "pnpm test && pnpm publish --no-git-checks", "publish-beta:patch": "pnpm version:patch && pnpm publish-beta", "publish-beta:minor": "pnpm version:minor && pnpm publish-beta", "publish-prod:patch": "pnpm version:patch && pnpm publish-prod",