Skip to content

Commit e8e4e93

Browse files
committed
feat: add comprehensive test suite and CI test workflow
Adds Jest-based tests across three layers: API handler tests (tests/api/): - spacecrafts, launchers, customer_satellites, centres, spacecraft_missions - Correct HTTP status and Content-Type on all endpoints - Correct record counts returned with no filters - Filter behaviour: case-insensitive, composable, unknown params ignored, empty array (not 404) when no matches - stats: all top-level sections present; every distribution sums to its collection total; total_mass_kg is positive - spacecrafts/:id and spacecraft_missions/:id: valid ID returns record, non-existent ID returns 404, non-integer returns 400, _links present and correctly formatted Data integrity tests (tests/data/integrity.test.js): - All IDs are unique positive integers - No trailing whitespace in name fields - All non-null dates match ISO 8601 format - All non-null numeric fields are positive numbers - status and orbit_type constrained to known enums - country names are title case (regression guard against all-caps) - centres use lowercase field names (regression against Place/State) - No values in wrong fields (mass field cannot be a string name) CI (`.github/workflows/test.yml`): - Runs on every PR and push to master - Installs deps, runs jest, then runs schema validation - Both must pass for CI to go green Test runner: Jest 29 (devDependency only, zero runtime impact). All tests pass against current normalized data.
1 parent c055ddb commit e8e4e93

12 files changed

Lines changed: 758 additions & 1 deletion

.github/workflows/test.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Tests
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- master
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Set up Node.js
16+
uses: actions/setup-node@v4
17+
with:
18+
node-version: "20"
19+
cache: "npm"
20+
21+
- name: Install dependencies
22+
run: npm install
23+
24+
- name: Run tests
25+
run: npm test
26+
27+
- name: Validate data schemas
28+
run: npm run validate

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
"author": "isro",
88
"license": "MIT",
99
"scripts": {
10+
"test": "jest --testPathPattern=tests/",
1011
"validate": "node scripts/validate_schemas.js"
1112
},
13+
"jest": {
14+
"testEnvironment": "node"
15+
},
1216
"dependencies": {},
1317
"devDependencies": {
14-
"ajv": "^8.17.1"
18+
"ajv": "^8.17.1",
19+
"jest": "^29.7.0"
1520
}
1621
}

