Skip to content

Commit 53b47a1

Browse files
committed
Expand test coverage from 48 to 152 tests across 14 suites
Add comprehensive tests for NetworkService (IP/subnet math, LAN detection), Throttle utility (stream rate limiting), FileOperationsService (path security, CRUD operations), OfflineMatchesService (YAML generation, match lifecycle), WebrtcService (LAN detection, signaling), RedisManagerService (connections, error handling), RconService (timeouts, event handlers), and RconGateway (WebSocket commands). Raise coverage thresholds to 40% lines/statements, 35% branches, 30% functions. Update test-coverage.md.
1 parent 4a64b51 commit 53b47a1

10 files changed

Lines changed: 2242 additions & 126 deletions

File tree

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,10 @@
9191
"testEnvironment": "node",
9292
"coverageThreshold": {
9393
"global": {
94-
"branches": 15,
95-
"functions": 10,
96-
"lines": 20,
97-
"statements": 20
94+
"branches": 35,
95+
"functions": 30,
96+
"lines": 40,
97+
"statements": 40
9898
}
9999
}
100100
}

src/file-operations/file-operations.service.spec.ts

Lines changed: 310 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
1+
jest.mock("fs/promises", () => ({
2+
access: jest.fn(),
3+
stat: jest.fn(),
4+
readdir: jest.fn(),
5+
readFile: jest.fn(),
6+
writeFile: jest.fn(),
7+
mkdir: jest.fn(),
8+
unlink: jest.fn(),
9+
rmdir: jest.fn(),
10+
rename: jest.fn(),
11+
}));
12+
113
import { FileOperationsService } from "./file-operations.service";
2-
import { ForbiddenException } from "@nestjs/common";
14+
import {
15+
ForbiddenException,
16+
NotFoundException,
17+
BadRequestException,
18+
} from "@nestjs/common";
19+
import * as fs from "fs/promises";
20+
21+
const mockedFs = jest.mocked(fs);
322

