"use strict"; const assert = require("assert").strict; const path = require("path"); const fs = require("node:fs/promises"); const stream = require("node:stream"); const JSZip = require("jszip"); // Added for creating mock zips // Capture native fetch before any potential mocks are applied const nativeFetch = global.fetch; const { ModStore, ModInfo, ModVersionEquality } = require("@clusterio/lib"); // Adjust path based on compiled output const { externalTest } = require("../integration"); const MODS_DIR = path.join("temp", "test", "mod_store", "mods"); const CACHE_FILE = path.join(MODS_DIR, "mod-info-cache.json"); function createMockModJSZip(name, version, factorioVersion = "1.1", dependencies = ["base >= 1.1"]) { const zip = new JSZip(); const infoJson = { name: name, version: version, title: `${name} Mod Title`, author: "Test Author", factorio_version: factorioVersion, dependencies: dependencies, description: `Description for ${name}`, }; zip.file(`${name}/info.json`, JSON.stringify(infoJson, null, 4)); zip.file(`${name}/dummy.lua`, "-- Mock Lua file"); return zip; } // Helper function to create a mock mod zip file async function createMockModZip(filePath, name, version, factorioVersion = "1.1", dependencies = ["base >= 1.1"]) { const zip = createMockModJSZip(name, version, factorioVersion, dependencies); const buffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); await fs.writeFile(filePath, buffer); } // Helper to wait briefly for mtime changes async function waitBriefly() { return new Promise(resolve => setTimeout(resolve, 50)); // 50ms should be enough for mtime resolution } describe("lib/ModStore", function () { before(async function () { await fs.mkdir(MODS_DIR, { recursive: true }); }); after(function() { global.fetch = nativeFetch; }); beforeEach(function () { // Reset mocks before each test global.fetch = async (url, options) => { throw new Error(`fetch mock not implemented for ${url} in this test`); }; }); afterEach(async function () { await fs.rm(MODS_DIR, { force: true, recursive: true, maxRetries: 10 }); await fs.mkdir(MODS_DIR, { recursive: true }); }); describe("constructor", function () { it("should create an instance with empty files map", function () { const modStore = new ModStore(MODS_DIR, new Map()); assert(modStore instanceof ModStore); assert.equal(modStore.modsDirectory, MODS_DIR); assert.equal(modStore.files.size, 0); }); }); describe("fromDirectory", function () { it("should load from an empty directory", async function () { const modStore = await ModStore.fromDirectory(MODS_DIR); assert(modStore instanceof ModStore); assert.equal(modStore.modsDirectory, MODS_DIR); assert.equal(modStore.files.size, 0); }); it("should load a single mod zip file", async function () { const modName = "test-mod"; const modVersion = "1.0.0"; const filename = `${modName}_${modVersion}.zip`; await createMockModZip(path.join(MODS_DIR, filename), modName, modVersion); const modStore = await ModStore.fromDirectory(MODS_DIR); assert.equal(modStore.files.size, 1); const modInfo = modStore.files.get(filename); assert(modInfo instanceof ModInfo); assert.equal(modInfo.name, modName); assert.equal(modInfo.version, modVersion); assert.equal(modInfo.filename, filename); }); it("should load multiple mod zip files", async function () { const mod1Name = "test-mod-1"; const mod1Version = "1.0.0"; const mod1Filename = `${mod1Name}_${mod1Version}.zip`; await createMockModZip(path.join(MODS_DIR, mod1Filename), mod1Name, mod1Version); const mod2Name = "test-mod-2"; const mod2Version = "0.1.0"; const mod2Filename = `${mod2Name}_${mod2Version}.zip`; await createMockModZip(path.join(MODS_DIR, mod2Filename), mod2Name, mod2Version); const modStore = await ModStore.fromDirectory(MODS_DIR); assert.equal(modStore.files.size, 2); assert(modStore.files.has(mod1Filename)); assert(modStore.files.has(mod2Filename)); }); it("should ignore non-zip files and directories", async function () { // Add a valid mod const modName = "real-mod"; const modVersion = "1.0.0"; const filename = `${modName}_${modVersion}.zip`; await createMockModZip(path.join(MODS_DIR, filename), modName, modVersion); // Add other file types and directories await fs.writeFile(path.join(MODS_DIR, "some-text.txt"), "hello"); await fs.mkdir(path.join(MODS_DIR, "a-directory"), { recursive: true }); await fs.writeFile(path.join(MODS_DIR, "archive.tar.gz"), "not a zip"); await createMockModZip(path.join(MODS_DIR, "temp_file.tmp.zip"), "temp", "1.0.0"); // Should ignore .tmp.zip const modStore = await ModStore.fromDirectory(MODS_DIR); assert.equal(modStore.files.size, 1); assert(modStore.files.has(filename)); // Should only load the real mod }); it("should invalidate cache if mod file is modified", async function () { const modName = "updated-mod"; const modVersion = "1.0.0"; const filename = `${modName}_${modVersion}.zip`; const modPath = path.join(MODS_DIR, filename); // Create mod and load once to generate cache await createMockModZip(modPath, modName, modVersion); let modStore = await ModStore.fromDirectory(MODS_DIR); assert.equal(modStore.files.size, 1); await assert.doesNotReject(fs.access(CACHE_FILE), "Cache file should exist"); // Wait and recreate the file to update mtime await waitBriefly(); await createMockModZip(modPath, modName, modVersion, "1.1", ["base >= 1.1", "? updated-dep"]); // Load again modStore = await ModStore.fromDirectory(MODS_DIR); assert.equal(modStore.files.size, 1, "Mod should be reloaded"); const modInfo = modStore.files.get(filename); assert(modInfo instanceof ModInfo); assert.equal(modInfo.name, modName); assert.equal(modInfo.version, modVersion); // Check if the dependency was updated (indicating re-read) assert(modInfo.dependencySpecifications.includes("? updated-dep")); }); it("should handle corrupted zip files gracefully", async function () { // Create a valid mod const validModName = "valid-mod"; const validModVersion = "1.0.0"; const validFilename = `${validModName}_${validModVersion}.zip`; await createMockModZip(path.join(MODS_DIR, validFilename), validModName, validModVersion); // Create an invalid zip file (just text) const invalidFilename = "invalid-mod_0.0.1.zip"; await fs.writeFile(path.join(MODS_DIR, invalidFilename), "This is not a zip file"); const modStore = await ModStore.fromDirectory(MODS_DIR); assert.equal(modStore.files.size, 1, "Only the valid mod should be loaded"); assert(modStore.files.has(validFilename)); assert(!modStore.files.has(invalidFilename)); }); // TODO: Tests for loading mods from directory // - Directory with cache file // - Cache invalidation // - Error handling for corrupt zips }); describe("downloadMods", function () { let modStore; const username = "testuser"; const token = "testtoken"; const factorioVersion = "1.1"; // Store fetch and restore it after tests let originalFetch; before(function () { originalFetch = global.fetch; }); after(function () { global.fetch = originalFetch; }); beforeEach(async function () { // Start with an empty store for download tests // Clear any existing files in the temp dir await fs.rm(MODS_DIR, { force: true, recursive: true, maxRetries: 10 }); await fs.mkdir(MODS_DIR, { recursive: true }); modStore = await ModStore.fromDirectory(MODS_DIR); // Initialize using fromDirectory // Restore fetch before each test in this suite to avoid pollution global.fetch = originalFetch; }); // Helper to create the mock fetch function for the Factorio Mod Portal API function createMockPortalApiFetch(modsToDownload, fetchCallsArray = null) { return async (url, options) => { const urlString = url.toString(); if (fetchCallsArray) { fetchCallsArray.push({ url: urlString, options }); // Track call if array is provided } if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") { const body = options.body; let requestedModNames = []; if (body instanceof URLSearchParams) { requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); } const results = requestedModNames.map(name => { const requestedMod = modsToDownload.find(mod => mod.name === name); if (!requestedMod) { // Use assert or throw for clearer test failure messages assert.fail(`[Test Mock ERROR] Mock fetch received unexpected mod name: ${name}`); } const requestedVersion = requestedMod.version.version; return { name: name, latest_release: { download_url: `/download/${name}/${requestedVersion}`, file_name: `${name}_${requestedVersion}.zip`, info_json: { factorio_version: factorioVersion }, version: requestedVersion, sha1: `sha1_${name}_${requestedVersion}`, }, }; }); return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) }; } if (urlString.startsWith("https://mods.factorio.com/download/")) { const [, name, version] = /\/([^\/]+)\/([^\/]+)$/.exec(url.pathname); const requestedMod = modsToDownload.find(mod => ( mod.name === name && mod.version.version === version )); if (!requestedMod) { return { ok: false, status: 404, statusText: `Test Mock 404, no such mod: ${urlString}` }; } if (name === "bad-download-mod") { return { ok: false, status: 503, statusText: "Test Mock download error" }; } const zip = createMockModJSZip(name, version); return { ok: true, status: 200, body: stream.Readable.toWeb(zip.generateNodeStream()) }; } // Fallback for unhandled fetches in this test return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` }; }; } it("should download and add a single mod", async function () { const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }]; // Define fetch mock specific to this test global.fetch = createMockPortalApiFetch(modsToDownload); await modStore.downloadMods(modsToDownload, username, token, factorioVersion); assert.equal(modStore.files.size, 1, "Should have 1 mod after download"); const filename = "download-mod-1_1.1.0.zip"; assert(modStore.files.has(filename), `Mod ${filename} should be in the store`); await assert.doesNotReject(fs.access(path.join(MODS_DIR, filename)), `File ${filename} should exist`); const modInfo = modStore.files.get(filename); assert(modInfo instanceof ModInfo); assert.equal(modInfo.name, "download-mod-1"); assert.equal(modInfo.version, "1.1.0"); }); it("should download multiple mods", async function () { const modsToDownload = [ { name: "multi-mod-a", version: ModVersionEquality.fromString("1.0.0") }, { name: "multi-mod-b", version: ModVersionEquality.fromString("2.0.0") }, ]; // Define fetch mock specific to this test global.fetch = createMockPortalApiFetch(modsToDownload); await modStore.downloadMods(modsToDownload, username, token, factorioVersion); assert.equal(modStore.files.size, 2); assert(modStore.files.has("multi-mod-a_1.0.0.zip")); assert(modStore.files.has("multi-mod-b_2.0.0.zip")); await fs.access(path.join(MODS_DIR, "multi-mod-a_1.0.0.zip")); await fs.access(path.join(MODS_DIR, "multi-mod-b_2.0.0.zip")); }); it("should throw when no releases available", async function () { const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }]; // Define fetch mock specific to this test global.fetch = async (url, options) => { const urlString = url.toString(); if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") { const body = options.body; let requestedModNames = []; if (body instanceof URLSearchParams) { requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); } const results = requestedModNames.map(name => ({ name: name })); return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) }; } // Fallback for unhandled fetches in this test return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` }; }; await assert.rejects(modStore.downloadMods(modsToDownload, username, token, factorioVersion)); }); it("should skip built-in mods", async function () { const modsToDownload = [ { name: "base", version: ModVersionEquality.fromString("1.1.0") }, // Built-in { name: "core", version: ModVersionEquality.fromString("1.1.0") }, // Built-in { name: "another-new-mod", version: ModVersionEquality.fromString("1.0.0") }, // Not built-in ]; let fetchCalls = []; // Define fetch mock specific to this test global.fetch = createMockPortalApiFetch(modsToDownload, fetchCalls); await modStore.downloadMods(modsToDownload, username, token, factorioVersion); assert.equal(modStore.files.size, 1, "Only non-builtin mod should be downloaded"); assert(modStore.files.has("another-new-mod_1.0.0.zip")); const postCall = fetchCalls.find(call => call.options?.method === "POST"); assert(postCall, "POST call to API should have happened"); const requestedMods = (postCall.options.body.get("namelist") || "").split(","); assert.deepEqual(requestedMods, ["another-new-mod"], "Only non-builtin mod should be in the namelist"); }); it("should handle API error during mod info fetch", async function () { const modsToDownload = [{ name: "api-error-mod", version: ModVersionEquality.fromString("1.0.0") }]; // Define fetch mock specific to this test (simulates API error) global.fetch = async (url, options) => { if (url.toString().startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") { return { ok: false, status: 503, statusText: "Service Unavailable", }; } // This fallback shouldn't be reached in this test return { ok: false, status: 404, statusText: `Test Mock 404: ${url.toString()}` }; }; await assert.rejects( modStore.downloadMods(modsToDownload, username, token, factorioVersion), /Mod portal request to https:\/\/mods\.factorio\.com\/api\/mods.* failed: 503 Service Unavailable/ ); assert.equal(modStore.files.size, 0, "No mods should be added on API error"); }); it("should handle download error for a specific mod", async function () { const modsToDownload = [ { name: "good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success { name: "bad-download-mod", version: ModVersionEquality.fromString("1.0.0") }, // Fail { name: "another-good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success ]; // Define fetch mock specific to this test (for the API call) global.fetch = createMockPortalApiFetch(modsToDownload); await modStore.downloadMods(modsToDownload, username, token, factorioVersion); assert.equal(modStore.files.size, 2, "Only successfully downloaded mods should be added"); assert(modStore.files.has("good-mod_1.0.0.zip")); assert(modStore.files.has("another-good-mod_1.0.0.zip")); assert(!modStore.files.has("bad-download-mod_1.0.0.zip")); }); it("should download mods in chunks", async function () { const modsToDownload = [ { name: "chunk-mod-1", version: ModVersionEquality.fromString("1.0.0") }, { name: "chunk-mod-2", version: ModVersionEquality.fromString("1.0.0") }, { name: "chunk-mod-3", version: ModVersionEquality.fromString("1.0.0") }, { name: "chunk-mod-4", version: ModVersionEquality.fromString("1.0.0") }, ]; let fetchCalls = []; // Define fetch mock specific to this test global.fetch = createMockPortalApiFetch(modsToDownload, fetchCalls); await modStore.downloadMods(modsToDownload, username, token, factorioVersion); let postCalls = fetchCalls.reduce((a, v) => a + (v.options?.method === "POST"), 0); // Assuming chunkSize is 500, 4 mods should be 1 chunk/POST call assert.equal(postCalls, 1, "Should make 1 POST call for 4 mods"); assert.equal(modStore.files.size, modsToDownload.length, "All chunked mods should be downloaded"); assert(modStore.files.has("chunk-mod-1_1.0.0.zip")); assert(modStore.files.has("chunk-mod-4_1.0.0.zip")); }); }); describe("fetchAllModsFromPortal (static)", function () { const factorioVersion = "1.1"; const apiBaseUrl = "https://mods.factorio.com/api/mods"; const pageSize = 2; // Small page size for mock tests // Keep track of the original fetch for the live test let originalFetch; before(function() { originalFetch = global.fetch; }); after(function() { global.fetch = originalFetch; // Restore original fetch after this suite }); // Simplified mock mod data for mock tests const mockPortalMods = [ { name: "pa", title: "A", owner: "oA", latest_release: { version: "1.0.0", sha1: "sha1pa", info_json: { factorio_version: factorioVersion }, } }, { name: "pb", title: "B", owner: "oB", latest_release: { version: "2.0.0", sha1: "sha1pb", info_json: { factorio_version: factorioVersion }, } }, { name: "pc", title: "C", owner: "oC", latest_release: { version: "3.0.0", sha1: "sha1pc", info_json: { factorio_version: factorioVersion }, } }, { name: "pd", title: "D", owner: "oD", latest_release: { version: "4.0.0", sha1: "sha1pd", info_json: { factorio_version: factorioVersion }, } }, ]; const totalMods = mockPortalMods.length; const totalPages = Math.ceil(totalMods / pageSize); beforeEach(function () { // Set up mock fetch for most tests in this suite global.fetch = async (url, options) => { const urlObj = new URL(url); if (urlObj.origin + urlObj.pathname === apiBaseUrl && !options?.method /* GET */) { const params = urlObj.searchParams; const page = parseInt(params.get("page") || "1", 10); const reqPageSize = parseInt(params.get("page_size") || String(pageSize), 10); const reqVersion = params.get("version"); const hideDeprecated = params.get("hide_deprecated") === "true"; // Helper to create the mock response const createMockResponse = (results, pagination) => { const jsonFunc = async () => ({ results, pagination }); return { ok: true, status: 200, statusText: "OK", headers: { entries: () => [["content-type", "application/json"]], get: (headerName) => ( headerName.toLowerCase() === "content-type" ? "application/json" : null ), }, json: jsonFunc, }; }; // Use larger page size for mock fetch logic if not specified const effectivePageSize = reqPageSize || 50; if (reqVersion !== factorioVersion) { return { ok: false, status: 400, statusText: "Bad Request - Incorrect Version" }; } const mockTotalPages = Math.ceil(totalMods / effectivePageSize); const pagination = { page, page_count: mockTotalPages, page_size: effectivePageSize, count: totalMods, }; if (page > mockTotalPages) { return createMockResponse([], pagination); } const startIndex = (page - 1) * effectivePageSize; const endIndex = startIndex + effectivePageSize; const pageResults = mockPortalMods.slice(startIndex, endIndex); return createMockResponse(pageResults, pagination); } // Fallback for unhandled fetch in mock tests return { ok: false, status: 404, statusText: `Not Found - Mock fetch unhandled for ${url}`, body: null, }; }; }); it("should fetch all mods by handling pagination (using mock)", async function () { // This test uses the mock fetch setup in beforeEach const allMods = await ModStore.fetchAllModsFromPortal(factorioVersion); assert.equal(allMods.length, totalMods, "Should fetch all mock mods"); const fetchedNames = allMods.map(m => m.name).sort(); const expectedNames = mockPortalMods.map(m => m.name).sort(); assert.deepEqual(fetchedNames, expectedNames, "Fetched names mismatch"); }); it("should pass hide_deprecated parameter correctly (using mock)", async function () { // This test uses the mock fetch setup in beforeEach let capturedUrl = null; const currentFetch = global.fetch; // Capture the mock fetch global.fetch = async (url, options) => { capturedUrl = url.toString(); return currentFetch(url, options); }; await ModStore.fetchAllModsFromPortal(factorioVersion, 100, true); // hide_deprecated = true assert(capturedUrl.includes("hide_deprecated=true"), "URL should contain hide_deprecated=true"); await ModStore.fetchAllModsFromPortal(factorioVersion, 100, false); // hide_deprecated = false assert(capturedUrl.includes("hide_deprecated=false"), "URL should contain hide_deprecated=false"); }); it("should handle API errors during pagination (using mock)", async function () { // This test uses the mock fetch setup in beforeEach const errorPage = 2; const currentFetch = global.fetch; // Capture the mock fetch global.fetch = async (url, options) => { const urlObj = new URL(url); const page = parseInt(urlObj.searchParams.get("page") || "1", 10); // Modify pagination for this test to ensure page 2 is requested if (page === errorPage) { return { ok: false, status: 500, statusText: "Internal Server Error", text: async () => "API Error", }; } // For other pages (like page 1), return the standard mock response, // but ensure pagination suggests more pages exist. const response = await currentFetch(url, options); if (response.ok && page === 1) { const json = await response.json(); json.pagination.page_count = Math.max(json.pagination.page_count, errorPage); return { ...response, json: async () => json }; } return response; // Return other pages/errors as is }; await assert.rejects( ModStore.fetchAllModsFromPortal(factorioVersion), /Mod portal fetch page 2 failed: 500 Internal Server Error - API Error/ ); }); it("should handle empty results from the portal (using mock)", async function () { // Override fetch to return empty results on the first page global.fetch = async (url, options) => { const urlObj = new URL(url); if (urlObj.origin + urlObj.pathname === apiBaseUrl && !options?.method) { const pagination = { page: 1, page_count: 0, page_size: 50, count: 0 }; return { ok: true, status: 200, json: async () => ({ results: [], pagination }) }; } return { ok: false, status: 404, statusText: `Mock fetch unhandled for ${url}`, }; }; const allMods = await ModStore.fetchAllModsFromPortal(factorioVersion); assert.equal(allMods.length, 0, "Should return an empty array for no mods"); }); // Note: This test connects to the actual Factorio Mod Portal API. // It requires network access and may take a while to run. // It might also break if the API changes or is unavailable. it("should fetch all mods from the LIVE portal API and check structure/count", async function () { externalTest(this); // Ensure we are using the true native fetch we captured earlier global.fetch = nativeFetch; assert.strictEqual( global.fetch, nativeFetch, // Check against the truly native fetch "global.fetch was not restored to the original native fetch" ); const liveFactorioVersion = "1.1"; // Use a common, stable version let allMods = []; try { allMods = await ModStore.fetchAllModsFromPortal(liveFactorioVersion, 100, true); // hide deprecated } catch (err) { // Provide more context if the API call fails const cause = err.cause ? ` - Cause: ${err.cause}` : ""; const status = err.response ? ` - Status: ${err.response.status}` : ""; let detailedMessage = `Live API call failed: ${err.message} (Type: ${err.constructor.name})`; if (err.cause) { detailedMessage += ` - Cause: ${err.cause}`; } // Try to get response info safely const response = err.response || (typeof err.status === "number" ? err : null); if (response) { detailedMessage += ` - Status: ${response.status} ${response.statusText || ""}`; if (typeof response.text === "function") { try { // Attempt to get text body for more context const textBody = await response.text(); detailedMessage += ` - Body Preview: ${textBody.substring(0, 200)}...`; } catch (textErr) { detailedMessage += ` - (Failed to read response body as text: ${textErr.message})`; } } } else { // Log the whole error object if it's not a response-related error try { detailedMessage += ` - Error Details: ${JSON.stringify(err, Object.getOwnPropertyNames(err))}`; } catch (stringifyErr) { detailedMessage += " - (Could not stringify error object)"; } } assert.fail(detailedMessage); } assert(Array.isArray(allMods), "Result should be an array"); // Check for a realistically large number of mods. Adjust if needed. assert( allMods.length > 3000, `Expected > 3000 mods for Factorio ${liveFactorioVersion}, but got ${allMods.length}` ); // Check the structure of the first mod (assuming the list is not empty) if (allMods.length > 0) { const firstMod = allMods[0]; assert.equal(typeof firstMod.name, "string", "First mod should have a string name"); assert.equal(typeof firstMod.title, "string", "First mod should have a string title"); assert.equal(typeof firstMod.owner, "string", "First mod should have a string owner"); assert.ok(firstMod.latest_release, "First mod should have latest_release info"); assert.equal( typeof firstMod.latest_release.version, "string", "latest_release version should be string" ); assert.equal( typeof firstMod.latest_release.sha1, "string", "latest_release should have a sha1 string" ); assert.ok(firstMod.latest_release.info_json, "latest_release should have info_json"); assert.equal( typeof firstMod.latest_release.info_json.factorio_version, "string", "info_json should have factorio_version string" ); // Check for an expected field from the user example assert.equal( typeof firstMod.downloads_count, "number", "First mod should have downloads_count number" ); } else { // This should not happen for v1.1 but handles edge cases // eslint-disable-next-line no-console console.warn("Warning: Live Mod Portal API returned 0 mods."); } }); }); describe("fetchModReleases (static)", function() { // Keep track of the original fetch for the live test let originalFetch; before(function() { originalFetch = global.fetch; }); after(function() { global.fetch = originalFetch; // Restore original fetch after this suite }); it("should fetch from the correct api url (mock)", async function() { global.fetch = async function(url) { assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full"); return { ok: true, status: 200, json: async () => ({ name: "my-mod" }) }; }; const result = await ModStore.fetchModReleases("my-mod"); assert.equal(result.name, "my-mod"); }); it("should throw an error when the fetch fails (mock)", async function() { global.fetch = async function(url) { assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full"); return { ok: false, status: 400, json: async () => ({ name: "my-mod" }) }; }; await assert.rejects(ModStore.fetchModReleases("my-mod")); }); it("should fetch from the correct api url (live)", async function() { externalTest(this); global.fetch = nativeFetch; const result = await ModStore.fetchModReleases("clusterio_lib"); assert(result.releases && result.releases.length > 1, "Failed to fetch releases"); }); }); // === Instance Methods === describe("Instance Methods", function () { let modStore; let testMod1Name = "test-mod-1"; let testMod1Version = "1.0.0"; let testMod1Filename = `${testMod1Name}_${testMod1Version}.zip`; let testMod1Path = path.join(MODS_DIR, testMod1Filename); beforeEach(async function () { // Create a fresh store and a default mod for instance tests await createMockModZip(testMod1Path, testMod1Name, testMod1Version); modStore = await ModStore.fromDirectory(MODS_DIR); assert.equal(modStore.files.size, 1, "Test setup failed: ModStore should have 1 mod initially"); }); describe("getMod", function () { it("should return ModInfo for an existing mod", function () { const modInfo = modStore.getMod(testMod1Name, testMod1Version); assert(modInfo instanceof ModInfo); assert.equal(modInfo.name, testMod1Name); assert.equal(modInfo.version, testMod1Version); }); it("should return undefined for a non-existent mod name", function () { const modInfo = modStore.getMod("non-existent-mod", testMod1Version); assert.equal(modInfo, undefined); }); it("should return undefined for a non-existent mod version", function () { const modInfo = modStore.getMod(testMod1Name, "99.9.9"); assert.equal(modInfo, undefined); }); it("should return undefined if sha1 does not match", function () { const modInfo = modStore.getMod(testMod1Name, testMod1Version, "mismatched-sha1"); assert.equal(modInfo, undefined); }); it("should return ModInfo if sha1 matches", function () { const existingMod = modStore.files.get(testMod1Filename); assert(existingMod, "Test setup error: existing mod not found"); // Ensure mod exists const modInfo = modStore.getMod(testMod1Name, testMod1Version, existingMod.sha1); assert(modInfo instanceof ModInfo); assert.equal(modInfo.name, testMod1Name); assert.equal(modInfo.sha1, existingMod.sha1); }); }); describe("loadFile (instance)", function () { let testMod2Name = "test-mod-2"; let testMod2Version = "0.1.0"; let testMod2Filename = `${testMod2Name}_${testMod2Version}.zip`; let testMod2Path = path.join(MODS_DIR, testMod2Filename); it("should load an existing file and add it to the store", async function () { await createMockModZip(testMod2Path, testMod2Name, testMod2Version); assert(!modStore.files.has(testMod2Filename), "Mod should not be present before loadFile"); const modInfo = await modStore.loadFile(testMod2Filename); assert(modInfo instanceof ModInfo); assert.equal(modInfo.name, testMod2Name); assert.equal(modInfo.version, testMod2Version); assert(modStore.files.has(testMod2Filename), "Mod should be present after loadFile"); assert.equal(modStore.files.get(testMod2Filename), modInfo); }); it("should emit 'change' event when loading a file", async function () { await createMockModZip(testMod2Path, testMod2Name, testMod2Version); let changeEventFired = false; let eventModInfo = null; modStore.on("change", (mod) => { changeEventFired = true; eventModInfo = mod; }); const loadedModInfo = await modStore.loadFile(testMod2Filename); assert(changeEventFired, "Change event should have fired"); assert.equal(eventModInfo, loadedModInfo, "Event should pass the loaded ModInfo"); }); it("should throw when loading a non-existent file", async function () { await assert.rejects( modStore.loadFile("non-existent.zip"), /ENOENT: no such file or directory/ // Check for fs error ); }); it("should throw if filename does not match info.json", async function () { const wrongFilename = `wrong-name_${testMod2Version}.zip`; await createMockModZip(path.join(MODS_DIR, wrongFilename), testMod2Name, testMod2Version); await assert.rejects( modStore.loadFile(wrongFilename), /filename does not match the expected name/ // Check for ModStore.loadFile's specific error ); }); }); describe("deleteFile", function () { it("should delete an existing file from store and filesystem", async function () { assert(modStore.files.has(testMod1Filename), "Mod should exist before deletion"); await assert.doesNotReject(fs.access(testMod1Path), "Mod file should exist before deletion"); await modStore.deleteFile(testMod1Filename); assert(!modStore.files.has(testMod1Filename), "Mod should not exist in store after deletion"); await assert.rejects(fs.access(testMod1Path), "Mod file should not exist after deletion"); }); it("should emit 'change' event with isDeleted flag when deleting", async function () { const originalModInfo = modStore.files.get(testMod1Filename); let changeEventFired = false; let eventModInfo = null; modStore.on("change", (mod) => { changeEventFired = true; eventModInfo = mod; }); await modStore.deleteFile(testMod1Filename); assert(changeEventFired, "Change event should have fired"); assert.equal(eventModInfo, originalModInfo, "Event should pass the original ModInfo object"); assert(eventModInfo.isDeleted, "Event ModInfo should have isDeleted flag set"); }); it("should throw when deleting a non-existent file", async function () { await assert.rejects( modStore.deleteFile("non-existent.zip"), /Mod non-existent.zip does not exist/ ); }); }); describe("addMod", function () { it("should add a ModInfo object to the files map", function () { const modInfo = new ModInfo({ name: "added-mod", version: "0.5.0", factorio_version: "1.1", title: "T", author: "A", dependencies: [], description: "D", sha1: "dummy", size: 100, mtimeMs: Date.now(), }); assert(!modStore.files.has(modInfo.filename), "Mod should not be present before addMod"); modStore.addMod(modInfo); assert(modStore.files.has(modInfo.filename), "Mod should be present after addMod"); assert.equal(modStore.files.get(modInfo.filename), modInfo); }); it("should emit 'change' event when adding a mod", function () { const modInfo = new ModInfo({ name: "added-mod-event", version: "0.5.1", factorio_version: "1.1", title: "T", author: "A", dependencies: [], description: "D", sha1: "dummy", size: 100, mtimeMs: Date.now(), }); let changeEventFired = false; let eventModInfo = null; modStore.on("change", (mod) => { changeEventFired = true; eventModInfo = mod; }); modStore.addMod(modInfo); assert(changeEventFired, "Change event should have fired"); assert.equal(eventModInfo, modInfo, "Event should pass the added ModInfo"); }); }); describe("filterInstalled", function() { it("should filter out installed mods", function() { const mods = [ { name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) }, { name: "not-installed", version: ModVersionEquality.fromString("1.0.0") }, ]; assert.deepEqual(modStore.filterInstalled(mods, false), [ { name: "not-installed", version: ModVersionEquality.fromString("1.0.0") }, ]); }); it("should filter out uninstalled mods", function() { const mods = [ { name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) }, { name: "not-installed", version: ModVersionEquality.fromString("1.0.0") }, ]; assert.deepEqual(modStore.filterInstalled(mods, true), [ { name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) }, ]); }); }); }); });