Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions __tests__/space-reputation-serialization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import axios from "axios";

import { searchContent } from "../src/modules/search";
import { fetchManyEntities, fetchEntity } from "../src/modules/entities";
import { fetchUserByForeignId } from "../src/modules/users";
import { buildSpaceReputationParams } from "../src/core/spaceReputationParams";
import { makeClient, MockAxiosInstance } from "./helpers/mockClient";

/**
* THE serialization regression guard for the `spaceReputation` object (Task 3.4).
*
* The central risk: if the nested `spaceReputation` object is forwarded to axios
* `params` un-normalized, axios bracket-encodes it
* (`spaceReputation%5BspaceId%5D=…`) and the server IGNORES it — so the new
* (primary) form silently no-ops while the deprecated flat props keep working.
* Typecheck and the existing flat-prop tests both stay green when that happens.
*
* These tests pass the OBJECT form, capture the exact `params` object handed to
* the mocked axios instance, serialize it through axios's OWN default param
* serializer (`getUri`), and assert the resulting query string is the FLAT
* `spaceReputationId=…&spaceReputationDescendants=…` — never dropped, never
* bracketed. Each forwarding shape present in the modules is covered:
* - silent-drop (explicit `params: { spaceReputationId, ... }`) → searchContent
* - silent-drop (explicit field-assign params object) → fetchUserByForeignId
* - `params: data` bracket-leak → fetchManyEntities
* - rest-spread `const { id, ...params } = data` bracket-leak → fetchEntity
*/

// A real axios instance used purely as an oracle for default param
// serialization — the same algorithm the SDK's live instances use.
const serializer = axios.create();

/** Serialize a captured `params` object exactly as axios would put it on the wire. */
function serializeParams(params: unknown): string {
const uri = serializer.getUri({ url: "/x", params: params as any });
const qIndex = uri.indexOf("?");
return qIndex === -1 ? "" : uri.slice(qIndex + 1);
}

/** Pull the `params` object out of the most recent axios get/post mock call. */
function capturedParams(method: jest.Mock): unknown {
const call = method.mock.calls[method.mock.calls.length - 1];
// get(path, config) → config is arg 1; post(path, body, config) → config is arg 2.
const config = call.length >= 3 ? call[2] : call[1];
return (config as { params?: unknown })?.params;
}

function assertFlatRepQuery(method: jest.Mock) {
const params = capturedParams(method) as Record<string, unknown>;

// The object must never reach axios params un-normalized.
expect(params).not.toHaveProperty("spaceReputation");
// It must be flattened (not dropped).
expect(params.spaceReputationId).toBe("rep1");
expect(params.spaceReputationDescendants).toBe(true);

// And the serialized wire string must be flat, never bracketed.
const query = serializeParams(params);
expect(query).toContain("spaceReputationId=rep1");
expect(query).toContain("spaceReputationDescendants=true");
expect(query).not.toContain("spaceReputation%5B"); // spaceReputation[
expect(query).not.toContain("spaceReputation["); // un-encoded, just in case
}

describe("space-reputation serialization guard — object form never drops or brackets", () => {
it("silent-drop shape (explicit params object): searchContent", async () => {
const { client, projectInstance } = makeClient();
await searchContent(client, {
query: "hello",
spaceReputation: { spaceId: "rep1", includeDescendants: true },
});
assertFlatRepQuery(projectInstance.post as unknown as jest.Mock);
});

it("silent-drop shape (explicit field-assign): fetchUserByForeignId", async () => {
const { client, projectInstance } = makeClient();
await fetchUserByForeignId(client, {
foreignId: "fid-1",
spaceReputation: { spaceId: "rep1", includeDescendants: true },
});
assertFlatRepQuery(projectInstance.get as unknown as jest.Mock);
});

it("`params: data` bracket-leak shape: fetchManyEntities", async () => {
const { client, projectInstance } = makeClient();
await fetchManyEntities(client, {
sortBy: "hot",
spaceReputation: { spaceId: "rep1", includeDescendants: true },
});
assertFlatRepQuery(projectInstance.get as unknown as jest.Mock);
// The unrelated param still rides along.
const params = capturedParams(projectInstance.get as unknown as jest.Mock) as Record<
string,
unknown
>;
expect(params.sortBy).toBe("hot");
});

it("rest-spread bracket-leak shape: fetchEntity", async () => {
const { client, projectInstance } = makeClient();
await fetchEntity(client, {
entityId: "e1",
include: "author",
spaceReputation: { spaceId: "rep1", includeDescendants: true },
});
assertFlatRepQuery(projectInstance.get as unknown as jest.Mock);
const params = capturedParams(projectInstance.get as unknown as jest.Mock) as Record<
string,
unknown
>;
expect(params.include).toBe("author");
expect(params).not.toHaveProperty("entityId"); // path param, not a query param
});

it("object form wins over deprecated flat props (precedence)", () => {
const out = buildSpaceReputationParams({
spaceReputation: { spaceId: "rep1", includeDescendants: true },
spaceReputationId: "ignored",
spaceReputationDescendants: false,
});
expect(out).toEqual({
spaceReputationId: "rep1",
spaceReputationDescendants: true,
});
});

it("`spaceId: \"none\"` flattens to `spaceReputationId=none` without descendants", () => {
const out = buildSpaceReputationParams({
spaceReputation: { spaceId: "none" },
});
expect(out).toEqual({ spaceReputationId: "none" });
});
});
123 changes: 123 additions & 0 deletions src/core/spaceReputationParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Input accepted by {@link buildSpaceReputationParams}: either the new
* `spaceReputation` object or the deprecated flat props.
*
* The flat inputs accept `null` (in addition to `undefined`) so the helper's
* contract matches `@sublay/core` exactly; `null` is treated as unset. node-sdk
* never persists these params, so the `null` tolerance is not strictly required
* here, but matching core is harmless and consistent.
*/
export interface BuildSpaceReputationParamsInput {
spaceReputation?: {
spaceId: string | "none" | "context";
includeDescendants?: boolean;
};
/**
* @deprecated Pass `spaceReputation` instead. Accepted for back-compat.
*/
spaceReputationId?: string | null;
/**
* @deprecated Pass `spaceReputation` instead. Accepted for back-compat.
*/
spaceReputationDescendants?: boolean | null;
}