tests/api/centres.test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
const handler = require("../../api/centres");
2+
const { mockReq, mockRes } = require("../helpers/mock");
3+
4+
describe("GET /api/centres", () => {
5+
test("returns 200 with application/json header", async () => {
6+
const res = mockRes();
7+
await handler(mockReq(), res);
8+
expect(res._status).toBe(200);
9+
expect(res._headers["Content-Type"]).toBe("application/json");
10+
});
11+
12+
test("response has centres wrapper key with 44 records", async () => {
13+
const res = mockRes();
14+
await handler(mockReq(), res);
15+
expect(res._body).toHaveProperty("centres");
16+
expect(res._body.centres).toHaveLength(44);
17+
});
18+
19+
test("each record has consistent lowercase field names", async () => {
20+
const res = mockRes();
21+
await handler(mockReq(), res);
22+
for (const r of res._body.centres) {
23+
expect(r).not.toHaveProperty("Place");
24+
expect(r).not.toHaveProperty("State");
25+
expect(r).toHaveProperty("id");
26+
expect(r).toHaveProperty("name");
27+
}
28+
});
29+
30+
test("filters by state (case-insensitive)", async () => {
31+
const res1 = mockRes();
32+
await handler(mockReq({ state: "Karnataka" }), res1);
33+
const res2 = mockRes();
34+
await handler(mockReq({ state: "karnataka" }), res2);
35+
expect(res1._body.centres.length).toBeGreaterThan(0);
36+
expect(res1._body.centres).toEqual(res2._body.centres);
37+
for (const r of res1._body.centres) {
38+
expect(r.state.toLowerCase()).toBe("karnataka");
39+
}
40+
});
41+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const handler = require("../../api/customer_satellites");
2+
const { mockReq, mockRes } = require("../helpers/mock");
3+
4+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
5+
6+
describe("GET /api/customer_satellites", () => {
7+
test("returns 200 with application/json header", async () => {
8+
const res = mockRes();
9+
await handler(mockReq(), res);
10+
expect(res._status).toBe(200);
11+
expect(res._headers["Content-Type"]).toBe("application/json");
12+
});
13+
14+
test("response has customer_satellites wrapper key with 75 records", async () => {
15+
const res = mockRes();
16+
await handler(mockReq(), res);
17+
expect(res._body).toHaveProperty("customer_satellites");
18+
expect(res._body.customer_satellites).toHaveLength(75);
19+
});
20+
21+
test("all non-null launch_dates are ISO 8601 format", async () => {
22+
const res = mockRes();
23+
await handler(mockReq(), res);
24+
for (const r of res._body.customer_satellites) {
25+
if (r.launch_date !== null) {
26+
expect(r.launch_date).toMatch(ISO_DATE);
27+
}
28+
}
29+
});
30+
31+
test("all non-null mass_kg values are positive numbers", async () => {
32+
const res = mockRes();
33+
await handler(mockReq(), res);
34+
for (const r of res._body.customer_satellites) {
35+
if (r.mass_kg !== null) {
36+
expect(typeof r.mass_kg).toBe("number");
37+
expect(r.mass_kg).toBeGreaterThan(0);
38+
}
39+
}
40+
});
41+
42+
test("filters by country", async () => {
43+
const res = mockRes();
44+
await handler(mockReq({ country: "Germany" }), res);
45+
expect(res._body.customer_satellites.length).toBeGreaterThan(0);
46+
for (const r of res._body.customer_satellites) {
47+
expect(r.country.toLowerCase()).toBe("germany");
48+
}
49+
});
50+
51+
test("country filter is case-insensitive", async () => {
52+
const res1 = mockRes();
53+
await handler(mockReq({ country: "germany" }), res1);
54+
const res2 = mockRes();
55+
await handler(mockReq({ country: "GERMANY" }), res2);
56+
expect(res1._body.customer_satellites).toEqual(res2._body.customer_satellites);
57+
});
58+
});

tests/api/launchers.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const handler = require("../../api/launchers");
2+
const { mockReq, mockRes } = require("../helpers/mock");
3+
4+
describe("GET /api/launchers", () => {
5+
test("returns 200 with application/json header", async () => {
6+
const res = mockRes();
7+
await handler(mockReq(), res);
8+
expect(res._status).toBe(200);
9+
expect(res._headers["Content-Type"]).toBe("application/json");
10+
});
11+
12+
test("response has launchers wrapper key with array of 81 records", async () => {
13+
const res = mockRes();
14+
await handler(mockReq(), res);
15+
expect(res._body).toHaveProperty("launchers");
16+
expect(res._body.launchers).toHaveLength(81);
17+
});
18+
19+
test("each record has an integer id", async () => {
20+
const res = mockRes();
21+
await handler(mockReq(), res);
22+
for (const record of res._body.launchers) {
23+
expect(Number.isInteger(record.id)).toBe(true);
24+
}
25+
});
26+
27+
test("filters by vehicle_family (case-insensitive)", async () => {
28+
const res1 = mockRes();
29+
await handler(mockReq({ vehicle_family: "PSLV" }), res1);
30+
const res2 = mockRes();
31+
await handler(mockReq({ vehicle_family: "pslv" }), res2);
32+
expect(res1._body.launchers.length).toBeGreaterThan(0);
33+
expect(res1._body.launchers).toEqual(res2._body.launchers);
34+
for (const r of res1._body.launchers) {
35+
expect(r.vehicle_family).toBe("PSLV");
36+
}
37+
});
38+
39+
test("known vehicle families are present in data", async () => {
40+
const res = mockRes();
41+
await handler(mockReq(), res);
42+
const families = new Set(res._body.launchers.map((r) => r.vehicle_family));
43+
for (const f of ["PSLV", "GSLV", "SLV", "ASLV"]) {
44+
expect(families.has(f)).toBe(true);
45+
}
46+
});
47+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const handler = require("../../api/spacecraft_missions/[id]");
2+
const { mockReq, mockRes } = require("../helpers/mock");
3+
4+
describe("GET /api/spacecraft_missions/:id", () => {
5+
test("returns the correct record for a valid ID", async () => {
6+
const res = mockRes();
7+
await handler(mockReq({ id: "1" }), res);
8+
expect(res._status).toBe(200);
9+
expect(res._body.id).toBe(1);
10+
expect(typeof res._body.name).toBe("string");
11+
});
12+
13+
test("returns 200 with application/json header", async () => {
14+
const res = mockRes();
15+
await handler(mockReq({ id: "1" }), res);
16+
expect(res._headers["Content-Type"]).toBe("application/json");
17+
});
18+
19+
test("returns 404 for non-existent ID", async () => {
20+
const res = mockRes();
21+
await handler(mockReq({ id: "99999" }), res);
22+
expect(res._status).toBe(404);
23+
expect(res._body).toHaveProperty("error");
24+
});
25+
26+
test("returns 400 for non-integer ID", async () => {
27+
const res = mockRes();
28+
await handler(mockReq({ id: "abc" }), res);
29+
expect(res._status).toBe(400);
30+
expect(res._body).toHaveProperty("error");
31+
});
32+
33+
test("record includes _links.self and _links.spacecraft", async () => {
34+
const res = mockRes();
35+
await handler(mockReq({ id: "1" }), res);
36+
expect(res._body._links).toHaveProperty("self", "/api/spacecraft_missions/1");
37+
expect(res._body._links).toHaveProperty("spacecraft");
38+
expect(res._body._links.spacecraft).toMatch(/^\/api\/spacecrafts\/\d+$/);
39+
});
40+
41+
test("record has the full 17-field normalized schema", async () => {
42+
const res = mockRes();
43+
await handler(mockReq({ id: "1" }), res);
44+
const EXPECTED_FIELDS = [
45+
"id", "name", "mission_type", "launch_date", "launch_site", "launch_vehicle",
46+
"orbit", "orbit_type", "altitude_km", "inclination_deg", "mass_kg", "power_watts",
47+
"mission_life", "status", "payloads", "stabilization", "propulsion",
48+
];
49+
for (const field of EXPECTED_FIELDS) {
50+
expect(res._body).toHaveProperty(field);
51+
}
52+
});
53+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
const handler = require("../../api/spacecraft_missions");
2+
const { mockReq, mockRes } = require("../helpers/mock");
3+
4+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
5+
const VALID_STATUSES = new Set(["active", "decommissioned", "failed", "unknown"]);
6+
const VALID_ORBIT_TYPES = new Set(["LEO", "SSO", "GEO", "Lunar", "Interplanetary", "Failed"]);
7+
8+
describe("GET /api/spacecraft_missions", () => {
9+
test("returns 200 with application/json header", async () => {
10+
const res = mockRes();
11+
await handler(mockReq(), res);
12+
expect(res._status).toBe(200);
13+
expect(res._headers["Content-Type"]).toBe("application/json");
14+
});
15+
16+
test("response has spacecraft_missions wrapper key with 64 records", async () => {
17+
const res = mockRes();
18+
await handler(mockReq(), res);
19+
expect(res._body).toHaveProperty("spacecraft_missions");
20+
expect(res._body.spacecraft_missions).toHaveLength(64);
21+
});
22+
23+
test("all records have valid status values", async () => {
24+
const res = mockRes();
25+
await handler(mockReq(), res);
26+
for (const r of res._body.spacecraft_missions) {
27+
expect(VALID_STATUSES.has(r.status)).toBe(true);
28+
}
29+
});
30+
31+
test("all non-null orbit_type values are from known set", async () => {
32+
const res = mockRes();
33+
await handler(mockReq(), res);
34+
for (const r of res._body.spacecraft_missions) {
35+
if (r.orbit_type !== null) {
36+
expect(VALID_ORBIT_TYPES.has(r.orbit_type)).toBe(true);
37+
}
38+
}
39+
});
40+
41+
test("all non-null launch_dates are ISO 8601 format", async () => {
42+
const res = mockRes();
43+
await handler(mockReq(), res);
44+
for (const r of res._body.spacecraft_missions) {
45+
if (r.launch_date !== null) {
46+
expect(r.launch_date).toMatch(ISO_DATE);
47+
}
48+
}
49+
});
50+
51+
test("all non-null mass_kg values are positive numbers", async () => {
52+
const res = mockRes();
53+
await handler(mockReq(), res);
54+
for (const r of res._body.spacecraft_missions) {
55+
if (r.mass_kg !== null) {
56+
expect(typeof r.mass_kg).toBe("number");
57+
expect(r.mass_kg).toBeGreaterThan(0);
58+
}
59+
}
60+
});
61+
62+
test("all non-null power_watts values are positive numbers", async () => {
63+
const res = mockRes();
64+
await handler(mockReq(), res);
65+
for (const r of res._body.spacecraft_missions) {
66+
if (r.power_watts !== null) {
67+
expect(typeof r.power_watts).toBe("number");
68+
expect(r.power_watts).toBeGreaterThan(0);
69+
}
70+
}
71+
});
72+
73+
test("filters by status", async () => {
74+
const res = mockRes();
75+
await handler(mockReq({ status: "active" }), res);
76+
expect(res._body.spacecraft_missions.length).toBeGreaterThan(0);
77+
for (const r of res._body.spacecraft_missions) {
78+
expect(r.status).toBe("active");
79+
}
80+
});
81+
82+
test("filters by orbit_type", async () => {
83+
const res = mockRes();
84+
await handler(mockReq({ orbit_type: "SSO" }), res);
85+
expect(res._body.spacecraft_missions.length).toBeGreaterThan(0);
86+
for (const r of res._body.spacecraft_missions) {
87+
expect(r.orbit_type).toBe("SSO");
88+
}
89+
});
90+
91+
test("combined filters return intersection", async () => {
92+
const res = mockRes();
93+
await handler(mockReq({ orbit_type: "GEO", status: "decommissioned" }), res);
94+
for (const r of res._body.spacecraft_missions) {
95+
expect(r.orbit_type).toBe("GEO");
96+
expect(r.status).toBe("decommissioned");
97+
}
98+
});
99+
});

tests/api/spacecrafts-id.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const handler = require("../../api/spacecrafts/[id]");
2+
const { mockReq, mockRes } = require("../helpers/mock");
3+
4+
describe("GET /api/spacecrafts/:id", () => {
5+
test("returns the correct record for a valid ID", async () => {
6+
const res = mockRes();
7+
await handler(mockReq({ id: "1" }), res);
8+
expect(res._status).toBe(200);
9+
expect(res._body.id).toBe(1);
10+
expect(res._body.name).toBe("Aryabhata");
11+
});
12+
13+
test("returns 200 with application/json header", async () => {
14+
const res = mockRes();
15+
await handler(mockReq({ id: "1" }), res);
16+
expect(res._headers["Content-Type"]).toBe("application/json");
17+
});
18+
19+
test("returns 404 for non-existent ID", async () => {
20+
const res = mockRes();
21+
await handler(mockReq({ id: "99999" }), res);
22+
expect(res._status).toBe(404);
23+
expect(res._body).toHaveProperty("error");
24+
});
25+
26+
test("returns 400 for non-integer ID", async () => {
27+
const res = mockRes();
28+
await handler(mockReq({ id: "abc" }), res);
29+
expect(res._status).toBe(400);
30+
expect(res._body).toHaveProperty("error");
31+
});
32+
33+
test("record includes _links.self", async () => {
34+
const res = mockRes();
35+
await handler(mockReq({ id: "1" }), res);
36+
expect(res._body._links).toHaveProperty("self", "/api/spacecrafts/1");
37+
});
38+
39+
test("record includes _links.mission when a matching mission exists", async () => {
40+
const res = mockRes();
41+
await handler(mockReq({ id: "1" }), res);
42+
// Aryabhata has a mission record
43+
expect(res._body._links).toHaveProperty("mission");
44+
expect(res._body._links.mission).toMatch(/^\/api\/spacecraft_missions\/\d+$/);
45+
});
46+
});

0 commit comments

Comments
 (0)