Skip to content

Commit 2ae064e

Browse files
committed
test: Fix missing coverage from new introduced code
- Add a reusable router agent test utility and replace all supertest-dependent suites with in-memory request execution so tests run without socket permissions. - Expand helper and controller specs (including multi-domain and watchlist error paths) - Build new targeted suites, and rework mocks to exercise every branch required for full coverage.
1 parent 1f9840d commit 2ae064e

13 files changed

+598
-187
lines changed

AGENTS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Repository Guidelines
2+
3+
## Project Structure & Module Organization
4+
- `api/index.ts` boots the Express server and wires middleware; domain logic sits in `controllers/`, `routes/`, `models/`, and `middleware/` to keep concerns isolated.
5+
- Configuration lives in `config/` with environment-driven exports; shared utilities extend `helpers/` and TypeScript declarations in `types/`.
6+
- UI assets are split between `views/` (EJS templates and partials such as `views/partials/card-add.ejs`) and `public/` for static files.
7+
- Data and ops artefacts live in `migrations/`, `docs/`, `system/`, and automated checks under `tests/e2e` and `tests/integration`.
8+
9+
## Build, Test & Development Commands
10+
- `yarn start` launches the production server through `tsx api/index.ts`; use `yarn start:dev` for watch mode with source maps.
11+
- `yarn lint` performs TypeScript type-checking plus `npx ejslint` over templates; run `yarn lint:ts` or `yarn lint:ejs` to isolate either track.
12+
- `yarn db:migrate` and `yarn db:rollback` apply or revert Mongo migrations in `migrations/`; check current state with `yarn db:status`.
13+
- `yarn test` runs the Jest suite; add `:coverage` to emit reports into `coverage/` and update `junit.xml` for CI.
14+
15+
## Coding Style & Naming Conventions
16+
- TypeScript files use 2-space indentation, trailing commas, and TSDoc-style blocks (`/** ... */`) for exported members as seen in `controllers/`.
17+
- Prefer `camelCase` for functions/variables, `PascalCase` for classes and interfaces, and `kebab-case` for EJS partial filenames.
18+
- Keep logic pure in controllers/helpers; place request wiring in routes; export a single default per module when practical.
19+
20+
## Testing Guidelines
21+
- Tests run on Jest with the `ts-jest` preset; name specs `*.spec.ts` or `*.test.ts` under `tests/` to match `jest.config.js`.
22+
- Aim to maintain coverage across `api`, `config`, `controllers`, `helpers`, `middleware`, `models`, and `routes` as enforced by `collectCoverageFrom`.
23+
- Consume supertest for request flows and prefer fixture factories over network calls; refresh snapshots after intentional UI changes.
24+
25+
## Commit & Pull Request Guidelines
26+
- Follow the observed Conventional Commit style (`feat:`, `refactor:`, `fix:`, `yarn:`) with concise, imperative summaries under ~70 characters.
27+
- Group related changes per commit; include migration IDs or view names when helpful.
28+
- Pull requests should link issues, describe behavioural impact, list validation commands (`yarn test`, `yarn db:migrate`), and attach UI screenshots for template updates.
29+
30+
## Security & Configuration Tips
31+
- Never commit `.env`; update `.env.example` when introducing new settings and document defaults in `config/`.
32+
- Rotate API keys when sharing recordings or tests, and prefer local `.env.test` overrides for automation to avoid polluting development data.

controllers/appController.multidomain.spec.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import { fetchOmdbData, getSeriesDetail } from '../helpers/appHelper';
22
import History from '../models/History';
33

4-
jest.mock('../helpers/appHelper', () => ({
5-
fetchOmdbData: jest.fn(),
6-
fetchAndUpdatePosters: jest.fn(),
7-
getSeriesDetail: jest.fn(),
8-
}));
4+
jest.mock('../helpers/appHelper', () => {
5+
const actual = jest.requireActual('../helpers/appHelper');
6+
return {
7+
...actual,
8+
fetchOmdbData: jest.fn(),
9+
getSeriesDetail: jest.fn(),
10+
};
11+
});
912

1013
jest.mock('../models/History', () => ({
11-
findOneAndUpdate: jest.fn(),
14+
__esModule: true,
15+
default: {
16+
findOneAndUpdate: jest.fn(),
17+
},
1218
}));
1319