/**
* Flat output handed to the query-string serializer (axios `params`). Both keys
* are omitted when unset — never bracketed, never nested.
*/
export interface SpaceReputationFlatParams {
spaceReputationId?: string;
spaceReputationDescendants?: boolean;
}

// Ambient `process` declaration so this file typechecks under the publish build
// tsconfig (which does not pull in @types/node). At runtime node-sdk always runs
// under Node, so `process.env` is present; the `typeof` guard keeps it safe
// anywhere it isn't.
declare const process:
| { env?: Record<string, string | undefined> }
| undefined;

let bothFormsWarned = false;

function isProduction(): boolean {
return (
typeof process !== "undefined" &&
!!process.env &&
process.env.NODE_ENV === "production"
);
}

/**
* Normalize the `spaceReputation` object or the deprecated flat props down to
* the flat query params the server understands
* (`spaceReputationId` / `spaceReputationDescendants`).
*
* **Critical:** this is the only thing that prevents the nested
* `spaceReputation` object from reaching axios `params`, where axios would
* bracket-encode it (`spaceReputation[spaceId]=…`) and the server would ignore
* it — silently no-opping the new (primary) form. Every reputation-consuming
* module must route the object through here and merge only the flat output.
*
* Rules (mirrors the `@sublay/core` helper contract):
* - **Object wins when present.** "Present" means the `spaceReputation` key was
* supplied at all (`spaceReputation !== undefined`) — even `{}` or a partial
* object suppresses the flat props.
* - When **both** the object and a flat prop are supplied, the object wins and a
* one-time dev-only `console.warn` fires (never throws).
* - The flat inputs tolerate `null` (treated as unset → param omitted).
* - Output keys are omitted when unset; the result is always the two flat keys,
* never a bracketed/nested object.
*/
export function buildSpaceReputationParams(
input: BuildSpaceReputationParamsInput
): SpaceReputationFlatParams {
const { spaceReputation, spaceReputationId, spaceReputationDescendants } =
input;

const objectPresent = spaceReputation !== undefined;
const flatPresent =
(spaceReputationId !== undefined && spaceReputationId !== null) ||
(spaceReputationDescendants !== undefined &&
spaceReputationDescendants !== null);

if (objectPresent && flatPresent && !bothFormsWarned && !isProduction()) {
bothFormsWarned = true;
// eslint-disable-next-line no-console
console.warn(
"[Sublay] Both `spaceReputation` and the deprecated flat props " +
"(`spaceReputationId` / `spaceReputationDescendants`) were supplied. " +
"The `spaceReputation` object takes precedence; the flat props are ignored. " +
"Remove the flat props — they are deprecated."
);
}

const result: SpaceReputationFlatParams = {};

if (objectPresent) {
// Object form chosen. Read from the object; ignore the flat props entirely.
const spaceId = spaceReputation!.spaceId;
if (spaceId !== undefined && spaceId !== null) {
result.spaceReputationId = spaceId;
}
const includeDescendants = spaceReputation!.includeDescendants;
if (includeDescendants !== undefined && includeDescendants !== null) {
result.spaceReputationDescendants = includeDescendants;
}
return result;
}

// Flat form. `null` is treated as unset (param omitted).
if (spaceReputationId !== undefined && spaceReputationId !== null) {
result.spaceReputationId = spaceReputationId;
}
if (
spaceReputationDescendants !== undefined &&
spaceReputationDescendants !== null
) {
result.spaceReputationDescendants = spaceReputationDescendants;
}

return result;
}
51 changes: 45 additions & 6 deletions src/interfaces/SpaceReputation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@
* module). Accept `<uuid> | "none"` only; `"context"` is rejected (400).
*
* Both classes share the same param names and types — only the accepted
* `spaceReputationId` value set (documented in JSDoc) differs.
* `spaceId` value set (documented in JSDoc) differs.
*
* **Preferred form:** pass the `spaceReputation` object (`{ spaceId,
* includeDescendants? }`). The flat props (`spaceReputationId` /
* `spaceReputationDescendants`) are `@deprecated` but still accepted; when both
* forms are supplied the object wins. The object is normalized to the flat wire
* params by `buildSpaceReputationParams` before it reaches the request — it must
* never be forwarded to axios `params` un-normalized (axios would bracket-encode
* it and the server would ignore it).
*/

/**
Expand All @@ -22,6 +30,21 @@
*/
export interface SpaceReputationContextParams {
/**
* Opt the returned/embedded user(s) into a space-scoped `spaceReputation`.
* Accepted `spaceId` forms:
* - a space `<uuid>` — reputation scoped to that specific space
* - `"none"` — the user's global, non-space reputation
* - `"context"` — reputation scoped to each row's own space (per-row)
*
* `includeDescendants` includes reputation accrued in descendant spaces; only
* honored when `spaceId` is an explicit `<uuid>`.
*/
spaceReputation?: {
spaceId: string | "none" | "context";
includeDescendants?: boolean;
};
/**
* @deprecated Pass `spaceReputation` instead. Retained for back-compat.
* Opt the returned/embedded user(s) into a space-scoped `spaceReputation`.
* Accepted forms:
* - a space `<uuid>` — reputation scoped to that specific space
Expand All @@ -30,8 +53,9 @@ export interface SpaceReputationContextParams {
*/
spaceReputationId?: string;
/**
* Include reputation accrued in descendant spaces. Only honored when
* `spaceReputationId` is an explicit `<uuid>`; ignored for `"none"` and
* @deprecated Pass `spaceReputation.includeDescendants` instead. Retained for
* back-compat. Include reputation accrued in descendant spaces. Only honored
* when `spaceReputationId` is an explicit `<uuid>`; ignored for `"none"` and
* disallowed (not applicable) with `"context"`.
*/
spaceReputationDescendants?: boolean;
Expand All @@ -46,17 +70,32 @@ export interface SpaceReputationContextParams {
export interface SpaceReputationUserParams {
/**
* Opt the returned user(s) into a space-scoped `spaceReputation`.
* Accepted forms:
* Accepted `spaceId` forms:
* - a space `<uuid>` — reputation scoped to that specific space
* - `"none"` — the user's global, non-space reputation
*
* Note: `"context"` is rejected by the server (400) on user-direct routes;
* pass an explicit `<uuid>` or `"none"` here.
*
* `includeDescendants` includes reputation accrued in descendant spaces; only
* honored when `spaceId` is an explicit `<uuid>`.
*/
spaceReputation?: {
spaceId: string | "none";
includeDescendants?: boolean;
};
/**
* @deprecated Pass `spaceReputation` instead. Retained for back-compat.
* Opt the returned user(s) into a space-scoped `spaceReputation`.
* Accepted forms:
* - a space `<uuid>` — reputation scoped to that specific space
* - `"none"` — the user's global, non-space reputation
*/
spaceReputationId?: string;
/**
* Include reputation accrued in descendant spaces. Only honored when
* `spaceReputationId` is an explicit `<uuid>`; ignored for `"none"`.
* @deprecated Pass `spaceReputation.includeDescendants` instead. Retained for
* back-compat. Include reputation accrued in descendant spaces. Only honored
* when `spaceReputationId` is an explicit `<uuid>`; ignored for `"none"`.
*/
spaceReputationDescendants?: boolean;
}
21 changes: 19 additions & 2 deletions src/modules/chat/getMessage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SublayHttpClient } from "../../core/client";
import { ChatMessage } from "../../interfaces/ChatMessage";
import { SpaceReputationContextParams } from "../../interfaces/SpaceReputation";
import { buildSpaceReputationParams } from "../../core/spaceReputationParams";

export interface GetMessageProps extends SpaceReputationContextParams {
conversationId: string;
Expand All @@ -13,10 +14,26 @@ export async function getMessage(
client: SublayHttpClient,
data: GetMessageProps
): Promise<ChatMessage> {
const { conversationId, messageId, ...params } = data;
const {
conversationId,
messageId,
spaceReputation,
spaceReputationId,
spaceReputationDescendants,
...rest
} = data;
const response = await client.projectInstance.get<ChatMessage>(
`/chat/conversations/${conversationId}/messages/${messageId}`,
{ params }
{
params: {
...rest,
...buildSpaceReputationParams({
spaceReputation,
spaceReputationId,
spaceReputationDescendants,
}),
},
}
);
return response.data;
}
Loading
Loading