423
describe("FileOperationsService", () => {
524
let service: FileOperationsService;
625

726
beforeEach(() => {
827
service = new FileOperationsService();
28+
jest.resetAllMocks();
929
});
1030

1131
describe("validatePath (tested via public methods)", () => {
@@ -47,4 +67,293 @@ describe("FileOperationsService", () => {
4767
).rejects.not.toThrow(ForbiddenException);
4868
});
4969
});
70+
71+
describe("validatePath edge cases", () => {
72+
it("does not throw ForbiddenException for encoded traversal like ..%2F", async () => {
73+
// path.normalize treats %2F as literal characters, not as a slash,
74+
// so this does not escape the base path. It will fail at pathExists instead.
75+
mockedFs.access.mockRejectedValue(new Error("ENOENT"));
76+
await expect(
77+
service.readFile("/servers/", "..%2Fetc/passwd"),
78+
).rejects.toThrow(NotFoundException);
79+
});
80+
81+
it("rejects path traversal via /servers/../etc", async () => {
82+
await expect(
83+
service.readFile("/servers/", "../etc"),
84+
).rejects.toThrow(ForbiddenException);
85+
});
86+
});
87+
88+
describe("readFile", () => {
89+
it("returns file content, path, and size for a valid file", async () => {
90+
mockedFs.access.mockResolvedValue(undefined);
91+
mockedFs.stat.mockResolvedValue({
92+
isFile: () => true,
93+
size: 100,
94+
} as any);
95+
mockedFs.readFile.mockResolvedValue("file content here");
96+
97+
const result = await service.readFile("/servers/", "config.cfg");
98+
99+
expect(result).toEqual({
100+
content: "file content here",
101+
path: "config.cfg",
102+
size: 100,
103+
});
104+
expect(mockedFs.readFile).toHaveBeenCalledWith(
105+
"/servers/config.cfg",
106+
"utf8",
107+
);
108+
});
109+
110+
it("throws NotFoundException when file does not exist", async () => {
111+
mockedFs.access.mockRejectedValue(new Error("ENOENT"));
112+
113+
await expect(
114+
service.readFile("/servers/", "missing.txt"),
115+
).rejects.toThrow(NotFoundException);
116+
});
117+
118+
it("throws BadRequestException when path is a directory", async () => {
119+
mockedFs.access.mockResolvedValue(undefined);
120+
mockedFs.stat.mockResolvedValue({
121+
isFile: () => false,
122+
} as any);
123+
124+
await expect(
125+
service.readFile("/servers/", "somedir"),
126+
).rejects.toThrow(BadRequestException);
127+
});
128+
});
129+
130+
describe("createDirectory", () => {
131+
it("creates a new directory with recursive option", async () => {
132+
mockedFs.access.mockRejectedValue(new Error("ENOENT"));
133+
mockedFs.mkdir.mockResolvedValue(undefined);
134+
135+
await service.createDirectory("/servers/", "new-dir/sub");
136+
137+
expect(mockedFs.mkdir).toHaveBeenCalledWith("/servers/new-dir/sub", {
138+
recursive: true,
139+
});
140+
});
141+
142+
it("does not call mkdir when directory already exists", async () => {
143+
mockedFs.access.mockResolvedValue(undefined);
144+
145+
await service.createDirectory("/servers/", "existing-dir");
146+
147+
expect(mockedFs.mkdir).not.toHaveBeenCalled();
148+
});
149+
});
150+
151+
describe("deleteFileOrDirectory", () => {
152+
it("deletes a file using unlink", async () => {
153+
mockedFs.access.mockResolvedValue(undefined);
154+
mockedFs.stat.mockResolvedValue({
155+
isDirectory: () => false,
156+
} as any);
157+
mockedFs.unlink.mockResolvedValue(undefined);
158+
159+
await service.deleteFileOrDirectory("/servers/", "old.cfg");
160+
161+
expect(mockedFs.unlink).toHaveBeenCalledWith("/servers/old.cfg");
162+
});
163+
164+
it("deletes an empty directory using rmdir", async () => {
165+
mockedFs.access.mockResolvedValue(undefined);
166+
mockedFs.stat.mockResolvedValue({
167+
isDirectory: () => true,
168+
} as any);
169+
mockedFs.readdir.mockResolvedValue([] as any);
170+
mockedFs.rmdir.mockResolvedValue(undefined);
171+
172+
await service.deleteFileOrDirectory("/servers/", "empty-dir");
173+
174+
expect(mockedFs.rmdir).toHaveBeenCalledWith("/servers/empty-dir");
175+
});
176+
177+
it("throws NotFoundException when target does not exist", async () => {
178+
mockedFs.access.mockRejectedValue(new Error("ENOENT"));
179+
180+
await expect(
181+
service.deleteFileOrDirectory("/servers/", "gone.txt"),
182+
).rejects.toThrow(NotFoundException);
183+
});
184+
});
185+
186+
describe("moveFileOrDirectory", () => {
187+
it("throws NotFoundException when source does not exist", async () => {
188+
// First access call (source pathExists) rejects
189+
mockedFs.access.mockRejectedValueOnce(new Error("ENOENT"));
190+
191+
await expect(
192+
service.moveFileOrDirectory("/servers/", "missing", "dest"),
193+
).rejects.toThrow(NotFoundException);
194+
});
195+
196+
it("moves source into existing destination directory", async () => {
197+
// access calls in order:
198+
// 1. source pathExists -> exists
199+
// 2. dest pathExists -> exists (it's a directory)
200+
// 3. final dest pathExists (fullDestPath with source basename) -> does not exist
201+
// 4. dest parent dir pathExists -> exists
202+
mockedFs.access
203+
.mockResolvedValueOnce(undefined) // source exists
204+
.mockResolvedValueOnce(undefined) // dest exists
205+
.mockRejectedValueOnce(new Error("ENOENT")) // final path does not exist
206+
.mockResolvedValueOnce(undefined); // dest parent dir exists
207+
mockedFs.stat.mockResolvedValue({
208+
isDirectory: () => true,
209+
} as any);
210+
mockedFs.rename.mockResolvedValue(undefined);
211+
212+
await service.moveFileOrDirectory(
213+
"/servers/",
214+
"file.cfg",
215+
"target-dir",
216+
);
217+
218+
expect(mockedFs.rename).toHaveBeenCalledWith(
219+
"/servers/file.cfg",
220+
"/servers/target-dir/file.cfg",
221+
);
222+
});
223+
224+
it("throws BadRequestException when destination is an existing file", async () => {
225+
mockedFs.access
226+
.mockResolvedValueOnce(undefined) // source exists
227+
.mockResolvedValueOnce(undefined); // dest exists
228+
mockedFs.stat.mockResolvedValue({
229+
isDirectory: () => false,
230+
} as any);
231+
232+
await expect(
233+
service.moveFileOrDirectory("/servers/", "a.cfg", "b.cfg"),
234+
).rejects.toThrow(BadRequestException);
235+
});
236+
});
237+
238+
describe("renameFileOrDirectory", () => {
239+
it("renames successfully when source exists and destination does not", async () => {
240+
mockedFs.access
241+
.mockResolvedValueOnce(undefined) // old path exists
242+
.mockRejectedValueOnce(new Error("ENOENT")); // new path does not exist
243+
mockedFs.rename.mockResolvedValue(undefined);
244+
245+
await service.renameFileOrDirectory(
246+
"/servers/",
247+
"old-name",
248+
"new-name",
249+
);
250+
251+
expect(mockedFs.rename).toHaveBeenCalledWith(
252+
"/servers/old-name",
253+
"/servers/new-name",
254+
);
255+
});
256+
257+
it("throws NotFoundException when source does not exist", async () => {
258+
mockedFs.access.mockRejectedValueOnce(new Error("ENOENT"));
259+
260+
await expect(
261+
service.renameFileOrDirectory("/servers/", "missing", "new-name"),
262+
).rejects.toThrow(NotFoundException);
263+
});
264+
265+
it("throws BadRequestException when destination already exists", async () => {
266+
mockedFs.access
267+
.mockResolvedValueOnce(undefined) // old path exists
268+
.mockResolvedValueOnce(undefined); // new path also exists
269+
270+
await expect(
271+
service.renameFileOrDirectory("/servers/", "a", "b"),
272+
).rejects.toThrow(BadRequestException);
273+
});
274+
});
275+
276+
describe("uploadFile", () => {
277+
it("writes buffer to file when parent directory exists", async () => {
278+
mockedFs.access.mockResolvedValue(undefined);
279+
mockedFs.writeFile.mockResolvedValue(undefined);
280+
281+
const buffer = Buffer.from("binary data");
282+
await service.uploadFile("/servers/", "maps/map.bsp", buffer);
283+
284+
expect(mockedFs.writeFile).toHaveBeenCalledWith(
285+
"/servers/maps/map.bsp",
286+
buffer,
287+
);
288+
expect(mockedFs.mkdir).not.toHaveBeenCalled();
289+
});
290+
291+
it("auto-creates parent directory when it does not exist", async () => {
292+
mockedFs.access.mockRejectedValueOnce(new Error("ENOENT"));
293+
mockedFs.mkdir.mockResolvedValue(undefined);
294+
mockedFs.writeFile.mockResolvedValue(undefined);
295+
296+
const buffer = Buffer.from("binary data");
297+
await service.uploadFile("/servers/", "new-dir/file.dat", buffer);
298+
299+
expect(mockedFs.mkdir).toHaveBeenCalledWith("/servers/new-dir", {
300+
recursive: true,
301+
});
302+
expect(mockedFs.writeFile).toHaveBeenCalledWith(
303+
"/servers/new-dir/file.dat",
304+
buffer,
305+
);
306+
});
307+
});
308+
309+
describe("writeTextFile", () => {
310+
it("writes text content with utf8 encoding", async () => {
311+
mockedFs.access.mockResolvedValue(undefined);
312+
mockedFs.writeFile.mockResolvedValue(undefined);
313+
314+
await service.writeTextFile(
315+
"/servers/",
316+
"config.cfg",
317+
"sv_cheats 0",
318+
);
319+
320+
expect(mockedFs.writeFile).toHaveBeenCalledWith(
321+
"/servers/config.cfg",
322+
"sv_cheats 0",
323+
"utf8",
324+
);
325+
});
326+
});
327+
328+
describe("getFileStats", () => {
329+
it("returns stats for an existing path", async () => {
330+
const mtime = new Date("2026-01-15T10:00:00Z");
331+
mockedFs.access.mockResolvedValue(undefined);
332+
mockedFs.stat.mockResolvedValue({
333+
size: 500,
334+
mtime,
335+
isDirectory: () => false,
336+
isFile: () => true,
337+
} as any);
338+
339+
const result = await service.getFileStats("/servers/", "config.cfg");
340+
341+
expect(result).toEqual({
342+
name: "config.cfg",
343+
path: "config.cfg",
344+
size: 500,
345+
modified: mtime,
346+
isDirectory: false,
347+
isFile: true,
348+
});
349+
});
350+
351+
it("throws NotFoundException when path does not exist", async () => {
352+
mockedFs.access.mockRejectedValue(new Error("ENOENT"));
353+
354+
await expect(
355+
service.getFileStats("/servers/", "missing.cfg"),
356+
).rejects.toThrow(NotFoundException);
357+
});
358+
});
50359
});

0 commit comments

Comments
 (0)