Files
factorio-clusterio/test/lib/ModStore.test.js
T
bbassieandClaude Opus 4.8 97a6c32838 Parse mod versions leniently like the game does
The mod portal "ApiVersion" enum was validated strictly when loading a
mod's info.json, which rejected versions the game itself accepts. Versions
are now normalised the same lenient way the game reads them: leading
whitespace is allowed, the major and minor are required, the patch is
optional, any trailing content is ignored, and numbers are read as integers.

This applies to both a mod's version and its factorio_version, validated by
a regex schema rather than the fixed enum. The enum is no longer used to
validate versions at all - the mod portal is the source of truth, so its
error is now surfaced when a version is unsupported. The known-version list
kept for the web interface fallback gains 2.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:24:43 +00:00

893 lines
35 KiB
JavaScript

"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 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 () {
this.timeout(60000); // Increase timeout to 60 seconds for live API call
// 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() {
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) },
]);
});
});
});
});