getMessage(); } } /** Join header lines the way curl reports them. */ function headers(string ...$lines): string { return implode("\r\n", $lines) . "\r\n\r\n"; } // =========================================================================== // classifyArchiveEntries // =========================================================================== test('classify: ExportDLL with asset folder', function () { same('ExportDLL', classifyArchiveEntries([ 'lazHCEffects.dll', 'lazHCEffects/lazHCEffects.txt', 'lazHCEffects/License.txt', ]), 'flat DLL plus assets'); }); test('classify: an .exe makes it a Tool even with DLLs', function () { same('Tool', classifyArchiveEntries(['ToolBox.exe', 'helper.dll', 'readme.txt']), 'exe + dll'); }); test('classify: map set with thumbnail bitmap', function () { same('Map', classifyArchiveEntries(['CleanTopo2/world.tif', 'CleanTopo2/2048-16bit.bmp']), 'tif wins over bmp'); same('Map', classifyArchiveEntries(['x.TIFF']), '.tiff, upper case'); }); test('classify: sounds and graphics', function () { same('Sound', classifyArchiveEntries(['SND_Explode.wav', 'readme.txt']), 'wav'); same('Graphic', classifyArchiveEntries(['GR_Ship.BMP', 'GR_Sub.bmp']), 'bmp'); same('Sound', classifyArchiveEntries(['a.wav', 'icon.bmp']), 'wav outranks bmp'); }); test('classify: nothing specific', function () { same('', classifyArchiveEntries(['Orders.pdf', 'notes.txt']), 'docs only'); same('', classifyArchiveEntries([]), 'empty'); same('', classifyArchiveEntries(['folder/', 'other/']), 'directories only'); }); test('classify: junk entries ignored', function () { same('', classifyArchiveEntries(['__MACOSX/x.dll', 'dir/._y.dll', 'readme.txt']), 'macOS debris'); same('ExportDLL', classifyArchiveEntries(['Pack\\Foo.dll']), 'backslash separators'); }); test('classify: a bare filename', function () { same('ExportDLL', classifyArchiveEntries(['DataDump1.dll']), 'raw DLL upload'); same('', classifyArchiveEntries(['WestPac Orders.pdf']), 'raw PDF upload'); }); // =========================================================================== // parseFileMetaHeaders // =========================================================================== test('headers: 206 range response', function () { same(['filename' => 'HCExportDLL_lazHCEffects_v003.zip', 'size' => 5411224], parseFileMetaHeaders(headers( 'HTTP/1.1 206 Partial Content', 'Content-Type: application/zip', 'Content-Range: bytes 0-0/5411224', 'Content-Length: 1', 'Content-Disposition: attachment; filename="HCExportDLL_lazHCEffects_v003.zip"' )), '206'); }); test('headers: IPS error page is rejected (the original bug)', function () { same(null, parseFileMetaHeaders(headers( 'HTTP/1.1 500 Internal Server Error', 'Content-Type: text/html;charset=UTF-8', 'Content-Length: 65860' )), '500 html'); same(null, parseFileMetaHeaders(headers( 'HTTP/1.1 200 OK', 'Content-Type: text/html; charset=UTF-8', 'Content-Length: 65860' )), '200 html'); }); test('headers: other failures', function () { same(null, parseFileMetaHeaders(headers('HTTP/1.1 403 Forbidden', 'Content-Length: 10')), '403'); same(null, parseFileMetaHeaders(''), 'empty'); same(null, parseFileMetaHeaders(headers('HTTP/1.1 206 Partial Content', 'Content-Type: application/zip')), 'no name and no size'); }); test('headers: only the last redirect hop counts', function () { $raw = headers('HTTP/1.1 302 Found', 'Location: /x', 'Content-Type: text/html') . headers('HTTP/1.1 206 Partial Content', 'Content-Range: bytes 0-0/42', 'Content-Disposition: attachment; filename=a.zip'); same(['filename' => 'a.zip', 'size' => 42], parseFileMetaHeaders($raw), 'redirect then file'); $raw = headers('HTTP/1.1 302 Found', 'Content-Range: bytes 0-0/42', 'Content-Disposition: attachment; filename=a.zip') . headers('HTTP/1.1 500 Internal Server Error', 'Content-Type: text/html'); same(null, parseFileMetaHeaders($raw), 'file-looking redirect then error'); }); test('headers: size sources', function () { same(['filename' => 'a.zip', 'size' => 123], parseFileMetaHeaders(headers( 'HTTP/1.1 200 OK', 'Content-Type: application/zip', 'Content-Length: 123', 'Content-Disposition: attachment; filename="a.zip"' )), 'server ignored Range: Content-Length is the file'); same(['filename' => 'a.zip', 'size' => 0], parseFileMetaHeaders(headers( 'HTTP/1.1 206 Partial Content', 'Content-Length: 1', 'Content-Disposition: attachment; filename="a.zip"' )), '206 without Content-Range: Content-Length is the slice, not the file'); }); test('headers: filename forms', function () { same('real name.zip', parseFileMetaHeaders(headers( 'HTTP/1.1 206 Partial Content', 'Content-Range: bytes 0-0/1', "Content-Disposition: attachment; filename=\"fallback.zip\"; filename*=UTF-8''real%20name.zip" ))['filename'] ?? null, 'RFC 5987 preferred'); same('evil.dll', parseFileMetaHeaders(headers( 'HTTP/1.1 206 Partial Content', 'Content-Range: bytes 0-0/1', 'Content-Disposition: attachment; filename="..\\..\\evil.dll"' ))['filename'] ?? null, 'path stripped'); same('ok.zip', parseFileMetaHeaders(headers( 'HTTP/1.1 206 Partial Content', 'Content-Range: bytes 0-0/1', 'content-disposition: ATTACHMENT; FILENAME=ok.zip; size=1' ))['filename'] ?? null, 'case-insensitive, unquoted, trailing param'); }); // =========================================================================== // Metadata cache // =========================================================================== test('cache: round trip and invalidation', function () { $dir = sys_get_temp_dir() . '/hcl-cache-test-' . getmypid(); @mkdir($dir); $now = 1_800_000_000; $meta = ['filename' => 'a.zip', 'size' => 10, 'ok' => true, 'artifactType' => null, 'reason' => null, 'record_id' => null, 'sha256' => null]; saveFileMeta(1, '2023-03-31', $meta, $dir, $now); same($meta, getCachedFileMeta(1, '2023-03-31', $dir, $now + 999999), 'round trip; success never expires'); same(null, getCachedFileMeta(1, '2024-01-01', $dir, $now), 'file updated since'); same(null, getCachedFileMeta(2, '2023-03-31', $dir, $now), 'no entry'); $meta['artifactType'] = ''; saveFileMeta(1, '2023-03-31', $meta, $dir, $now); same('', getCachedFileMeta(1, '2023-03-31', $dir, $now)['artifactType'] ?? 'x', "'' (inspected, nothing found) survives as distinct from null"); // A version-1 entry: what every server cache file holds today. file_put_contents("{$dir}/3.json", json_encode(['updated' => '2023-03-31', 'filename' => '', 'size' => 65752])); same(null, getCachedFileMeta(3, '2023-03-31', $dir, $now), 'v1 entry ignored'); $failed = ['filename' => '', 'size' => 0, 'ok' => false, 'artifactType' => null, 'reason' => 'download: HTTP 500 text/html EX1406', 'record_id' => null, 'sha256' => null]; saveFileMeta(4, '2023-03-31', $failed, $dir, $now); $retry = hclOptions()['meta_retry_seconds']; same($failed, getCachedFileMeta(4, '2023-03-31', $dir, $now + $retry - 1), 'failure cached within retry window'); same(null, getCachedFileMeta(4, '2023-03-31', $dir, $now + $retry), 'failure retried after the window'); array_map('unlink', glob("{$dir}/*.json") ?: []); @rmdir($dir); }); // =========================================================================== // ZIP listing and inspection of local archives // =========================================================================== test('zip: list entries of a real archive', function () { $path = tempnam(sys_get_temp_dir(), 'hclz'); $zip = new ZipArchive(); $zip->open($path, ZipArchive::OVERWRITE); $zip->addFromString('Foo.dll', 'x'); $zip->addFromString('Foo/readme.txt', 'y'); $zip->close(); same(['Foo.dll', 'Foo/readme.txt'], listZipEntries($path), 'names'); same('ExportDLL', classifyArchiveEntries(listZipEntries($path) ?? []), 'classified'); unlink($path); $junk = tempnam(sys_get_temp_dir(), 'hclj'); file_put_contents($junk, 'not a zip'); same(null, listZipEntries($junk), 'an HTML page is not a zip'); unlink($junk); }); test('inspect: non-archive and oversized files need no download', function () { same('ExportDLL', inspectArtifactType('https://invalid.example/', ['filename' => 'DataDump1.dll', 'size' => 100]), 'raw DLL classified by name'); same('', inspectArtifactType('https://invalid.example/', ['filename' => 'big.zip', 'size' => hclOptions()['inspect_max_bytes'] + 1]), 'too big: skipped'); same('', inspectArtifactType('https://invalid.example/', ['filename' => 'unknown.zip', 'size' => 0]), 'unknown size: skipped'); }); // =========================================================================== // Failure diagnostics // =========================================================================== test('describeResponseFailure: says what refused the request', function () { same('HTTP 500 text/html EX1406', describeResponseFailure( headers('HTTP/1.1 500 Internal Server Error', 'Content-Type: text/html;charset=UTF-8'), '
Sorry, there is a problem
Error code: EX1406
'), 'IPS error page'); same('HTTP 403 application/json', describeResponseFailure( headers('HTTP/1.1 302 Found', 'Content-Type: text/html', 'Location: /x') . headers('HTTP/1.1 403 Forbidden', 'Content-Type: application/json'), '{}'), 'last hop only'); same('HTTP 429', describeResponseFailure(headers('HTTP/1.1 429 Too Many Requests'), ''), 'rate limited'); check(str_contains(describeResponseFailure(headers('HTTP/1.1 200 OK', 'Content-Type: text/html'), 'Error code: 2D161/4'), '2D161/4'), 'slash-style IPS codes kept whole'); same('HTTP 206 application/zip no filename/size', describeResponseFailure( headers('HTTP/1.1 206 Partial Content', 'Content-Type: application/zip'), ''), 'a file without metadata'); same('no response', describeResponseFailure('', ''), 'nothing at all'); }); test('failure reasons are tallied, most frequent first', function () { hclFailureReasons('test-reason-rare'); hclFailureReasons('test-reason-common'); hclFailureReasons('test-reason-common'); $keys = array_keys(hclFailureReasons()); check(array_search('test-reason-common', $keys, true) < array_search('test-reason-rare', $keys, true), 'common before rare'); same(2, hclFailureReasons()['test-reason-common'] ?? 0, 'counted'); }); test('failed lookups are retried every run, not once a day', function () { check(hclOptions()['meta_retry_seconds'] < 4 * 3600, 'retry window shorter than the 4-hour cron interval'); }); // =========================================================================== // Lookup planning and budget // =========================================================================== /** Write a cache entry directly, bypassing saveFileMeta, for state setups. */ function put_cache(string $dir, int $id, array $entry): void { file_put_contents("{$dir}/{$id}.json", json_encode($entry)); } test('cacheState: every state', function () { $dir = sys_get_temp_dir() . '/hcl-state-test-' . getmypid(); @mkdir($dir); $now = 1_800_000_000; $v = HCL_META_CACHE_VERSION; same('need_meta_new', cacheState(1, 'u1', $dir, $now, false), 'no entry'); put_cache($dir, 2, ['updated' => 'u1', 'filename' => '', 'size' => 65752]); same('need_meta_new', cacheState(2, 'u1', $dir, $now, false), 'old-format entry'); put_cache($dir, 3, ['v' => $v, 'updated' => 'u0', 'checked' => $now, 'ok' => true, 'filename' => 'a.zip', 'size' => 1, 'artifactType' => null]); same('need_meta_new', cacheState(3, 'u1', $dir, $now, false), 'file changed since'); put_cache($dir, 4, ['v' => $v, 'updated' => 'u1', 'checked' => $now, 'ok' => true, 'filename' => 'a.zip', 'size' => 1, 'artifactType' => null]); same('fresh', cacheState(4, 'u1', $dir, $now, false), 'good entry, category not inspected'); same('need_inspect', cacheState(4, 'u1', $dir, $now, true), 'good entry, inspection outstanding'); put_cache($dir, 5, ['v' => $v, 'updated' => 'u1', 'checked' => $now, 'ok' => true, 'filename' => 'a.zip', 'size' => 1, 'artifactType' => '']); same('fresh', cacheState(5, 'u1', $dir, $now, true), 'inspected, nothing more specific'); put_cache($dir, 6, ['v' => $v, 'updated' => 'u1', 'checked' => $now, 'ok' => false, 'filename' => '', 'size' => 0, 'artifactType' => null]); same('fresh', cacheState(6, 'u1', $dir, $now + 60, false), 'failed recently: wait'); same('need_meta_retry', cacheState(6, 'u1', $dir, $now + hclOptions()['meta_retry_seconds'], false), 'failed, now due'); array_map('unlink', glob("{$dir}/*.json") ?: []); @rmdir($dir); }); test('planLookups: inspected categories first, then new, then retries; budget respected', function () { $dir = sys_get_temp_dir() . '/hcl-plan-test-' . getmypid(); @mkdir($dir); $now = 1_800_000_000; $v = HCL_META_CACHE_VERSION; $ok = fn($type = null) => ['v' => $v, 'updated' => 'u', 'checked' => $now, 'ok' => true, 'filename' => 'a.zip', 'size' => 1, 'artifactType' => $type]; $failed = ['v' => $v, 'updated' => 'u', 'checked' => $now - 86400, 'ok' => false, 'filename' => '', 'size' => 0, 'artifactType' => null]; put_cache($dir, 1, $ok()); // BattleSet, fresh // 2: BattleSet, never looked up put_cache($dir, 3, $failed); // BattleSet, retry due // 4: Scenario (subcategory), never looked up // 10: Tool, never looked up put_cache($dir, 11, $ok()); // Tool, awaiting inspection put_cache($dir, 12, $ok('')); // Tool, done $file = fn(int $id) => ['id' => $id, 'updated' => 'u']; $tree = [ ['catId' => 15, 'name' => 'BattleSets', 'parentId' => 1, 'artifactType' => 'BattleSet', 'rawFiles' => [$file(1), $file(2), $file(3)], 'subcategories' => [ ['catId' => 99, 'name' => 'Sub', 'parentId' => 15, 'artifactType' => 'Scenario', 'rawFiles' => [$file(4)], 'subcategories' => []]]], ['catId' => 12, 'name' => 'Tools/Mods/Docs', 'parentId' => 1, 'artifactType' => 'Tool', 'rawFiles' => [$file(10), $file(11), $file(12)], 'subcategories' => []], ]; $before = hclCount(); same([10, 11, 2], array_keys(planLookups($tree, $dir, $now, 3)), 'Tools first, then new files, in catalog order'); $after = hclCount(); same(3, ($after['lookups_planned'] ?? 0) - ($before['lookups_planned'] ?? 0), 'planned counted'); same(2, ($after['backlog_after_run'] ?? 0) - ($before['backlog_after_run'] ?? 0), 'backlog counted (4 and 3 left)'); same([10, 11, 2, 4, 3], array_keys(planLookups($tree, $dir, $now, 100)), 'retries come last; fresh files never'); same([], planLookups($tree, $dir, $now, 0), 'zero budget plans nothing'); array_map('unlink', glob("{$dir}/*.json") ?: []); @rmdir($dir); }); test('buildFileObject: over budget means no network and no cache write', function () { $dir = sys_get_temp_dir() . '/hcl-defer-test-' . getmypid(); @mkdir($dir); // An unresolvable URL: any attempt to fetch it would show up as a failure. $raw = ['id' => 77, 'title' => 'T', 'updated' => '2026-01-01T00:00:00Z', 'url' => 'https://invalid.example/files/file/77-t/']; $before = hclCount(); $t0 = microtime(true); $obj = buildFileObject($raw, 'harpgamer', $dir, 'BattleSet', false); $after = hclCount(); same('', $obj['fileName'], 'no filename yet'); same(0, $obj['size'], 'no size yet'); check(!is_file("{$dir}/77.json"), 'nothing cached'); same(1, ($after['deferred'] ?? 0) - ($before['deferred'] ?? 0), 'counted as deferred'); same($before['meta_failed'] ?? 0, $after['meta_failed'] ?? 0, 'no lookup attempted'); check(microtime(true) - $t0 < 1.0, 'returned without waiting on the network'); // A fresh cache entry is still used when over budget. saveFileMeta(78, '2026-01-01T00:00:00Z', ['filename' => 'b.zip', 'size' => 5, 'ok' => true, 'artifactType' => 'ExportDLL', 'reason' => null], $dir, time()); $obj = buildFileObject(['id' => 78, 'updated' => '2026-01-01T00:00:00Z', 'url' => 'https://invalid.example/'], 'harpgamer', $dir, 'Tool', false); same('b.zip', $obj['fileName'], 'cached filename served'); same('ExportDLL', $obj['artifactType'] ?? null, 'cached refined type served'); array_map('unlink', glob("{$dir}/*.json") ?: []); @rmdir($dir); }); test('hclPace: first lookup immediate, later ones spaced', function () { $saved = hclOptions()['lookup_delay_ms']; hclOptions(['lookup_delay_ms' => 150]); $t0 = microtime(true); hclPace(); $first = microtime(true) - $t0; $t0 = microtime(true); hclPace(); $second = microtime(true) - $t0; hclOptions(['lookup_delay_ms' => $saved]); check($first < 0.05, sprintf('first call does not wait (%.3fs)', $first)); check($second >= 0.14, sprintf('second call waits the delay (%.3fs)', $second)); }); // =========================================================================== // IPS database source // =========================================================================== /** * A SQLite database shaped like the parts of the IPS schema the generator * reads, seeded for the tests below. Table and column names follow * applications/downloads/data/schema.json and core/data/schema.json. */ function seeded_ips_db(): PDO { $pdo = new PDO('sqlite::memory:'); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->exec("CREATE TABLE ibf_downloads_files_records ( record_id INTEGER PRIMARY KEY, record_file_id INT, record_type TEXT DEFAULT 'file', record_location TEXT, record_realname TEXT, record_size INT, record_backup INT DEFAULT 0, record_default INT DEFAULT 0, record_hidden INT DEFAULT 0, record_time INT DEFAULT 0)"); $pdo->exec('CREATE TABLE ibf_downloads_filebackup ( b_id INTEGER PRIMARY KEY, b_fileid INT, b_filetitle TEXT, b_hidden INT DEFAULT 0, b_backup INT, b_updated INT, b_records TEXT, b_version TEXT, b_changelog TEXT)'); $pdo->exec('CREATE TABLE ibf_core_sys_conf_settings (conf_key TEXT, conf_value TEXT)'); $pdo->exec('CREATE TABLE ibf_core_file_storage (id INT, method TEXT, configuration TEXT)'); $ins = $pdo->prepare('INSERT INTO ibf_downloads_files_records (record_id, record_file_id, record_type, record_location, record_realname, record_size, record_backup, record_default, record_hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); foreach ([ [9, 1, 'upload', 'monthly_2020_01/old_zip.x', 'old.zip', 50, 1, 0, 0], // superseded version [10, 1, 'upload', 'monthly_2023_03/a_zip.x', 'a.zip', 100, 0, 0, 0], // current [11, 1, 'ssupload','monthly_2023_03/shot.jpg.x', 'shot.jpg', 10, 0, 1, 0], // screenshot [20, 2, 'upload', 'monthly_2024_01/readme_txt.x','readme.txt', 5, 0, 0, 0], // multi-file entry [21, 2, 'upload', 'monthly_2024_01/b_zip.x', 'b.zip', 200, 0, 0, 0], [30, 3, 'link', 'https://example.org/x.zip', 'x.zip', 0, 0, 0, 0], // external link [40, 4, 'upload', 'monthly_2024_02/h_zip.x', 'h.zip', 70, 0, 0, 1], // hidden [50, 5, 'upload', 'monthly_2024_03/n_zip.x', 'n.zip', 80, 0, 0, 0], // not requested [22, 2, 'upload', 'monthly_2023_01/b_old_zip.x', 'b-0.9.zip', 150, 1, 0, 0], // file 2's old version ] as $row) { $ins->execute($row); } // Previous versions. b_updated = when that version was published, // b_backup = when it was replaced. $bak = $pdo->prepare('INSERT INTO ibf_downloads_filebackup (b_id, b_fileid, b_hidden, b_backup, b_updated, b_records, b_version, b_changelog) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); foreach ([ [100, 1, 0, 1680000000, 1577836800, '9', '', 'First & best
'], [101, 1, 1, 1690000000, 1600000000, '9', 'x', ''], // hidden: guests can't see it [102, 2, 0, 1700000000, 1690000000, '22,50', 'v0.9', ''], // 50 belongs to file 5 [103, 2, 0, 1650000000, 1640000000, '', '', ''], // its files are gone [104, 5, 0, 1650000000, 1640000000, '50', '', ''], // not requested ] as $row) { $bak->execute($row); } return $pdo; } /** A query function over PDO with the same contract as ipsDbOpen()'s. */ function pdo_query(PDO $pdo): callable { return static function (string $sql, array $params = []) use ($pdo): array { $st = $pdo->prepare($sql); $st->execute($params); return $st->fetchAll(PDO::FETCH_ASSOC); }; } test('db: pdo_sqlite is available for these tests', function () { check(extension_loaded('pdo_sqlite'), 'run with -d extension=pdo_sqlite'); }); test('db: only current, visible uploads of the requested files', function () { $records = ipsFetchUploadRecords(pdo_query(seeded_ips_db()), 'ibf_', [1, 2, 3, 4, 1]); same([1, 2], array_keys($records), 'files 1 and 2 (3 is a link, 4 is hidden, 5 was not asked for)'); same([10], array_map(fn($r) => (int)$r['record_id'], $records[1]), 'file 1: the current upload only - no backup, no screenshot'); same([20, 21], array_map(fn($r) => (int)$r['record_id'], $records[2]), 'file 2: both uploads, oldest first'); same('a.zip', $records[1][0]['record_realname'], 'real name'); same(100, (int)$records[1][0]['record_size'], 'size'); same('monthly_2023_03/a_zip.x', $records[1][0]['record_location'], 'storage location'); }); test('db: large ID lists are queried in batches', function () { $ids = array_merge(range(1000, 1449), [2, 1]); $records = ipsFetchUploadRecords(pdo_query(seeded_ips_db()), 'ibf_', $ids); same([1, 2], array_keys($records), 'found across batches of 200'); }); test('db: choosePrimaryRecord', function () { $r = fn(int $id, string $name, int $default = 0) => ['record_id' => $id, 'record_realname' => $name, 'record_default' => $default]; same(null, choosePrimaryRecord([]), 'none'); same(21, choosePrimaryRecord([$r(20, 'readme.txt'), $r(21, 'b.zip')])['record_id'], 'the first .zip'); same(22, choosePrimaryRecord([$r(20, 'a.zip'), $r(22, 'c.zip', 1)])['record_id'], 'a record flagged default wins'); same(20, choosePrimaryRecord([$r(20, 'readme.txt'), $r(23, 'notes.pdf')])['record_id'], 'otherwise the oldest'); same(20, choosePrimaryRecord([$r(20, 'A.ZIP'), $r(21, 'b.zip')])['record_id'], '.zip matched case-insensitively'); }); test('db: storage directory from IPS settings', function () { $pdo = seeded_ips_db(); $q = pdo_query($pdo); $why = null; same(null, ipsDownloadsStorageDir($q, 'ibf_', '/root', $why), 'no settings yet'); check(str_contains((string)$why, 'filestorage__downloads_Files'), 'says what is missing'); $pdo->exec("INSERT INTO ibf_core_sys_conf_settings VALUES ('upload_settings', '{\"filestorage__downloads_Files\":3,\"filestorage__downloads_Screenshots\":4}')"); $why = null; same(null, ipsDownloadsStorageDir($q, 'ibf_', '/root', $why), 'storage row missing'); check(str_contains((string)$why, 'not found'), 'says so'); $pdo->exec("INSERT INTO ibf_core_file_storage VALUES (3, 'FileSystem', '{\"dir\":\"{root}/../../IPB/IDM/downloads\",\"url\":\"x\"}')"); same('/home/site/public_html/harpforum/../../IPB/IDM/downloads', ipsDownloadsStorageDir($q, 'ibf_', '/home/site/public_html/harpforum/', $why), '{root} substituted'); $pdo->exec("UPDATE ibf_core_file_storage SET configuration = '{\"dir\":\"/abs/IDM/downloads/\"}' WHERE id = 3"); same('/abs/IDM/downloads', ipsDownloadsStorageDir($q, 'ibf_', '/root', $why), 'absolute dir, trailing slash dropped'); $pdo->exec("UPDATE ibf_core_file_storage SET method = 'Amazon' WHERE id = 3"); $why = null; same(null, ipsDownloadsStorageDir($q, 'ibf_', '/root', $why), 'not on local disk'); check(str_contains((string)$why, 'Amazon'), 'names the method'); }); test('db: resolveStoredFile stays inside the storage directory', function () { $dir = sys_get_temp_dir() . '/hcl-store-test-' . getmypid(); @mkdir("{$dir}/store/monthly_2023_03", 0777, true); file_put_contents("{$dir}/store/monthly_2023_03/a_zip.x", 'data'); file_put_contents("{$dir}/outside.txt", 'secret'); same(realpath("{$dir}/store/monthly_2023_03/a_zip.x"), resolveStoredFile("{$dir}/store", 'monthly_2023_03/a_zip.x'), 'found'); same(null, resolveStoredFile("{$dir}/store", '../outside.txt'), 'no escaping the directory'); same(null, resolveStoredFile("{$dir}/store", 'monthly_2023_03/missing'), 'missing file'); same(null, resolveStoredFile("{$dir}/store", 'monthly_2023_03'), 'a directory is not a file'); same(null, resolveStoredFile('', 'monthly_2023_03/a_zip.x'), 'no storage directory'); same(null, resolveStoredFile("{$dir}/store", ''), 'no location'); @unlink("{$dir}/store/monthly_2023_03/a_zip.x"); @unlink("{$dir}/outside.txt"); @rmdir("{$dir}/store/monthly_2023_03"); @rmdir("{$dir}/store"); @rmdir($dir); }); test('db: ipsDbOpen refuses missing settings and reports connection failures without the password', function () { FakeMysqliBackend::$pdo = seeded_ips_db(); $good = ['host' => 'localhost', 'name' => 'tgpnet_hg_ivbd1', 'user' => 'tgpnet_hg_ivbd1_ro', 'pass' => 'S3cret-pw']; $why = null; same(null, ipsDbOpen(array_merge($good, ['pass' => 'CHANGE_ME']), $why), 'placeholder password refused'); same('ips_db.pass is not set', $why, 'says which setting'); $why = null; same(null, ipsDbOpen(array_merge($good, ['host' => '']), $why), 'missing host refused'); same('ips_db.host is not set', $why, 'says which setting'); $why = null; same(null, ipsDbOpen(array_merge($good, ['user' => 'someone_else']), $why), 'wrong user: no connection'); check(str_contains((string)$why, 'Access denied'), 'reason passed through'); check(!str_contains((string)$why, 'S3cret-pw'), 'password never in the reason'); }); test('db: ipsDbOpen adapter runs real queries (prepare/bind_param/get_result)', function () { FakeMysqliBackend::$pdo = seeded_ips_db(); $why = null; $q = ipsDbOpen(['host' => 'localhost', 'name' => 'tgpnet_hg_ivbd1', 'user' => 'tgpnet_hg_ivbd1_ro', 'pass' => 'x'], $why); check(is_callable($q), 'connected (' . $why . ')'); if (!is_callable($q)) { return; } $records = ipsFetchUploadRecords($q, 'ibf_', [1, 2, 3, 4]); same([1, 2], array_keys($records), 'same answer as through PDO directly'); same(2, count($records[2]), 'multi-file entry intact'); }); /** Create a storage directory holding one real ZIP, as IPS names stored files. */ function make_stored_zip(string $dir, array $entries): array { @mkdir("{$dir}/monthly_2023_03", 0777, true); $path = "{$dir}/monthly_2023_03/HCExportDLL_Foo_v1_zip.0123456789abcdef"; $zip = new ZipArchive(); $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE); foreach ($entries as $name) { $zip->addFromString($name, "content of {$name}"); } $zip->close(); return ['record_id' => 10, 'record_file_id' => 90, 'record_realname' => 'HCExportDLL_Foo_v1.zip', 'record_size' => filesize($path), 'record_location' => 'monthly_2023_03/HCExportDLL_Foo_v1_zip.0123456789abcdef', 'record_default' => 0, 'path' => $path]; } test('db: buildFileObject takes name, size, hash and type from the database and disk - no web request', function () { $dir = sys_get_temp_dir() . '/hcl-dbfile-test-' . getmypid(); $store = "{$dir}/store"; $cache = "{$dir}/cache"; @mkdir($cache, 0777, true); $rec = make_stored_zip($store, ['Foo.dll', 'Foo/readme.txt']); // An unresolvable page URL and no budget: any web attempt would show. $raw = ['id' => 90, 'title' => 'ExportDLL - Foo', 'updated' => '2026-01-01T00:00:00Z', 'url' => 'https://invalid.example/files/file/90-foo/']; $before = hclCount(); $obj = buildFileObject($raw, 'harpgamer', $cache, 'Tool', false, $rec, $store); $after = hclCount(); same('HCExportDLL_Foo_v1.zip', $obj['fileName'], 'filename from the database'); same($rec['record_size'], $obj['size'], 'size from the database'); same(hash_file('sha256', $rec['path']), $obj['sha256'], 'SHA-256 of the stored file'); same('ExportDLL', $obj['artifactType'] ?? null, 'classified from the stored archive'); same(1, ($after['db_resolved'] ?? 0) - ($before['db_resolved'] ?? 0), 'counted as db_resolved'); same(1, ($after['sha256_computed'] ?? 0) - ($before['sha256_computed'] ?? 0), 'hashed once'); same(1, ($after['inspected_local'] ?? 0) - ($before['inspected_local'] ?? 0), 'inspected from disk'); same($before['meta_failed'] ?? 0, $after['meta_failed'] ?? 0, 'no web lookup attempted'); same($before['deferred'] ?? 0, $after['deferred'] ?? 0, 'not deferred despite no budget'); $cached = getCachedFileMeta(90, '2026-01-01T00:00:00Z', $cache, time()); same(10, $cached['record_id'] ?? null, 'cache remembers the record'); same('https://invalid.example/files/file/90-foo/?do=download&r=10', $obj['downloadUrl'], 'download link names exactly the record described'); check(!isset($obj['externalLink']), 'not flagged as an external link'); // Second run: nothing to recompute. $before = hclCount(); $again = buildFileObject($raw, 'harpgamer', $cache, 'Tool', false, $rec, $store); $after = hclCount(); same($obj['sha256'], $again['sha256'], 'same hash'); same($before['sha256_computed'] ?? 0, $after['sha256_computed'] ?? 0, 'not re-hashed'); same($before['inspected_local'] ?? 0, $after['inspected_local'] ?? 0, 'not re-inspected'); // The database is authoritative: a renamed record shows at once. $renamed = array_merge($rec, ['record_realname' => 'Foo_renamed.zip']); same('Foo_renamed.zip', buildFileObject($raw, 'harpgamer', $cache, 'Tool', false, $renamed, $store)['fileName'], 'new name without re-hashing'); // A different record (the file was replaced) is hashed afresh. $before = hclCount(); buildFileObject($raw, 'harpgamer', $cache, 'Tool', false, array_merge($rec, ['record_id' => 11]), $store); $after = hclCount(); same(1, ($after['sha256_computed'] ?? 0) - ($before['sha256_computed'] ?? 0), 'replaced file re-hashed'); // Storage unreadable: name and size still come through. @unlink("{$cache}/91.json"); $before = hclCount(); $obj = buildFileObject(array_merge($raw, ['id' => 91]), 'harpgamer', $cache, 'Tool', false, $rec, ''); $after = hclCount(); same('HCExportDLL_Foo_v1.zip', $obj['fileName'], 'name without disk access'); same('', $obj['sha256'], 'no hash without disk access'); check(!isset($obj['artifactType']), 'no refined type without disk access'); same(1, ($after['stored_file_unreadable'] ?? 0) - ($before['stored_file_unreadable'] ?? 0), 'counted'); array_map('unlink', glob("{$cache}/*.json") ?: []); @unlink($rec['path']); @rmdir("{$store}/monthly_2023_03"); @rmdir($store); @rmdir($cache); @rmdir($dir); }); test('db: planLookups leaves database-resolved files out of the web budget', function () { $dir = sys_get_temp_dir() . '/hcl-dbplan-test-' . getmypid(); @mkdir($dir); $tree = [['catId' => 12, 'name' => 'Tools', 'parentId' => 1, 'artifactType' => 'Tool', 'rawFiles' => [['id' => 1, 'updated' => 'u'], ['id' => 2, 'updated' => 'u']], 'subcategories' => []]]; same([2], array_keys(planLookups($tree, $dir, time(), 10, [1 => [['record_id' => 10]]])), 'only the file without a database record'); @rmdir($dir); }); test('db: renderCategoryTree picks the main record and counts multi-file entries', function () { $dir = sys_get_temp_dir() . '/hcl-dbrender-test-' . getmypid(); @mkdir($dir); $tree = [['catId' => 15, 'name' => 'BattleSets', 'parentId' => 1, 'artifactType' => 'BattleSet', 'rawFiles' => [['id' => 2, 'title' => 'B', 'updated' => 'u', 'url' => 'https://invalid.example/']], 'subcategories' => []]]; $records = ipsFetchUploadRecords(pdo_query(seeded_ips_db()), 'ibf_', [2]); $total = 0; $before = hclCount(); $out = renderCategoryTree($tree, 1, 'harpgamer', $dir, [], $total, $records, ''); $after = hclCount(); same('b.zip', $out[0]['files'][0]['fileName'], 'the .zip, not the readme'); same(1, ($after['multi_file_entries'] ?? 0) - ($before['multi_file_entries'] ?? 0), 'multi-file entry counted'); same('https://invalid.example/?do=download&r=21', $out[0]['files'][0]['downloadUrl'], 'multi-file entry links to its chosen record, not the chooser page'); array_map('unlink', glob("{$dir}/*.json") ?: []); @rmdir($dir); }); // =========================================================================== // Files without a current upload; unreadable stored files // =========================================================================== test('classifyNoUploadRecords: why a file has no current upload', function () { $r = fn(string $type, int $backup = 0, int $hidden = 0) => ['record_type' => $type, 'record_backup' => $backup, 'record_hidden' => $hidden]; same('external_link', classifyNoUploadRecords([$r('link')]), 'a current link'); same('external_link', classifyNoUploadRecords([$r('ssupload'), $r('link')]), 'link with a screenshot'); same('no_current_upload', classifyNoUploadRecords([$r('upload', 1)]), 'only a superseded upload'); same('no_current_upload', classifyNoUploadRecords([$r('upload', 0, 1)]), 'only a pending (hidden) upload'); same('no_current_upload', classifyNoUploadRecords([$r('link', 1)]), 'only a superseded link'); same('no_records', classifyNoUploadRecords([$r('ssupload')]), 'screenshots only'); same('no_records', classifyNoUploadRecords([]), 'nothing at all'); }); test('ipsFetchRecordSummary: every record of the asked-for files', function () { $summary = ipsFetchRecordSummary(pdo_query(seeded_ips_db()), 'ibf_', [3, 4, 99]); same([3, 4], array_keys($summary), 'files with records (99 has none)'); same('external_link', classifyNoUploadRecords($summary[3]), 'file 3 is a link'); same('no_current_upload', classifyNoUploadRecords($summary[4]), 'file 4 has only a hidden upload'); same('no_records', classifyNoUploadRecords($summary[99] ?? []), 'file 99 has nothing'); }); test('buildFileObject: an external link is flagged and never looked up on the web', function () { $dir = sys_get_temp_dir() . '/hcl-link-test-' . getmypid(); @mkdir($dir); $raw = ['id' => 3, 'title' => 'Linked', 'updated' => '2026-01-01T00:00:00Z', 'url' => 'https://invalid.example/files/file/3-linked/']; $before = hclCount(); // Even with a budget slot, the database's verdict means no web request. $obj = buildFileObject($raw, 'harpgamer', $dir, 'Tool', true, null, '', 'external_link'); $after = hclCount(); same(true, $obj['externalLink'] ?? null, 'flagged'); same('', $obj['fileName'], 'no filename'); same('', $obj['downloadUrl'], 'no download link'); same(1, ($after['db_external_link'] ?? 0) - ($before['db_external_link'] ?? 0), 'counted'); same($before['meta_failed'] ?? 0, $after['meta_failed'] ?? 0, 'no web lookup attempted'); same($before['meta_fetched'] ?? 0, $after['meta_fetched'] ?? 0, 'nor succeeded'); $obj = buildFileObject(array_merge($raw, ['id' => 4]), 'harpgamer', $dir, 'Tool', true, null, '', 'no_current_upload'); check(!isset($obj['externalLink']), 'other verdicts are not flagged as links'); array_map('unlink', glob("{$dir}/*.json") ?: []); @rmdir($dir); }); test('planLookups: a consulted database keeps every listed file off the web', function () { $dir = sys_get_temp_dir() . '/hcl-known-test-' . getmypid(); @mkdir($dir); $tree = [['catId' => 12, 'name' => 'Tools', 'parentId' => 1, 'artifactType' => 'Tool', 'rawFiles' => [['id' => 1, 'updated' => 'u'], ['id' => 3, 'updated' => 'u']], 'subcategories' => []]]; same([], planLookups($tree, $dir, time(), 10, [1 => true, 3 => true]), 'nothing planned'); @rmdir($dir); }); test('resolveStoredFile: says why a stored file is unusable', function () { $dir = sys_get_temp_dir() . '/hcl-why-test-' . getmypid(); @mkdir("{$dir}/store/sub", 0777, true); file_put_contents("{$dir}/outside.txt", 'x'); $cases = [ ['', 'a', 'storage directory unknown'], ["{$dir}/store", '', 'record has no location'], ["{$dir}/nope", 'a', 'storage directory missing'], ["{$dir}/store", 'missing_zip.x', 'missing on disk'], ["{$dir}/store", '../outside.txt', 'outside the storage directory'], ["{$dir}/store", 'sub', 'not a file'], ]; foreach ($cases as [$store, $loc, $expected]) { $why = null; same(null, resolveStoredFile($store, $loc, $why), "no path for '{$loc}'"); same($expected, $why, "reason for '{$loc}'"); } @unlink("{$dir}/outside.txt"); @rmdir("{$dir}/store/sub"); @rmdir("{$dir}/store"); @rmdir($dir); }); test('buildFileObject: an unreadable stored file is named in a note', function () { $dir = sys_get_temp_dir() . '/hcl-note-test-' . getmypid(); @mkdir("{$dir}/store", 0777, true); @mkdir("{$dir}/cache", 0777, true); $rec = ['record_id' => 77, 'record_file_id' => 700, 'record_realname' => 'gone.zip', 'record_size' => 9, 'record_location' => 'monthly_2020_01/gone_zip.abc', 'record_default' => 0]; $raw = ['id' => 700, 'title' => 'Gone File', 'updated' => 'u', 'url' => 'https://invalid.example/']; $before = count(hclNote('stored_file_unreadable')); $obj = buildFileObject($raw, 'harpgamer', "{$dir}/cache", 'BattleSet', false, $rec, "{$dir}/store"); $notes = hclNote('stored_file_unreadable'); same($before + 1, count($notes), 'one note added'); same('file 700 "Gone File" (monthly_2020_01/gone_zip.abc): missing on disk', end($notes), 'names file, title, location and reason'); same('gone.zip', $obj['fileName'], 'name and size still published'); array_map('unlink', glob("{$dir}/cache/*.json") ?: []); @rmdir("{$dir}/cache"); @rmdir("{$dir}/store"); @rmdir($dir); }); // =========================================================================== // Previous versions // =========================================================================== test('ipsFetchVersions: visible versions of the asked-for files, newest first, own records only', function () { $v = ipsFetchVersions(pdo_query(seeded_ips_db()), 'ibf_', [1, 2, 3, 2]); same([1, 2], array_keys($v), 'files 1 and 2 (3 has none, 5 was not asked for)'); same([100], array_column($v[1], 'b_id'), 'file 1: the hidden version is left out'); same([9], array_map(fn($r) => (int)$r['record_id'], $v[1][0]['records']), "file 1's old upload"); same('', $v[1][0]['version'], 'no version field'); same(1577836800, $v[1][0]['updated'], 'published time'); same([102, 103], array_column($v[2], 'b_id'), 'file 2: newest replaced first'); same([22], array_map(fn($r) => (int)$r['record_id'], $v[2][0]['records']), "another file's record is ignored"); same([], $v[2][1]['records'], 'a version whose files are gone has none'); }); test('versionLabel: version field, else the filename stem', function () { same('v0.9', versionLabel('v0.9', 'b-0.9.zip'), 'version field wins'); same('v0.9', versionLabel(' v0.9 ', ''), 'trimmed'); same('HCDB2-170611', versionLabel('', 'HCDB2-170611.zip'), 'stem of the filename'); same('Foo.v1', versionLabel('', 'Foo.v1.zip'), 'only the last extension goes'); same('README', versionLabel('', 'README'), 'no extension'); same('.hidden', versionLabel('', '.hidden'), 'a leading dot is not an extension'); same('', versionLabel('', ''), 'nothing to go on'); same('HCDB2-161204', versionLabel('161204', 'HCDB2-161204.zip'), 'a file name containing the version field is the fuller label'); same('HCDB2-161204', versionLabel('hcdb2-161204', 'HCDB2-161204.zip'), 'compared case-insensitively'); same('1.2', versionLabel('1.2', 'Toolbox.zip'), 'a file name reused across releases does not replace the version'); same('161204', versionLabel('161204', ''), 'no file name'); }); test('buildVersionObjects: a repeated label keeps only the newest upload', function () { $dir = sys_get_temp_dir() . '/hcl-dupver-test-' . getmypid(); @mkdir($dir); $rec = fn(int $id, string $name) => ['record_id' => $id, 'record_realname' => $name, 'record_size' => 1, 'record_location' => 'x', 'record_default' => 0, 'record_time' => 0]; $ver = fn(int $b, string $vf, string $name, int $when) => ['b_id' => $b, 'version' => $vf, 'changelog' => '', 'updated' => $when, 'backup' => $when + 1, 'records' => [$rec($b * 10, $name)]]; // HCDB2's real history, newest first. $versions = [ $ver(1094, '', 'HCDB2-170714.zip', 1500422400), // respin, 2017-07-19 $ver(1085, '', 'HCDB2-170714.zip', 1500076800), // first 170714, 2017-07-15 $ver(1082, '', 'HCDB2-170611.zip', 1497139200), $ver(1060, '', 'HCDB2-170909.zip', 1490000000), // same label as the current version $ver(1022, '', 'HCDB2-161204.zip', 1480982400), // corrected 161204 $ver(1019, '161204', 'HCDB2-161204.zip', 1480896000), // held HCDB-151001 by mistake $ver(900, '1.1', 'Other.zip', 1400000000), $ver(899, '1.0', 'Other.zip', 1300000000), // same file name, different version: kept ]; $before = hclCount(); $out = buildVersionObjects(934, 'HCDB2', 'https://invalid.example/f/934/', $versions, '', $dir, 'HCDB2-170909'); $after = hclCount(); same(['HCDB2-170714', 'HCDB2-170611', 'HCDB2-161204', '1.1', '1.0'], array_column($out, 'label'), 'one of each'); same([1094, 1082, 1022], array_map(fn($o) => (int)preg_replace('/.*version=(\d+).*/', '$1', $o['downloadUrl']), array_slice($out, 0, 3)), 'the newest of each'); same(3, ($after['versions_superseded_same_label'] ?? 0) - ($before['versions_superseded_same_label'] ?? 0), 'three left out'); @rmdir($dir); }); test('plainChangelog: text from IPS HTML, shortened', function () { same("First & best\nSecond line", plainChangelog('First & best
Second line
'), 'tags, entities, paragraphs'); same("a\nb", plainChangelog("aFixes
', 'updated' => 1497139200, 'backup' => 1500077100, 'records' => [$rec]], ['b_id' => 1080, 'version' => '', 'changelog' => '', 'updated' => 0, 'backup' => 1400000000, 'records' => []], ['b_id' => 1079, 'version' => '170507', 'changelog' => '', 'updated' => 0, 'backup' => 1400000000, 'records' => [array_merge($rec, ['record_id' => 23, 'record_time' => 1494115200, 'record_location' => 'monthly_2017_05/gone_zip.x'])]], ]; $page = 'https://invalid.example/files/file/934-hcdb2/'; $before = hclCount(); $out = buildVersionObjects(934, 'HCDB2', $page, $versions, $store, $cache); $after = hclCount(); same(2, count($out), 'the fileless version is dropped'); same(1, ($after['versions_without_file'] ?? 0) - ($before['versions_without_file'] ?? 0), 'and counted'); same('HCDB2-170611', $out[0]['label'], 'labelled by filename'); same('', $out[0]['version'], 'raw version field kept'); same('2017-06-11', $out[0]['date'], 'dated when published, not when replaced'); same('Fixes', $out[0]['changelog'], 'changelog as text'); same('HCDB2-170611.zip', $out[0]['fileName'], 'file name'); same($rec['record_size'], $out[0]['size'], 'size'); same(hash_file('sha256', $rec['path']), $out[0]['sha256'], 'hash of the stored file'); same($page . '?do=download&version=1082&r=22', $out[0]['downloadUrl'], 'names the version and the record'); same(1, ($after['version_sha256_computed'] ?? 0) - ($before['version_sha256_computed'] ?? 0), 'hashed'); same('170507', $out[1]['label'], 'version field wins when set'); same('2017-05-07', $out[1]['date'], 'falls back to the record time'); same('', $out[1]['sha256'], 'no hash for a missing file'); same(1, ($after['version_file_unreadable'] ?? 0) - ($before['version_file_unreadable'] ?? 0), 'missing file counted'); $notes = hclNote('version_file_unreadable'); check(str_contains((string)end($notes), 'version 1079'), 'and named'); // Records never change: the next run reuses the hash. $before = hclCount(); $again = buildVersionObjects(934, 'HCDB2', $page, $versions, $store, $cache); $after = hclCount(); same($out[0]['sha256'], $again[0]['sha256'], 'same hash'); same($before['version_sha256_computed'] ?? 0, $after['version_sha256_computed'] ?? 0, 'not re-hashed'); // ...unless the record was moved to another location. $moved = $versions; $moved[0]['records'][0]['record_location'] = 'monthly_2023_03/../monthly_2023_03/' . basename($rec['path']); $before = hclCount(); buildVersionObjects(934, 'HCDB2', $page, $moved, $store, $cache); same(1, (hclCount()['version_sha256_computed'] ?? 0) - ($before['version_sha256_computed'] ?? 0), 'moved record re-hashed'); // Without storage: listed, just unhashed. same('', buildVersionObjects(934, 'HCDB2', $page, $versions, '', $cache)[0]['sha256'], 'no hash without storage'); // Past the run's hashing cap: listed without a hash, left for a later run. array_map('unlink', glob("{$cache}/records/*.json") ?: []); $saved = hclOptions()['max_version_hashes_per_run']; hclOptions(['max_version_hashes_per_run' => hclCount()['version_sha256_computed'] ?? 0]); // already spent $before = hclCount(); $capped = buildVersionObjects(934, 'HCDB2', $page, $versions, $store, $cache); $after = hclCount(); hclOptions(['max_version_hashes_per_run' => $saved]); same('', $capped[0]['sha256'], 'no hash over the cap'); same('HCDB2-170611.zip', $capped[0]['fileName'], 'but still listed'); same(1, ($after['version_hash_deferred'] ?? 0) - ($before['version_hash_deferred'] ?? 0), 'counted as deferred'); check(!is_file("{$cache}/records/22.json"), 'nothing cached, so a later run hashes it'); same(hash_file('sha256', $rec['path']), buildVersionObjects(934, 'HCDB2', $page, $versions, $store, $cache)[0]['sha256'], 'hashed once the cap allows'); array_map('unlink', glob("{$cache}/records/*.json") ?: []); @rmdir("{$cache}/records"); @unlink($rec['path']); @rmdir("{$store}/monthly_2023_03"); @rmdir($store); @rmdir($cache); @rmdir($dir); }); test('buildFileObject: versionLabel for the current version, versions when there are any', function () { $dir = sys_get_temp_dir() . '/hcl-fileversions-test-' . getmypid(); @mkdir($dir); $raw = ['id' => 934, 'title' => 'HCDB2', 'updated' => 'u', 'url' => 'https://invalid.example/f/934/']; $rec = ['record_id' => 5000, 'record_realname' => 'HCDB2-170909.zip', 'record_size' => 10, 'record_location' => 'x', 'record_default' => 0]; $old = ['b_id' => 1094, 'version' => '', 'changelog' => '', 'updated' => 1499990400, 'backup' => 1505000000, 'records' => [array_merge($rec, ['record_id' => 4990, 'record_realname' => 'HCDB2-170714.zip'])]]; $obj = buildFileObject($raw, 'harpgamer', $dir, 'PlatformDB', false, $rec, '', null, [$old]); same('HCDB2-170909', $obj['versionLabel'] ?? null, 'current version named from its file'); same(1, count($obj['versions'] ?? []), 'one previous version'); same('HCDB2-170714', $obj['versions'][0]['label'], 'its label'); $plain = buildFileObject(array_merge($raw, ['version' => '2.1']), 'harpgamer', $dir, 'PlatformDB', false, $rec, ''); same('2.1', $plain['versionLabel'], 'the version field when set'); check(!isset($plain['versions']), 'no versions key without previous versions'); // An entry whose current upload is gone may still offer older versions. $gone = buildFileObject(array_merge($raw, ['id' => 935]), 'harpgamer', $dir, 'PlatformDB', false, null, '', 'no_current_upload', [$old]); same('', $gone['fileName'], 'nothing current'); check(!isset($gone['versionLabel']), 'no label for nothing'); same(1, count($gone['versions'] ?? []), 'but the old version is listed'); array_map('unlink', glob("{$dir}/*.json") ?: []); @rmdir($dir); }); test('renderCategoryTree: passes each file its versions', function () { $dir = sys_get_temp_dir() . '/hcl-rendervers-test-' . getmypid(); @mkdir($dir); $tree = [['catId' => 15, 'name' => 'BattleSets', 'parentId' => 1, 'artifactType' => 'BattleSet', 'rawFiles' => [['id' => 2, 'title' => 'B', 'updated' => 'u', 'url' => 'https://invalid.example/f/2/']], 'subcategories' => []]]; $q = pdo_query(seeded_ips_db()); $total = 0; $out = renderCategoryTree($tree, 1, 'harpgamer', $dir, [], $total, ipsFetchUploadRecords($q, 'ibf_', [2]), '', [], ipsFetchVersions($q, 'ibf_', [2])); $f = $out[0]['files'][0]; same(['v0.9'], array_column($f['versions'] ?? [], 'label'), 'the version with a file'); same('https://invalid.example/f/2/?do=download&version=102&r=22', $f['versions'][0]['downloadUrl'], 'its link'); array_map('unlink', glob("{$dir}/*.json") ?: []); @rmdir($dir); }); // =========================================================================== // Companion files // =========================================================================== test('companionObjects: only the databases that name 328 get its commondb.rsr', function () { $want = [['id' => 'harpgamer:328', 'provides' => 'commondb.rsr']]; foreach ([605, 257, 276, 597] as $id) { same($want, companionObjects($id, 'harpgamer'), "file {$id}"); } same([], companionObjects(552, 'harpgamer'), '50to65 is not named by 328: stock pictures'); same([], companionObjects(934, 'harpgamer'), 'HCDB2 uses the stock pictures'); }); test('companionObjects: configured list replaces the built-in one; bad rows skipped', function () { $saved = hclOptions()['companions']; hclOptions(['companions' => [7 => [[8, 'a.rsr'], [0, 'x'], [9, ''], [10, '../evil'], [11, 'sub\\f'], 'junk', [12, '..']]]]); same([['id' => 's:8', 'provides' => 'a.rsr']], companionObjects(7, 's'), 'only the valid row'); same([], companionObjects(605, 's'), 'built-in list replaced'); hclOptions(['companions' => $saved]); }); test('buildFileObject: companions published on the file', function () { $dir = sys_get_temp_dir() . '/hcl-companion-test-' . getmypid(); @mkdir($dir); $rec = ['record_id' => 461, 'record_realname' => 'hce_databases.zip', 'record_size' => 5, 'record_location' => 'x', 'record_default' => 0]; $obj = buildFileObject(['id' => 605, 'title' => 'hc-timelines-db collection', 'updated' => 'u', 'url' => 'https://invalid.example/f/605/'], 'harpgamer', $dir, 'PlatformDB', false, $rec, ''); same([['id' => 'harpgamer:328', 'provides' => 'commondb.rsr']], $obj['companions'] ?? null, 'companions'); $plain = buildFileObject(['id' => 934, 'title' => 'HCDB2', 'updated' => 'u', 'url' => 'https://invalid.example/f/934/'], 'harpgamer', $dir, 'PlatformDB', false, array_merge($rec, ['record_id' => 1]), ''); check(!isset($plain['companions']), 'no key when none'); array_map('unlink', glob("{$dir}/*.json") ?: []); @rmdir($dir); }); // =========================================================================== // Live integration (opt-in) // =========================================================================== if ($live) { $base = 'https://harpgamer.com/harpforum/files/file/'; test('live: metadata for an ExportDLL', function () use ($base) { $meta = fetchFileMeta($base . '835-exportdll-lazhceffects/'); check($meta['ok'], 'lookup succeeded'); same('HCExportDLL_lazHCEffects_v003.zip', $meta['filename'], 'filename from Content-Disposition'); same(5411224, $meta['size'], 'real size, not an error page'); }); test('live: metadata for a BattleSet', function () use ($base) { $meta = fetchFileMeta($base . '274-caribean/'); check($meta['ok'], 'lookup succeeded'); same('caribic.zip', $meta['filename'], 'filename'); check($meta['size'] > 100000, 'plausible size (' . $meta['size'] . ')'); }); test('live: inspection classifies a real ExportDLL archive', function () use ($base) { $page = $base . '835-exportdll-lazhceffects/'; same('ExportDLL', inspectArtifactType($page, fetchFileMeta($page)), 'type'); }); test('live: the old user agent is still refused (informational)', function () use ($base) { $saved = hclOptions()['user_agent']; hclOptions(['user_agent' => 'HCLauncher-CatalogGenerator/1.0']); $meta = fetchFileMeta($base . '835-exportdll-lazhceffects/'); hclOptions(['user_agent' => $saved]); // Server behaviour that may change, so only reported, not asserted - // but when it fails, the reason must be diagnosable. echo ' note: old UA lookup ' . ($meta['ok'] ? 'now SUCCEEDS' : "still fails: {$meta['reason']}") . "\n"; check($meta['ok'] || ($meta['reason'] !== null && $meta['reason'] !== ''), 'a failure carries a reason'); }); } // =========================================================================== $total = $passed + count($failures); echo "{$passed}/{$total} checks passed" . ($live ? ' (including live)' : ' (offline; add --live for integration)') . "\n"; foreach ($failures as $f) { echo "FAIL {$f}\n"; } exit($failures ? 1 : 0);