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
19 changes: 19 additions & 0 deletions backend/src/db/migrations/018_market_comments.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Migration: create market_comments table and thumbs-up deduplication table
CREATE TABLE IF NOT EXISTS market_comments (
id SERIAL PRIMARY KEY,
market_id INT REFERENCES markets(id) ON DELETE CASCADE,
wallet_address TEXT NOT NULL,
content VARCHAR(500) NOT NULL,
thumbs_up_count INT NOT NULL DEFAULT 0,
is_hidden BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_market_comments_market_id ON market_comments(market_id, is_hidden, created_at DESC);

CREATE TABLE IF NOT EXISTS comment_thumbs_up (
comment_id INT REFERENCES market_comments(id) ON DELETE CASCADE,
wallet_address TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (comment_id, wallet_address)
);
2 changes: 2 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ app.use("/api", appCheckMiddleware);
// Routes (MERGED — keep ALL)
app.use("/api/auth", require("./routes/auth"));
app.use("/api/markets", require("./routes/markets"));
app.use("/api/markets/:id/comments", require("./routes/comments"));
app.use("/api/comments", require("./routes/commentActions"));
app.use("/api/bets", require("./routes/bets"));
app.use("/api/notifications", require("./routes/notifications"));
app.use("/api/reserves", require("./routes/reserves"));
Expand Down
69 changes: 69 additions & 0 deletions backend/src/routes/commentActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use strict";

const express = require("express");
const router = express.Router();
const db = require("../db");
const logger = require("../utils/logger");
const jwtAuth = require("../middleware/jwtAuth");

// POST /api/comments/:id/thumbs-up (JWT required, one per wallet)
router.post("/:id/thumbs-up", jwtAuth, async (req, res) => {
const commentId = parseInt(req.params.id, 10);
const walletAddress = req.admin?.sub || req.admin?.wallet_address;

try {
// Insert deduplication record — PK constraint prevents duplicates
await db.query("INSERT INTO comment_thumbs_up (comment_id, wallet_address) VALUES ($1, $2)", [
commentId,
walletAddress,
]);

const { rows } = await db.query(
"UPDATE market_comments SET thumbs_up_count = thumbs_up_count + 1 WHERE id = $1 RETURNING thumbs_up_count",
[commentId]
);

if (rows.length === 0) {
return res.status(404).json({ error: "Comment not found" });
}

res.json({ thumbs_up_count: rows[0].thumbs_up_count });
} catch (err) {
if (err.code === "23505") {
return res.status(409).json({ error: "Already thumbed up" });
}
if (err.code === "23503") {
return res.status(404).json({ error: "Comment not found" });
}
logger.error({ err: err.message, commentId }, "Failed to thumbs-up comment");
res.status(500).json({ error: "Internal server error" });
}
});

// DELETE /api/comments/:id (admin JWT required — sets is_hidden = TRUE)
router.delete("/:id", jwtAuth, async (req, res) => {
const commentId = parseInt(req.params.id, 10);

// Require admin role
if (!req.admin?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}

try {
const { rows } = await db.query(
"UPDATE market_comments SET is_hidden = TRUE WHERE id = $1 RETURNING id",
[commentId]
);

if (rows.length === 0) {
return res.status(404).json({ error: "Comment not found" });
}

res.json({ success: true });
} catch (err) {
logger.error({ err: err.message, commentId }, "Failed to hide comment");
res.status(500).json({ error: "Internal server error" });
}
});

module.exports = router;
220 changes: 220 additions & 0 deletions backend/src/tests/comments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"use strict";

jest.mock("../db");
jest.mock("../utils/logger", () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
jest.mock("firebase-admin", () => ({ apps: [true], initializeApp: jest.fn() }));
jest.mock("../middleware/appCheck", () => (req, res, next) => next());

const request = require("supertest");
const express = require("express");
const jwt = require("jsonwebtoken");
const db = require("../db");

const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";

function makeToken(payload = {}) {
return jwt.sign({ sub: "WALLET123", ...payload }, JWT_SECRET);
}

function makeAdminToken() {
return jwt.sign({ sub: "ADMIN_WALLET", isAdmin: true }, JWT_SECRET);
}

const commentsRouter = require("../routes/comments");
const commentActionsRouter = require("../routes/commentActions");

const app = express();
app.use(express.json());
app.use("/api/markets/:id/comments", commentsRouter);
app.use("/api/comments", commentActionsRouter);

const makeComment = (id, overrides = {}) => ({
id,
market_id: 1,
wallet_address: "WALLET123",
content: "Test comment",
thumbs_up_count: 0,
created_at: new Date().toISOString(),
...overrides,
});

describe("Market Comments API", () => {
beforeEach(() => jest.clearAllMocks());

// ── GET /api/markets/:id/comments ──────────────────────────────────────────
describe("GET /api/markets/:id/comments", () => {
it("returns paginated non-hidden comments", async () => {
const comments = [makeComment(1), makeComment(2)];
db.query
.mockResolvedValueOnce({ rows: comments })
.mockResolvedValueOnce({ rows: [{ total: "2" }] });

const res = await request(app).get("/api/markets/1/comments");

expect(res.status).toBe(200);
expect(res.body.comments).toHaveLength(2);
expect(res.body.meta).toMatchObject({ page: 0, pageSize: 20, total: 2 });
});

it("uses page query param for offset", async () => {
db.query
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ total: "0" }] });

await request(app).get("/api/markets/1/comments?page=2");

expect(db.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining("OFFSET $3"),
[1, 20, 40]
);
});

it("returns 500 on db error", async () => {
db.query.mockRejectedValueOnce(new Error("DB down"));
const res = await request(app).get("/api/markets/1/comments");
expect(res.status).toBe(500);
});
});