1420
describe('controllers/appController with MULTI_DOMAIN', () => {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
jest.mock('../helpers/appHelper', () => {
2+
const actual = jest.requireActual('../helpers/appHelper');
3+
return {
4+
...actual,
5+
fetchOmdbData: jest.fn(async () => ({})),
6+
getSeriesDetail: jest.fn(async () => ({ totalSeasons: 1, currentSeason: { season: 1, episodes: [] } })),
7+
getResumeRedirect: jest.fn(async () => '/view/tt/series/5/11'),
8+
upsertSeriesProgress: jest.fn(),
9+
};
10+
});
11+
12+
jest.mock('../models/History', () => ({
13+
__esModule: true,
14+
default: {
15+
findOne: jest.fn(),
16+
findOneAndUpdate: jest.fn(),
17+
},
18+
}));
19+
20+
import appController from './appController';
21+
import { getResumeRedirect, upsertSeriesProgress } from '../helpers/appHelper';
22+
import History from '../models/History';
23+
24+
describe('appController getView resume redirect', () => {
25+
afterEach(() => {
26+
jest.clearAllMocks();
27+
});
28+
29+
test('redirects to resume location when available', async () => {
30+
const req: any = { params: { q: '', id: 'tt', type: 'series' }, user: { id: 'user-1' } };
31+
const res: any = { locals: { APP_URL: 'http://app' }, render: jest.fn(), redirect: jest.fn() };
32+
33+
(getResumeRedirect as jest.Mock).mockResolvedValue('/view/tt/series/5/11');
34+
35+
await appController.getView(req, res, jest.fn());
36+
37+
expect(res.redirect).toHaveBeenCalledWith('/view/tt/series/5/11');
38+
expect(upsertSeriesProgress).not.toHaveBeenCalled();
39+
expect(History.findOneAndUpdate).not.toHaveBeenCalled();
40+
});
41+
});

controllers/appController.spec.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ jest.mock('../helpers/httpClient', () => ({
88
__esModule: true,
99
default: { get: jest.fn() },
1010
}));
11-
jest.mock('../helpers/appHelper', () => ({
12-
fetchOmdbData: jest.fn(),
13-
fetchAndUpdatePosters: jest.fn(),
14-
getSeriesDetail: jest.fn(),
15-
}));
11+
jest.mock('../helpers/appHelper', () => {
12+
const actual = jest.requireActual('../helpers/appHelper');
13+
return {
14+
...actual,
15+
fetchOmdbData: jest.fn(),
16+
fetchAndUpdatePosters: jest.fn(),
17+
getSeriesDetail: jest.fn(),
18+
};
19+
});
1620

1721
jest.mock('../helpers/cache', () => ({
1822
getLatest: jest.fn(),
@@ -21,8 +25,11 @@ jest.mock('../helpers/cache', () => ({
2125
}));
2226

2327
jest.mock('../models/History', () => ({
24-
findOne: jest.fn(),
25-
findOneAndUpdate: jest.fn(),
28+
__esModule: true,
29+
default: {
30+
findOne: jest.fn(),
31+
findOneAndUpdate: jest.fn(),
32+
},
2633
}));
2734

2835
jest.mock('../config/app', () => ({
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
jest.mock('../models/Watchlist', () => {
2+
const ctor: any = function (this: any, payload: any) {
3+
Object.assign(this, payload);
4+
this.save = jest.fn(async () => undefined);
5+
if (!this.items) this.items = [];
6+
};
7+
ctor.findOne = jest.fn(async () => null);
8+
ctor.find = jest.fn(async () => []);
9+
return {
10+
__esModule: true,
11+
default: ctor,
12+
};
13+
});
14+
15+
import watchlistController from './watchlistController';
16+
import Watchlist from '../models/Watchlist';
17+
18+
const makeReq = (body: any = {}, user: any = { id: 'user-1' }) => {
19+
const req: any = { body, user, flash: jest.fn() };
20+
return req;
21+
};
22+
23+
const makeRes = () => {
24+
const res: any = { locals: { APP_URL: 'http://app' }, redirect: jest.fn(), render: jest.fn() };
25+
return res;
26+
};
27+
28+
describe('watchlistController branch coverage', () => {
29+
afterEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
test('addToWatchlist creates new watchlist when missing', async () => {
34+
const req = makeReq({ imdbId: 'tt1', title: 'Title', poster: 'p', type: 'movie' });
35+
const res = makeRes();
36+
37+
await watchlistController.addToWatchlist(req, res, jest.fn());
38+
39+
expect((Watchlist as any).findOne).toHaveBeenCalled();
40+
expect(req.flash).toHaveBeenCalledWith('success_msg', expect.stringMatching(/Added Title/));
41+
expect(res.redirect).toHaveBeenCalledWith('/watchlist');
42+
});
43+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
jest.mock('./httpClient', () => ({
2+
__esModule: true,
3+
default: { request: jest.fn(), head: jest.fn() },
4+
}));
5+
6+
type AppConfig = {
7+
OMDB_API_KEY: string;
8+
OMDB_API_URL: string;
9+
APP_URL: string;
10+
MONGO_DB_URI: string;
11+
MONGO_DB_NAME: string;
12+
APP_NAME: string;
13+
APP_SUBTITLE: string;
14+
APP_DESCRIPTION: string;
15+
API_HOST: string;
16+
API_PORT: number;
17+
VIDSRC_DOMAIN: string;
18+
MULTI_DOMAIN: string;
19+
};
20+
21+
const multiConfig: AppConfig = {
22+
OMDB_API_KEY: 'key',
23+
OMDB_API_URL: 'http://omdb',
24+
APP_URL: 'http://app',
25+
MONGO_DB_URI: '',
26+
MONGO_DB_NAME: '',
27+
APP_NAME: 'app',
28+
APP_SUBTITLE: '',
29+
APP_DESCRIPTION: '',
30+
API_HOST: 'localhost',
31+
API_PORT: 3000,
32+
VIDSRC_DOMAIN: 'domain',
33+
MULTI_DOMAIN: 'multi.example',
34+
};
35+
36+
jest.mock('../config/app', () => multiConfig);
37+
38+
import { buildSources } from './appHelper';
39+
40+
describe('appHelper buildSources with MULTI_DOMAIN', () => {
41+
test('series sources prefer multi domain', () => {
42+
const result = buildSources('tt123', 'series', '1', '5');
43+
expect(result.server2Src).toBe('https://multi.example/?video_id=tt123&s=1&e=5');
44+
expect(result.currentServer).toBe('2');
45+
});
46+
47+
test('movie sources prefer multi domain', () => {
48+
const result = buildSources('tt123', 'movie');
49+
expect(result.server2Src).toBe('https://multi.example/?video_id=tt123');
50+
expect(result.currentServer).toBe('2');
51+
});
52+
});

helpers/appHelper.spec.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const mockAppConfig = {
1717
API_HOST: 'localhost',
1818
API_PORT: 3000,
1919
VIDSRC_DOMAIN: 'domain',
20+
MULTI_DOMAIN: '',
2021
};
2122

2223
jest.mock('../config/app', () => mockAppConfig);
@@ -88,8 +89,8 @@ describe('helpers/appHelper', () => {
8889
{ imdb_id: '3' },
8990
{ imdb_id: '4' },
9091
];
91-
const spy = jest
92-
.spyOn(helper, 'fetchOmdbData')
92+
const fetchMock = jest
93+
.fn<ReturnType<typeof helper.fetchOmdbData>, Parameters<typeof helper.fetchOmdbData>>()
9394
.mockResolvedValueOnce({ Response: 'True', Poster: 'p1' })
9495
.mockResolvedValueOnce({ Response: 'True', Poster: 'p2' })
9596
.mockResolvedValueOnce({ Response: 'True', Poster: 'N/A' })
@@ -98,8 +99,8 @@ describe('helpers/appHelper', () => {
9899
.mockResolvedValueOnce({})
99100
.mockRejectedValueOnce(new Error('404'));
100101

101-
await helper.fetchAndUpdatePosters(shows);
102-
expect(spy).toHaveBeenCalledTimes(4);
102+
await helper.fetchAndUpdatePosters(shows, fetchMock);
103+
expect(fetchMock).toHaveBeenCalledTimes(4);
103104
expect(http.head).toHaveBeenCalledTimes(2);
104105
expect(http.head).toHaveBeenNthCalledWith(1, 'p1');
105106
expect(http.head).toHaveBeenNthCalledWith(2, 'p2');
@@ -179,6 +180,27 @@ describe('helpers/appHelper', () => {
179180
expect(http.request).toHaveBeenCalledTimes(3);
180181
});
181182

183+
test('buildSources returns single-domain movie sources by default', () => {
184+
const sources = helper.buildSources('tt1', 'movie');
185+
expect(sources.server1Src).toBe('https://domain/embed/movie/tt1');
186+
expect(sources.server2Src).toBe('');
187+
expect(sources.currentServer).toBe('1');
188+
});
189+
190+
test('buildSources prefers MULTI_DOMAIN sources when configured', () => {
191+
jest.isolateModules(() => {
192+
mockAppConfig.MULTI_DOMAIN = 'multi.example';
193+
const mod = require('./appHelper');
194+
const seriesSources = mod.buildSources('tt2', 'series', '1', '3');
195+
expect(seriesSources.server2Src).toBe('https://multi.example/?video_id=tt2&s=1&e=3');
196+
expect(seriesSources.currentServer).toBe('2');
197+
const movieSources = mod.buildSources('tt2', 'movie');
198+
expect(movieSources.server2Src).toBe('https://multi.example/?video_id=tt2');
199+
expect(movieSources.currentServer).toBe('2');
200+
});
201+
mockAppConfig.MULTI_DOMAIN = '';
202+
});
203+
182204
test('useAuth is false when no mongo uri', () => {
183205
expect(helper.useAuth).toBe(false);
184206
});

helpers/appHelper.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,14 @@ const fetchOmdbData = async (
8989
* If poster isn't available or request fails, sets default 'no-binger' image.
9090
* Updates are performed in parallel using Promise.all
9191
*/
92-
const fetchAndUpdatePosters = async (show: any[]): Promise<void> => {
92+
const fetchAndUpdatePosters = async (
93+
show: any[],
94+
fetchFn: typeof fetchOmdbData = fetchOmdbData
95+
): Promise<void> => {
9396
const fallback = `${appConfig.APP_URL}/images/no-binger.jpg`;
9497
await Promise.all(
9598
show.map(async (x: any) => {
96-
const data = await fetchOmdbData(x.imdb_id, false);
99+
const data = await fetchFn(x.imdb_id, false);
97100
if (data.Response === 'True' && data.Poster !== 'N/A') {
98101
try {
99102
await http.head(data.Poster);

0 commit comments

Comments
 (0)