diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 9e63b68c..7665a61c 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -129,3 +129,49 @@ Follow-ups: - Continue with remaining non-standard API outlier(s), notably `pages/api/file/generateappealpdfcopy.js`. - Keep future slices logged in this file at commit-time now that memory-bank is versioned. + +--- + +### CL-004: TASK22224 file + static endpoint contract hardening bundle (phase21) + +date: 2026-03-23 +author: Cline +scope: `pages/api/file/{downloadblob,generateappealpdfcopy}.js`, `pages/api/endpoint/{getsipsmedia_api,getappealtypesfornewappeal_api}.js`, `tests/phase21/{file-handler-contract,endpoint-handler-contract}.test.cjs` +type: change +rationale: Deliver the agreed larger bounded slice for remaining non-standard file/static handlers, improving negative-path consistency while preserving current success payload behavior. +impact: Standardized error envelopes/codes for download and generated PDF copy flows, method guard parity for static endpoints, and expanded phase21 contract coverage for both file and endpoint handlers. +status: completed + +Summary: + +- `downloadblob.js`: + - added explicit catch-path response via `respondError` with `DOWNLOAD_BLOB_FAILED` + - kept success behavior intact (attachment header + raw file body) + - removed dead internal helper (`streamToBuffer`) and tightened local declarations +- `generateappealpdfcopy.js`: + - removed unused imports/noisy console warnings + - standardized required-input and negative-path contracts: + - `INCIDENT_ID_REQUIRED` (400) + - `CASE_NOT_FOUND` (404) + - `FORM_COLLECTION_NOT_FOUND` (400) + - `APPEAL_PDF_COPY_GENERATION_FAILED` (400) + - preserved success output contract (PDF content headers + buffer body) +- `getsipsmedia_api.js` and `getappealtypesfornewappeal_api.js`: + - added method guard for non-GET requests using `METHOD_NOT_ALLOWED` (405) + - preserved existing GET success payloads +- Expanded phase21 tests: + - `file-handler-contract.test.cjs`: added coverage for download failure + full generated PDF copy contract/negative paths + - `endpoint-handler-contract.test.cjs`: added method guard tests for both static endpoints + +Validation: + +- `node tests/phase21/api-contract-slice1.test.cjs` -> pass + - helper: 4/4 + - file-handler: 17/17 + - email-handler: 12/12 + - endpoint-handler: 149/149 +- `npm run lint` -> warnings only (pre-existing `react-hooks/exhaustive-deps`; no new lint errors) + +Follow-ups: + +- If desired, next slice can target remaining file-route parity candidates outside this bundle, but this closes the planned TASK22224 scope. diff --git a/pages/api/endpoint/getappealtypesfornewappeal_api.js b/pages/api/endpoint/getappealtypesfornewappeal_api.js index 06dde026..bbc4afc3 100644 --- a/pages/api/endpoint/getappealtypesfornewappeal_api.js +++ b/pages/api/endpoint/getappealtypesfornewappeal_api.js @@ -1,4 +1,4 @@ -import { respondSuccess } from "../middleware/apiResponse"; +import { respondError, respondSuccess } from "../middleware/apiResponse"; /** * @swagger @@ -13,6 +13,14 @@ import { respondSuccess } from "../middleware/apiResponse"; */ const ApiResponse = (req, res) => { + if (req.method && req.method !== "GET") { + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Only GET is supported" + }); + } + return respondSuccess(res, { "value": [ { diff --git a/pages/api/endpoint/getsipsmedia_api.js b/pages/api/endpoint/getsipsmedia_api.js index 1ef461b4..deb143ad 100644 --- a/pages/api/endpoint/getsipsmedia_api.js +++ b/pages/api/endpoint/getsipsmedia_api.js @@ -1,29 +1,14 @@ -import { respondSuccess } from "../middleware/apiResponse"; +import { respondError, respondSuccess } from "../middleware/apiResponse"; -// export default async function ApiProxy(req, res) { -// var caseid = req.query.caseid; -// var token = await getToken(); +export default function ApiProxy(req, res) { + if (req.method && req.method !== "GET") { + return respondError(res, { + status: 405, + code: "METHOD_NOT_ALLOWED", + message: "Only GET is supported" + }); + } -// var queryUrl = -// "pinswg_sipsevents?$filter=_pinswg_sipseventsid_value eq " + -// caseid + -// "&$count=true"; - -// return axios -// .get( -// WEBAPI_URL + queryUrl + hashAPIPath(queryUrl), -// azureHeaders(token.access_token), -// ) -// .then(({ data }) => { -// res.status(200).json(data); -// }) -// .catch((error) => { -// consoleLogger(error); -// res.status(400).json(error); -// }); -// } - -export default function ApiProx(req, res) { const data = { "@odata.count": 4, "value": [ diff --git a/pages/api/file/downloadblob.js b/pages/api/file/downloadblob.js index f7683731..e3cc0ff0 100644 --- a/pages/api/file/downloadblob.js +++ b/pages/api/file/downloadblob.js @@ -10,10 +10,10 @@ const ApiProxy = nextConnect(); ApiProxy.use(middleware); ApiProxy.get(async (req, res) => { - var containerName = req.query.container; - var casefolderID = req.query.casefolderID; - var blobName = req.query.blobname; - var checkHash = req.query.hash; + const containerName = req.query.container; + const casefolderID = req.query.casefolderID; + const blobName = req.query.blobname; + const checkHash = req.query.hash; if ( typeof containerName === "undefined" || @@ -32,7 +32,7 @@ ApiProxy.get(async (req, res) => { }); } - var checkquerypath = + const checkquerypath = "/api/file/downloadblob?container=" + containerName + "&casefolderID=" + @@ -48,34 +48,29 @@ ApiProxy.get(async (req, res) => { }); } - const bloblocation = - casefolderID + (blobName.indexOf(".json") > 0 ? "/" : "/files/"); + try { + const bloblocation = + casefolderID + (blobName.indexOf(".json") > 0 ? "/" : "/files/"); - const downloaded = await downloadFile( - containerName, - bloblocation + decodeURI(blobName) - ); + const downloaded = await downloadFile( + containerName, + bloblocation + decodeURI(blobName) + ); - res.setHeader( - "content-disposition", - "attachment; filename=" + decodeURI(blobName) - ); - return res.status(200).send(downloaded); + res.setHeader( + "content-disposition", + "attachment; filename=" + decodeURI(blobName) + ); + return res.status(200).send(downloaded); + } catch (error) { + return respondError(res, { + status: 400, + code: "DOWNLOAD_BLOB_FAILED", + message: "Unable to download blob" + }); + } }); -async function streamToBuffer(readableStream) { - return new Promise((resolve, reject) => { - const chunks = []; - readableStream.on("data", (data) => { - chunks.push(data instanceof Buffer ? data : Buffer.from(data)); - }); - readableStream.on("end", () => { - resolve(Buffer.concat(chunks)); - }); - readableStream.on("error", reject); - }); -} - export const config = { api: { bodyParser: false diff --git a/pages/api/file/generateappealpdfcopy.js b/pages/api/file/generateappealpdfcopy.js index 4a1b7778..a3c87d83 100644 --- a/pages/api/file/generateappealpdfcopy.js +++ b/pages/api/file/generateappealpdfcopy.js @@ -1,45 +1,17 @@ -import { - createContainer, - getContainers, - getBlobs, - createRepBlob, - uploadFile, - uploadPDFAppealFiles, - downloadProgressFile, - getProgressBlobs, - getTempCaseBlob, - createAppealPDFBlob -} from "../../../actions/azurestorage"; import { getCaseByID, getPortalModuleDetails, getAppealPDFDocs } from "../../../actions/services/caseService"; import { getFormCollectionByID } from "../../../components/utils"; -import ReactPDF, { - Document, - Page, - Text, - View, - StyleSheet, - PDFViewer, - pdf -} from "@react-pdf/renderer"; - -import middleware from "../middleware/middleware"; -import nextConnect from "next-connect"; -import fs from "fs"; -import path from "path"; +import { pdf } from "@react-pdf/renderer"; import { getPickLists } from "../../../actions/services/referenceDataService"; import { planningappeals78_pdf } from "../../../components/pdftemplates/planningappeals78_pdf"; -import { finalComments_pdf } from "../../../components/pdftemplates/finalComments_pdf"; -import { statement_pdf } from "../../../components/pdftemplates/statement_pdf"; -import { writtenStatement_pdf } from "../../../components/pdftemplates/writtenStatement_pdf"; import { other_pdf } from "../../../components/pdftemplates/other_pdf"; +import { respondError } from "../middleware/apiResponse"; -const getDetails = (resultsObj, detailsType) => { +const getDetails = (resultsObj) => { if (!Array.isArray(resultsObj)) { - console.warn("getDetails: resultsObj is not an array"); return Promise.resolve([]); } @@ -51,7 +23,6 @@ const getDetails = (resultsObj, detailsType) => { const form = getFormCollectionByID(appealType); if (!form) { - console.warn("No form found for appeal type:", appealType); return null; } @@ -64,54 +35,74 @@ const getDetails = (resultsObj, detailsType) => { export default async function handler(req, res) { const incidentid = req.body?.docProps; - if (!incidentid) return res.status(400).send("Missing incident ID"); - - const caseObj = await getCaseByID(incidentid); - if (!Array.isArray(caseObj) || caseObj.length === 0) - return res.status(404).send("Case not found"); - - const caseDetailsObj = await getDetails(caseObj, "myCases"); - const fileList = (await getAppealPDFDocs(incidentid)) || []; - - // Inject into the first item in `value` array - - if (caseDetailsObj[0]?.value?.[0]) { - caseDetailsObj[0].value[0].pinswg_developmentdescription = - caseObj[0].description; - caseDetailsObj[0].value[0].caseObj = caseObj[0]; - caseDetailsObj[0].value[0].filesList = fileList; + if (!incidentid) { + return respondError(res, { + status: 400, + code: "INCIDENT_ID_REQUIRED", + message: "Incident id is required" + }); } - const whichForm = getFormCollectionByID(caseObj[0].pinswg_appealcasetype); - const pickListData = await getPickLists(whichForm.UrlName); + try { + const caseObj = await getCaseByID(incidentid); + if (!Array.isArray(caseObj) || caseObj.length === 0) { + return respondError(res, { + status: 404, + code: "CASE_NOT_FOUND", + message: "Case not found" + }); + } - const appealTypeMap = { - 846040000: planningappeals78_pdf, - 846040004: planningappeals78_pdf - }; + const caseDetailsObj = await getDetails(caseObj); + const fileList = (await getAppealPDFDocs(incidentid)) || []; - //console.log(caseDetailsObj[0].value[0]); + if (caseDetailsObj[0]?.value?.[0]) { + caseDetailsObj[0].value[0].pinswg_developmentdescription = + caseObj[0].description; + caseDetailsObj[0].value[0].caseObj = caseObj[0]; + caseDetailsObj[0].value[0].filesList = fileList; + } - const MyDocument = (values) => { - const type = caseObj[0].pinswg_appealcasetype; - const renderDocument = appealTypeMap[type] || other_pdf; - return renderDocument(values); - }; + const whichForm = getFormCollectionByID( + caseObj[0].pinswg_appealcasetype + ); + if (!whichForm?.UrlName) { + return respondError(res, { + status: 400, + code: "FORM_COLLECTION_NOT_FOUND", + message: "Unable to resolve form collection" + }); + } + const pickListData = await getPickLists(whichForm.UrlName); - const pdfComponent = MyDocument({ - docProps: caseDetailsObj[0].value[0], - pickListData - }); + const appealTypeMap = { + 846040000: planningappeals78_pdf, + 846040004: planningappeals78_pdf + }; - const pdfBuffer = await pdf(pdfComponent).toBuffer(); + const renderDocument = + appealTypeMap[caseObj[0].pinswg_appealcasetype] || other_pdf; + const pdfComponent = renderDocument({ + docProps: caseDetailsObj?.[0]?.value?.[0] || {}, + pickListData + }); - const repDate = new Date(); - const formattedDate = repDate.toISOString().split("T")[0]; + const pdfBuffer = await pdf(pdfComponent).toBuffer(); - res.setHeader("Content-Type", "application/pdf"); - res.setHeader( - "Content-Disposition", - `attachment; filename=${formattedDate}_-_Appeal_Form.pdf` - ); - res.send(pdfBuffer); + const repDate = new Date(); + const formattedDate = repDate.toISOString().split("T")[0]; + + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + `attachment; filename=${formattedDate}_-_Appeal_Form.pdf` + ); + return res.send(pdfBuffer); + } catch (error) { + return respondError(res, { + status: 400, + code: "APPEAL_PDF_COPY_GENERATION_FAILED", + message: "Unable to generate appeal PDF copy" + }); + } } diff --git a/tests/phase21/endpoint-handler-contract.test.cjs b/tests/phase21/endpoint-handler-contract.test.cjs index 0b3e09e1..bb523855 100644 --- a/tests/phase21/endpoint-handler-contract.test.cjs +++ b/tests/phase21/endpoint-handler-contract.test.cjs @@ -3688,6 +3688,37 @@ test("getsipsmedia returns success contract", async () => { assert.strictEqual(res.state.jsonBody["@odata.count"], 4); }); +test("getsipsmedia returns METHOD_NOT_ALLOWED for non-GET methods", async () => { + const mod = loadModule("pages/api/endpoint/getsipsmedia_api.js", { + respondSuccess: respondSuccessMock, + respondError: respondErrorMock + }); + + const req = { method: "POST", query: {} }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 405); + assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED"); +}); + +test("getappealtypesfornewappeal returns METHOD_NOT_ALLOWED for non-GET methods", async () => { + const mod = loadModule( + "pages/api/endpoint/getappealtypesfornewappeal_api.js", + { + respondSuccess: respondSuccessMock, + respondError: respondErrorMock + } + ); + + const req = { method: "POST", query: {} }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 405); + assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED"); +}); + const run = async () => { let passed = 0; for (const currentTest of tests) { diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index cc0f9ee9..18d573aa 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -263,6 +263,175 @@ test("setupcontainer dependency failure returns SETUP_CONTAINER_FAILED", async ( assert.strictEqual(res.state.jsonBody.error.code, "SETUP_CONTAINER_FAILED"); }); +test("downloadblob dependency failure returns DOWNLOAD_BLOB_FAILED", async () => { + const mod = loadModule("pages/api/file/downloadblob.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + downloadFile: async () => { + throw new Error("download failed"); + } + }); + + 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, 400); + assert.strictEqual(res.state.jsonBody.error.code, "DOWNLOAD_BLOB_FAILED"); +}); + +test("generateappealpdfcopy returns INCIDENT_ID_REQUIRED when docProps missing", async () => { + const mod = loadModule("pages/api/file/generateappealpdfcopy.js", { + respondError: respondErrorMock, + getCaseByID: async () => [], + getPortalModuleDetails: async () => ({ value: [{}] }), + getAppealPDFDocs: async () => [], + getFormCollectionByID: () => ({ UrlName: "planningappeals78" }), + getPickLists: async () => ({}), + planningappeals78_pdf: () => ({}), + other_pdf: () => ({}), + pdf: () => ({ toBuffer: async () => Buffer.from("pdf") }) + }); + + const req = { body: {} }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INCIDENT_ID_REQUIRED"); +}); + +test("generateappealpdfcopy returns CASE_NOT_FOUND when case lookup is empty", async () => { + const mod = loadModule("pages/api/file/generateappealpdfcopy.js", { + respondError: respondErrorMock, + getCaseByID: async () => [], + getPortalModuleDetails: async () => ({ value: [{}] }), + getAppealPDFDocs: async () => [], + getFormCollectionByID: () => ({ UrlName: "planningappeals78" }), + getPickLists: async () => ({}), + planningappeals78_pdf: () => ({}), + other_pdf: () => ({}), + pdf: () => ({ toBuffer: async () => Buffer.from("pdf") }) + }); + + const req = { body: { docProps: "i1" } }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 404); + assert.strictEqual(res.state.jsonBody.error.code, "CASE_NOT_FOUND"); +}); + +test("generateappealpdfcopy returns FORM_COLLECTION_NOT_FOUND when collection is unresolved", async () => { + const mod = loadModule("pages/api/file/generateappealpdfcopy.js", { + respondError: respondErrorMock, + getCaseByID: async () => [ + { + pinswg_appealcasetype: 846040000, + ticketnumber: "CAS-1", + description: "desc" + } + ], + getPortalModuleDetails: async () => ({ value: [{}] }), + getAppealPDFDocs: async () => [], + getFormCollectionByID: () => null, + getPickLists: async () => ({}), + planningappeals78_pdf: () => ({}), + other_pdf: () => ({}), + pdf: () => ({ toBuffer: async () => Buffer.from("pdf") }) + }); + + const req = { body: { docProps: "i1" } }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + "FORM_COLLECTION_NOT_FOUND" + ); +}); + +test("generateappealpdfcopy success returns pdf buffer and headers", async () => { + const mod = loadModule("pages/api/file/generateappealpdfcopy.js", { + respondError: respondErrorMock, + getCaseByID: async () => [ + { + pinswg_appealcasetype: 846040000, + ticketnumber: "CAS-1", + description: "desc" + } + ], + getPortalModuleDetails: async () => ({ value: [{}] }), + getAppealPDFDocs: async () => [{ id: "d1" }], + getFormCollectionByID: () => ({ + UrlName: "planningappeals78", + LogicalCollectionName: "pinswg_planningappeals78s" + }), + getPickLists: async () => ({ a: 1 }), + planningappeals78_pdf: () => ({ type: "pdfComponent" }), + other_pdf: () => ({ type: "otherComponent" }), + pdf: () => ({ toBuffer: async () => Buffer.from("pdf-content") }) + }); + + const req = { body: { docProps: "i1" } }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.headers["content-type"], "application/pdf"); + assert.strictEqual( + typeof res.state.headers["content-disposition"], + "string" + ); + assert.strictEqual(res.state.sentBody.toString(), "pdf-content"); +}); + +test("generateappealpdfcopy catch path returns APPEAL_PDF_COPY_GENERATION_FAILED", async () => { + const mod = loadModule("pages/api/file/generateappealpdfcopy.js", { + respondError: respondErrorMock, + getCaseByID: async () => [ + { + pinswg_appealcasetype: 846040000, + ticketnumber: "CAS-1", + description: "desc" + } + ], + getPortalModuleDetails: async () => ({ value: [{}] }), + getAppealPDFDocs: async () => [{ id: "d1" }], + getFormCollectionByID: () => ({ + UrlName: "planningappeals78", + LogicalCollectionName: "pinswg_planningappeals78s" + }), + getPickLists: async () => ({ a: 1 }), + planningappeals78_pdf: () => ({ type: "pdfComponent" }), + other_pdf: () => ({ type: "otherComponent" }), + pdf: () => ({ + toBuffer: async () => { + throw new Error("pdf failure"); + } + }) + }); + + const req = { body: { docProps: "i1" } }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + "APPEAL_PDF_COPY_GENERATION_FAILED" + ); +}); + const run = async () => { let passed = 0; for (const currentTest of tests) {