diff --git a/__tests__/nsfw.test.ts b/__tests__/nsfw.test.ts new file mode 100644 index 0000000..c91abe3 --- /dev/null +++ b/__tests__/nsfw.test.ts @@ -0,0 +1,101 @@ +import { setSpaceEntityNsfw, fetchManySpaces } from "../src/modules/spaces"; +import { createEntity, updateEntity, fetchManyEntities } from "../src/modules/entities"; +import { createSpace, updateSpace } from "../src/modules/spaces"; +import { makeClient } from "./helpers/mockClient"; +import type { Entity } from "../src/interfaces/Entity"; +import type { Space } from "../src/interfaces/Space"; + +/** + * NSFW flagging surface (Phase 5, Task 5.2). Asserts the new mod function hits + * the exact server route `.../spaces/:spaceId/entities/:entityId/nsfw` and that + * the `nsfw` create/update flags + `nsfwFilter` fetch param are shaped through + * to the request. Type-level assertions below guard the interface fields. + */ +describe("node-sdk NSFW — request shaping", () => { + it("setSpaceEntityNsfw patches /spaces/:spaceId/entities/:entityId/nsfw with { nsfw }", async () => { + const { client, projectInstance } = makeClient(); + await setSpaceEntityNsfw(client, { + spaceId: "s1", + entityId: "e1", + nsfw: true, + }); + expect(projectInstance.patch).toHaveBeenCalledWith( + "/spaces/s1/entities/e1/nsfw", + { nsfw: true }, + ); + }); + + it("setSpaceEntityNsfw returns response.data", async () => { + const { client, projectInstance } = makeClient(); + const result = { message: "Entity NSFW flag updated.", nsfw: true }; + projectInstance.patch.mockResolvedValueOnce({ data: result }); + await expect( + setSpaceEntityNsfw(client, { spaceId: "s1", entityId: "e1", nsfw: true }), + ).resolves.toEqual(result); + }); + + it("createEntity forwards the own nsfw flag in the body", async () => { + const { client, projectInstance } = makeClient(); + await createEntity(client, { nsfw: true }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/entities", + expect.objectContaining({ nsfw: true }), + ); + }); + + it("updateEntity forwards the own nsfw flag in the body", async () => { + const { client, projectInstance } = makeClient(); + await updateEntity(client, { entityId: "e1", nsfw: false }); + const [, body] = projectInstance.patch.mock.calls[0]; + expect(body).toEqual(expect.objectContaining({ nsfw: false })); + }); + + it("fetchManyEntities forwards nsfwFilter into the query params", async () => { + const { client, projectInstance } = makeClient(); + await fetchManyEntities(client, { nsfwFilter: "only" }); + const [, config] = projectInstance.get.mock.calls[0]; + expect(config.params).toEqual(expect.objectContaining({ nsfwFilter: "only" })); + }); + + it("createSpace forwards the own nsfw flag in the body", async () => { + const { client, projectInstance } = makeClient(); + await createSpace(client, { name: "NSFW space", userId: "u1", nsfw: true }); + expect(projectInstance.post).toHaveBeenCalledWith( + "/spaces", + expect.objectContaining({ nsfw: true }), + ); + }); + + it("updateSpace forwards the own nsfw flag in the body", async () => { + const { client, projectInstance } = makeClient(); + await updateSpace(client, { spaceId: "s1", nsfw: true }); + const [, body] = projectInstance.patch.mock.calls[0]; + expect(body).toEqual(expect.objectContaining({ nsfw: true })); + }); + + it("fetchManySpaces forwards nsfwFilter into the query params", async () => { + const { client, projectInstance } = makeClient(); + await fetchManySpaces(client, { nsfwFilter: "exclude" }); + const [, config] = projectInstance.get.mock.calls[0]; + expect(config.params).toEqual(expect.objectContaining({ nsfwFilter: "exclude" })); + }); +}); + +// Compile-time guards: the model interfaces carry the new NSFW fields. These +// are type-level assertions — if the fields are dropped or mistyped, `tsc` +// (and the jest ts-jest transform) fails to compile this file. +describe("node-sdk NSFW — interface fields (type-level)", () => { + it("Entity exposes nsfw + nsfwEffective as booleans", () => { + const e = {} as Entity; + const nsfw: boolean | undefined = e.nsfw; + const eff: boolean | undefined = e.nsfwEffective; + expect([nsfw, eff]).toBeDefined(); + }); + + it("Space exposes nsfw + nsfwEffective as booleans", () => { + const s = {} as Space; + const nsfw: boolean | undefined = s.nsfw; + const eff: boolean | undefined = s.nsfwEffective; + expect([nsfw, eff]).toBeDefined(); + }); +}); diff --git a/src/interfaces/Entity.ts b/src/interfaces/Entity.ts index edacbd4..a1e82c0 100644 --- a/src/interfaces/Entity.ts +++ b/src/interfaces/Entity.ts @@ -41,6 +41,8 @@ export interface Entity { coordinates: [number, number]; // [longitude, latitude] } | null; metadata: Record; + nsfw: boolean; // The entity's own NSFW flag + nsfwEffective: boolean; // Computed live: entity.nsfw OR the entity's space's effective NSFW topComment: TopComment | null; isSaved?: boolean; // Populated when include contains "saved" isDraft: boolean | null; diff --git a/src/interfaces/Space.ts b/src/interfaces/Space.ts index 332b836..02f64aa 100644 --- a/src/interfaces/Space.ts +++ b/src/interfaces/Space.ts @@ -45,6 +45,8 @@ export interface Space { postingPermission: PostingPermission; visibility: SpaceVisibility; requireJoinApproval: boolean; + nsfw: boolean; // The space's own NSFW flag + nsfwEffective: boolean; // Denormalized: own nsfw OR any ancestor space's nsfw parentSpaceId: string | null; depth: number; metadata: Record; diff --git a/src/modules/entities/createEntity.ts b/src/modules/entities/createEntity.ts index 581a488..1614d5c 100644 --- a/src/modules/entities/createEntity.ts +++ b/src/modules/entities/createEntity.ts @@ -16,6 +16,7 @@ export interface CreateEntityProps { longitude: number; }; metadata?: Record; + nsfw?: boolean; userId?: string; isDraft?: boolean; excludeUserId?: boolean; diff --git a/src/modules/entities/fetchManyEntities.ts b/src/modules/entities/fetchManyEntities.ts index eb617ef..de25fa3 100644 --- a/src/modules/entities/fetchManyEntities.ts +++ b/src/modules/entities/fetchManyEntities.ts @@ -93,6 +93,9 @@ export interface FetchManyEntitiesProps extends SpaceReputationContextParams { // Location filtering locationFilters?: LocationFilters; + + // NSFW filtering (keyed off effective NSFW) + nsfwFilter?: "include-all" | "exclude" | "only"; } export async function fetchManyEntities( diff --git a/src/modules/entities/updateEntity.ts b/src/modules/entities/updateEntity.ts index 972a0a7..5cb3486 100644 --- a/src/modules/entities/updateEntity.ts +++ b/src/modules/entities/updateEntity.ts @@ -14,6 +14,7 @@ export interface UpdateEntityProps { longitude: number; }; metadata?: Record; + nsfw?: boolean; } export async function updateEntity( diff --git a/src/modules/spaces/createSpace.ts b/src/modules/spaces/createSpace.ts index 18532eb..75703a1 100644 --- a/src/modules/spaces/createSpace.ts +++ b/src/modules/spaces/createSpace.ts @@ -17,6 +17,7 @@ export interface CreateSpaceProps { requireJoinApproval?: boolean; parentSpaceId?: string; metadata?: Record; + nsfw?: boolean; } export async function createSpace( diff --git a/src/modules/spaces/fetchManySpaces.ts b/src/modules/spaces/fetchManySpaces.ts index d32b8b4..4e96756 100644 --- a/src/modules/spaces/fetchManySpaces.ts +++ b/src/modules/spaces/fetchManySpaces.ts @@ -12,6 +12,7 @@ export interface FetchManySpacesProps { searchAny?: string; memberOf?: "true"; parentSpaceId?: string | "null"; + nsfwFilter?: "include-all" | "exclude" | "only"; include?: string; } diff --git a/src/modules/spaces/index.ts b/src/modules/spaces/index.ts index eefcd2b..998b896 100644 --- a/src/modules/spaces/index.ts +++ b/src/modules/spaces/index.ts @@ -29,6 +29,7 @@ export { reorderRules } from "./reorderRules"; export { handleEntityReport } from "./handleEntityReport"; export { handleCommentReport } from "./handleCommentReport"; export { moderateSpaceEntity } from "./moderateSpaceEntity"; +export { setSpaceEntityNsfw } from "./setSpaceEntityNsfw"; export { moderateSpaceComment } from "./moderateSpaceComment"; export { fetchDigestConfig } from "./fetchDigestConfig"; export { updateDigestConfig } from "./updateDigestConfig"; diff --git a/src/modules/spaces/setSpaceEntityNsfw.ts b/src/modules/spaces/setSpaceEntityNsfw.ts new file mode 100644 index 0000000..68dc323 --- /dev/null +++ b/src/modules/spaces/setSpaceEntityNsfw.ts @@ -0,0 +1,29 @@ +import { SublayHttpClient } from "../../core/client"; + +export interface SetSpaceEntityNsfwProps { + spaceId: string; + entityId: string; + nsfw: boolean; +} + +export interface SetSpaceEntityNsfwResponse { + message: string; + nsfw: boolean; +} + +/** + * Space-moderator endpoint to set the NSFW flag on an entity within their space + * (including other users' entities). Near-copy of `moderateSpaceEntity`, but it + * writes the entity's own `nsfw` (the NSFW axis), not `moderationStatus`. + */ +export async function setSpaceEntityNsfw( + client: SublayHttpClient, + data: SetSpaceEntityNsfwProps +): Promise { + const { spaceId, entityId, ...body } = data; + const response = await client.projectInstance.patch( + `/spaces/${spaceId}/entities/${entityId}/nsfw`, + body + ); + return response.data; +} diff --git a/src/modules/spaces/updateSpace.ts b/src/modules/spaces/updateSpace.ts index 693c914..4d2afc3 100644 --- a/src/modules/spaces/updateSpace.ts +++ b/src/modules/spaces/updateSpace.ts @@ -15,6 +15,7 @@ export interface UpdateSpaceProps { postingPermission?: PostingPermission; visibility?: SpaceVisibility; metadata?: Record; + nsfw?: boolean; } export async function updateSpace(