Skip to content

Commit 181991f

Browse files
committed
implement multi-version-support
1 parent a013034 commit 181991f

16 files changed

Lines changed: 1294 additions & 44 deletions

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# GameVault Backend Server Changelog
22

3-
## 16.3.1
3+
## 17.0.0
4+
5+
### Breaking Changes & Migration
46

57
### Changes
68

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gamevault-backend",
3-
"version": "16.3.0",
3+
"version": "17.0.0",
44
"description": "the self-hosted gaming platform for drm-free games",
55
"author": "Alkan Alper, Schäfer Philip GbR / Phalcode",
66
"private": true,
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class GameVersions1771165504000 implements MigrationInterface {
4+
name = "GameVersions1771165504000";
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`
8+
CREATE TABLE IF NOT EXISTS game_version (
9+
id SERIAL NOT NULL,
10+
created_at TIMESTAMP NOT NULL DEFAULT now(),
11+
updated_at TIMESTAMP NOT NULL DEFAULT now(),
12+
deleted_at TIMESTAMP,
13+
entity_version integer NOT NULL DEFAULT 1,
14+
game_id integer NOT NULL,
15+
file_path character varying NOT NULL,
16+
version character varying,
17+
size bigint NOT NULL DEFAULT '0',
18+
release_date TIMESTAMP,
19+
early_access boolean NOT NULL DEFAULT false,
20+
type character varying NOT NULL DEFAULT 'UNDETECTABLE',
21+
indexed_at TIMESTAMP NOT NULL DEFAULT now(),
22+
CONSTRAINT PK_272f36b21fb4f0c43edd12fcfbe PRIMARY KEY (id),
23+
CONSTRAINT UQ_b0b88b548562b921436bdacea35 UNIQUE (game_id, file_path)
24+
);
25+
`);
26+
27+
await queryRunner.query(`
28+
CREATE INDEX IF NOT EXISTS IDX_272f36b21fb4f0c43edd12fcfb ON game_version (id);
29+
`);
30+
31+
await queryRunner.query(`
32+
CREATE INDEX IF NOT EXISTS IDX_5a4e407c2898e29b00136632b3 ON game_version (game_id);
33+
`);
34+
35+
await queryRunner.query(`
36+
ALTER TABLE game_version
37+
ADD CONSTRAINT FK_5a4e407c2898e29b00136632b33
38+
FOREIGN KEY (game_id) REFERENCES gamevault_game(id)
39+
ON DELETE CASCADE ON UPDATE NO ACTION;
40+
`);
41+
42+
await queryRunner.query(`
43+
INSERT INTO game_version (
44+
game_id,
45+
file_path,
46+
version,
47+
size,
48+
release_date,
49+
early_access,
50+
type,
51+
indexed_at
52+
)
53+
SELECT
54+
g.id,
55+
g.file_path,
56+
g.version,
57+
g.size,
58+
g.release_date,
59+
g.early_access,
60+
g.type::text,
61+
COALESCE(g.updated_at, g.created_at, now())
62+
FROM gamevault_game g
63+
WHERE g.file_path IS NOT NULL
64+
ON CONFLICT (game_id, file_path) DO NOTHING;
65+
`);
66+
}
67+
68+
public async down(queryRunner: QueryRunner): Promise<void> {
69+
await queryRunner.query(`
70+
ALTER TABLE game_version
71+
DROP CONSTRAINT IF EXISTS FK_5a4e407c2898e29b00136632b33;
72+
`);
73+
74+
await queryRunner.query(`
75+
DROP INDEX IF EXISTS IDX_5a4e407c2898e29b00136632b3;
76+
`);
77+
78+
await queryRunner.query(`
79+
DROP INDEX IF EXISTS IDX_272f36b21fb4f0c43edd12fcfb;
80+
`);
81+
82+
await queryRunner.query(`
83+
DROP TABLE IF EXISTS game_version;
84+
`);
85+
}
86+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class GameVersions1771165504000 implements MigrationInterface {
4+
name = "GameVersions1771165504000";
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`
8+
CREATE TABLE IF NOT EXISTS game_version (
9+
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
10+
created_at datetime NOT NULL DEFAULT (datetime('now')),
11+
updated_at datetime NOT NULL DEFAULT (datetime('now')),
12+
deleted_at datetime,
13+
entity_version integer NOT NULL DEFAULT 1,
14+
game_id integer NOT NULL,
15+
file_path varchar NOT NULL,
16+
version varchar,
17+
size bigint NOT NULL DEFAULT 0,
18+
release_date datetime,
19+
early_access boolean NOT NULL DEFAULT 0,
20+
type varchar NOT NULL DEFAULT 'UNDETECTABLE',
21+
indexed_at datetime NOT NULL DEFAULT (datetime('now')),
22+
CONSTRAINT UQ_b0b88b548562b921436bdacea35 UNIQUE (game_id, file_path),
23+
CONSTRAINT FK_5a4e407c2898e29b00136632b33 FOREIGN KEY (game_id) REFERENCES gamevault_game(id) ON DELETE CASCADE
24+
);
25+
`);
26+
27+
await queryRunner.query(`
28+
CREATE INDEX IF NOT EXISTS IDX_272f36b21fb4f0c43edd12fcfb ON game_version (id);
29+
`);
30+
31+
await queryRunner.query(`
32+
CREATE INDEX IF NOT EXISTS IDX_5a4e407c2898e29b00136632b3 ON game_version (game_id);
33+
`);
34+
35+
await queryRunner.query(`
36+
INSERT OR IGNORE INTO game_version (
37+
game_id,
38+
file_path,
39+
version,
40+
size,
41+
release_date,
42+
early_access,
43+
type,
44+
indexed_at
45+
)
46+
SELECT
47+
g.id,
48+
g.file_path,
49+
g.version,
50+
g.size,
51+
g.release_date,
52+
g.early_access,
53+
g.type,
54+
COALESCE(g.updated_at, g.created_at, datetime('now'))
55+
FROM gamevault_game g
56+
WHERE g.file_path IS NOT NULL;
57+
`);
58+
}
59+
60+
public async down(queryRunner: QueryRunner): Promise<void> {
61+
await queryRunner.query(`
62+
DROP INDEX IF EXISTS IDX_5a4e407c2898e29b00136632b3;
63+
`);
64+
65+
await queryRunner.query(`
66+
DROP INDEX IF EXISTS IDX_272f36b21fb4f0c43edd12fcfb;
67+
`);
68+
69+
await queryRunner.query(`
70+
DROP TABLE IF EXISTS game_version;
71+
`);
72+
}
73+
}

