diff --git a/tests/phase21/_shared.cjs b/tests/phase21/_shared.cjs new file mode 100644 index 00000000..9cf1aaab --- /dev/null +++ b/tests/phase21/_shared.cjs @@ -0,0 +1,110 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadModule = (relativePath, injected = {}) => { + const filePath = path.join(rootDir, relativePath); + let source = fs.readFileSync(filePath, "utf8"); + + source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); + source = source.replace(/export const\s+/g, "const "); + source = source.replace( + /export default\s+(\w+);/g, + "module.exports.default = $1;" + ); + + source += + '\nif (typeof respondSuccess !== "undefined") module.exports.respondSuccess = respondSuccess;\n'; + source += + '\nif (typeof respondError !== "undefined") module.exports.respondError = respondError;\n'; + source += + '\nif (typeof ApiProxy !== "undefined" && !module.exports.default) module.exports.default = ApiProxy;\n'; + + const context = { + module: { exports: {} }, + exports: {}, + require, + process, + console: { + log: () => {}, + info: () => {}, + warn: () => {}, + error: () => {} + }, + ...injected + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const createRes = () => { + const state = { + statusCode: null, + jsonBody: undefined, + sentBody: undefined, + headers: {} + }; + + return { + state, + status(code) { + state.statusCode = code; + return this; + }, + json(payload) { + state.jsonBody = payload; + return payload; + }, + send(payload) { + state.sentBody = payload; + return payload; + }, + setHeader(key, value) { + state.headers[key.toLowerCase()] = value; + } + }; +}; + +const createNextConnectMock = () => { + const router = { + handler: null, + use: () => {}, + get(fn) { + this.handler = fn; + }, + post(fn) { + this.handler = fn; + } + }; + + return () => router; +}; + +const respondSuccessMock = (res, data, status = 200) => { + return res.status(status).json(data); +}; + +const respondErrorMock = ( + res, + { + status = 500, + code = "INTERNAL_SERVER_ERROR", + message = "Request failed" + } = {} +) => { + return res.status(status).json({ + success: false, + error: { code, message } + }); +}; + +module.exports = { + loadModule, + createRes, + createNextConnectMock, + respondSuccessMock, + respondErrorMock +}; diff --git a/tests/phase21/api-contract-slice1.test.cjs b/tests/phase21/api-contract-slice1.test.cjs new file mode 100644 index 00000000..5d493fb0 --- /dev/null +++ b/tests/phase21/api-contract-slice1.test.cjs @@ -0,0 +1,13 @@ +const runHelperTests = require("./api-response-helper.test.cjs"); +const runHandlerTests = require("./file-handler-contract.test.cjs"); + +const run = async () => { + await runHelperTests(); + await runHandlerTests(); + console.log("Phase 21 combined suite passed."); +}; + +run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tests/phase21/api-response-helper.test.cjs b/tests/phase21/api-response-helper.test.cjs new file mode 100644 index 00000000..2ea205da --- /dev/null +++ b/tests/phase21/api-response-helper.test.cjs @@ -0,0 +1,102 @@ +const assert = require("assert"); +const { loadModule, createRes } = require("./_shared.cjs"); + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +test("apiResponse.respondSuccess returns data with default 200", () => { + const mod = loadModule("pages/api/middleware/apiResponse.js"); + const res = createRes(); + + mod.respondSuccess(res, { ok: true }); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + ok: true + }); +}); + +test("apiResponse.respondError includes details outside production", () => { + const original = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + + const mod = loadModule("pages/api/middleware/apiResponse.js"); + const res = createRes(); + mod.respondError(res, { + status: 400, + code: "INVALID_HASH", + message: "Invalid hash", + details: { expected: "x", actual: "y" } + }); + + assert.strictEqual(res.state.statusCode, 400); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + success: false, + error: { + code: "INVALID_HASH", + message: "Invalid hash", + details: { expected: "x", actual: "y" } + } + }); + + process.env.NODE_ENV = original; +}); + +test("apiResponse.respondError suppresses details in production", () => { + const original = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + + const mod = loadModule("pages/api/middleware/apiResponse.js"); + const res = createRes(); + mod.respondError(res, { + status: 500, + code: "X", + message: "Y", + details: { shouldNot: "appear" } + }); + + assert.strictEqual(res.state.statusCode, 500); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + success: false, + error: { + code: "X", + message: "Y" + } + }); + + process.env.NODE_ENV = original; +}); + +test("apiResponse.respondError default contract returns 500 envelope", () => { + const mod = loadModule("pages/api/middleware/apiResponse.js"); + const res = createRes(); + + mod.respondError(res); + + assert.strictEqual(res.state.statusCode, 500); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + success: false, + error: { + code: "INTERNAL_SERVER_ERROR", + message: "Request failed" + } + }); +}); + +const run = async () => { + let passed = 0; + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + console.log(`Phase 21 helper tests passed (${passed}/${tests.length}).`); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs new file mode 100644 index 00000000..cc0f9ee9 --- /dev/null +++ b/tests/phase21/file-handler-contract.test.cjs @@ -0,0 +1,284 @@ +const assert = require("assert"); +const { + loadModule, + createRes, + createNextConnectMock, + respondSuccessMock, + respondErrorMock +} = require("./_shared.cjs"); + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +test("upload handler returns HASH_REQUIRED for missing hash", async () => { + const mod = loadModule("pages/api/file/upload.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "?hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + createBlob: async () => ({ ok: true }), + createRepBlob: async () => ({ ok: true }) + }); + + const req = { query: {}, body: {} }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "HASH_REQUIRED"); +}); + +test("upload handler returns INVALID_HASH for wrong hash", async () => { + const mod = loadModule("pages/api/file/upload.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "?hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + createBlob: async () => ({ ok: true }), + createRepBlob: async () => ({ ok: true }) + }); + + const req = { + query: { hash: "wrong" }, + body: { containerID: ["c1"], casefolderID: ["f1"] } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH"); +}); + +test("deleteblob handler returns MISSING_REQUIRED_QUERY when blobname missing", async () => { + const mod = loadModule("pages/api/file/deleteblob.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + deleteBlob: async () => ({ ok: true }) + }); + + const req = { + query: { container: "c1", casefolderID: "f1", hash: "expected" } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY"); +}); + +test("getbloblist handler returns INVALID_HASH for mismatch", async () => { + const mod = loadModule("pages/api/file/getbloblist.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + getBlobs: async () => ({ id: "x" }), + getRepsFilesBlobs: async () => ({ id: "x" }) + }); + + const req = { + query: { container: "c1", casefolderID: "f1", hash: "wrong" } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH"); +}); + +test("setupcontainer handler returns MISSING_REQUIRED_QUERY when ident missing", async () => { + const mod = loadModule("pages/api/file/setupcontainer.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + consoleLogger: () => {}, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + createContainer: async () => ({ ok: true }) + }); + + const req = { query: { hash: "expected" } }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY"); +}); + +test("upload handler success returns data payload with 200", async () => { + const mod = loadModule("pages/api/file/upload.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "?hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + createBlob: async () => ({ id: "blob-1" }), + createRepBlob: async () => ({ id: "rep-1" }) + }); + + const req = { + query: { hash: "expected" }, + body: { + appealData: { key: "value" }, + containerID: ["c1"], + casefolderID: ["f1"], + repOrAppeal: false + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + data: { id: "blob-1" } + }); +}); + +test("setupcontainer handler success returns expected contract", async () => { + const mod = loadModule("pages/api/file/setupcontainer.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + consoleLogger: () => {}, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + createContainer: async () => ({ created: true }) + }); + + const req = { query: { ident: "cont-1", hash: "expected" } }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + data: "success", + output: { created: true } + }); +}); + +test("getbloblist handler success returns value array contract", async () => { + const mod = loadModule("pages/api/file/getbloblist.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + getBlobs: async () => ({ file: "a.pdf" }), + getRepsFilesBlobs: async () => ({ file: "rep.pdf" }) + }); + + const req = { + query: { container: "c1", casefolderID: "f1", hash: "expected" } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + value: [{ file: "a.pdf" }] + }); +}); + +test("downloadblob handler success sets attachment header and returns body", async () => { + const mod = loadModule("pages/api/file/downloadblob.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + downloadFile: async () => Buffer.from("file-content") + }); + + const req = { + query: { + container: "c1", + casefolderID: "f1", + blobname: "doc.pdf", + hash: "expected" + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual( + res.state.headers["content-disposition"], + "attachment; filename=doc.pdf" + ); + assert.strictEqual(res.state.sentBody.toString(), "file-content"); +}); + +test("upload handler dependency failure returns UPLOAD_FAILED", async () => { + const mod = loadModule("pages/api/file/upload.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "?hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + createBlob: async () => { + throw new Error("storage down"); + }, + createRepBlob: async () => ({ id: "rep-1" }) + }); + + const req = { + query: { hash: "expected" }, + body: { + appealData: { key: "value" }, + containerID: ["c1"], + casefolderID: ["f1"], + repOrAppeal: false + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "UPLOAD_FAILED"); +}); + +test("setupcontainer dependency failure returns SETUP_CONTAINER_FAILED", async () => { + const mod = loadModule("pages/api/file/setupcontainer.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + consoleLogger: () => {}, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + createContainer: async () => { + throw new Error("container failed"); + } + }); + + const req = { query: { ident: "cont-1", hash: "expected" } }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "SETUP_CONTAINER_FAILED"); +}); + +const run = async () => { + let passed = 0; + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + console.log( + `Phase 21 file-handler contract tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +}