Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
995e97c
feat: add comprehensive chat moderation tools for streamers
Josue19-08 Mar 25, 2026
ddd4e6e
chore: fix code formatting for CI
Josue19-08 Mar 25, 2026
c78b481
fix: add required curly braces for if statements
Josue19-08 Mar 25, 2026
90ceb73
test: update chat API tests for moderation enforcement
Josue19-08 Mar 25, 2026
466c282
test: fix mock count for chat moderation
Josue19-08 Mar 25, 2026
204a933
feat: add realtime notifications system
Depo-dev Mar 25, 2026
5450635
db: add stream access control foundation schema and migration
KevinMB0220 Mar 25, 2026
298da3b
lib: implement checkStreamAccess and API endpoint
KevinMB0220 Mar 25, 2026
3928f6e
api: update user and stream routes with access control fields
KevinMB0220 Mar 25, 2026
a707e94
feat: implement AccessGate component and frontend gating
KevinMB0220 Mar 25, 2026
b4a7ad8
feat: add Stream Access settings to creator dashboard
KevinMB0220 Mar 25, 2026
9899ae1
fix: normalize notification formatting for ci
Depo-dev Mar 25, 2026
987ae7c
Merge pull request #379 from Josue19-08/issue-370-chat-moderation
davedumto Mar 27, 2026
8840653
merge: resolve conflicts with origin/dev
KevinMB0220 Mar 27, 2026
78aba8a
Merge pull request #381 from KevinMB0220/feat/access-control-foundation
davedumto Mar 27, 2026
8829991
fix(stream-manager): preserve access config when saving stream info
KevinMB0220 Mar 27, 2026
38d1ce4
test(import): add 28 unit tests for import api route
KevinMB0220 Mar 27, 2026
fc14526
fix(lint): resolve pre-existing eslint errors blocking ci
KevinMB0220 Mar 27, 2026
50b7910
fix(security): single-pass html entity decode; fix remaining lint errors
KevinMB0220 Mar 27, 2026
54badeb
style: format streamaccesssettings and view-stream with prettier
KevinMB0220 Mar 27, 2026
913c8f2
Merge pull request #426 from KevinMB0220/feat/import-api-397
davedumto Mar 27, 2026
e1e2f69
merge: resolve conflict with upstream/dev in stream access check route
Depo-dev Mar 27, 2026
4b42b8d
Merge pull request #380 from Depo-dev/feat/realtime-notifications-system
davedumto Mar 27, 2026
c3e4697
feat(routes-f): add validation middleware, username conflict resoluti…
Josue19-08 Mar 27, 2026
a5cdc72
feat(routes-f): refactor import route to use shared validators and up…
Josue19-08 Mar 27, 2026
d8506f6
chore: apply prettier formatting to routes-f files
Josue19-08 Mar 27, 2026
e2ec08b
feat(deps): add @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner
KevinMB0220 Mar 27, 2026
11d771e
fix(conflicts): replace Math.random with crypto.randomInt for secure …
Josue19-08 Mar 27, 2026
4f78eba
feat(uploads): add pre-signed upload url route
KevinMB0220 Mar 27, 2026
a9610d6
test(uploads): add 21 unit tests for upload sign route
KevinMB0220 Mar 27, 2026
32a6e0d
Merge pull request #470 from Josue19-08/feat/routes-f-validation-conf…
davedumto Mar 27, 2026
6e8c1f5
style: format uploads/sign route and tests with prettier
KevinMB0220 Mar 27, 2026
31d7dd7
Merge pull request #472 from KevinMB0220/feat/upload-sign-api-392
davedumto Mar 27, 2026
fbd63e2
feat: add routes-f rewards endpoints
Obiajulu-gif Mar 28, 2026
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
246 changes: 246 additions & 0 deletions app/api/routes-f/_lib/__tests__/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/**
* Unit tests for shared routes-f Zod schemas.
* Covers valid and invalid inputs for each schema.
*/

import {
stellarPublicKeySchema,
usernameSchema,
usdcAmountSchema,
paginationSchema,
periodSchema,
emailSchema,
urlSchema,
uuidSchema,
} from "../schemas";

// ── stellarPublicKeySchema ─────────────────────────────────────────────────────

