From 9dcc473e784407380b56e77566e1c2db243a6ff8 Mon Sep 17 00:00:00 2001 From: Hoid Date: Thu, 26 Feb 2026 07:02:57 +0000 Subject: [PATCH 001/110] fix: replace misleading SDK claims with honest code examples messaging --- public/app.js | 2 +- public/index.html | 4 +-- public/src/index.html | 4 +-- src/__tests__/email.test.ts | 59 +++++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/email.test.ts diff --git a/public/app.js b/public/app.js index 86e9023..6ddc5a1 100644 --- a/public/app.js +++ b/public/app.js @@ -1 +1 @@ -var recoverEmail="";function showRecoverState(e){["recoverInitial","recoverLoading","recoverVerify","recoverResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openRecover(){document.getElementById("recoverModal").classList.add("active"),showRecoverState("recoverInitial");var e=document.getElementById("recoverError");e&&(e.style.display="none");var t=document.getElementById("recoverVerifyError");t&&(t.style.display="none"),document.getElementById("recoverEmailInput").value="",document.getElementById("recoverCode").value="",recoverEmail="",setTimeout(function(){document.getElementById("recoverEmailInput").focus()},100)}function closeRecover(){document.getElementById("recoverModal").classList.remove("active")}async function submitRecover(){var e=document.getElementById("recoverError"),t=document.getElementById("recoverBtn"),n=document.getElementById("recoverEmailInput").value.trim();if(!n||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showRecoverState("recoverLoading");try{var a=await fetch("/v1/recover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})}),o=await a.json();if(!a.ok)return showRecoverState("recoverInitial"),e.textContent=o.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);recoverEmail=n,document.getElementById("recoverEmailDisplay").textContent=n,showRecoverState("recoverVerify"),document.getElementById("recoverCode").focus(),t.disabled=!1}catch(n){showRecoverState("recoverInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitRecoverVerify(){var e=document.getElementById("recoverVerifyError"),t=document.getElementById("recoverVerifyBtn"),n=document.getElementById("recoverCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/recover/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:recoverEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);if(o.apiKey){document.getElementById("recoveredKeyText").textContent=o.apiKey,showRecoverState("recoverResult");var i=document.querySelector("#recoverResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}else e.textContent=o.message||"No key found for this email.",e.style.display="block",t.disabled=!1}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}function copyRecoveredKey(){doCopy(document.getElementById("recoveredKeyText").textContent,document.getElementById("copyRecoveredBtn"))}function doCopy(e,t){function n(){t.textContent="✓ Copied!",setTimeout(function(){t.textContent="Copy"},2e3)}function a(){t.textContent="Failed",setTimeout(function(){t.textContent="Copy"},2e3)}try{navigator.clipboard&&window.isSecureContext?navigator.clipboard.writeText(e).then(n).catch(function(){fallbackCopy(e,n,a)}):fallbackCopy(e,n,a)}catch(e){a()}}function fallbackCopy(e,t,n){try{var a=document.createElement("textarea");a.value=e,a.style.position="fixed",a.style.opacity="0",a.style.top="-9999px",document.body.appendChild(a),a.focus(),a.select();var o=document.execCommand("copy");document.body.removeChild(a),o?t():n()}catch(e){n()}}async function checkout(){try{var e=await fetch("/v1/billing/checkout",{method:"POST"}),t=await e.json();t.url?window.location.href=t.url:alert("Checkout is not available yet. Please try again later.")}catch(e){alert("Something went wrong. Please try again.")}}var pgTemplates={invoice:'\n\n\n\n\n
\n
\n
Acme Corp
\n
123 Business Ave, Suite 100
San Francisco, CA 94102
\n
\n
\n
Invoice
\n
#INV-2024-0042
\n
Feb 20, 2026
\n
\n
\n
\n
Bill To
Jane Smith
456 Client Road
New York, NY 10001
\n
Payment Due
March 20, 2026
Payment Method
Bank Transfer
\n
\n \n \n \n \n \n \n \n \n
DescriptionQtyRateAmount
Web Development — Landing Page40 hrs$150$6,000
UI/UX Design — Mockups16 hrs$125$2,000
API Integration & Testing24 hrs$150$3,600
Total$11,600
\n \n\n',report:'\n\n\n\n\n

Q4 2025 Performance Report

\n
Prepared by Analytics Team — February 2026
\n
\n
142%
Revenue Growth
\n
2.4M
API Calls
\n
99.9%
Uptime
\n
\n

Executive Summary

\n

Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

\n
\n

🎯 Key Achievement

\n

Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

\n
\n

Product Updates

\n

Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

\n

Outlook

\n

Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

\n\n',custom:"\n\n\n\n\n

Hello World!

\n

Edit this HTML and watch the preview update in real time.

\n

Then click Generate PDF to download it.

\n\n"},previewDebounce=null;function updatePreview(){var e=document.getElementById("demoPreview"),t=document.getElementById("demoHtml").value;if(e){var n=e.contentDocument||e.contentWindow.document;n.open(),n.write(t),n.close()}}function setTemplate(e){document.getElementById("demoHtml").value=pgTemplates[e]||pgTemplates.custom,updatePreview(),document.querySelectorAll(".pg-tab").forEach(function(t){var n=t.getAttribute("data-template")===e;t.classList.toggle("active",n),t.setAttribute("aria-selected",n?"true":"false")})}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void n.classList.remove("visible");a.style.display="none",n.classList.remove("visible"),e.disabled=!0,e.classList.add("pg-generating"),t.textContent="Generating…";var i=performance.now();try{var r=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!r.ok){var l=await r.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),void(t.textContent="")}var d=((performance.now()-i)/1e3).toFixed(1),c=await r.blob(),s=URL.createObjectURL(c);document.getElementById("demoDownload").href=s,document.getElementById("demoTime").textContent=d,n.classList.add("visible"),t.textContent="",e.disabled=!1,e.classList.remove("pg-generating")}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.querySelectorAll(".pg-tab").forEach(function(e){e.addEventListener("click",function(){setTemplate(this.getAttribute("data-template"))})}),setTemplate("invoice"),document.getElementById("demoHtml").addEventListener("input",function(){clearTimeout(previewDebounce),previewDebounce=setTimeout(updatePreview,150)});var e=document.getElementById("btn-checkout-playground");e&&e.addEventListener("click",checkout),document.getElementById("btn-checkout").addEventListener("click",checkout);var t=document.getElementById("btn-checkout-hero");t&&t.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){"demoDownload"!==e.id&&e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t\n\n\n\n\n
\n
\n
Acme Corp
\n
123 Business Ave, Suite 100
San Francisco, CA 94102
\n
\n
\n
Invoice
\n
#INV-2024-0042
\n
Feb 20, 2026
\n
\n
\n
\n
Bill To
Jane Smith
456 Client Road
New York, NY 10001
\n
Payment Due
March 20, 2026
Payment Method
Bank Transfer
\n
\n \n \n \n \n \n \n \n \n
DescriptionQtyRateAmount
Web Development — Landing Page40 hrs$150$6,000
UI/UX Design — Mockups16 hrs$125$2,000
API Integration & Testing24 hrs$150$3,600
Total$11,600
\n \n\n',report:'\n\n\n\n\n

Q4 2025 Performance Report

\n
Prepared by Analytics Team — February 2026
\n
\n
142%
Revenue Growth
\n
2.4M
API Calls
\n
99.9%
Uptime
\n
\n

Executive Summary

\n

Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

\n
\n

🎯 Key Achievement

\n

Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

\n
\n

Product Updates

\n

Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

\n

Outlook

\n

Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

\n\n',custom:"\n\n\n\n\n

Hello World!

\n

Edit this HTML and watch the preview update in real time.

\n

Then click Generate PDF to download it.

\n\n"},previewDebounce=null;function updatePreview(){var e=document.getElementById("demoPreview"),t=document.getElementById("demoHtml").value;if(e){var n=e.contentDocument||e.contentWindow.document;n.open(),n.write(t),n.close()}}function setTemplate(e){document.getElementById("demoHtml").value=pgTemplates[e]||pgTemplates.custom,updatePreview(),document.querySelectorAll(".pg-tab").forEach(function(t){var n=t.getAttribute("data-template")===e;t.classList.toggle("active",n),t.setAttribute("aria-selected",n?"true":"false")})}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void n.classList.remove("visible");a.style.display="none",n.classList.remove("visible"),e.disabled=!0,e.classList.add("pg-generating"),t.textContent="Generating…";var i=performance.now();try{var r=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!r.ok){var l=await r.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),void(t.textContent="")}var d=((performance.now()-i)/1e3).toFixed(1),c=await r.blob(),s=URL.createObjectURL(c);document.getElementById("demoDownload").href=s,document.getElementById("demoTime").textContent=d,n.classList.add("visible"),t.textContent="",e.disabled=!1,e.classList.remove("pg-generating")}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.querySelectorAll(".pg-tab").forEach(function(e){e.addEventListener("click",function(){setTemplate(this.getAttribute("data-template"))})}),setTemplate("invoice"),document.getElementById("demoHtml").addEventListener("input",function(){clearTimeout(previewDebounce),previewDebounce=setTimeout(updatePreview,150)});var e=document.getElementById("btn-checkout-playground");e&&e.addEventListener("click",checkout),document.getElementById("btn-checkout").addEventListener("click",checkout);var t=document.getElementById("btn-checkout-hero");t&&t.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){"demoDownload"!==e.id&&e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t

Everything you need

-

Official SDKs for Node.js, Python, Go, PHP, and Laravel. Or just use curl.

+

Code examples for Node.js, Python, Go, PHP, and cURL. Official SDKs coming soon.

diff --git a/public/src/index.html b/public/src/index.html index b8a3d8a..3e75c7d 100644 --- a/public/src/index.html +++ b/public/src/index.html @@ -60,7 +60,7 @@ "name": "Do you have official SDKs?", "acceptedAnswer": { "@type": "Answer", - "text": "Yes, DocFast provides official SDKs for Node.js, Python, Go, PHP, and Laravel. You can also use the REST API directly with curl or any HTTP client." + "text": "DocFast provides code examples for Node.js, Python, Go, PHP, and cURL. Official SDK packages are coming soon. You can use the REST API directly with any HTTP client." } } ] @@ -450,7 +450,7 @@ html, body {

Everything you need

-

Official SDKs for Node.js, Python, Go, PHP, and Laravel. Or just use curl.

+

Code examples for Node.js, Python, Go, PHP, and cURL. Official SDKs coming soon.

diff --git a/src/__tests__/email.test.ts b/src/__tests__/email.test.ts new file mode 100644 index 0000000..b58d15c --- /dev/null +++ b/src/__tests__/email.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock nodemailer before importing email service +const mockSendMail = vi.fn(); +vi.mock("nodemailer", () => ({ + default: { + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), + }, +})); + +// Mock logger +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +import { sendVerificationEmail } from "../services/email.js"; + +describe("Email Service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("sendVerificationEmail", () => { + it("constructs correct email with code", async () => { + mockSendMail.mockResolvedValueOnce({ messageId: "test-123" }); + + const result = await sendVerificationEmail("user@example.com", "654321"); + + expect(result).toBe(true); + expect(mockSendMail).toHaveBeenCalledOnce(); + + const mailOptions = mockSendMail.mock.calls[0][0]; + expect(mailOptions.to).toBe("user@example.com"); + expect(mailOptions.subject).toContain("DocFast"); + expect(mailOptions.subject).toContain("Verify"); + expect(mailOptions.text).toContain("654321"); + expect(mailOptions.html).toContain("654321"); + }); + + it("returns false when SMTP fails", async () => { + mockSendMail.mockRejectedValueOnce(new Error("SMTP connection refused")); + + const result = await sendVerificationEmail("user@example.com", "123456"); + + expect(result).toBe(false); + }); + + it("includes expiry notice in email body", async () => { + mockSendMail.mockResolvedValueOnce({ messageId: "test-456" }); + + await sendVerificationEmail("user@example.com", "111111"); + + const mailOptions = mockSendMail.mock.calls[0][0]; + expect(mailOptions.text).toContain("15 minutes"); + }); + }); +}); From 1a37765f41895d193c85f4d4f38a57660a41be18 Mon Sep 17 00:00:00 2001 From: Hoid Date: Thu, 26 Feb 2026 07:04:39 +0000 Subject: [PATCH 002/110] add verification service and email service tests (13 new tests) --- src/__tests__/email.test.ts | 30 ++++---- src/__tests__/verification.test.ts | 120 +++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 src/__tests__/verification.test.ts diff --git a/src/__tests__/email.test.ts b/src/__tests__/email.test.ts index b58d15c..346b75f 100644 --- a/src/__tests__/email.test.ts +++ b/src/__tests__/email.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -// Mock nodemailer before importing email service -const mockSendMail = vi.fn(); +vi.unmock("../services/email.js"); + +const { mockSendMail } = vi.hoisted(() => ({ + mockSendMail: vi.fn(), +})); + vi.mock("nodemailer", () => ({ default: { createTransport: vi.fn(() => ({ @@ -10,7 +14,6 @@ vi.mock("nodemailer", () => ({ }, })); -// Mock logger vi.mock("../services/logger.js", () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, })); @@ -25,35 +28,28 @@ describe("Email Service", () => { describe("sendVerificationEmail", () => { it("constructs correct email with code", async () => { mockSendMail.mockResolvedValueOnce({ messageId: "test-123" }); - const result = await sendVerificationEmail("user@example.com", "654321"); expect(result).toBe(true); expect(mockSendMail).toHaveBeenCalledOnce(); - - const mailOptions = mockSendMail.mock.calls[0][0]; - expect(mailOptions.to).toBe("user@example.com"); - expect(mailOptions.subject).toContain("DocFast"); - expect(mailOptions.subject).toContain("Verify"); - expect(mailOptions.text).toContain("654321"); - expect(mailOptions.html).toContain("654321"); + const opts = mockSendMail.mock.calls[0][0]; + expect(opts.to).toBe("user@example.com"); + expect(opts.subject).toContain("Verify"); + expect(opts.text).toContain("654321"); + expect(opts.html).toContain("654321"); }); it("returns false when SMTP fails", async () => { mockSendMail.mockRejectedValueOnce(new Error("SMTP connection refused")); - const result = await sendVerificationEmail("user@example.com", "123456"); - expect(result).toBe(false); }); it("includes expiry notice in email body", async () => { mockSendMail.mockResolvedValueOnce({ messageId: "test-456" }); - await sendVerificationEmail("user@example.com", "111111"); - - const mailOptions = mockSendMail.mock.calls[0][0]; - expect(mailOptions.text).toContain("15 minutes"); + const opts = mockSendMail.mock.calls[0][0]; + expect(opts.text).toContain("15 minutes"); }); }); }); diff --git a/src/__tests__/verification.test.ts b/src/__tests__/verification.test.ts new file mode 100644 index 0000000..50187fd --- /dev/null +++ b/src/__tests__/verification.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Unmock verification service (setup.ts mocks it globally) +vi.unmock("../services/verification.js"); + +const { mockQueryWithRetry } = vi.hoisted(() => ({ + mockQueryWithRetry: vi.fn(), +})); + +vi.mock("../services/db.js", () => ({ + default: { on: vi.fn(), end: vi.fn() }, + pool: { on: vi.fn(), end: vi.fn() }, + queryWithRetry: mockQueryWithRetry, + connectWithRetry: vi.fn(), + initDatabase: vi.fn(), + cleanupStaleData: vi.fn(), + isTransientError: vi.fn(), +})); + +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +import { verifyCode, createPendingVerification } from "../services/verification.js"; + +describe("Verification Service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("verifyCode", () => { + it('returns "invalid" for non-existent email', async () => { + mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const result = await verifyCode("nobody@example.com", "123456"); + expect(result.status).toBe("invalid"); + }); + + it('returns "expired" for expired codes', async () => { + const pastDate = new Date(Date.now() - 20 * 60 * 1000).toISOString(); + mockQueryWithRetry + .mockResolvedValueOnce({ + rows: [{ email: "test@example.com", code: "123456", created_at: pastDate, expires_at: pastDate, attempts: 0 }], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const result = await verifyCode("test@example.com", "123456"); + expect(result.status).toBe("expired"); + }); + + it('returns "max_attempts" after 3 wrong attempts', async () => { + const future = new Date(Date.now() + 10 * 60 * 1000).toISOString(); + mockQueryWithRetry + .mockResolvedValueOnce({ + rows: [{ email: "test@example.com", code: "123456", created_at: new Date().toISOString(), expires_at: future, attempts: 3 }], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const result = await verifyCode("test@example.com", "999999"); + expect(result.status).toBe("max_attempts"); + }); + + it('returns "ok" for correct code (timing-safe comparison)', async () => { + const future = new Date(Date.now() + 10 * 60 * 1000).toISOString(); + mockQueryWithRetry + .mockResolvedValueOnce({ + rows: [{ email: "test@example.com", code: "654321", created_at: new Date().toISOString(), expires_at: future, attempts: 0 }], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const result = await verifyCode("test@example.com", "654321"); + expect(result.status).toBe("ok"); + }); + + it('returns "invalid" for wrong code', async () => { + const future = new Date(Date.now() + 10 * 60 * 1000).toISOString(); + mockQueryWithRetry + .mockResolvedValueOnce({ + rows: [{ email: "test@example.com", code: "654321", created_at: new Date().toISOString(), expires_at: future, attempts: 0 }], + rowCount: 1, + }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const result = await verifyCode("test@example.com", "000000"); + expect(result.status).toBe("invalid"); + }); + + it("normalizes email to lowercase and trims whitespace", async () => { + mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }); + await verifyCode(" Test@Example.COM ", "123456"); + expect(mockQueryWithRetry).toHaveBeenCalledWith(expect.stringContaining("SELECT"), ["test@example.com"]); + }); + }); + + describe("createPendingVerification", () => { + it("generates a 6-digit code", async () => { + mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const result = await createPendingVerification("test@example.com"); + expect(result.code).toMatch(/^\d{6}$/); + }); + + it("replaces existing pending verification for same email", async () => { + mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ rows: [], rowCount: 0 }); + await createPendingVerification("test@example.com"); + expect(mockQueryWithRetry).toHaveBeenCalledWith(expect.stringContaining("DELETE FROM pending_verifications"), ["test@example.com"]); + }); + + it("sets expiry 15 minutes in the future", async () => { + mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const result = await createPendingVerification("test@example.com"); + const diff = new Date(result.expiresAt).getTime() - new Date(result.createdAt).getTime(); + expect(diff).toBe(15 * 60 * 1000); + }); + + it("initializes attempts to 0", async () => { + mockQueryWithRetry.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({ rows: [], rowCount: 0 }); + const result = await createPendingVerification("test@example.com"); + expect(result.attempts).toBe(0); + }); + }); +}); From 1aea9c872cf198c83664aa6dfbd0eb63b24ba998 Mon Sep 17 00:00:00 2001 From: Hoid Date: Thu, 26 Feb 2026 10:03:31 +0000 Subject: [PATCH 003/110] test: add auth, rate-limit, and keys service tests --- src/__tests__/auth.test.ts | 85 ++++++++++++++++ src/__tests__/keys.test.ts | 108 ++++++++++++++++++++ src/__tests__/pdfRateLimit.test.ts | 153 +++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 src/__tests__/auth.test.ts create mode 100644 src/__tests__/keys.test.ts create mode 100644 src/__tests__/pdfRateLimit.test.ts diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts new file mode 100644 index 0000000..bb11cda --- /dev/null +++ b/src/__tests__/auth.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { authMiddleware } from "../middleware/auth.js"; +import { isValidKey, getKeyInfo } from "../services/keys.js"; + +const mockJson = vi.fn(); +const mockStatus = vi.fn(() => ({ json: mockJson })); +const mockNext = vi.fn(); + +function makeReq(headers: Record = {}): any { + return { headers }; +} + +function makeRes(): any { + mockJson.mockClear(); + mockStatus.mockClear(); + return { status: mockStatus, json: mockJson }; +} + +describe("authMiddleware", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when no auth header and no x-api-key", () => { + const req = makeReq(); + const res = makeRes(); + authMiddleware(req, res, mockNext); + expect(mockStatus).toHaveBeenCalledWith(401); + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining("Missing API key") }) + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("returns 403 when Bearer token is invalid", () => { + vi.mocked(isValidKey).mockReturnValueOnce(false); + const req = makeReq({ authorization: "Bearer bad-key" }); + const res = makeRes(); + authMiddleware(req, res, mockNext); + expect(mockStatus).toHaveBeenCalledWith(403); + expect(mockJson).toHaveBeenCalledWith({ error: "Invalid API key" }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("returns 403 when x-api-key is invalid", () => { + vi.mocked(isValidKey).mockReturnValueOnce(false); + const req = makeReq({ "x-api-key": "bad-key" }); + const res = makeRes(); + authMiddleware(req, res, mockNext); + expect(mockStatus).toHaveBeenCalledWith(403); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("calls next() and attaches apiKeyInfo when Bearer token is valid", () => { + const info = { key: "test-key", tier: "pro", email: "t@t.com", createdAt: "2025-01-01" }; + vi.mocked(isValidKey).mockReturnValueOnce(true); + vi.mocked(getKeyInfo).mockReturnValueOnce(info as any); + const req = makeReq({ authorization: "Bearer test-key" }); + const res = makeRes(); + authMiddleware(req, res, mockNext); + expect(mockNext).toHaveBeenCalled(); + expect((req as any).apiKeyInfo).toEqual(info); + }); + + it("calls next() and attaches apiKeyInfo when x-api-key is valid", () => { + const info = { key: "xkey", tier: "free", email: "x@t.com", createdAt: "2025-01-01" }; + vi.mocked(isValidKey).mockReturnValueOnce(true); + vi.mocked(getKeyInfo).mockReturnValueOnce(info as any); + const req = makeReq({ "x-api-key": "xkey" }); + const res = makeRes(); + authMiddleware(req, res, mockNext); + expect(mockNext).toHaveBeenCalled(); + expect((req as any).apiKeyInfo).toEqual(info); + }); + + it("prefers Authorization header over x-api-key when both present", () => { + vi.mocked(isValidKey).mockReturnValueOnce(true); + vi.mocked(getKeyInfo).mockReturnValueOnce({ key: "bearer-key" } as any); + const req = makeReq({ authorization: "Bearer bearer-key", "x-api-key": "header-key" }); + const res = makeRes(); + authMiddleware(req, res, mockNext); + expect(isValidKey).toHaveBeenCalledWith("bearer-key"); + expect((req as any).apiKeyInfo).toEqual({ key: "bearer-key" }); + }); +}); diff --git a/src/__tests__/keys.test.ts b/src/__tests__/keys.test.ts new file mode 100644 index 0000000..8427ef5 --- /dev/null +++ b/src/__tests__/keys.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Unmock keys service — we want to test the real implementation +vi.unmock("../services/keys.js"); + +// DB is still mocked by setup.ts +import { queryWithRetry } from "../services/db.js"; + +describe("keys service", () => { + let keys: typeof import("../services/keys.js"); + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + // Re-import to get fresh cache + keys = await import("../services/keys.js"); + }); + + describe("after loadKeys", () => { + const mockRows = [ + { key: "df_free_abc", tier: "free", email: "a@b.com", created_at: "2025-01-01T00:00:00Z", stripe_customer_id: null }, + { key: "df_pro_xyz", tier: "pro", email: "pro@b.com", created_at: "2025-01-01T00:00:00Z", stripe_customer_id: "cus_123" }, + ]; + + beforeEach(async () => { + vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: mockRows, rowCount: 2 } as any); + await keys.loadKeys(); + }); + + it("isValidKey returns true for cached keys", () => { + expect(keys.isValidKey("df_free_abc")).toBe(true); + expect(keys.isValidKey("df_pro_xyz")).toBe(true); + }); + + it("isValidKey returns false for unknown keys", () => { + expect(keys.isValidKey("unknown")).toBe(false); + }); + + it("isProKey returns true for pro tier, false for free", () => { + expect(keys.isProKey("df_pro_xyz")).toBe(true); + expect(keys.isProKey("df_free_abc")).toBe(false); + }); + + it("getKeyInfo returns correct ApiKey object", () => { + const info = keys.getKeyInfo("df_pro_xyz"); + expect(info).toEqual({ + key: "df_pro_xyz", + tier: "pro", + email: "pro@b.com", + createdAt: "2025-01-01T00:00:00Z", + stripeCustomerId: "cus_123", + }); + }); + + it("getKeyInfo returns undefined for unknown key", () => { + expect(keys.getKeyInfo("nope")).toBeUndefined(); + }); + }); + + describe("createFreeKey", () => { + beforeEach(async () => { + vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); + await keys.loadKeys(); + }); + + it("creates key with df_free prefix", async () => { + vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); + const result = await keys.createFreeKey("new@test.com"); + expect(result.key).toMatch(/^df_free_/); + expect(result.tier).toBe("free"); + expect(result.email).toBe("new@test.com"); + }); + + it("returns existing key for same email", async () => { + vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); + const first = await keys.createFreeKey("dup@test.com"); + const second = await keys.createFreeKey("dup@test.com"); + expect(second.key).toBe(first.key); + }); + }); + + describe("createProKey", () => { + beforeEach(async () => { + vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); + await keys.loadKeys(); + }); + + it("uses UPSERT and returns key", async () => { + const returnedRow = { + key: "df_pro_newkey", + tier: "pro", + email: "pro@test.com", + created_at: "2025-06-01T00:00:00Z", + stripe_customer_id: "cus_new", + }; + vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [returnedRow], rowCount: 1 } as any); + + const result = await keys.createProKey("pro@test.com", "cus_new"); + expect(result.tier).toBe("pro"); + expect(result.stripeCustomerId).toBe("cus_new"); + + const call = vi.mocked(queryWithRetry).mock.calls.find( + (c) => typeof c[0] === "string" && c[0].includes("ON CONFLICT") + ); + expect(call).toBeTruthy(); + }); + }); +}); diff --git a/src/__tests__/pdfRateLimit.test.ts b/src/__tests__/pdfRateLimit.test.ts new file mode 100644 index 0000000..90a09a6 --- /dev/null +++ b/src/__tests__/pdfRateLimit.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { isProKey } from "../services/keys.js"; + +// We need to import the middleware fresh to reset internal state. +// The global setup already mocks keys service. + +// Since the module has internal state (rateLimitStore, activePdfCount), +// we need to be careful about test isolation. + +const mockNext = vi.fn(); +const headers: Record = {}; +const mockSet = vi.fn((k: string, v: string) => { headers[k] = v; }); +const mockJson = vi.fn(); +const mockStatus = vi.fn(() => ({ json: mockJson })); + +function makeReq(key = "test-key", tier = "free"): any { + return { + apiKeyInfo: { key, tier, email: "t@t.com", createdAt: "2025-01-01" }, + headers: {}, + }; +} + +function makeRes(): any { + Object.keys(headers).forEach((k) => delete headers[k]); + mockSet.mockClear(); + mockJson.mockClear(); + mockStatus.mockClear(); + return { set: mockSet, status: mockStatus, json: mockJson }; +} + +describe("pdfRateLimitMiddleware", () => { + // Re-import module each test to reset internal state + let pdfRateLimitMiddleware: any; + + beforeEach(async () => { + vi.clearAllMocks(); + // Reset module to clear internal rateLimitStore and counters + vi.resetModules(); + const mod = await import("../middleware/pdfRateLimit.js"); + pdfRateLimitMiddleware = mod.pdfRateLimitMiddleware; + }); + + it("sets rate limit headers on response", () => { + vi.mocked(isProKey).mockReturnValue(false); + const req = makeReq("key-a"); + const res = makeRes(); + pdfRateLimitMiddleware(req, res, mockNext); + expect(mockSet).toHaveBeenCalledWith("X-RateLimit-Limit", "10"); + expect(mockSet).toHaveBeenCalledWith("X-RateLimit-Remaining", expect.any(String)); + expect(mockSet).toHaveBeenCalledWith("X-RateLimit-Reset", expect.any(String)); + }); + + it("allows requests under rate limit", () => { + vi.mocked(isProKey).mockReturnValue(false); + const req = makeReq("key-b"); + const res = makeRes(); + pdfRateLimitMiddleware(req, res, mockNext); + expect(mockNext).toHaveBeenCalled(); + expect(mockStatus).not.toHaveBeenCalled(); + }); + + it("returns 429 with Retry-After when free tier rate limit exceeded (10/min)", () => { + vi.mocked(isProKey).mockReturnValue(false); + // Exhaust 10 requests + for (let i = 0; i < 10; i++) { + const req = makeReq("key-c"); + const res = makeRes(); + pdfRateLimitMiddleware(req, res, mockNext); + } + // 11th should be rejected + mockNext.mockClear(); + const req = makeReq("key-c"); + const res = makeRes(); + pdfRateLimitMiddleware(req, res, mockNext); + expect(mockStatus).toHaveBeenCalledWith(429); + expect(mockSet).toHaveBeenCalledWith("Retry-After", expect.any(String)); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("returns 429 for pro tier at 30/min limit", () => { + vi.mocked(isProKey).mockReturnValue(true); + for (let i = 0; i < 30; i++) { + const req = makeReq("key-d"); + const res = makeRes(); + pdfRateLimitMiddleware(req, res, mockNext); + } + mockNext.mockClear(); + const req = makeReq("key-d"); + const res = makeRes(); + pdfRateLimitMiddleware(req, res, mockNext); + expect(mockStatus).toHaveBeenCalledWith(429); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("resets rate limit after window expires", async () => { + vi.mocked(isProKey).mockReturnValue(false); + // Use fake timers + vi.useFakeTimers(); + try { + for (let i = 0; i < 10; i++) { + pdfRateLimitMiddleware(makeReq("key-e"), makeRes(), mockNext); + } + // Should be blocked + mockNext.mockClear(); + pdfRateLimitMiddleware(makeReq("key-e"), makeRes(), mockNext); + expect(mockNext).not.toHaveBeenCalled(); + + // Advance past window (60s) + vi.advanceTimersByTime(61_000); + + mockNext.mockClear(); + pdfRateLimitMiddleware(makeReq("key-e"), makeRes(), mockNext); + expect(mockNext).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("returns 429 QUEUE_FULL when concurrency queue is full", async () => { + vi.mocked(isProKey).mockReturnValue(false); + // Access getConcurrencyStats to verify + const mod = await import("../middleware/pdfRateLimit.js"); + + // Fill up concurrent slots (3) and queue (10) by acquiring slots without releasing + // We need 3 active + 10 queued = 13 acquires without release + const req = makeReq("key-f"); + const res = makeRes(); + pdfRateLimitMiddleware(req, res, mockNext); + + // The middleware attaches acquirePdfSlot; fill slots + const promises: Promise[] = []; + // Acquire 3 active slots + for (let i = 0; i < 3; i++) { + const r = makeReq(`fill-${i}`); + const s = makeRes(); + pdfRateLimitMiddleware(r, s, vi.fn()); + await (r as any).acquirePdfSlot(); + } + // Fill queue with 10 + for (let i = 0; i < 10; i++) { + const r = makeReq(`queue-${i}`); + const s = makeRes(); + pdfRateLimitMiddleware(r, s, vi.fn()); + promises.push((r as any).acquirePdfSlot()); + } + + // Next acquire should throw QUEUE_FULL + const rFull = makeReq("key-full"); + const sFull = makeRes(); + pdfRateLimitMiddleware(rFull, sFull, vi.fn()); + await expect((rFull as any).acquirePdfSlot()).rejects.toThrow("QUEUE_FULL"); + }); +}); From c01e88686a1800a4ea8585e80c0ea5d2f91cad45 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Thu, 26 Feb 2026 13:04:15 +0000 Subject: [PATCH 004/110] add unit tests for usage middleware (14 tests) --- src/__tests__/usage.test.ts | 231 ++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 src/__tests__/usage.test.ts diff --git a/src/__tests__/usage.test.ts b/src/__tests__/usage.test.ts new file mode 100644 index 0000000..2a1efbc --- /dev/null +++ b/src/__tests__/usage.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Unmock usage middleware — we want to test the real implementation +vi.unmock("../middleware/usage.js"); + +// DB and keys are still mocked by setup.ts +import { queryWithRetry } from "../services/db.js"; +import { isProKey } from "../services/keys.js"; + +describe("usage middleware", () => { + let usage: typeof import("../middleware/usage.js"); + + const mockJson = vi.fn(); + const mockStatus = vi.fn(() => ({ json: mockJson })); + const mockNext = vi.fn(); + + function makeReq(key = "df_free_testkey123"): any { + return { apiKeyInfo: { key } }; + } + + function makeRes(): any { + mockJson.mockClear(); + mockStatus.mockClear(); + return { status: mockStatus, json: mockJson }; + } + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + // Re-import to get fresh internal state (new Map) + usage = await import("../middleware/usage.js"); + }); + + // --- loadUsageData --- + + describe("loadUsageData", () => { + it("populates in-memory map from DB rows", async () => { + vi.mocked(queryWithRetry).mockResolvedValueOnce({ + rows: [ + { key: "df_free_abc12345", count: 42, month_key: "2026-02" }, + ], + rowCount: 1, + } as any); + + await usage.loadUsageData(); + + const stats = usage.getUsageStats("df_free_abc12345"); + expect(stats["df_free_..."]).toEqual({ count: 42, month: "2026-02" }); + }); + + it("handles empty DB result gracefully", async () => { + vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); + await usage.loadUsageData(); + const stats = usage.getUsageStats("df_free_nonexist"); + expect(stats).toEqual({}); + }); + + it("handles DB error gracefully (starts fresh)", async () => { + vi.mocked(queryWithRetry).mockRejectedValueOnce(new Error("DB down")); + await usage.loadUsageData(); + const stats = usage.getUsageStats("anything"); + expect(stats).toEqual({}); + }); + }); + + // --- getUsageStats --- + + describe("getUsageStats", () => { + it("returns empty object when key not found", () => { + expect(usage.getUsageStats("nonexistent")).toEqual({}); + }); + + it("returns empty object when no key provided", () => { + expect(usage.getUsageStats()).toEqual({}); + }); + + it("masks key to first 8 chars + '...'", async () => { + vi.mocked(queryWithRetry).mockResolvedValueOnce({ + rows: [{ key: "df_free_longkeyvalue", count: 5, month_key: "2026-02" }], + rowCount: 1, + } as any); + await usage.loadUsageData(); + + const stats = usage.getUsageStats("df_free_longkeyvalue"); + const keys = Object.keys(stats); + expect(keys).toHaveLength(1); + expect(keys[0]).toBe("df_free_..."); + }); + }); + + // --- usageMiddleware --- + + describe("usageMiddleware", () => { + it("calls next() for free key under limit", () => { + vi.mocked(isProKey).mockReturnValue(false); + const req = makeReq(); + const res = makeRes(); + + usage.usageMiddleware(req, res, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockStatus).not.toHaveBeenCalled(); + }); + + it("calls next() for pro key under limit", () => { + vi.mocked(isProKey).mockReturnValue(true); + const req = makeReq("df_pro_testkey123"); + const res = makeRes(); + + usage.usageMiddleware(req, res, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockStatus).not.toHaveBeenCalled(); + }); + + it("returns 429 when free key exceeds 100/month", async () => { + vi.mocked(isProKey).mockReturnValue(false); + const key = "df_free_limited1234"; + + // Seed usage at the limit + const monthKey = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`; + vi.mocked(queryWithRetry).mockResolvedValueOnce({ + rows: [{ key, count: 100, month_key: monthKey }], + rowCount: 1, + } as any); + await usage.loadUsageData(); + + const req = makeReq(key); + const res = makeRes(); + + usage.usageMiddleware(req, res, mockNext); + + expect(mockStatus).toHaveBeenCalledWith(429); + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining("Free tier limit") }) + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("returns 429 when pro key exceeds 5000/month", async () => { + vi.mocked(isProKey).mockReturnValue(true); + const key = "df_pro_limited12345"; + + const monthKey = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`; + vi.mocked(queryWithRetry).mockResolvedValueOnce({ + rows: [{ key, count: 5000, month_key: monthKey }], + rowCount: 1, + } as any); + await usage.loadUsageData(); + + const req = makeReq(key); + const res = makeRes(); + + usage.usageMiddleware(req, res, mockNext); + + expect(mockStatus).toHaveBeenCalledWith(429); + expect(mockJson).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining("Pro tier limit") }) + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("increments usage count on each call", () => { + vi.mocked(isProKey).mockReturnValue(false); + const key = "df_free_counting123"; + + // Call middleware 3 times + for (let i = 0; i < 3; i++) { + usage.usageMiddleware(makeReq(key), makeRes(), mockNext); + } + + const stats = usage.getUsageStats(key); + const masked = Object.keys(stats)[0]; + expect(stats[masked].count).toBe(3); + }); + + it("resets count when month changes", async () => { + vi.mocked(isProKey).mockReturnValue(false); + const key = "df_free_monthreset"; + + // Seed with old month data at limit + vi.mocked(queryWithRetry).mockResolvedValueOnce({ + rows: [{ key, count: 100, month_key: "2025-01" }], + rowCount: 1, + } as any); + await usage.loadUsageData(); + + // Current month is different, so it should reset and allow + const req = makeReq(key); + const res = makeRes(); + usage.usageMiddleware(req, res, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockStatus).not.toHaveBeenCalled(); + + // Count should be 1 (reset + this request) + const stats = usage.getUsageStats(key); + const masked = Object.keys(stats)[0]; + expect(stats[masked].count).toBe(1); + }); + + it("allows free key at count 99 (just under limit)", async () => { + vi.mocked(isProKey).mockReturnValue(false); + const key = "df_free_almost1234"; + const monthKey = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`; + + vi.mocked(queryWithRetry).mockResolvedValueOnce({ + rows: [{ key, count: 99, month_key: monthKey }], + rowCount: 1, + } as any); + await usage.loadUsageData(); + + const req = makeReq(key); + const res = makeRes(); + usage.usageMiddleware(req, res, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockStatus).not.toHaveBeenCalled(); + }); + + it("handles missing apiKeyInfo gracefully (uses 'unknown')", () => { + vi.mocked(isProKey).mockReturnValue(false); + const req = { apiKeyInfo: undefined } as any; + const res = makeRes(); + + usage.usageMiddleware(req, res, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + }); +}); From 1fe3f3746abb5c9f701a363716f1773b9737dc76 Mon Sep 17 00:00:00 2001 From: Hoid Date: Thu, 26 Feb 2026 16:05:05 +0000 Subject: [PATCH 005/110] test: add route tests for signup, recover, health --- package-lock.json | 286 ++++++++++++++++++++++++++++++++++ package.json | 2 + src/__tests__/health.test.ts | 68 ++++++++ src/__tests__/recover.test.ts | 96 ++++++++++++ src/__tests__/setup.ts | 3 + src/__tests__/signup.test.ts | 99 ++++++++++++ 6 files changed, 554 insertions(+) create mode 100644 src/__tests__/health.test.ts create mode 100644 src/__tests__/recover.test.ts create mode 100644 src/__tests__/signup.test.ts diff --git a/package-lock.json b/package-lock.json index 99652fa..5ddfdda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,9 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^7.0.9", "@types/pg": "^8.11.0", + "@types/supertest": "^7.2.0", "@types/swagger-jsdoc": "^6.0.4", + "supertest": "^7.2.2", "terser": "^5.46.0", "tsx": "^4.19.0", "typescript": "^5.7.0", @@ -600,6 +602,29 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1053,6 +1078,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1105,6 +1137,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", @@ -1171,6 +1210,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/swagger-jsdoc": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", @@ -1374,6 +1437,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1396,6 +1466,13 @@ "node": ">=4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1709,6 +1786,19 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -1716,6 +1806,16 @@ "dev": true, "license": "MIT" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -1794,6 +1894,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", @@ -1862,6 +1969,16 @@ "node": ">= 14" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1887,6 +2004,17 @@ "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", "license": "BSD-3-Clause" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -1998,6 +2126,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -2255,6 +2399,13 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -2300,6 +2451,41 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2504,6 +2690,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4001,6 +4203,90 @@ } } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/swagger-jsdoc": { "version": "6.2.8", "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", diff --git a/package.json b/package.json index e9db8d0..64440f2 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^7.0.9", "@types/pg": "^8.11.0", + "@types/supertest": "^7.2.0", "@types/swagger-jsdoc": "^6.0.4", + "supertest": "^7.2.2", "terser": "^5.46.0", "tsx": "^4.19.0", "typescript": "^5.7.0", diff --git a/src/__tests__/health.test.ts b/src/__tests__/health.test.ts new file mode 100644 index 0000000..e488e3a --- /dev/null +++ b/src/__tests__/health.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; +import { getPoolStats } from "../services/browser.js"; +import { pool } from "../services/db.js"; + +let app: express.Express; + +beforeEach(async () => { + vi.clearAllMocks(); + + // Default: healthy DB + const mockClient = { + query: vi.fn() + .mockResolvedValueOnce({ rows: [{ 1: 1 }] }) // SELECT 1 + .mockResolvedValueOnce({ rows: [{ version: "PostgreSQL 17.4 on x86_64" }] }), // SELECT version() + release: vi.fn(), + }; + vi.mocked(pool.connect).mockResolvedValue(mockClient as any); + + vi.mocked(getPoolStats).mockReturnValue({ + poolSize: 16, + totalPages: 16, + availablePages: 14, + queueDepth: 0, + pdfCount: 5, + restarting: false, + uptimeMs: 60000, + browsers: [], + }); + + const { healthRouter } = await import("../routes/health.js"); + app = express(); + app.use("/health", healthRouter); +}); + +describe("GET /health", () => { + it("returns 200 with status ok when DB is healthy", async () => { + const res = await request(app).get("/health"); + expect(res.status).toBe(200); + expect(res.body.status).toBe("ok"); + expect(res.body.database.status).toBe("ok"); + }); + + it("returns 503 with status degraded on DB error", async () => { + vi.mocked(pool.connect).mockRejectedValue(new Error("Connection refused")); + const res = await request(app).get("/health"); + expect(res.status).toBe(503); + expect(res.body.status).toBe("degraded"); + expect(res.body.database.status).toBe("error"); + }); + + it("includes pool stats", async () => { + const res = await request(app).get("/health"); + expect(res.body.pool).toMatchObject({ + size: 16, + available: 14, + queueDepth: 0, + pdfCount: 5, + }); + }); + + it("includes version", async () => { + const res = await request(app).get("/health"); + expect(res.body.version).toBeDefined(); + expect(typeof res.body.version).toBe("string"); + }); +}); diff --git a/src/__tests__/recover.test.ts b/src/__tests__/recover.test.ts new file mode 100644 index 0000000..aebde21 --- /dev/null +++ b/src/__tests__/recover.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +let app: express.Express; + +beforeEach(async () => { + vi.clearAllMocks(); + // resetModules to get fresh rate limiter instances + vi.resetModules(); + + // Re-import mocked services after resetModules + const { createPendingVerification, verifyCode } = await import("../services/verification.js"); + const { sendVerificationEmail } = await import("../services/email.js"); + const { getAllKeys } = await import("../services/keys.js"); + + vi.mocked(createPendingVerification).mockResolvedValue({ email: "test@test.com", code: "654321", createdAt: "", expiresAt: "", attempts: 0 }); + vi.mocked(verifyCode).mockResolvedValue({ status: "ok" }); + vi.mocked(sendVerificationEmail).mockResolvedValue(true); + vi.mocked(getAllKeys).mockReturnValue([ + { key: "existing-key", tier: "pro" as const, email: "found@test.com", createdAt: "2025-01-01" }, + ]); + + const { recoverRouter } = await import("../routes/recover.js"); + app = express(); + app.use(express.json()); + app.use("/recover", recoverRouter); +}); + +describe("POST /recover", () => { + it("returns 400 for missing email", async () => { + const res = await request(app).post("/recover").send({}); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid email", async () => { + const res = await request(app).post("/recover").send({ email: "bad" }); + expect(res.status).toBe(400); + }); + + it("returns 200 for email not found (anti-enumeration)", async () => { + const res = await request(app).post("/recover").send({ email: "nobody@test.com" }); + expect(res.status).toBe(200); + expect(res.body.status).toBe("recovery_sent"); + }); + + it("returns 200 and sends email for known email", async () => { + const { sendVerificationEmail } = await import("../services/email.js"); + const res = await request(app).post("/recover").send({ email: "found@test.com" }); + expect(res.status).toBe(200); + expect(res.body.status).toBe("recovery_sent"); + await new Promise(r => setTimeout(r, 50)); + expect(sendVerificationEmail).toHaveBeenCalledWith("found@test.com", "654321"); + }); +}); + +describe("POST /recover/verify", () => { + it("returns 400 for missing fields", async () => { + const res = await request(app).post("/recover/verify").send({ email: "a@b.com" }); + expect(res.status).toBe(400); + }); + + it("returns 410 for expired code", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "expired" }); + const res = await request(app).post("/recover/verify").send({ email: "a@b.com", code: "123456" }); + expect(res.status).toBe(410); + }); + + it("returns 429 for max attempts", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "max_attempts" }); + const res = await request(app).post("/recover/verify").send({ email: "a@b.com", code: "123456" }); + expect(res.status).toBe(429); + }); + + it("returns 400 for invalid code", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "invalid" }); + const res = await request(app).post("/recover/verify").send({ email: "a@b.com", code: "999999" }); + expect(res.status).toBe(400); + }); + + it("returns 200 with apiKey when key found", async () => { + const res = await request(app).post("/recover/verify").send({ email: "found@test.com", code: "123456" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: "recovered", apiKey: "existing-key", tier: "pro" }); + }); + + it("returns 200 with message only when no key found", async () => { + const res = await request(app).post("/recover/verify").send({ email: "nokey@test.com", code: "123456" }); + expect(res.status).toBe(200); + expect(res.body.status).toBe("recovered"); + expect(res.body.apiKey).toBeUndefined(); + }); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index eaf0e67..1c319c6 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -76,6 +76,9 @@ vi.mock("../services/verification.js", () => ({ loadVerifications: vi.fn().mockResolvedValue(undefined), createPendingVerification: vi.fn().mockResolvedValue({ email: "test@test.com", code: "123456" }), verifyCode: vi.fn().mockResolvedValue({ status: "ok" }), + isEmailVerified: vi.fn().mockResolvedValue(false), + createVerification: vi.fn().mockResolvedValue({ email: "test@test.com", token: "tok", apiKey: "key", createdAt: "", verifiedAt: null }), + getVerifiedApiKey: vi.fn().mockResolvedValue(null), })); // Mock email service diff --git a/src/__tests__/signup.test.ts b/src/__tests__/signup.test.ts new file mode 100644 index 0000000..5022dd2 --- /dev/null +++ b/src/__tests__/signup.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +let app: express.Express; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + const { isEmailVerified, createPendingVerification, verifyCode, createVerification } = await import("../services/verification.js"); + const { sendVerificationEmail } = await import("../services/email.js"); + const { createFreeKey } = await import("../services/keys.js"); + + vi.mocked(isEmailVerified).mockResolvedValue(false); + vi.mocked(createPendingVerification).mockResolvedValue({ email: "test@test.com", code: "123456", createdAt: "", expiresAt: "", attempts: 0 }); + vi.mocked(verifyCode).mockResolvedValue({ status: "ok" }); + vi.mocked(createFreeKey).mockResolvedValue({ key: "free-key-123", tier: "free", email: "test@test.com", createdAt: "" }); + vi.mocked(createVerification).mockResolvedValue({ email: "test@test.com", token: "tok", apiKey: "free-key-123", createdAt: "", verifiedAt: null }); + vi.mocked(sendVerificationEmail).mockResolvedValue(true); + + const { signupRouter } = await import("../routes/signup.js"); + app = express(); + app.use(express.json()); + app.use("/signup", signupRouter); +}); + +describe("POST /signup/free", () => { + it("returns 400 for missing email", async () => { + const res = await request(app).post("/signup/free").send({}); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid email format", async () => { + const res = await request(app).post("/signup/free").send({ email: "not-email" }); + expect(res.status).toBe(400); + }); + + it("returns 409 for already verified email", async () => { + const { isEmailVerified } = await import("../services/verification.js"); + vi.mocked(isEmailVerified).mockResolvedValue(true); + const res = await request(app).post("/signup/free").send({ email: "dup@test.com" }); + expect(res.status).toBe(409); + }); + + it("returns 200 with verification_required for valid email", async () => { + const res = await request(app).post("/signup/free").send({ email: "new@test.com" }); + expect(res.status).toBe(200); + expect(res.body.status).toBe("verification_required"); + }); + + it("sends verification email asynchronously", async () => { + const { sendVerificationEmail } = await import("../services/email.js"); + await request(app).post("/signup/free").send({ email: "new@test.com" }); + await new Promise(r => setTimeout(r, 50)); + expect(sendVerificationEmail).toHaveBeenCalledWith("new@test.com", "123456"); + }); +}); + +describe("POST /signup/verify", () => { + it("returns 400 for missing email/code", async () => { + const res = await request(app).post("/signup/verify").send({ email: "a@b.com" }); + expect(res.status).toBe(400); + }); + + it("returns 409 for already verified email", async () => { + const { isEmailVerified } = await import("../services/verification.js"); + vi.mocked(isEmailVerified).mockResolvedValue(true); + const res = await request(app).post("/signup/verify").send({ email: "dup@test.com", code: "123456" }); + expect(res.status).toBe(409); + }); + + it("returns 410 for expired code", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "expired" }); + const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" }); + expect(res.status).toBe(410); + }); + + it("returns 429 for max attempts", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "max_attempts" }); + const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" }); + expect(res.status).toBe(429); + }); + + it("returns 400 for invalid code", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "invalid" }); + const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "999999" }); + expect(res.status).toBe(400); + }); + + it("returns 200 with apiKey for valid code", async () => { + const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: "verified", apiKey: "free-key-123" }); + }); +}); From f0e9a79606a7684515e110bf4003d5e9cc2b6e7c Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Thu, 26 Feb 2026 19:03:48 +0000 Subject: [PATCH 006/110] test: add billing and convert route tests --- src/__tests__/billing.test.ts | 294 ++++++++++++++++++++++++++++++++++ src/__tests__/convert.test.ts | 184 +++++++++++++++++++++ 2 files changed, 478 insertions(+) create mode 100644 src/__tests__/billing.test.ts create mode 100644 src/__tests__/convert.test.ts diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts new file mode 100644 index 0000000..07790dc --- /dev/null +++ b/src/__tests__/billing.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +// We need to mock Stripe before importing billing router +vi.mock("stripe", () => { + const mockStripe = { + checkout: { + sessions: { + create: vi.fn(), + retrieve: vi.fn(), + }, + }, + webhooks: { + constructEvent: vi.fn(), + }, + products: { + search: vi.fn(), + create: vi.fn(), + }, + prices: { + list: vi.fn(), + create: vi.fn(), + }, + subscriptions: { + retrieve: vi.fn(), + }, + }; + return { default: vi.fn(() => mockStripe), __mockStripe: mockStripe }; +}); + +let app: express.Express; +let mockStripe: any; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + process.env.STRIPE_SECRET_KEY = "sk_test_fake"; + process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_fake"; + + // Re-import to get fresh mocks + const stripeMod = await import("stripe"); + mockStripe = (stripeMod as any).__mockStripe; + + // Default: product search returns existing product+price + mockStripe.products.search.mockResolvedValue({ data: [{ id: "prod_TygeG8tQPtEAdE" }] }); + mockStripe.prices.list.mockResolvedValue({ data: [{ id: "price_123" }] }); + + const { createProKey, findKeyByCustomerId, downgradeByCustomer, updateEmailByCustomer } = await import("../services/keys.js"); + vi.mocked(createProKey).mockResolvedValue({ key: "pro-key-123", tier: "pro", email: "test@test.com", createdAt: new Date().toISOString() } as any); + vi.mocked(findKeyByCustomerId).mockResolvedValue(null); + vi.mocked(downgradeByCustomer).mockResolvedValue(undefined as any); + vi.mocked(updateEmailByCustomer).mockResolvedValue(true as any); + + const { billingRouter } = await import("../routes/billing.js"); + app = express(); + // Webhook needs raw body + app.use("/v1/billing/webhook", express.raw({ type: "application/json" })); + app.use(express.json()); + app.use("/v1/billing", billingRouter); +}); + +describe("POST /v1/billing/checkout", () => { + it("returns url on success", async () => { + mockStripe.checkout.sessions.create.mockResolvedValue({ id: "cs_123", url: "https://checkout.stripe.com/pay/cs_123" }); + const res = await request(app).post("/v1/billing/checkout").send({}); + expect(res.status).toBe(200); + expect(res.body.url).toBe("https://checkout.stripe.com/pay/cs_123"); + }); + + it("returns 413 for body too large", async () => { + // The route checks content-length header; send a large body to trigger it + const largeBody = JSON.stringify({ padding: "x".repeat(2000) }); + const res = await request(app) + .post("/v1/billing/checkout") + .set("content-type", "application/json") + .send(largeBody); + expect(res.status).toBe(413); + }); + + it("returns 500 on Stripe error", async () => { + mockStripe.checkout.sessions.create.mockRejectedValue(new Error("Stripe down")); + const res = await request(app).post("/v1/billing/checkout").send({}); + expect(res.status).toBe(500); + expect(res.body.error).toMatch(/Failed to create checkout session/); + }); +}); + +describe("GET /v1/billing/success", () => { + it("returns 400 for missing session_id", async () => { + const res = await request(app).get("/v1/billing/success"); + expect(res.status).toBe(400); + }); + + it("returns 409 for duplicate session", async () => { + // First call succeeds + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_dup", + customer: "cus_123", + customer_details: { email: "test@test.com" }, + }); + await request(app).get("/v1/billing/success?session_id=cs_dup"); + // Second call with same session + const res = await request(app).get("/v1/billing/success?session_id=cs_dup"); + expect(res.status).toBe(409); + }); + + it("returns existing key page when key already in DB", async () => { + const { findKeyByCustomerId } = await import("../services/keys.js"); + vi.mocked(findKeyByCustomerId).mockResolvedValue({ key: "existing-key", tier: "pro" } as any); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_existing", + customer: "cus_existing", + customer_details: { email: "test@test.com" }, + }); + const res = await request(app).get("/v1/billing/success?session_id=cs_existing"); + expect(res.status).toBe(200); + expect(res.text).toContain("Key Already Provisioned"); + }); + + it("provisions new key on success", async () => { + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_new", + customer: "cus_new", + customer_details: { email: "new@test.com" }, + }); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app).get("/v1/billing/success?session_id=cs_new"); + expect(res.status).toBe(200); + expect(res.text).toContain("Welcome to Pro"); + expect(createProKey).toHaveBeenCalledWith("new@test.com", "cus_new"); + }); + + it("returns 500 on Stripe error", async () => { + mockStripe.checkout.sessions.retrieve.mockRejectedValue(new Error("Stripe error")); + const res = await request(app).get("/v1/billing/success?session_id=cs_err"); + expect(res.status).toBe(500); + }); +}); + +describe("POST /v1/billing/webhook", () => { + it("returns 500 when webhook secret missing", async () => { + delete process.env.STRIPE_WEBHOOK_SECRET; + // Need to re-import to pick up env change - but the router is already loaded + // The router reads env at request time, so this should work + const savedSecret = process.env.STRIPE_WEBHOOK_SECRET; + process.env.STRIPE_WEBHOOK_SECRET = ""; + delete process.env.STRIPE_WEBHOOK_SECRET; + + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "sig_test") + .send(JSON.stringify({ type: "test" })); + expect(res.status).toBe(500); + + process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_fake"; + }); + + it("returns 400 for missing signature", async () => { + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .send(JSON.stringify({ type: "test" })); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Missing stripe-signature/); + }); + + it("returns 400 for invalid signature", async () => { + mockStripe.webhooks.constructEvent.mockImplementation(() => { + throw new Error("Invalid signature"); + }); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "bad_sig") + .send(JSON.stringify({ type: "test" })); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid signature/); + }); + + it("provisions key on checkout.session.completed for DocFast product", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_wh", + customer: "cus_wh", + customer_details: { email: "wh@test.com" }, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_wh", + line_items: { + data: [{ price: { product: "prod_TygeG8tQPtEAdE" } }], + }, + }); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + expect(createProKey).toHaveBeenCalledWith("wh@test.com", "cus_wh"); + }); + + it("ignores checkout.session.completed for non-DocFast product", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_other", + customer: "cus_other", + customer_details: { email: "other@test.com" }, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_other", + line_items: { + data: [{ price: { product: "prod_OTHER" } }], + }, + }); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + expect(res.status).toBe(200); + expect(createProKey).not.toHaveBeenCalled(); + }); + + it("downgrades on customer.subscription.deleted", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.deleted", + data: { + object: { id: "sub_del", customer: "cus_del" }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_TygeG8tQPtEAdE" } } }] }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.deleted" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).toHaveBeenCalledWith("cus_del"); + }); + + it("downgrades on customer.subscription.updated with cancel_at_period_end", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { id: "sub_cancel", customer: "cus_cancel", status: "active", cancel_at_period_end: true }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_TygeG8tQPtEAdE" } } }] }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.updated" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).toHaveBeenCalledWith("cus_cancel"); + }); + + it("syncs email on customer.updated", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.updated", + data: { + object: { id: "cus_email", email: "newemail@test.com" }, + }, + }); + const { updateEmailByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.updated" })); + expect(res.status).toBe(200); + expect(updateEmailByCustomer).toHaveBeenCalledWith("cus_email", "newemail@test.com"); + }); +}); diff --git a/src/__tests__/convert.test.ts b/src/__tests__/convert.test.ts new file mode 100644 index 0000000..5cc7c1c --- /dev/null +++ b/src/__tests__/convert.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +// Mock dns before imports +vi.mock("node:dns/promises", () => ({ + default: { lookup: vi.fn() }, + lookup: vi.fn(), +})); + +let app: express.Express; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + const { renderPdf, renderUrlPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockResolvedValue(Buffer.from("%PDF-1.4 mock")); + vi.mocked(renderUrlPdf).mockResolvedValue(Buffer.from("%PDF-1.4 mock url")); + + const dns = await import("node:dns/promises"); + vi.mocked(dns.default.lookup).mockResolvedValue({ address: "93.184.216.34", family: 4 } as any); + + const { convertRouter } = await import("../routes/convert.js"); + app = express(); + app.use(express.json({ limit: "500kb" })); + app.use("/v1/convert", convertRouter); +}); + +describe("POST /v1/convert/html", () => { + it("returns 400 for missing html", async () => { + const res = await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({}); + expect(res.status).toBe(400); + }); + + it("returns 415 for wrong content-type", async () => { + const res = await request(app) + .post("/v1/convert/html") + .set("content-type", "text/plain") + .send("html=

hi

"); + expect(res.status).toBe(415); + }); + + it("returns PDF on success", async () => { + const res = await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({ html: "

Hello

" }); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/pdf/); + }); + + it("returns 429 on QUEUE_FULL", async () => { + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockRejectedValue(new Error("QUEUE_FULL")); + const res = await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({ html: "

Hello

" }); + expect(res.status).toBe(429); + }); + + it("returns 500 on PDF_TIMEOUT", async () => { + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockRejectedValue(new Error("PDF_TIMEOUT")); + const res = await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({ html: "

Hello

" }); + expect(res.status).toBe(500); + expect(res.body.error).toMatch(/PDF_TIMEOUT/); + }); + + it("wraps fragments (no { + const { renderPdf } = await import("../services/browser.js"); + await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({ html: "

Fragment

" }); + // wrapHtml should have been called; renderPdf receives wrapped HTML + const calledHtml = vi.mocked(renderPdf).mock.calls[0][0]; + expect(calledHtml).toContain(" { + const { renderPdf } = await import("../services/browser.js"); + const fullDoc = "

Full

"; + await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({ html: fullDoc }); + const calledHtml = vi.mocked(renderPdf).mock.calls[0][0]; + expect(calledHtml).toBe(fullDoc); + }); +}); + +describe("POST /v1/convert/markdown", () => { + it("returns 400 for missing markdown", async () => { + const res = await request(app) + .post("/v1/convert/markdown") + .set("content-type", "application/json") + .send({}); + expect(res.status).toBe(400); + }); + + it("returns 415 for wrong content-type", async () => { + const res = await request(app) + .post("/v1/convert/markdown") + .set("content-type", "text/plain") + .send("markdown=# hi"); + expect(res.status).toBe(415); + }); + + it("returns PDF on success", async () => { + const res = await request(app) + .post("/v1/convert/markdown") + .set("content-type", "application/json") + .send({ markdown: "# Hello World" }); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/pdf/); + }); +}); + +describe("POST /v1/convert/url", () => { + it("returns 400 for missing url", async () => { + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({}); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid URL", async () => { + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "not a url" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid URL/); + }); + + it("returns 400 for non-http protocol", async () => { + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "ftp://example.com" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/http\/https/); + }); + + it("returns 400 for private IP", async () => { + const dns = await import("node:dns/promises"); + vi.mocked(dns.default.lookup).mockResolvedValue({ address: "192.168.1.1", family: 4 } as any); + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "https://internal.example.com" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/private/i); + }); + + it("returns 400 for DNS failure", async () => { + const dns = await import("node:dns/promises"); + vi.mocked(dns.default.lookup).mockRejectedValue(new Error("ENOTFOUND")); + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "https://nonexistent.example.com" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/DNS/); + }); + + it("returns PDF on success", async () => { + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "https://example.com" }); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/pdf/); + }); +}); From e1084fb49c0fdf551f9cf023e6a5607a416a8777 Mon Sep 17 00:00:00 2001 From: Hoid Date: Fri, 27 Feb 2026 07:04:28 +0000 Subject: [PATCH 007/110] test: demo route tests --- src/__tests__/demo.test.ts | 148 +++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/__tests__/demo.test.ts diff --git a/src/__tests__/demo.test.ts b/src/__tests__/demo.test.ts new file mode 100644 index 0000000..e613bfe --- /dev/null +++ b/src/__tests__/demo.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +vi.mock("../services/browser.js", () => ({ + renderPdf: vi.fn(), + renderUrlPdf: vi.fn(), +})); + +let app: express.Express; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockResolvedValue(Buffer.from("%PDF-1.4 mock")); + + const { demoRouter } = await import("../routes/demo.js"); + app = express(); + app.use(express.json({ limit: "500kb" })); + app.use("/v1/demo", demoRouter); +}); + +describe("POST /v1/demo/html", () => { + it("returns 400 for missing html", async () => { + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({}); + expect(res.status).toBe(400); + }); + + it("returns 415 for wrong content-type", async () => { + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "text/plain") + .send("html=

hi

"); + expect(res.status).toBe(415); + }); + + it("returns 503 on QUEUE_FULL", async () => { + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockRejectedValue(new Error("QUEUE_FULL")); + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

Hello

" }); + expect(res.status).toBe(503); + }); + + it("returns 504 on PDF_TIMEOUT", async () => { + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockRejectedValue(new Error("PDF_TIMEOUT")); + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

Hello

" }); + expect(res.status).toBe(504); + }); + + it("returns 500 on unexpected error", async () => { + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockRejectedValue(new Error("something broke")); + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

Hello

" }); + expect(res.status).toBe(500); + expect(res.body.error).toMatch(/PDF generation failed/); + }); + + it("returns PDF with watermark on success", async () => { + const { renderPdf } = await import("../services/browser.js"); + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

Hello

" }); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/pdf/); + // Verify watermark was injected into the HTML passed to renderPdf + const calledHtml = vi.mocked(renderPdf).mock.calls[0][0]; + expect(calledHtml).toContain("DEMO"); + expect(calledHtml).toContain("docfast.dev"); + }); +}); + +describe("POST /v1/demo/markdown", () => { + it("returns 400 for missing markdown", async () => { + const res = await request(app) + .post("/v1/demo/markdown") + .set("content-type", "application/json") + .send({}); + expect(res.status).toBe(400); + }); + + it("returns 415 for wrong content-type", async () => { + const res = await request(app) + .post("/v1/demo/markdown") + .set("content-type", "text/plain") + .send("markdown=# hi"); + expect(res.status).toBe(415); + }); + + it("returns 503 on QUEUE_FULL", async () => { + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockRejectedValue(new Error("QUEUE_FULL")); + const res = await request(app) + .post("/v1/demo/markdown") + .set("content-type", "application/json") + .send({ markdown: "# Hello" }); + expect(res.status).toBe(503); + }); + + it("returns 504 on PDF_TIMEOUT", async () => { + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockRejectedValue(new Error("PDF_TIMEOUT")); + const res = await request(app) + .post("/v1/demo/markdown") + .set("content-type", "application/json") + .send({ markdown: "# Hello" }); + expect(res.status).toBe(504); + }); + + it("returns 500 on unexpected error", async () => { + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockRejectedValue(new Error("something broke")); + const res = await request(app) + .post("/v1/demo/markdown") + .set("content-type", "application/json") + .send({ markdown: "# Hello" }); + expect(res.status).toBe(500); + expect(res.body.error).toMatch(/PDF generation failed/); + }); + + it("returns PDF with watermark on success", async () => { + const { renderPdf } = await import("../services/browser.js"); + const res = await request(app) + .post("/v1/demo/markdown") + .set("content-type", "application/json") + .send({ markdown: "# Hello World" }); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/pdf/); + const calledHtml = vi.mocked(renderPdf).mock.calls[0][0]; + expect(calledHtml).toContain("DEMO"); + expect(calledHtml).toContain("docfast.dev"); + }); +}); From aa7fe5502463e2eaba5506c22782c24616eaf7aa Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Fri, 27 Feb 2026 07:04:33 +0000 Subject: [PATCH 008/110] fix: add Examples link to nav and footer on all pages Fixes BUG-089 --- public/examples.html | 1 + public/impressum.html | 1 + public/index.html | 2 ++ public/partials/_footer.html | 1 + public/privacy.html | 1 + public/src/index.html | 2 ++ public/status.html | 1 + public/terms.html | 1 + 8 files changed, 10 insertions(+) diff --git a/public/examples.html b/public/examples.html index 47b95bb..595559c 100644 --- a/public/examples.html +++ b/public/examples.html @@ -406,6 +406,7 @@ $pdf = DocFast::html(view('invoice')->render()); @@ -582,6 +583,7 @@ html, body {