diff --git a/test/data/xmltv/fixtures/invalid.json b/test/data/xmltv/fixtures/invalid.json
new file mode 100644
index 00000000..9992912b
--- /dev/null
+++ b/test/data/xmltv/fixtures/invalid.json
@@ -0,0 +1,14 @@
+{
+ "services": [
+ {
+ "id": 12346,
+ "networkId": 1,
+ "serviceId": 102,
+ "name": "非表示チャンネル",
+ "type": 0,
+ "logoId": -1,
+ "remoteControlKeyId": 6
+ }
+ ],
+ "programs": []
+}
diff --git a/test/data/xmltv/fixtures/logo.json b/test/data/xmltv/fixtures/logo.json
new file mode 100644
index 00000000..dca752a3
--- /dev/null
+++ b/test/data/xmltv/fixtures/logo.json
@@ -0,0 +1,14 @@
+{
+ "services": [
+ {
+ "id": 54321,
+ "networkId": 2,
+ "serviceId": 202,
+ "name": "ロゴチャンネル",
+ "type": 173,
+ "logoId": 10,
+ "remoteControlKeyId": 7
+ }
+ ],
+ "programs": []
+}
diff --git a/test/data/xmltv/fixtures/valid.json b/test/data/xmltv/fixtures/valid.json
new file mode 100644
index 00000000..27d89a43
--- /dev/null
+++ b/test/data/xmltv/fixtures/valid.json
@@ -0,0 +1,62 @@
+{
+ "services": [
+ {
+ "id": 12345,
+ "networkId": 1,
+ "serviceId": 101,
+ "name": "テストチャンネル & Co.",
+ "type": 1,
+ "logoId": -1,
+ "remoteControlKeyId": 5
+ },
+ {
+ "id": 12346,
+ "networkId": 1,
+ "serviceId": 102,
+ "name": "サブチャンネル",
+ "type": 1,
+ "logoId": -1,
+ "remoteControlKeyId": 5
+ },
+ {
+ "id": 12347,
+ "networkId": 1,
+ "serviceId": 999,
+ "name": "キーなしチャンネル",
+ "type": 173,
+ "logoId": -1
+ }
+ ],
+ "programs": [
+ {
+ "id": 1000,
+ "networkId": 1,
+ "serviceId": 101,
+ "startAt": 1610000000000,
+ "duration": 3600000,
+ "name": "テスト番組 <こんにちは>",
+ "description": "テスト説明 & \"引用\" 'シングル'",
+ "genres": [
+ { "lv1": 0, "lv2": 0, "un1": 0, "un2": 0 },
+ { "lv1": 0, "lv2": 0, "un1": 0, "un2": 0 },
+ { "lv1": 14, "lv2": 1, "un1": 2, "un2": 3 },
+ { "lv1": 14, "lv2": 9, "un1": 9, "un2": 9 }
+ ]
+ },
+ {
+ "id": 1001,
+ "networkId": 1,
+ "serviceId": 102,
+ "startAt": 1610003600000,
+ "duration": 1800000
+ },
+ {
+ "id": 1002,
+ "networkId": 1,
+ "serviceId": 9991,
+ "startAt": 1610005400000,
+ "duration": 1800000,
+ "name": "存在しないサービス"
+ }
+ ]
+}
diff --git a/test/data/xmltv/goldens/empty.xml b/test/data/xmltv/goldens/empty.xml
new file mode 100644
index 00000000..59fc074b
--- /dev/null
+++ b/test/data/xmltv/goldens/empty.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/test/data/xmltv/goldens/logo.xml b/test/data/xmltv/goldens/logo.xml
new file mode 100644
index 00000000..732fa54e
--- /dev/null
+++ b/test/data/xmltv/goldens/logo.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ロゴチャンネル
+7.1
+
+
diff --git a/test/data/xmltv/goldens/valid.xml b/test/data/xmltv/goldens/valid.xml
new file mode 100644
index 00000000..528d795b
--- /dev/null
+++ b/test/data/xmltv/goldens/valid.xml
@@ -0,0 +1,26 @@
+
+
+
+
+テストチャンネル & Co.
+5.1
+
+
+サブチャンネル
+5.2
+
+
+キーなしチャンネル
+999.1
+
+
+テスト番組 <こんにちは>
+テスト説明 & "引用" 'シングル'
+ニュース/報道 - 定時・総合
+邦画 - サスペンス/ミステリー
+
+
+
+
+
+
diff --git a/test/xmltv.spec.js b/test/xmltv.spec.js
new file mode 100644
index 00000000..62a41724
--- /dev/null
+++ b/test/xmltv.spec.js
@@ -0,0 +1,122 @@
+const { describe, it, beforeEach, afterEach } = require("node:test");
+const assert = require("assert");
+const fs = require("fs");
+const path = require("path");
+
+// Mock dependencies before requiring the module
+const _ = require("../lib/Mirakurun/_").default;
+const ServiceClass = require("../lib/Mirakurun/Service").default;
+
+// The file to test
+const xmltv = require("../lib/Mirakurun/api/iptv/xmltv");
+
+class MockResponse {
+ constructor() {
+ this.headers = {};
+ this.statusCode = 200;
+ this.body = "";
+ }
+ setHeader(name, value) {
+ this.headers[name] = value;
+ }
+ status(code) {
+ this.statusCode = code;
+ }
+ end(data) {
+ this.body += data;
+ }
+}
+
+function loadFixture(name) {
+ const jsonPath = path.join(__dirname, "data", "xmltv", "fixtures", `${name}.json`);
+ const data = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
+
+ // Add getOrder function to services
+ data.services.forEach((s, i) => {
+ s.getOrder = () => i + 1;
+ });
+
+ return data;
+}
+
+function loadGolden(name) {
+ const xmlPath = path.join(__dirname, "data", "xmltv", "goldens", `${name}.xml`);
+ return fs.readFileSync(xmlPath, "utf8").trim();
+}
+
+describe("api/iptv/xmltv", () => {
+ let originalIsLogoDataExists;
+
+ beforeEach(() => {
+ _.service = {
+ items: [],
+ get: (networkId, serviceId) => {
+ return _.service.items.find(s => s.networkId === networkId && s.serviceId === serviceId) || null;
+ }
+ };
+ _.program = {
+ itemMap: new Map()
+ };
+
+ originalIsLogoDataExists = ServiceClass.isLogoDataExists;
+ ServiceClass.isLogoDataExists = async (networkId, logoId) => false;
+ });
+
+ afterEach(() => {
+ ServiceClass.isLogoDataExists = originalIsLogoDataExists;
+ });
+
+ it("should generate empty XMLTV when no services or programs exist", async () => {
+ const req = { protocol: "http", headers: { host: "localhost:40772" } };
+ const res = new MockResponse();
+
+ await xmltv.get(req, res);
+
+ assert.strictEqual(res.statusCode, 200);
+ assert.strictEqual(res.headers["Content-Type"], "text/xml; charset=utf-8");
+ assert.strictEqual(res.body, loadGolden("empty"));
+ });
+
+ it("should generate XMLTV with a valid service and program", async () => {
+ const req = { protocol: "http", headers: { host: "localhost:40772" } };
+ const res = new MockResponse();
+
+ const data = loadFixture("valid");
+ _.service.items = data.services;
+ for (const prog of data.programs) {
+ _.program.itemMap.set(prog.id, prog);
+ }
+
+ await xmltv.get(req, res);
+
+ assert.strictEqual(res.statusCode, 200);
+ assert.strictEqual(res.body, loadGolden("valid"));
+ });
+
+ it("should ignore services that are not type 1 or 173", async () => {
+ const req = { protocol: "http", headers: { host: "localhost:40772" } };
+ const res = new MockResponse();
+
+ const data = loadFixture("invalid");
+ _.service.items = data.services;
+
+ await xmltv.get(req, res);
+
+ assert.strictEqual(res.statusCode, 200);
+ assert.strictEqual(res.body, loadGolden("empty"));
+ });
+
+ it("should include logo tags if Service.isLogoDataExists is true", async () => {
+ ServiceClass.isLogoDataExists = async (networkId, logoId) => networkId === 2 && logoId === 10;
+
+ const req = { protocol: "http", headers: { host: "localhost:40772" } };
+ const res = new MockResponse();
+
+ const data = loadFixture("logo");
+ _.service.items = data.services;
+
+ await xmltv.get(req, res);
+
+ assert.strictEqual(res.body, loadGolden("logo"));
+ });
+});