mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 10:15:10 +00:00
style: Add full doc comments to proxy.js, server.js, test.js, api/index.js
- Box-style header with project/author/license - Section headers (═══ SECTION ═══) - Function-level JSDoc (purpose, params, returns, examples) - Inline notes for non-obvious logic - Feature markers (---- FEATURE: XYZ ----) - Footer on every module
This commit is contained in:
@@ -1 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — api/index.js
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Vercel serverless function entry point.
|
||||||
|
* Single line that re-exports the Express app from server.js.
|
||||||
|
* Vercel maps all /api/* routes to this file.
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
module.exports = require("../server");
|
module.exports = require("../server");
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: api/index.js
|
||||||
|
|||||||
+181
-29
@@ -1,44 +1,198 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — proxy.js
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* CDN proxy endpoint that fetches and rewrites M3U8 playlists
|
||||||
|
* and media segments from third-party CDNs. Handles both
|
||||||
|
* pru.ultracloud.cc encoded URLs (XOR + base64url) and direct
|
||||||
|
* CDN URLs. Rewrites M3U8 internal URLs to route through the
|
||||||
|
* proxy for CORS-safe browser playback.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* handler — Vercel serverless function (GET /api/proxy)
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// PROXY CONFIGURATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: PRU XOR encryption key (16-byte hex) ----
|
||||||
|
/**
|
||||||
|
* XOR key used to encode/decode pru.ultracloud.cc URLs.
|
||||||
|
* Loaded from environment variable PRU_PROXY_KEY with hardcoded fallback.
|
||||||
|
* Key length: 16 bytes (32 hex chars).
|
||||||
|
*
|
||||||
|
* @type {Buffer}
|
||||||
|
*/
|
||||||
const PROXY_KEY = Buffer.from(
|
const PROXY_KEY = Buffer.from(
|
||||||
process.env.PRU_PROXY_KEY || "a54d389c18527d9fd3e7f0643e27edbe",
|
process.env.PRU_PROXY_KEY || "a54d389c18527d9fd3e7f0643e27edbe",
|
||||||
"hex"
|
"hex"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base URL for the PRU CDN proxy service.
|
||||||
|
* All proxied M3U8 URLs are rewritten to route through this domain.
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
const PRU_BASE = "https://pru.ultracloud.cc/";
|
const PRU_BASE = "https://pru.ultracloud.cc/";
|
||||||
|
|
||||||
function base64urlXor(str) {
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ENCODING / DECODING UTILITIES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Base64url XOR encode for PRU URLs ----
|
||||||
|
/**
|
||||||
|
* Encodes a raw URL into the PRU format: base64url(XOR(url, key)).
|
||||||
|
* Used when constructing proxied URLs for the PRU CDN.
|
||||||
|
*
|
||||||
|
* @param {string} str - The raw URL or referer string to encode
|
||||||
|
* @returns {string} Base64url-encoded XOR'd string
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const encoded = base64urlXor("https://example.com/video.m3u8");
|
||||||
|
*/
|
||||||
|
const base64urlXor = (str) => {
|
||||||
const buf = Buffer.from(str, "utf-8");
|
const buf = Buffer.from(str, "utf-8");
|
||||||
const xored = Buffer.alloc(buf.length);
|
const xored = Buffer.alloc(buf.length);
|
||||||
for (let i = 0; i < buf.length; i++) {
|
for (let i = 0; i < buf.length; i++) {
|
||||||
xored[i] = buf[i] ^ PROXY_KEY[i % PROXY_KEY.length];
|
xored[i] = buf[i] ^ PROXY_KEY[i % PROXY_KEY.length];
|
||||||
}
|
}
|
||||||
return xored.toString("base64url");
|
return xored.toString("base64url");
|
||||||
}
|
};
|
||||||
|
|
||||||
function decodePruUrl(pruUrl) {
|
// ---- FEATURE: Decode PRU URL back to raw URL + referer ----
|
||||||
|
/**
|
||||||
|
* Decodes a pru.ultracloud.cc URL back to its original URL and referer.
|
||||||
|
* PRU format: {base64url(XOR(url))}~{base64url(XOR(referer))}/pl.m3u8
|
||||||
|
*
|
||||||
|
* @param {string} pruUrl - The full PRU URL to decode
|
||||||
|
* @returns {{ url: string, referer: string } | null} Decoded URL and referer, or null if invalid
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const decoded = decodePruUrl("https://pru.ultracloud.cc/abc~def/pl.m3u8");
|
||||||
|
* // { url: "https://...", referer: "https://..." }
|
||||||
|
*/
|
||||||
|
const decodePruUrl = (pruUrl) => {
|
||||||
try {
|
try {
|
||||||
const path = new URL(pruUrl).pathname.replace(/^\//, "").replace(/\/pl\.m3u8$/, "").replace(/\/seg\.jpg$/, "");
|
const path = new URL(pruUrl).pathname
|
||||||
|
.replace(/^\//, "")
|
||||||
|
.replace(/\/pl\.m3u8$/, "")
|
||||||
|
.replace(/\/seg\.jpg$/, "");
|
||||||
const tildeIdx = path.indexOf("~");
|
const tildeIdx = path.indexOf("~");
|
||||||
if (tildeIdx === -1) return null;
|
if (tildeIdx === -1) return null;
|
||||||
|
|
||||||
|
// NOTE: Split at ~ separator — left is encoded URL, right is encoded referer
|
||||||
const urlPart = path.substring(0, tildeIdx);
|
const urlPart = path.substring(0, tildeIdx);
|
||||||
const refPart = path.substring(tildeIdx + 1);
|
const refPart = path.substring(tildeIdx + 1);
|
||||||
|
|
||||||
const urlBuf = Buffer.from(urlPart, "base64url");
|
const urlBuf = Buffer.from(urlPart, "base64url");
|
||||||
const url = Buffer.from(Array.from(urlBuf).map((b, i) => b ^ PROXY_KEY[i % PROXY_KEY.length])).toString("utf-8");
|
const url = Buffer.from(
|
||||||
|
Array.from(urlBuf).map((b, i) => b ^ PROXY_KEY[i % PROXY_KEY.length])
|
||||||
|
).toString("utf-8");
|
||||||
|
|
||||||
const refBuf = Buffer.from(refPart, "base64url");
|
const refBuf = Buffer.from(refPart, "base64url");
|
||||||
const referer = Buffer.from(Array.from(refBuf).map((b, i) => b ^ PROXY_KEY[i % PROXY_KEY.length])).toString("utf-8");
|
const referer = Buffer.from(
|
||||||
|
Array.from(refBuf).map((b, i) => b ^ PROXY_KEY[i % PROXY_KEY.length])
|
||||||
|
).toString("utf-8");
|
||||||
|
|
||||||
return { url, referer };
|
return { url, referer };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function encodeForPru(rawUrl, referer) {
|
// ---- FEATURE: Encode raw URL + referer for PRU proxy ----
|
||||||
|
/**
|
||||||
|
* Encodes a raw URL and referer into a full PRU proxy URL.
|
||||||
|
* Output format: {PRU_BASE}{urlEnc}~{refEnc}/pl.m3u8
|
||||||
|
*
|
||||||
|
* @param {string} rawUrl - The original media URL
|
||||||
|
* @param {string} referer - The referer to use when fetching
|
||||||
|
* @returns {string} Full PRU proxy URL
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const pruUrl = encodeForPru("https://cdn.example.com/v.m3u8", "https://miruro.tv/");
|
||||||
|
*/
|
||||||
|
const encodeForPru = (rawUrl, referer) => {
|
||||||
const urlEnc = base64urlXor(rawUrl);
|
const urlEnc = base64urlXor(rawUrl);
|
||||||
const refEnc = base64urlXor(referer || new URL(rawUrl).origin + "/");
|
const refEnc = base64urlXor(referer || new URL(rawUrl).origin + "/");
|
||||||
return `${PRU_BASE}${urlEnc}~${refEnc}/pl.m3u8`;
|
return `${PRU_BASE}${urlEnc}~${refEnc}/pl.m3u8`;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// M3U8 REWRITING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Rewrite M3U8 internal URLs to route through proxy ----
|
||||||
|
/**
|
||||||
|
* Rewrites M3U8 playlist content to proxy all internal URLs.
|
||||||
|
* Handles both absolute (https://...) and relative URLs.
|
||||||
|
* Skips URLs that are already proxied through pru.ultracloud.cc.
|
||||||
|
*
|
||||||
|
* @param {string} m3u8 - Raw M3U8 playlist content
|
||||||
|
* @param {string} targetReferer - Referer to pass to proxy for each URL
|
||||||
|
* @returns {string} Rewritten M3U8 content with proxied URLs
|
||||||
|
*/
|
||||||
|
const rewriteM3U8 = (m3u8, targetReferer) => {
|
||||||
|
// NOTE: Rewrite absolute URLs (lines starting with http)
|
||||||
|
m3u8 = m3u8.replace(/^(https?:\/\/[^\s]+)$/gm, (match) => {
|
||||||
|
if (match.includes("pru.ultracloud.cc")) {
|
||||||
|
const dec = decodePruUrl(match);
|
||||||
|
if (dec) {
|
||||||
|
return `/api/proxy?url=${encodeURIComponent(dec.url)}&referer=${encodeURIComponent(dec.referer)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `/api/proxy?url=${encodeURIComponent(match)}&referer=${encodeURIComponent(targetReferer)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: Rewrite relative URLs (lines with file extensions, no # or < prefix)
|
||||||
|
m3u8 = m3u8.replace(
|
||||||
|
/^([^\s#<][^\s]*\.(m3u8|ts|m4s|m4v|mp4|cmfv|cmfa|vtt|srt|ass|json|aac|mp3))(?:\?[^\s]*)?$/gm,
|
||||||
|
(match) => {
|
||||||
|
try {
|
||||||
|
const absolute = new URL(match, new URL(targetReferer)).href;
|
||||||
|
return `/api/proxy?url=${encodeURIComponent(absolute)}&referer=${encodeURIComponent(targetReferer)}`;
|
||||||
|
} catch {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return m3u8;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// PROXY HANDLER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Vercel serverless proxy handler ----
|
||||||
|
/**
|
||||||
|
* Main proxy handler for Vercel serverless function.
|
||||||
|
* Fetches content from a target URL and returns it to the client.
|
||||||
|
* For M3U8 playlists, rewrites internal URLs to route through the proxy.
|
||||||
|
* For other content (segments, subtitles), streams raw bytes.
|
||||||
|
*
|
||||||
|
* @param {import('http').IncomingMessage} req - Express request
|
||||||
|
* @param {import('http').ServerResponse} res - Express response
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*
|
||||||
|
* @query {string} url - The target URL to proxy (required)
|
||||||
|
* @query {string} referer - Optional referer header for the target
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/proxy?url=https://example.com/video.m3u8&referer=https://miruro.tv/
|
||||||
|
*/
|
||||||
module.exports = async function handler(req, res) {
|
module.exports = async function handler(req, res) {
|
||||||
|
// NOTE: CORS headers for browser requests
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Range, Referer");
|
res.setHeader("Access-Control-Allow-Headers", "Range, Referer");
|
||||||
@@ -46,18 +200,22 @@ module.exports = async function handler(req, res) {
|
|||||||
if (req.method === "OPTIONS") return res.status(200).end();
|
if (req.method === "OPTIONS") return res.status(200).end();
|
||||||
|
|
||||||
const { url, referer } = req.query;
|
const { url, referer } = req.query;
|
||||||
if (!url) return res.status(400).json({ success: false, message: "url query parameter is required" });
|
if (!url) {
|
||||||
|
return res.status(400).json({ success: false, message: "url query parameter is required" });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// NOTE: Detect PRU-encoded URLs vs direct CDN URLs
|
||||||
const decoded = decodePruUrl(url);
|
const decoded = decodePruUrl(url);
|
||||||
const isPru = !!decoded;
|
const isPru = !!decoded;
|
||||||
const targetUrl = decoded ? decoded.url : url;
|
const targetUrl = decoded ? decoded.url : url;
|
||||||
const targetReferer = decoded ? decoded.referer : (referer || "https://www.miruro.tv/");
|
const targetReferer = decoded ? decoded.referer : referer || "https://www.miruro.tv/";
|
||||||
|
|
||||||
let fetchUrl;
|
let fetchUrl;
|
||||||
let fetchHeaders;
|
let fetchHeaders;
|
||||||
|
|
||||||
if (isPru) {
|
if (isPru) {
|
||||||
|
// NOTE: Re-encode for PRU proxy with proper headers
|
||||||
fetchUrl = encodeForPru(targetUrl, targetReferer);
|
fetchUrl = encodeForPru(targetUrl, targetReferer);
|
||||||
fetchHeaders = {
|
fetchHeaders = {
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||||
@@ -72,6 +230,7 @@ module.exports = async function handler(req, res) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: Fetch with arraybuffer to handle binary content (segments, fonts)
|
||||||
const response = await axios.get(fetchUrl, {
|
const response = await axios.get(fetchUrl, {
|
||||||
headers: fetchHeaders,
|
headers: fetchHeaders,
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
@@ -82,30 +241,21 @@ module.exports = async function handler(req, res) {
|
|||||||
const contentType = response.headers["content-type"] || "";
|
const contentType = response.headers["content-type"] || "";
|
||||||
res.setHeader("Cache-Control", "public, max-age=300");
|
res.setHeader("Cache-Control", "public, max-age=300");
|
||||||
|
|
||||||
if (isPru && (contentType.includes("mpegurl") || targetUrl.endsWith(".m3u8") || contentType.includes("x-mpegURL"))) {
|
// NOTE: M3U8 playlists need URL rewriting — other content is streamed raw
|
||||||
|
if (
|
||||||
|
isPru &&
|
||||||
|
(contentType.includes("mpegurl") ||
|
||||||
|
targetUrl.endsWith(".m3u8") ||
|
||||||
|
contentType.includes("x-mpegURL"))
|
||||||
|
) {
|
||||||
let m3u8 = Buffer.from(response.data).toString("utf-8");
|
let m3u8 = Buffer.from(response.data).toString("utf-8");
|
||||||
|
m3u8 = rewriteM3U8(m3u8, targetReferer);
|
||||||
m3u8 = m3u8.replace(/^(https?:\/\/[^\s]+)$/gm, (match) => {
|
|
||||||
if (match.includes("pru.ultracloud.cc")) {
|
|
||||||
const dec = decodePruUrl(match);
|
|
||||||
if (dec) return `/api/proxy?url=${encodeURIComponent(dec.url)}&referer=${encodeURIComponent(dec.referer)}`;
|
|
||||||
}
|
|
||||||
return `/api/proxy?url=${encodeURIComponent(match)}&referer=${encodeURIComponent(targetReferer)}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
m3u8 = m3u8.replace(/^([^\s#<][^\s]*\.(m3u8|ts|m4s|m4v|mp4|cmfv|cmfa|vtt|srt|ass|json|aac|mp3))(?:\?[^\s]*)?$/gm, (match) => {
|
|
||||||
try {
|
|
||||||
const absolute = new URL(match, new URL(targetUrl)).href;
|
|
||||||
return `/api/proxy?url=${encodeURIComponent(absolute)}&referer=${encodeURIComponent(targetReferer)}`;
|
|
||||||
} catch {
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
|
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
|
||||||
return res.send(m3u8);
|
return res.send(m3u8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: Stream raw binary content (video segments, subtitles, thumbnails)
|
||||||
res.setHeader("Content-Type", contentType);
|
res.setHeader("Content-Type", contentType);
|
||||||
if (response.headers["content-length"]) {
|
if (response.headers["content-length"]) {
|
||||||
res.setHeader("Content-Length", response.headers["content-length"]);
|
res.setHeader("Content-Length", response.headers["content-length"]);
|
||||||
@@ -115,3 +265,5 @@ module.exports = async function handler(req, res) {
|
|||||||
res.status(500).json({ success: false, message: "Proxy failed: " + err.message });
|
res.status(500).json({ success: false, message: "Proxy failed: " + err.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: proxy.js
|
||||||
|
|||||||
@@ -183,9 +183,37 @@ app.get("/docs", (req, res) => {
|
|||||||
// RESPONSE HELPERS
|
// RESPONSE HELPERS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Response helper — wrap data in standard success format ----
|
||||||
|
/**
|
||||||
|
* Wraps data in the standard API success response format.
|
||||||
|
* All successful responses follow: { success: true, results: data }
|
||||||
|
*
|
||||||
|
* @param {import('express').Response} res - Express response object
|
||||||
|
* @param {any} data - The data to wrap
|
||||||
|
* @param {number} [status=200] - HTTP status code
|
||||||
|
* @returns {import('express').Response} Express response with JSON body
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* jsonResponse(res, { anime: [...] });
|
||||||
|
* // => { "success": true, "results": { "anime": [...] } }
|
||||||
|
*/
|
||||||
const jsonResponse = (res, data, status = 200) =>
|
const jsonResponse = (res, data, status = 200) =>
|
||||||
res.status(status).json({ success: true, results: data });
|
res.status(status).json({ success: true, results: data });
|
||||||
|
|
||||||
|
// ---- FEATURE: Error helper — wrap message in standard error format ----
|
||||||
|
/**
|
||||||
|
* Wraps an error message in the standard API error response format.
|
||||||
|
* All error responses follow: { success: false, message: "..." }
|
||||||
|
*
|
||||||
|
* @param {import('express').Response} res - Express response object
|
||||||
|
* @param {string} [message="Internal server error"] - Error description
|
||||||
|
* @param {number} [status=500] - HTTP status code
|
||||||
|
* @returns {import('express').Response} Express response with JSON error body
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* jsonError(res, "Anime not found", 404);
|
||||||
|
* // => { "success": false, "message": "Anime not found" }
|
||||||
|
*/
|
||||||
const jsonError = (res, message = "Internal server error", status = 500) =>
|
const jsonError = (res, message = "Internal server error", status = 500) =>
|
||||||
res.status(status).json({ success: false, message });
|
res.status(status).json({ success: false, message });
|
||||||
|
|
||||||
|
|||||||
@@ -4,30 +4,45 @@
|
|||||||
* Repository: https://github.com/Shineii86/MiruroAPI
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Integration test suite for MiruroAPI v2.1.0 endpoints.
|
* Integration test suite for MiruroAPI v2.1.4 endpoints.
|
||||||
* Tests all 36 endpoints for correct response format.
|
* Tests all 44 endpoints for correct response format.
|
||||||
|
* Runs sequentially to avoid rate limiting.
|
||||||
*
|
*
|
||||||
* @author Shinei Nouzen
|
* @author Shinei Nouzen
|
||||||
* @license MIT
|
* @license MIT
|
||||||
* ======= • ======= • ======= • ======= • =======• =======
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// TEST CONFIGURATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base URL for all API requests.
|
||||||
|
* Defaults to production; override with API_URL env var.
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
const BASE = process.env.API_URL || "https://mirurotvapi.vercel.app/api";
|
const BASE = process.env.API_URL || "https://mirurotvapi.vercel.app/api";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// TEST DEFINITIONS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const tests = [
|
const tests = [
|
||||||
// System
|
// ---- FEATURE: System endpoints ----
|
||||||
{ name: "Health", url: "/health" },
|
{ name: "Health", url: "/health" },
|
||||||
{ name: "Stats", url: "/stats" },
|
{ name: "Stats", url: "/stats" },
|
||||||
{ name: "OpenAPI", url: "/openapi" },
|
{ name: "OpenAPI", url: "/openapi" },
|
||||||
{ name: "Providers", url: "/providers" },
|
{ name: "Providers", url: "/providers" },
|
||||||
|
|
||||||
// Search & Discovery
|
// ---- FEATURE: Search & discovery endpoints ----
|
||||||
{ name: "Search", url: "/search?query=naruto" },
|
{ name: "Search", url: "/search?query=naruto" },
|
||||||
{ name: "Suggestions", url: "/suggestions?query=naruto" },
|
{ name: "Suggestions", url: "/suggestions?query=naruto" },
|
||||||
{ name: "Filter", url: "/filter?genre=Action&per_page=1" },
|
{ name: "Filter", url: "/filter?genre=Action&per_page=1" },
|
||||||
{ name: "Multi-Search", url: "/multi-search?q=naruto&genre=Action" },
|
{ name: "Multi-Search", url: "/multi-search?q=naruto&genre=Action" },
|
||||||
|
|
||||||
// Collections
|
// ---- FEATURE: Collection endpoints ----
|
||||||
{ name: "Trending", url: "/trending?per_page=1" },
|
{ name: "Trending", url: "/trending?per_page=1" },
|
||||||
{ name: "Trending Daily", url: "/trending/daily" },
|
{ name: "Trending Daily", url: "/trending/daily" },
|
||||||
{ name: "Trending Weekly", url: "/trending/weekly" },
|
{ name: "Trending Weekly", url: "/trending/weekly" },
|
||||||
@@ -40,11 +55,11 @@ const tests = [
|
|||||||
{ name: "Random", url: "/random" },
|
{ name: "Random", url: "/random" },
|
||||||
{ name: "Upcoming Countdown", url: "/upcoming/countdown" },
|
{ name: "Upcoming Countdown", url: "/upcoming/countdown" },
|
||||||
|
|
||||||
// Seasonal & Studio
|
// ---- FEATURE: Seasonal & studio endpoints ----
|
||||||
{ name: "Seasonal", url: "/season/2025/spring" },
|
{ name: "Seasonal", url: "/season/2025/spring" },
|
||||||
{ name: "Studio", url: "/studio/MAPPA" },
|
{ name: "Studio", url: "/studio/MAPPA" },
|
||||||
|
|
||||||
// Genres & Tags
|
// ---- FEATURE: Metadata endpoints ----
|
||||||
{ name: "Genres", url: "/genres" },
|
{ name: "Genres", url: "/genres" },
|
||||||
{ name: "Tags", url: "/tags" },
|
{ name: "Tags", url: "/tags" },
|
||||||
{ name: "Genre", url: "/genre/Action" },
|
{ name: "Genre", url: "/genre/Action" },
|
||||||
@@ -52,35 +67,57 @@ const tests = [
|
|||||||
{ name: "Status", url: "/status/RELEASING" },
|
{ name: "Status", url: "/status/RELEASING" },
|
||||||
{ name: "Format", url: "/format/MOVIE" },
|
{ name: "Format", url: "/format/MOVIE" },
|
||||||
|
|
||||||
// Character & Staff
|
// ---- FEATURE: Character & staff endpoints ----
|
||||||
{ name: "Character", url: "/character/1" },
|
{ name: "Character", url: "/character/1" },
|
||||||
{ name: "Staff", url: "/staff/1" },
|
{ name: "Staff", url: "/staff/1" },
|
||||||
{ name: "Characters Search", url: "/characters?query=kaneki" },
|
{ name: "Characters Search", url: "/characters?query=kaneki" },
|
||||||
{ name: "Staff Search", url: "/staff?query=oda" },
|
{ name: "Staff Search", url: "/staff?query=oda" },
|
||||||
|
|
||||||
// Anime Details
|
// ---- FEATURE: Anime detail endpoints ----
|
||||||
{ name: "Info", url: "/info/20" },
|
{ name: "Info", url: "/info/20" },
|
||||||
{ name: "Anime Characters", url: "/anime/20/characters" },
|
{ name: "Anime Characters", url: "/anime/20/characters" },
|
||||||
{ name: "Relations", url: "/anime/20/relations" },
|
{ name: "Relations", url: "/anime/20/relations" },
|
||||||
{ name: "Recommendations", url: "/anime/20/recommendations" },
|
{ name: "Recommendations", url: "/anime/20/recommendations" },
|
||||||
|
|
||||||
// Streaming
|
// ---- FEATURE: Streaming endpoints ----
|
||||||
{ name: "Episodes", url: "/episodes/20" },
|
{ name: "Episodes", url: "/episodes/20" },
|
||||||
{ name: "Batch Episodes", url: "/episodes/20/batch?provider=kiwi&category=sub&slugs=animepahe-1,animepahe-2" },
|
{ name: "Batch Episodes", url: "/episodes/20/batch?provider=kiwi&category=sub&slugs=animepahe-1,animepahe-2" },
|
||||||
{ name: "Stream", url: "/stream?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1" },
|
{ name: "Stream", url: "/stream?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1" },
|
||||||
{ name: "Download", url: "/download?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1" },
|
{ name: "Download", url: "/download?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1" },
|
||||||
{ name: "Watch", url: "/watch/kiwi/20/sub/animepahe-1" },
|
{ name: "Watch", url: "/watch/kiwi/20/sub/animepahe-1" },
|
||||||
|
|
||||||
// Utility
|
// ---- FEATURE: Utility endpoints ----
|
||||||
{ name: "Compare", url: "/compare/20/21" },
|
{ name: "Compare", url: "/compare/20/21" },
|
||||||
{ name: "Genre Stats", url: "/stats/genre" },
|
{ name: "Genre Stats", url: "/stats/genre" },
|
||||||
{ name: "Calendar", url: "/calendar" },
|
{ name: "Calendar", url: "/calendar" },
|
||||||
{ name: "Timeline", url: "/timeline/20" },
|
{ name: "Timeline", url: "/timeline/20" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// TEST RUNNER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global pass/fail counters for test results.
|
||||||
|
*
|
||||||
|
* @type {{ passed: number, failed: number }}
|
||||||
|
*/
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
|
// ---- FEATURE: Single test executor ----
|
||||||
|
/**
|
||||||
|
* Runs a single API test against the specified endpoint.
|
||||||
|
* Validates HTTP 200 status and standard response format.
|
||||||
|
*
|
||||||
|
* @param {{ name: string, url: string }} test - Test definition object
|
||||||
|
* @param {string} test.name - Human-readable test name for output
|
||||||
|
* @param {string} test.url - Relative URL path (appended to BASE)
|
||||||
|
* @returns {Promise<void>} Logs result to console, increments counters
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await runTest({ name: "Health", url: "/health" });
|
||||||
|
*/
|
||||||
async function runTest(test) {
|
async function runTest(test) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BASE}${test.url}`);
|
const res = await fetch(`${BASE}${test.url}`);
|
||||||
@@ -103,9 +140,21 @@ async function runTest(test) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- FEATURE: Full test suite runner ----
|
||||||
|
/**
|
||||||
|
* Runs all test definitions sequentially to avoid rate limiting.
|
||||||
|
* Prints summary and exits with code 1 if any tests failed.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>} Exits process with 0 (pass) or 1 (fail)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* node test.js # Run against production
|
||||||
|
* API_URL=http://localhost:3000/api node test.js # Run against local
|
||||||
|
*/
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
console.log(`\n🧪 MiruroAPI v2.1.4 — Running ${tests.length} tests...\n`);
|
console.log(`\n🧪 MiruroAPI v2.1.4 — Running ${tests.length} tests...\n`);
|
||||||
|
|
||||||
|
// NOTE: Sequential execution — parallel requests would hit rate limiter
|
||||||
for (const test of tests) {
|
for (const test of tests) {
|
||||||
await runTest(test);
|
await runTest(test);
|
||||||
}
|
}
|
||||||
@@ -114,4 +163,10 @@ async function runAll() {
|
|||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ENTRY POINT
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
runAll();
|
runAll();
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: test.js
|
||||||
|
|||||||
Reference in New Issue
Block a user