describe("stellarPublicKeySchema", () => {
it("accepts a valid Stellar public key", () => {
// 56 characters: G + 55 uppercase alphanumeric
const key = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN2";
expect(stellarPublicKeySchema.safeParse(key).success).toBe(true);
});

it("rejects a key that does not start with G", () => {
const result = stellarPublicKeySchema.safeParse(
"BAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"
);
expect(result.success).toBe(false);
});

it("rejects a key that is too short", () => {
expect(stellarPublicKeySchema.safeParse("GAAZI4TCR3").success).toBe(false);
});

it("rejects a key with lowercase letters", () => {
const result = stellarPublicKeySchema.safeParse(
"gaazi4tcr3ty5ojhctjc2a4qsy6cjwjh5iajtgkin2er7lbnvkoccwn"
);
expect(result.success).toBe(false);
});

it("rejects an empty string", () => {
expect(stellarPublicKeySchema.safeParse("").success).toBe(false);
});
});

// ── usernameSchema ─────────────────────────────────────────────────────────────

describe("usernameSchema", () => {
it("accepts a valid username", () => {
expect(usernameSchema.safeParse("alice_99").success).toBe(true);
});

it("accepts the minimum length (3 characters)", () => {
expect(usernameSchema.safeParse("abc").success).toBe(true);
});

it("accepts the maximum length (30 characters)", () => {
expect(usernameSchema.safeParse("a".repeat(30)).success).toBe(true);
});

it("rejects a username that is too short", () => {
const result = usernameSchema.safeParse("ab");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toMatch(/at least 3/);
}
});

it("rejects a username that is too long", () => {
expect(usernameSchema.safeParse("a".repeat(31)).success).toBe(false);
});

it("rejects a username with special characters", () => {
expect(usernameSchema.safeParse("alice!").success).toBe(false);
});

it("rejects a username with spaces", () => {
expect(usernameSchema.safeParse("alice bob").success).toBe(false);
});
});

// ── usdcAmountSchema ───────────────────────────────────────────────────────────

describe("usdcAmountSchema", () => {
it("accepts a whole number", () => {
expect(usdcAmountSchema.safeParse("100").success).toBe(true);
});

it("accepts a number with 1 decimal place", () => {
expect(usdcAmountSchema.safeParse("10.5").success).toBe(true);
});

it("accepts a number with 2 decimal places", () => {
expect(usdcAmountSchema.safeParse("9.99").success).toBe(true);
});

it("rejects a number with 3 decimal places", () => {
expect(usdcAmountSchema.safeParse("9.999").success).toBe(false);
});

it("rejects zero", () => {
const result = usdcAmountSchema.safeParse("0");
expect(result.success).toBe(false);
});

it("rejects a negative number", () => {
expect(usdcAmountSchema.safeParse("-5.00").success).toBe(false);
});

it("rejects non-numeric strings", () => {
expect(usdcAmountSchema.safeParse("abc").success).toBe(false);
});
});

// ── paginationSchema ───────────────────────────────────────────────────────────

describe("paginationSchema", () => {
it("applies default limit of 20 when omitted", () => {
const result = paginationSchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(20);
}
});

it("coerces a string limit to a number", () => {
const result = paginationSchema.safeParse({ limit: "50" });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(50);
}
});

it("rejects limit below 1", () => {
expect(paginationSchema.safeParse({ limit: 0 }).success).toBe(false);
});

it("rejects limit above 100", () => {
expect(paginationSchema.safeParse({ limit: 101 }).success).toBe(false);
});

it("accepts an optional cursor", () => {
const result = paginationSchema.safeParse({ cursor: "abc123" });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.cursor).toBe("abc123");
}
});

it("cursor is optional", () => {
const result = paginationSchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.cursor).toBeUndefined();
}
});
});

// ── periodSchema ───────────────────────────────────────────────────────────────

describe("periodSchema", () => {
const valid = ["7d", "30d", "90d", "all"] as const;

valid.forEach(period => {
it(`accepts "${period}"`, () => {
expect(periodSchema.safeParse(period).success).toBe(true);
});
});

it("rejects an unknown period", () => {
expect(periodSchema.safeParse("1y").success).toBe(false);
});

it("rejects an empty string", () => {
expect(periodSchema.safeParse("").success).toBe(false);
});
});

// ── emailSchema ────────────────────────────────────────────────────────────────

describe("emailSchema", () => {
it("accepts a valid email", () => {
expect(emailSchema.safeParse("user@example.com").success).toBe(true);
});

it("rejects an email without @", () => {
expect(emailSchema.safeParse("userexample.com").success).toBe(false);
});

it("rejects an empty string", () => {
expect(emailSchema.safeParse("").success).toBe(false);
});

it("rejects an email over 255 characters", () => {
// 256 chars: 249 + "@" + "b" + ".co" + padding
const long = "a".repeat(251) + "@b.co";
expect(emailSchema.safeParse(long).success).toBe(false);
});
});