// ── POST /api/markets/:id/comments ────────────────────────────────────────
describe("POST /api/markets/:id/comments", () => {
it("creates a comment with valid content", async () => {
const comment = makeComment(1);
db.query.mockResolvedValueOnce({ rows: [comment] });

const res = await request(app)
.post("/api/markets/1/comments")
.set("Authorization", `Bearer ${makeToken()}`)
.send({ content: "Hello world" });

expect(res.status).toBe(201);
expect(res.body.comment).toMatchObject({ id: 1 });
});

it("rejects missing content", async () => {
const res = await request(app)
.post("/api/markets/1/comments")
.set("Authorization", `Bearer ${makeToken()}`)
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/required/i);
});

it("rejects content over 500 chars", async () => {
const res = await request(app)
.post("/api/markets/1/comments")
.set("Authorization", `Bearer ${makeToken()}`)
.send({ content: "x".repeat(501) });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/500/);
});

it("rejects empty string content", async () => {
const res = await request(app)
.post("/api/markets/1/comments")
.set("Authorization", `Bearer ${makeToken()}`)
.send({ content: " " });
expect(res.status).toBe(400);
});

it("requires JWT", async () => {
const res = await request(app).post("/api/markets/1/comments").send({ content: "Hello" });
expect(res.status).toBe(401);
});

it("rejects invalid JWT", async () => {
const res = await request(app)
.post("/api/markets/1/comments")
.set("Authorization", "Bearer invalid.token.here")
.send({ content: "Hello" });
expect(res.status).toBe(401);
});
});

// ── POST /api/comments/:id/thumbs-up ──────────────────────────────────────
describe("POST /api/comments/:id/thumbs-up", () => {
it("increments thumbs_up_count", async () => {
db.query
.mockResolvedValueOnce({ rows: [] }) // insert dedup
.mockResolvedValueOnce({ rows: [{ thumbs_up_count: 1 }] }); // update

const res = await request(app)
.post("/api/comments/1/thumbs-up")
.set("Authorization", `Bearer ${makeToken()}`);

expect(res.status).toBe(200);
expect(res.body.thumbs_up_count).toBe(1);
});

it("returns 409 on duplicate thumbs-up", async () => {
const dupErr = new Error("duplicate");
dupErr.code = "23505";
db.query.mockRejectedValueOnce(dupErr);

const res = await request(app)
.post("/api/comments/1/thumbs-up")
.set("Authorization", `Bearer ${makeToken()}`);

expect(res.status).toBe(409);
expect(res.body.error).toMatch(/already/i);
});

it("returns 404 when comment not found", async () => {
db.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] }); // no rows from UPDATE

const res = await request(app)
.post("/api/comments/999/thumbs-up")
.set("Authorization", `Bearer ${makeToken()}`);

expect(res.status).toBe(404);
});

it("requires JWT", async () => {
const res = await request(app).post("/api/comments/1/thumbs-up");
expect(res.status).toBe(401);
});
});

// ── DELETE /api/comments/:id ──────────────────────────────────────────────
describe("DELETE /api/comments/:id", () => {
it("sets is_hidden = TRUE (admin only)", async () => {
db.query.mockResolvedValueOnce({ rows: [{ id: 1 }] });

const res = await request(app)
.delete("/api/comments/1")
.set("Authorization", `Bearer ${makeAdminToken()}`);

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(db.query).toHaveBeenCalledWith(expect.stringContaining("is_hidden = TRUE"), [1]);
});

it("returns 403 for non-admin JWT", async () => {
const res = await request(app)
.delete("/api/comments/1")
.set("Authorization", `Bearer ${makeToken()}`);
expect(res.status).toBe(403);
});

it("returns 404 when comment not found", async () => {
db.query.mockResolvedValueOnce({ rows: [] });

const res = await request(app)
.delete("/api/comments/999")
.set("Authorization", `Bearer ${makeAdminToken()}`);

expect(res.status).toBe(404);
});

it("requires JWT", async () => {
const res = await request(app).delete("/api/comments/1");
expect(res.status).toBe(401);
});
});
});
Loading