Sternboard: free retro board, no signup (initial)
Servidor Fastify + Socket.IO + Drizzle/Postgres; front Vite+React (EN/ES, tema claro/oscuro). Tableros por columnas con plantillas, notas privadas/públicas, agrupación, votos configurables (multivoto), timer compartido con alarma, fases, action items, export Markdown/JSON e import, autodestrucción, gestión de columnas. Imagen única (front servido por el servidor) y CI que la construye y publica.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
import { runCleanup } from "../src/app.js";
|
||||
import { db, schema } from "../src/db/index.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Autodestrucción", () => {
|
||||
it("borra boards caducados y avisa a la room", async () => {
|
||||
const board = await createBoard(server.url, { ttlHours: 1 });
|
||||
// forzamos expires_at al pasado
|
||||
await db
|
||||
.update(schema.boards)
|
||||
.set({ expiresAt: new Date(Date.now() - 1000) })
|
||||
.where(eq(schema.boards.id, board.boardId));
|
||||
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const destroyed = new Promise<void>((resolve) =>
|
||||
alice.socket.once("board:destroyed", () => resolve()),
|
||||
);
|
||||
|
||||
const deleted = await runCleanup(server.io);
|
||||
expect(deleted).toContain(board.boardId);
|
||||
await destroyed; // la room recibió el aviso
|
||||
|
||||
// ya no existe en DB
|
||||
const rows = await db.select().from(schema.boards).where(eq(schema.boards.id, board.boardId));
|
||||
expect(rows).toHaveLength(0);
|
||||
|
||||
alice.socket.close();
|
||||
});
|
||||
|
||||
it("no borra boards sin caducidad ni los aún vigentes", async () => {
|
||||
const persistent = await createBoard(server.url, { ttlHours: null });
|
||||
const future = await createBoard(server.url, { ttlHours: 24 });
|
||||
|
||||
const deleted = await runCleanup(server.io);
|
||||
expect(deleted).not.toContain(persistent.boardId);
|
||||
expect(deleted).not.toContain(future.boardId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Columnas y color", () => {
|
||||
it("recolorear cambia el color de TODAS mis notas", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "a", color: "#ffe08a" });
|
||||
alice.emit("note:add", { columnId: colId, text: "b", color: "#ffe08a" });
|
||||
await alice.waitFor((s) => s.notes.length === 2);
|
||||
|
||||
alice.emit("note:recolorMine", { color: "#b9ddf5" });
|
||||
const recolored = await alice.waitFor((s) => s.notes.every((n) => n.color === "#b9ddf5"));
|
||||
expect(recolored.notes.map((n) => n.color)).toEqual(["#b9ddf5", "#b9ddf5"]);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("añadir, renombrar, reordenar y eliminar columnas (moderador)", async () => {
|
||||
const board = await createBoard(server.url); // mad-sad-glad => 3 columnas
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const tok = board.moderatorToken;
|
||||
|
||||
// añadir
|
||||
alice.emit("column:add", { title: "Extra", moderatorToken: tok });
|
||||
let st = await alice.waitFor((s) => s.columns.length === 4);
|
||||
const extra = st.columns.find((c) => c.title === "Extra")!;
|
||||
expect(extra).toBeTruthy();
|
||||
|
||||
// renombrar
|
||||
alice.emit("column:rename", { columnId: extra.id, title: "Renombrada", moderatorToken: tok });
|
||||
st = await alice.waitFor((s) => s.columns.some((c) => c.id === extra.id && c.title === "Renombrada"));
|
||||
expect(st.columns.find((c) => c.id === extra.id)!.title).toBe("Renombrada");
|
||||
|
||||
// reordenar: poner la última primera
|
||||
const ids = st.columns.map((c) => c.id);
|
||||
const reordered = [ids[ids.length - 1], ...ids.slice(0, -1)];
|
||||
alice.emit("column:reorder", { orderedIds: reordered, moderatorToken: tok });
|
||||
st = await alice.waitFor((s) => s.columns[0].id === reordered[0]);
|
||||
expect(st.columns.map((c) => c.id)).toEqual(reordered);
|
||||
|
||||
// eliminar
|
||||
alice.emit("column:delete", { columnId: extra.id, moderatorToken: tok });
|
||||
st = await alice.waitFor((s) => s.columns.length === 3);
|
||||
expect(st.columns.some((c) => c.id === extra.id)).toBe(false);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("la fase se comparte (board:phase)", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
expect(alice.last!.phase).toBe("writing");
|
||||
|
||||
alice.emit("board:phase", { phase: "voting", moderatorToken: board.moderatorToken });
|
||||
const bobState = await bob.waitFor((s) => s.phase === "voting");
|
||||
expect(bobState.phase).toBe("voting");
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import postgres from "postgres";
|
||||
|
||||
// DDL self-contained: crea la base de datos de test desde cero (idempotente).
|
||||
const DDL = `
|
||||
DROP TABLE IF EXISTS action_items, votes, notes, groups, columns, boards CASCADE;
|
||||
|
||||
CREATE TABLE boards (
|
||||
id text PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
phase text NOT NULL DEFAULT 'writing',
|
||||
revealed boolean NOT NULL DEFAULT false,
|
||||
votes_revealed boolean NOT NULL DEFAULT false,
|
||||
votes_per_user integer NOT NULL DEFAULT 3,
|
||||
allow_multi_vote boolean NOT NULL DEFAULT true,
|
||||
moderation_mode text NOT NULL DEFAULT 'everyone',
|
||||
show_card_creators boolean NOT NULL DEFAULT true,
|
||||
locked boolean NOT NULL DEFAULT false,
|
||||
timer_ends_at timestamptz,
|
||||
timer_running boolean NOT NULL DEFAULT false,
|
||||
moderator_token text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE columns (
|
||||
id text PRIMARY KEY,
|
||||
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
position integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE groups (
|
||||
id text PRIMARY KEY,
|
||||
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
column_id text NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||
title text NOT NULL DEFAULT '',
|
||||
position double precision NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE notes (
|
||||
id text PRIMARY KEY,
|
||||
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
column_id text NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||
group_id text REFERENCES groups(id) ON DELETE SET NULL,
|
||||
author_id text NOT NULL,
|
||||
author_name text NOT NULL,
|
||||
text text NOT NULL,
|
||||
color text,
|
||||
revealed boolean NOT NULL DEFAULT false,
|
||||
position double precision NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE votes (
|
||||
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
target_type text NOT NULL,
|
||||
target_id text NOT NULL,
|
||||
participant_id text NOT NULL,
|
||||
count integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX votes_target_participant_uniq ON votes (target_id, participant_id);
|
||||
|
||||
CREATE TABLE action_items (
|
||||
id text PRIMARY KEY,
|
||||
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
text text NOT NULL,
|
||||
owner text,
|
||||
done boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`;
|
||||
|
||||
export async function setup() {
|
||||
// globalSetup no recibe `test.env`; replicamos el default de vitest.config.ts.
|
||||
const url =
|
||||
process.env.TEST_DATABASE_URL ??
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://retro:retro@localhost:5433/retro_test";
|
||||
// Asegura que exista la base de datos de test (crea retro_test si falta).
|
||||
const dbName = new URL(url).pathname.slice(1);
|
||||
const adminUrl = url.replace(`/${dbName}`, "/postgres");
|
||||
const admin = postgres(adminUrl, { max: 1 });
|
||||
try {
|
||||
const exists = await admin`SELECT 1 FROM pg_database WHERE datname = ${dbName}`;
|
||||
if (exists.length === 0) await admin.unsafe(`CREATE DATABASE ${dbName}`);
|
||||
} finally {
|
||||
await admin.end();
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
try {
|
||||
await sql.unsafe(DDL);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
async function twoNotes(boardId: string, url: string) {
|
||||
const alice = await join(url, boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "A" });
|
||||
alice.emit("note:add", { columnId: colId, text: "B" });
|
||||
const st = await alice.waitFor((s) => s.notes.length === 2);
|
||||
const [a, b] = st.notes;
|
||||
return { alice, colId, a, b };
|
||||
}
|
||||
|
||||
describe("Grupos", () => {
|
||||
it("agrupar dos notas crea un grupo votable", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const { alice, a, b } = await twoNotes(board.boardId, server.url);
|
||||
|
||||
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||
const grouped = await alice.waitFor((s) => s.groups.length === 1);
|
||||
const groupId = grouped.groups[0].id;
|
||||
expect(grouped.notes.every((n) => n.groupId === groupId)).toBe(true);
|
||||
|
||||
// votar el grupo como unidad
|
||||
alice.emit("vote:toggle", { targetType: "group", targetId: groupId });
|
||||
const voted = await alice.waitFor((s) => s.votes.some((v) => v.targetId === groupId));
|
||||
expect(voted.votes.filter((v) => v.targetId === groupId)).toHaveLength(1);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("al agrupar se reinician los votos sueltos de las notas", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const { alice, a, b } = await twoNotes(board.boardId, server.url);
|
||||
|
||||
alice.emit("vote:toggle", { targetType: "note", targetId: a.id });
|
||||
await alice.waitFor((s) => s.votes.some((v) => v.targetId === a.id));
|
||||
|
||||
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||
const after = await alice.waitFor((s) => s.groups.length === 1);
|
||||
expect(after.votes.filter((v) => v.targetId === a.id)).toHaveLength(0);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("sacar la última nota de un grupo lo elimina", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const { alice, colId, a, b } = await twoNotes(board.boardId, server.url);
|
||||
|
||||
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||
const grouped = await alice.waitFor((s) => s.groups.length === 1);
|
||||
expect(grouped.groups).toHaveLength(1);
|
||||
|
||||
// saca una nota: el grupo sigue (1 miembro)
|
||||
alice.emit("note:move", { noteId: a.id, columnId: colId, groupId: null, position: 5 });
|
||||
await alice.waitFor((s) => s.notes.filter((n) => n.groupId).length === 1);
|
||||
|
||||
// saca la última: el grupo se borra
|
||||
alice.emit("note:move", { noteId: b.id, columnId: colId, groupId: null, position: 6 });
|
||||
const empty = await alice.waitFor((s) => s.groups.length === 0);
|
||||
expect(empty.groups).toHaveLength(0);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { buildServer, type BuiltServer } from "../src/app.js";
|
||||
import { io as ioc, type Socket } from "socket.io-client";
|
||||
import type { BoardState } from "../src/room.js";
|
||||
|
||||
export type TestServer = BuiltServer & { port: number; url: string };
|
||||
|
||||
export async function startTestServer(): Promise<TestServer> {
|
||||
const server = buildServer({ cleanup: false });
|
||||
const port = await server.listen(0); // puerto efímero
|
||||
return Object.assign(server, { port, url: `http://localhost:${port}` });
|
||||
}
|
||||
|
||||
export async function createBoard(
|
||||
url: string,
|
||||
body: Record<string, unknown> = {},
|
||||
): Promise<{ boardId: string; moderatorToken: string; expiresAt: string | null }> {
|
||||
const res = await fetch(`${url}/api/boards`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ templateId: "mad-sad-glad", votesPerUser: 3, ...body }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Cliente de test: mantiene el último estado recibido para evitar carreras
|
||||
// (el estado buscado puede haber llegado antes de adjuntar el listener).
|
||||
export class Client {
|
||||
last: BoardState | null = null;
|
||||
constructor(public socket: Socket) {
|
||||
socket.on("board:state", (s: BoardState) => (this.last = s));
|
||||
}
|
||||
|
||||
emit(event: string, payload: Record<string, unknown>) {
|
||||
this.socket.emit(event, payload);
|
||||
}
|
||||
|
||||
// Resuelve cuando el último estado (presente o futuro) cumple el predicado.
|
||||
waitFor(predicate: (s: BoardState) => boolean, timeoutMs = 10000): Promise<BoardState> {
|
||||
if (this.last && predicate(this.last)) return Promise.resolve(this.last);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.socket.off("board:state", handler);
|
||||
reject(new Error("waitFor timeout"));
|
||||
}, timeoutMs);
|
||||
const handler = (s: BoardState) => {
|
||||
if (predicate(s)) {
|
||||
clearTimeout(timer);
|
||||
this.socket.off("board:state", handler);
|
||||
resolve(s);
|
||||
}
|
||||
};
|
||||
this.socket.on("board:state", handler);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Conecta, hace join y devuelve un Client ya con su primer estado.
|
||||
export function join(
|
||||
url: string,
|
||||
boardId: string,
|
||||
participantId: string,
|
||||
name: string,
|
||||
): Promise<Client> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = ioc(url, { transports: ["websocket"], forceNew: true });
|
||||
const client = new Client(socket);
|
||||
const timer = setTimeout(() => reject(new Error("join timeout")), 10000);
|
||||
socket.on("connect", () => socket.emit("board:join", { boardId, participantId, name }));
|
||||
socket.once("error", (e) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`join error: ${JSON.stringify(e)}`));
|
||||
});
|
||||
const ready = (s: BoardState) => {
|
||||
clearTimeout(timer);
|
||||
socket.off("board:state", ready);
|
||||
client.last = s;
|
||||
resolve(client);
|
||||
};
|
||||
socket.on("board:state", ready);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
version: 1,
|
||||
board: { title: "Importado", votesPerUser: 4 },
|
||||
columns: [
|
||||
{ id: "c1", title: "Columna A", position: 0 },
|
||||
{ id: "c2", title: "Columna B", position: 1 },
|
||||
],
|
||||
groups: [],
|
||||
notes: [
|
||||
{
|
||||
id: "n1",
|
||||
columnId: "c1",
|
||||
groupId: null,
|
||||
authorId: "x",
|
||||
authorName: "X",
|
||||
text: "nota importada",
|
||||
revealed: true,
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
votes: [{ targetType: "note", targetId: "n1", participantId: "x" }],
|
||||
actionItems: [{ text: "tarea importada", owner: "X" }],
|
||||
};
|
||||
|
||||
async function importAndExport(mode: "full" | "structure") {
|
||||
const res = await fetch(`${server.url}/api/boards/import`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot, mode }),
|
||||
});
|
||||
const { boardId } = await res.json();
|
||||
const md = await (await fetch(`${server.url}/api/boards/${boardId}/export`)).text();
|
||||
return { boardId, md };
|
||||
}
|
||||
|
||||
describe("Import", () => {
|
||||
it("modo 'full' importa columnas, notas y votos", async () => {
|
||||
const { md } = await importAndExport("full");
|
||||
expect(md).toContain("# Importado");
|
||||
expect(md).toContain("## Columna A");
|
||||
expect(md).toContain("nota importada — _X_ (1 👍)");
|
||||
expect(md).toContain("tarea importada");
|
||||
});
|
||||
|
||||
it("modo 'structure' importa solo columnas y config, sin notas", async () => {
|
||||
const { md } = await importAndExport("structure");
|
||||
expect(md).toContain("## Columna A");
|
||||
expect(md).toContain("## Columna B");
|
||||
expect(md).not.toContain("nota importada");
|
||||
expect(md).not.toContain("tarea importada");
|
||||
});
|
||||
|
||||
it("rechaza un snapshot sin columnas", async () => {
|
||||
const res = await fetch(`${server.url}/api/boards/import`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot: { version: 1, columns: [] }, mode: "full" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Export con opciones", () => {
|
||||
async function importFull(): Promise<string> {
|
||||
const res = await fetch(`${server.url}/api/boards/import`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot, mode: "full" }),
|
||||
});
|
||||
return (await res.json()).boardId;
|
||||
}
|
||||
const md = (id: string, qs: string) =>
|
||||
fetch(`${server.url}/api/boards/${id}/export${qs}`).then((r) => r.text());
|
||||
|
||||
it("autores y votos se pueden desactivar por query", async () => {
|
||||
const id = await importFull();
|
||||
expect(await md(id, "")).toContain("nota importada — _X_ (1 👍)"); // defaults
|
||||
const noAuthors = await md(id, "?authors=0");
|
||||
expect(noAuthors).toContain("nota importada (1 👍)");
|
||||
expect(noAuthors).not.toContain("_X_");
|
||||
const noVotes = await md(id, "?votes=0");
|
||||
expect(noVotes).not.toContain("👍");
|
||||
});
|
||||
|
||||
it("la fecha de creación se añade al título con created=1", async () => {
|
||||
const id = await importFull();
|
||||
const withDate = await md(id, "?created=1");
|
||||
expect(withDate).toMatch(/^# \d+\/\d+\/\d+ - Importado/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const myCount = (state: { votes: { targetId: string; participantId: string; count: number }[] }, targetId: string) =>
|
||||
state.votes
|
||||
.filter((v) => v.targetId === targetId && v.participantId === "alice")
|
||||
.reduce((s, v) => s + v.count, 0);
|
||||
|
||||
describe("Multivoto", () => {
|
||||
it("apila votos en el mismo ítem hasta el cupo y deja quitar", async () => {
|
||||
const board = await createBoard(server.url, { votesPerUser: 3 }); // allowMultiVote por defecto: true
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "favorita" });
|
||||
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
const stacked = await alice.waitFor((s) => myCount(s, noteId) === 3);
|
||||
expect(myCount(stacked, noteId)).toBe(3);
|
||||
|
||||
// 4º voto: cupo agotado, no sube
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(myCount(alice.last!, noteId)).toBe(3);
|
||||
|
||||
// quitar uno
|
||||
alice.emit("vote:remove", { targetId: noteId });
|
||||
const afterRemove = await alice.waitFor((s) => myCount(s, noteId) === 2);
|
||||
expect(myCount(afterRemove, noteId)).toBe(2);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("con allowMultiVote=false no se puede apilar en el mismo ítem", async () => {
|
||||
const board = await createBoard(server.url, { votesPerUser: 5 });
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
alice.emit("board:update", { allowMultiVote: false, moderatorToken: board.moderatorToken });
|
||||
await alice.waitFor((s) => s.allowMultiVote === false);
|
||||
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "uno" });
|
||||
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
const after = await alice.waitFor((s) => myCount(s, noteId) >= 1);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(myCount(after, noteId)).toBe(1);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Moderación y opciones", () => {
|
||||
it("por defecto cualquiera modera; al reclamar solo el reclamante", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
|
||||
// modo abierto por defecto
|
||||
expect(alice.last!.moderationMode).toBe("everyone");
|
||||
|
||||
// Alice reclama (sin token, permitido en modo abierto) y recibe un token nuevo
|
||||
const claimed = new Promise<string>((resolve) =>
|
||||
alice.socket.once("board:claimed", (p: { moderatorToken: string }) => resolve(p.moderatorToken)),
|
||||
);
|
||||
alice.emit("board:claim", {});
|
||||
const token = await claimed;
|
||||
await alice.waitFor((s) => s.moderationMode === "single");
|
||||
|
||||
// Bob (sin token) ya no puede revelar; Alice con su token sí
|
||||
bob.emit("board:reveal", { revealed: true });
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(bob.last!.revealed).toBe(false);
|
||||
|
||||
alice.emit("board:reveal", { revealed: true, moderatorToken: token });
|
||||
const revealed = await alice.waitFor((s) => s.revealed === true);
|
||||
expect(revealed.revealed).toBe(true);
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("bloquear deja el board en solo lectura (no se añaden notas)", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("board:update", { locked: true }); // modo abierto: cualquiera modera
|
||||
await alice.waitFor((s) => s.locked === true);
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "no debería entrar" });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(alice.last!.notes).toHaveLength(0);
|
||||
|
||||
// desbloquear y ahora sí
|
||||
alice.emit("board:update", { locked: false });
|
||||
await alice.waitFor((s) => s.locked === false);
|
||||
alice.emit("note:add", { columnId: colId, text: "ahora sí" });
|
||||
const after = await alice.waitFor((s) => s.notes.length === 1);
|
||||
expect(after.notes[0].text).toBe("ahora sí");
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("votes:clear borra todos los votos", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "x" });
|
||||
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
await alice.waitFor((s) => s.votes.length === 1);
|
||||
|
||||
alice.emit("votes:clear", {});
|
||||
const cleared = await alice.waitFor((s) => s.votes.length === 0);
|
||||
expect(cleared.votes).toHaveLength(0);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("participant:rename actualiza la autoría de mis notas", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "mía" });
|
||||
await alice.waitFor((s) => s.notes.length === 1 && s.notes[0].authorName === "Alice");
|
||||
|
||||
alice.emit("participant:rename", { name: "Alicia" });
|
||||
const renamed = await alice.waitFor((s) => s.notes[0]?.authorName === "Alicia");
|
||||
expect(renamed.notes[0].authorName).toBe("Alicia");
|
||||
expect(renamed.participants.find((p) => p.id === "alice")?.name).toBe("Alicia");
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("la presencia lista a los participantes conectados", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const st = await alice.waitFor((s) => s.participants.length === 2);
|
||||
expect(st.participants.map((p) => p.name).sort()).toEqual(["Alice", "Bob"]);
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("action item: marcar hecho", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
alice.emit("actionitem:add", { text: "documentar" });
|
||||
const withItem = await alice.waitFor((s) => s.actionItems.length === 1);
|
||||
const id = withItem.actionItems[0].id;
|
||||
expect(withItem.actionItems[0].done).toBe(false);
|
||||
|
||||
alice.emit("actionitem:toggle", { id, done: true });
|
||||
const done = await alice.waitFor((s) => s.actionItems[0]?.done === true);
|
||||
expect(done.actionItems[0].done).toBe(true);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Notas privadas y publicar", () => {
|
||||
it("note:publishMine publica todas mis notas de la columna", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "uno" });
|
||||
alice.emit("note:add", { columnId: colId, text: "dos" });
|
||||
await alice.waitFor((s) => s.notes.length === 2);
|
||||
|
||||
// Bob recibe las notas pero sin texto (privadas)
|
||||
const bobBefore = await bob.waitFor((s) => s.notes.length === 2);
|
||||
expect(bobBefore.notes.every((n) => n.text === "")).toBe(true);
|
||||
|
||||
alice.emit("note:publishMine", { columnId: colId });
|
||||
|
||||
// guard de longitud: every() es vacuamente true sobre [] -> exigimos las 2 notas
|
||||
const bobAfter = await bob.waitFor((s) => s.notes.length === 2 && s.notes.every((n) => n.text !== ""));
|
||||
expect(bobAfter.notes.map((n) => n.text).sort()).toEqual(["dos", "uno"]);
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("agrupar publica las notas implicadas", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "A" });
|
||||
alice.emit("note:add", { columnId: colId, text: "B" });
|
||||
const st = await alice.waitFor((s) => s.notes.length === 2);
|
||||
const [a, b] = st.notes;
|
||||
|
||||
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||
|
||||
// Bob ve ambas notas con texto (agrupar es público)
|
||||
const bobAfter = await bob.waitFor(
|
||||
(s) => s.groups.length === 1 && s.notes.length === 2 && s.notes.every((n) => n.text !== ""),
|
||||
);
|
||||
expect(bobAfter.notes.map((n) => n.text).sort()).toEqual(["A", "B"]);
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("REST", () => {
|
||||
it("lista plantillas predefinidas", async () => {
|
||||
const res = await fetch(`${server.url}/api/templates`);
|
||||
const templates = await res.json();
|
||||
expect(Array.isArray(templates)).toBe(true);
|
||||
expect(templates.find((t: any) => t.id === "mad-sad-glad")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("crea un board desde plantilla y devuelve token de moderador", async () => {
|
||||
const board = await createBoard(server.url, { title: "Sprint 1" });
|
||||
expect(board.boardId).toHaveLength(12);
|
||||
expect(board.moderatorToken.length).toBeGreaterThan(10);
|
||||
|
||||
const meta = await (await fetch(`${server.url}/api/boards/${board.boardId}`)).json();
|
||||
expect(meta.title).toBe("Sprint 1");
|
||||
});
|
||||
|
||||
it("crea un board con columnas libres", async () => {
|
||||
const board = await createBoard(server.url, {
|
||||
templateId: undefined,
|
||||
columns: ["Bien", "Mal", "Acciones"],
|
||||
});
|
||||
const md = await (await fetch(`${server.url}/api/boards/${board.boardId}/export`)).text();
|
||||
expect(md).toContain("## Bien");
|
||||
expect(md).toContain("## Mal");
|
||||
});
|
||||
|
||||
it("fija expiresAt cuando se pasa ttlHours", async () => {
|
||||
const board = await createBoard(server.url, { ttlHours: 2 });
|
||||
expect(board.expiresAt).toBeTruthy();
|
||||
expect(new Date(board.expiresAt!).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("devuelve 404 para board inexistente", async () => {
|
||||
const res = await fetch(`${server.url}/api/boards/noexiste123`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rechaza plantilla inexistente con 400", async () => {
|
||||
const res = await fetch(`${server.url}/api/boards`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ templateId: "nope" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Revelado granular", () => {
|
||||
it("el autor revela solo su propia nota", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "mía" });
|
||||
let bobState = await bob.waitFor((s) => s.notes.length === 1);
|
||||
expect(bobState.notes[0].text).toBe(""); // oculta
|
||||
|
||||
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||
alice.emit("note:reveal", { noteId, revealed: true });
|
||||
|
||||
bobState = await bob.waitFor((s) => s.notes[0]?.text === "mía");
|
||||
expect(bobState.notes[0].text).toBe("mía");
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("los totales de votos se ocultan hasta que el moderador los revela", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "vota esto" });
|
||||
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||
await bob.waitFor((s) => s.notes.length === 1);
|
||||
|
||||
// Bob vota; Alice NO debe ver el voto de Bob (totales ocultos)
|
||||
bob.emit("vote:toggle", { targetType: "note", targetId: noteId });
|
||||
await bob.waitFor((s) => s.votes.some((v) => v.targetId === noteId)); // Bob ve el suyo
|
||||
|
||||
const aliceView = alice.last!;
|
||||
expect(aliceView.votes.filter((v) => v.targetId === noteId)).toHaveLength(0);
|
||||
|
||||
// El moderador revela los votos => Alice ve el de Bob
|
||||
alice.emit("board:revealVotes", { revealed: true, moderatorToken: board.moderatorToken });
|
||||
const revealed = await alice.waitFor((s) => s.votesRevealed && s.votes.length === 1);
|
||||
expect(revealed.votes[0].participantId).toBe("bob");
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("cambiar los ajustes del board requiere token de moderador", async () => {
|
||||
const board = await createBoard(server.url, { votesPerUser: 3 });
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
|
||||
// sin token: ignorado
|
||||
alice.emit("board:update", { title: "Hackeado", votesPerUser: 99 });
|
||||
// con token: aplica
|
||||
alice.emit("board:update", { title: "Renombrado", votesPerUser: 7, moderatorToken: board.moderatorToken });
|
||||
|
||||
const updated = await alice.waitFor((s) => s.title === "Renombrado");
|
||||
expect(updated.votesPerUser).toBe(7);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Socket.IO — flujo de retro", () => {
|
||||
it("oculta las notas de otros hasta revelar", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "secreto de Alice" });
|
||||
|
||||
const bobState = await bob.waitFor((s) => s.notes.length === 1);
|
||||
expect(bobState.notes[0].text).toBe(""); // oculta para Bob
|
||||
expect(bobState.notes[0].authorName).toBe("Alice");
|
||||
|
||||
const aliceState = await alice.waitFor((s) => s.notes.length === 1);
|
||||
expect(aliceState.notes[0].text).toBe("secreto de Alice"); // visible para la autora
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("revela las notas a todos cuando el moderador lo pide", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "visible tras revelar" });
|
||||
await bob.waitFor((s) => s.notes.length === 1);
|
||||
|
||||
// Bob (sin token) no puede revelar; Alice (moderadora) sí
|
||||
bob.emit("board:reveal", { revealed: true });
|
||||
alice.emit("board:reveal", { revealed: true, moderatorToken: board.moderatorToken });
|
||||
|
||||
const revealed = await bob.waitFor((s) => s.revealed === true);
|
||||
expect(revealed.notes[0].text).toBe("visible tras revelar");
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("respeta el límite de votos por persona", async () => {
|
||||
const board = await createBoard(server.url, { votesPerUser: 2 });
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
for (const txt of ["a", "b", "c"]) alice.emit("note:add", { columnId: colId, text: txt });
|
||||
const withNotes = await alice.waitFor((s) => s.notes.length === 3);
|
||||
const ids = withNotes.notes.map((n) => n.id);
|
||||
const mine = (s: { votes: { participantId: string }[] }) =>
|
||||
s.votes.filter((v) => v.participantId === "alice").length;
|
||||
|
||||
// vota las dos primeras (confirmando cada una)
|
||||
alice.emit("vote:toggle", { noteId: ids[0] });
|
||||
await alice.waitFor((s) => mine(s) === 1);
|
||||
alice.emit("vote:toggle", { noteId: ids[1] });
|
||||
await alice.waitFor((s) => mine(s) === 2);
|
||||
|
||||
// el tercer voto se rechaza (cupo = 2)
|
||||
alice.emit("vote:toggle", { noteId: ids[2] });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(mine(alice.last!)).toBe(2);
|
||||
|
||||
// quitar un voto libera cupo
|
||||
alice.emit("vote:toggle", { noteId: ids[0] });
|
||||
const afterUnvote = await alice.waitFor((s) => mine(s) === 1);
|
||||
expect(afterUnvote.votes).toHaveLength(1);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("comparte el timer (endsAt) con todos los participantes", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
|
||||
alice.emit("timer:start", { seconds: 300, moderatorToken: board.moderatorToken });
|
||||
const bobState = await bob.waitFor((s) => s.timerRunning);
|
||||
expect(bobState.timerEndsAt).toBeTruthy();
|
||||
expect(new Date(bobState.timerEndsAt!).getTime()).toBeGreaterThan(Date.now());
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("crea action items visibles para todos", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
|
||||
alice.emit("actionitem:add", { text: "documentar el deploy" });
|
||||
const bobState = await bob.waitFor((s) => s.actionItems.length === 1);
|
||||
expect(bobState.actionItems[0].text).toBe("documentar el deploy");
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { beforeEach } from "vitest";
|
||||
import postgres from "postgres";
|
||||
|
||||
// Limpia las tablas antes de cada test para aislarlos.
|
||||
const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
|
||||
|
||||
beforeEach(async () => {
|
||||
await sql.unsafe("TRUNCATE action_items, votes, notes, groups, columns, boards CASCADE");
|
||||
});
|
||||
Reference in New Issue
Block a user