// ── urlSchema ──────────────────────────────────────────────────────────────────

describe("urlSchema", () => {
it("accepts a valid https URL", () => {
expect(urlSchema.safeParse("https://example.com/path").success).toBe(true);
});

it("accepts a valid http URL", () => {
expect(urlSchema.safeParse("http://localhost:3000").success).toBe(true);
});

it("rejects a string without a protocol", () => {
expect(urlSchema.safeParse("example.com").success).toBe(false);
});

it("rejects an empty string", () => {
expect(urlSchema.safeParse("").success).toBe(false);
});
});

// ── uuidSchema ─────────────────────────────────────────────────────────────────

describe("uuidSchema", () => {
it("accepts a valid UUIDv4", () => {
expect(
uuidSchema.safeParse("550e8400-e29b-41d4-a716-446655440000").success
).toBe(true);
});

it("rejects a UUID missing hyphens", () => {
expect(
uuidSchema.safeParse("550e8400e29b41d4a716446655440000").success
).toBe(false);
});

it("rejects a string that is too short", () => {
expect(uuidSchema.safeParse("550e8400-e29b").success).toBe(false);
});

it("rejects an empty string", () => {
expect(uuidSchema.safeParse("").success).toBe(false);
});
});
122 changes: 122 additions & 0 deletions app/api/routes-f/_lib/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Unit tests for validateBody() and validateQuery() helpers.
*/

jest.mock("next/server", () => ({
NextResponse: {
json: (body: unknown, init?: ResponseInit) =>
new Response(JSON.stringify(body), {
...init,
headers: { "Content-Type": "application/json" },
}),
},
}));

import { z } from "zod";
import { validateBody, validateQuery } from "../validate";

const testSchema = z.object({
name: z.string().min(1),
age: z.number().int().positive(),
});

// ── validateBody ───────────────────────────────────────────────────────────────

describe("validateBody()", () => {
function makeRequest(body: unknown, contentType = "application/json") {
return new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Type": contentType },
body: typeof body === "string" ? body : JSON.stringify(body),
});
}

it("returns { data } for a valid body", async () => {
const result = await validateBody(
makeRequest({ name: "Alice", age: 30 }),
testSchema
);
expect(result).not.toBeInstanceOf(Response);
if (!(result instanceof Response)) {
expect(result.data).toEqual({ name: "Alice", age: 30 });
}
});

it("returns a 400 Response for an invalid body", async () => {
const result = await validateBody(
makeRequest({ name: "", age: -1 }),
testSchema
);
expect(result).toBeInstanceOf(Response);
if (result instanceof Response) {
expect(result.status).toBe(400);
const json = await result.json();
expect(json.error).toBe("Validation failed");
expect(Array.isArray(json.issues)).toBe(true);
expect(json.issues.length).toBeGreaterThan(0);
}
});

it("returns a 400 Response for malformed JSON", async () => {
const result = await validateBody(
makeRequest("{not json}", "text/plain"),
testSchema
);
expect(result).toBeInstanceOf(Response);
if (result instanceof Response) {
expect(result.status).toBe(400);
const json = await result.json();
expect(json.error).toBe("Invalid JSON body");
}
});

it("includes per-field error messages", async () => {
const result = await validateBody(
makeRequest({ name: "", age: 0 }),
testSchema
);
if (result instanceof Response) {
const json = await result.json();
const fields = json.issues.map((i: { field: string }) => i.field);
expect(fields).toContain("name");
expect(fields).toContain("age");
}
});
});

// ── validateQuery ──────────────────────────────────────────────────────────────

describe("validateQuery()", () => {
const querySchema = z.object({
limit: z.coerce.number().min(1).max(100).default(20),
q: z.string().optional(),
});

it("returns { data } for valid query params", () => {
const params = new URLSearchParams({ limit: "50", q: "hello" });
const result = validateQuery(params, querySchema);
expect(result).not.toBeInstanceOf(Response);
if (!(result instanceof Response)) {
expect(result.data.limit).toBe(50);
expect(result.data.q).toBe("hello");
}
});

it("applies default values for missing optional params", () => {
const params = new URLSearchParams();
const result = validateQuery(params, querySchema);
expect(result).not.toBeInstanceOf(Response);
if (!(result instanceof Response)) {
expect(result.data.limit).toBe(20);
}
});

it("returns a 400 Response for invalid query params", () => {
const params = new URLSearchParams({ limit: "999" });
const result = validateQuery(params, querySchema);
expect(result).toBeInstanceOf(Response);
if (result instanceof Response) {
expect(result.status).toBe(400);
}
});
});
Loading