src/modules/games/files.service.spec.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ describe("FilesService", () => {
5454
let gamesService: jest.Mocked<GamesService>;
5555
let metadataService: jest.Mocked<MetadataService>;
5656
let schedulerRegistry: jest.Mocked<SchedulerRegistry>;
57+
let gameVersionRepository: {
58+
find: jest.Mock;
59+
findOne: jest.Mock;
60+
save: jest.Mock;
61+
};
5762
let fsExtra: {
5863
access: jest.Mock;
5964
pathExists: jest.Mock;
@@ -85,10 +90,17 @@ describe("FilesService", () => {
8590
deleteTimeout: jest.fn(),
8691
} as any;
8792

93+
gameVersionRepository = {
94+
find: jest.fn().mockResolvedValue([]),
95+
findOne: jest.fn(),
96+
save: jest.fn(),
97+
};
98+
8899
service = new FilesService(
89100
gamesService,
90101
metadataService,
91102
schedulerRegistry,
103+
gameVersionRepository as any,
92104
);
93105

94106
fsExtra.access.mockResolvedValue(undefined);
@@ -240,5 +252,135 @@ describe("FilesService", () => {
240252
});
241253
expect(gamesService.save).not.toHaveBeenCalled();
242254
});
255+
256+
it("should reject download when requested version does not exist", async () => {
257+
gamesService.findOneByGameIdOrFail.mockResolvedValue({
258+
id: 42,
259+
file_path: "/tmp/test-files/My Game (v1.0.0).zip",
260+
version: "v1.0.0",
261+
size: 1000n,
262+
type: "WINDOWS_SETUP",
263+
early_access: false,
264+
download_count: 0,
265+
} as any);
266+
267+
const response = { setHeader: jest.fn() } as any;
268+
269+
await expect(
270+
service.download(response, 42, undefined, undefined, 18, "v9.9.9"),
271+
).rejects.toThrow(NotFoundException);
272+
});
273+
});
274+
275+
describe("listAvailableVersions", () => {
276+
it("should return sorted available versions", async () => {
277+
gamesService.findOneByGameIdOrFail.mockResolvedValue({
278+
id: 1,
279+
file_path: "/tmp/test-files/My Game (v1.0.0).zip",
280+
version: "v1.0.0",
281+
size: 1000n,
282+
type: "WINDOWS_SETUP",
283+
early_access: false,
284+
} as any);
285+
gameVersionRepository.find.mockResolvedValue([
286+
{
287+
file_path: "/tmp/test-files/My Game (v1.0.0).zip",
288+
version: "v1.0.0",
289+
size: 1000n,
290+
type: "WINDOWS_SETUP",
291+
early_access: false,
292+
indexed_at: new Date("2026-01-01"),
293+
},
294+
{
295+
file_path: "/tmp/test-files/My Game (v2.0.0).zip",
296+
version: "v2.0.0",
297+
size: 1000n,
298+
type: "WINDOWS_SETUP",
299+
early_access: false,
300+
indexed_at: new Date("2026-01-02"),
301+
},
302+
] as any);
303+
304+
const result = await service.listAvailableVersions(1, 18);
305+
306+
expect(result.map((v) => v.version)).toEqual(["v2.0.0", "v1.0.0"]);
307+
expect(gamesService.findOneByGameIdOrFail).toHaveBeenCalledWith(1, {
308+
loadDeletedEntities: false,
309+
filterByAge: 18,
310+
});
311+
});
312+
313+
it("should sort mixed non-semver versions with best-effort fallback", async () => {
314+
gamesService.findOneByGameIdOrFail.mockResolvedValue({
315+
id: 1,
316+
file_path: "/tmp/test-files/My Game (vBuild 15-01-2024).zip",
317+
version: "vBuild 15-01-2024",
318+
size: 1000n,
319+
type: "WINDOWS_SETUP",
320+
early_access: false,
321+
} as any);
322+
gameVersionRepository.find.mockResolvedValue([
323+
{
324+
file_path: "/tmp/test-files/My Game (v1.0.0.2).zip",
325+
version: "v1.0.0.2",
326+
size: 1000n,
327+
type: "WINDOWS_SETUP",
328+
early_access: false,
329+
indexed_at: new Date("2026-01-01"),
330+
},
331+
{
332+
file_path: "/tmp/test-files/My Game (v2025-04-27).zip",
333+
version: "v2025-04-27",
334+
size: 1000n,
335+
type: "WINDOWS_SETUP",
336+
early_access: false,
337+
indexed_at: new Date("2026-01-02"),
338+
},
339+
{
340+
file_path: "/tmp/test-files/My Game (vBuild 15-01-2024).zip",
341+
version: "vBuild 15-01-2024",
342+
size: 1000n,
343+
type: "WINDOWS_SETUP",
344+
early_access: false,
345+
indexed_at: new Date("2026-01-03"),
346+
},
347+
] as any);
348+
349+
const result = await service.listAvailableVersions(1, 18);
350+
351+
expect(result.map((v) => v.version)).toEqual([
352+
"v1.0.0.2",
353+
"v2025-04-27",
354+
"vBuild 15-01-2024",
355+
]);
356+
});
357+
});
358+
359+
describe("getLatestVersion", () => {
360+
it("should return the first sorted version", async () => {
361+
jest.spyOn(service, "listAvailableVersions").mockResolvedValue([
362+
{
363+
file_path: "/tmp/test-files/My Game (v2.0.0).zip",
364+
version: "v2.0.0",
365+
},
366+
{
367+
file_path: "/tmp/test-files/My Game (v1.0.0).zip",
368+
version: "v1.0.0",
369+
},
370+
] as any);
371+
372+
const result = await service.getLatestVersion(1, 18);
373+
374+
expect(result.version).toBe("v2.0.0");
375+
expect(service.listAvailableVersions).toHaveBeenCalledWith(1, 18);
376+
});
377+
378+
it("should throw if no versions are available", async () => {
379+
jest.spyOn(service, "listAvailableVersions").mockResolvedValue([] as any);
380+
381+
await expect(service.getLatestVersion(1, 18)).rejects.toThrow(
382+
NotFoundException,
383+
);
384+
});
243385
});
244386
});

0 commit comments

Comments
 (0)