Files
TX/artifacts/api-server/tests/apps-open.test.mjs
T
riyadhafraa 72cd414208 Add automated coverage for app-open tracking edge cases (Task #22)
Original task: verify POST /api/apps/:id/open behaves correctly under slow
networks (the keepalive POST must survive the user navigating away),
unauthenticated callers (401 with no row inserted), and unknown app ids
(404 with no row inserted).

Changes
- New committed test file artifacts/api-server/tests/apps-open.test.mjs
  using Node's built-in node:test runner (no new test framework added):
  * happy path: authenticated POST returns 204 and inserts an app_opens row
  * unauthenticated POST returns 401 and inserts no row
  * authenticated POST to a non-existent app id (max(id)+100000) returns 404
    and inserts no row
  * slow-network simulation: opens a raw http.request to /api/apps/:id/open,
    aborts the socket ~50 ms after sending so the client never reads the
    response (mimicking a navigation-aborted keepalive POST), then asserts
    the server still inserted the row. This proves the route's
    `await db.insert(...)` runs to completion independently of whether the
    client is still around to read the 204.
- The tests create a dedicated test user with a precomputed bcrypt hash for
  "TestPass123!", assign the standard "user" role, log in via
  POST /api/auth/login to obtain a connect.sid cookie, run the four cases,
  and clean up (app_opens / user_roles / users) in an `after` hook.
- Added `pg` as a devDependency on @workspace/api-server (used by the
  tests for direct DB assertions) and a `test` script:
  `node --test 'tests/**/*.test.mjs'`.
- Also ran in-browser end-to-end coverage via the testing skill that
  exercised the keepalive + wouter navigation flow against a live home
  page with a 3 s route delay; that run also passed.

Schema drift fixed during the run
- The dev DB was missing the `app_opens` table and the `users.clock_style`
  column referenced by the running schema. `pnpm --filter @workspace/db
  push` blocked on an interactive rename/create prompt that could not be
  answered non-interactively, so I brought the dev DB in line with the
  Drizzle schema using idempotent SQL (CREATE TABLE IF NOT EXISTS for
  app_opens with its two indexes; ALTER TABLE users ADD COLUMN IF NOT
  EXISTS clock_style varchar(30)). No schema files were modified.

No production code changes were required — the existing route already
returns 401/404/204 correctly and the tests now lock that behavior in.

Replit-Task-Id: b7422abb-cc1b-4727-b70b-cde090f1a748
2026-04-21 06:38:55 +00:00

181 lines
6.2 KiB
JavaScript

import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
let testUserId;
let testUsername;
let validAppId;
let validAppRoute;
let badAppId;
let sessionCookie;
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
const setCookie = res.headers.get("set-cookie");
assert.ok(setCookie, "expected Set-Cookie header from login");
const sid = setCookie.split(",").map((c) => c.split(";")[0].trim()).find((c) =>
c.startsWith("connect.sid="),
);
assert.ok(sid, "expected connect.sid cookie");
return sid;
}
async function countOpens({ userId, appId } = {}) {
let query = "SELECT COUNT(*)::int AS c FROM app_opens WHERE 1=1";
const params = [];
if (userId !== undefined) {
params.push(userId);
query += ` AND user_id = $${params.length}`;
}
if (appId !== undefined) {
params.push(appId);
query += ` AND app_id = $${params.length}`;
}
const { rows } = await pool.query(query, params);
return rows[0].c;
}
before(async () => {
testUsername = `open_test_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const userRes = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Open Test', 'en', true) RETURNING id`,
[testUsername, `${testUsername}@example.com`, TEST_PASSWORD_HASH],
);
testUserId = userRes.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'user'`,
[testUserId],
);
const appRes = await pool.query(
`SELECT id, route FROM apps WHERE is_active = true ORDER BY id ASC LIMIT 1`,
);
assert.ok(appRes.rows[0], "no active app found to test against");
validAppId = appRes.rows[0].id;
validAppRoute = appRes.rows[0].route;
const maxRes = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`);
badAppId = Number(maxRes.rows[0].m) + 100000;
sessionCookie = await loginAndGetCookie(testUsername, TEST_PASSWORD);
});
after(async () => {
if (testUserId !== undefined) {
await pool.query(`DELETE FROM app_opens WHERE user_id = $1`, [testUserId]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [testUserId]);
await pool.query(`DELETE FROM users WHERE id = $1`, [testUserId]);
}
await pool.end();
});
test("happy path: authenticated POST inserts a row and returns 204", async () => {
const before = await countOpens({ userId: testUserId, appId: validAppId });
const res = await fetch(`${API_BASE}/api/apps/${validAppId}/open`, {
method: "POST",
headers: { Cookie: sessionCookie },
});
assert.equal(res.status, 204);
const after = await countOpens({ userId: testUserId, appId: validAppId });
assert.equal(after, before + 1, "app_opens row should be inserted");
});
test("unauthenticated POST returns 401 and inserts no row", async () => {
const before = await countOpens({ appId: validAppId });
const res = await fetch(`${API_BASE}/api/apps/${validAppId}/open`, {
method: "POST",
});
assert.equal(res.status, 401);
const after = await countOpens({ appId: validAppId });
assert.equal(after, before, "no row should be inserted on 401");
});
test("authenticated POST to non-existent app id returns 404 and inserts no row", async () => {
const before = await countOpens({ appId: badAppId });
const res = await fetch(`${API_BASE}/api/apps/${badAppId}/open`, {
method: "POST",
headers: { Cookie: sessionCookie },
});
assert.equal(res.status, 404);
const after = await countOpens({ appId: badAppId });
assert.equal(after, before);
assert.equal(after, 0);
});
// Simulates a slow network where the client navigates away (and the response
// is never consumed) before the server finishes. The fire-and-forget POST in
// home.tsx uses keepalive so the request reaches the server; the server-side
// handler awaits the DB insert before responding, so the row must still be
// recorded even if the client aborts before reading the response.
test("slow network: row is still inserted when the client aborts before reading the response", async () => {
const before = await countOpens({ userId: testUserId, appId: validAppId });
await new Promise((resolve, reject) => {
const url = new URL(`${API_BASE}/api/apps/${validAppId}/open`);
const req = http.request(
{
method: "POST",
hostname: url.hostname,
port: url.port,
path: url.pathname,
headers: {
Cookie: sessionCookie,
"Content-Length": "0",
},
},
(res) => {
// If we get a response back quickly, just consume and resolve.
res.resume();
res.on("end", resolve);
},
);
req.on("error", (err) => {
// ECONNRESET from our destroy() below is expected.
if (err && (err.code === "ECONNRESET" || err.code === "ECONNABORTED")) {
resolve();
} else {
reject(err);
}
});
req.end();
// Abort the connection ~50ms after sending so the client never reads the
// response, mimicking a navigation-aborted keepalive request.
setTimeout(() => req.destroy(), 50);
});
// Give the server time to finish its insert even though we abandoned the
// socket on the client side.
await new Promise((r) => setTimeout(r, 1500));
const after = await countOpens({ userId: testUserId, appId: validAppId });
assert.equal(
after,
before + 1,
"row should be inserted server-side even when the client aborts before reading the response",
);
});