Skip to content

Commit abb78a3

Browse files
start on application unit tests
1 parent 532f14b commit abb78a3

File tree

6 files changed

+247
-27
lines changed

6 files changed

+247
-27
lines changed

components/Application.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,43 +13,62 @@ import { extract } from 'tar-fs';
1313
import gunzip from 'gunzip-maybe';
1414

1515
interface ApplicationConfig {
16+
// define known config properties
1617
package: string;
1718
install?: {
1819
command?: string;
1920
timeout?: number;
2021
};
22+
// an application config can have other arbitrary properties
23+
[key: string]: unknown;
24+
}
25+
26+
export class InvalidPackageIdentifierError extends TypeError {
27+
constructor(applicationName: string, packageIdentifier: unknown) {
28+
super(`Invalid 'package' property for application ${applicationName}: expected string, got ${typeof packageIdentifier}`)
29+
}
30+
}
31+
32+
export class InvalidInstallPropertyError extends TypeError {
33+
constructor(applicationName: string, installProperty: unknown) {
34+
super(`Invalid 'install' property for application ${applicationName}: expected object, got ${typeof installProperty}`)
35+
}
36+
}
37+
38+
export class InvalidInstallCommandError extends TypeError {
39+
constructor(applicationName: string, command: unknown) {
40+
super(`Invalid 'install.command' property for application ${applicationName}: expected string, got ${typeof command}`)
41+
}
42+
}
43+
44+
export class InvalidInstallTimeoutError extends TypeError {
45+
constructor(applicationName: string, timeout: unknown) {
46+
super(`Invalid 'install.timeout' property for application ${applicationName}: expected non-negative number, got ${typeof timeout}`)
47+
}
2148
}
2249

2350
export function assertApplicationConfig(
2451
applicationName: string,
25-
applicationConfig: object & Record<'package', unknown>
52+
applicationConfig: Record<'package', unknown> & Record<string, unknown>
2653
): asserts applicationConfig is ApplicationConfig {
2754
if (typeof applicationConfig.package !== 'string') {
28-
throw new TypeError(
29-
`Invalid 'package' property for application ${applicationName}: expected string, got ${typeof applicationConfig.package}`
30-
);
55+
throw new InvalidPackageIdentifierError(applicationName, applicationConfig.package);
3156
}
3257

3358
if ('install' in applicationConfig) {
34-
if (typeof applicationConfig.install !== 'object' || applicationConfig.install === null) {
35-
throw new TypeError(
36-
`Invalid 'install' property for application ${applicationName}: expected object, got ${typeof applicationConfig.install}`
37-
);
59+
if (typeof applicationConfig.install !== 'object' || applicationConfig.install === null || Array.isArray(applicationConfig.install)) {
60+
throw new InvalidInstallPropertyError(applicationName, applicationConfig.install);
3861
}
3962

4063
if ('command' in applicationConfig.install && typeof applicationConfig.install.command !== 'string') {
41-
throw new TypeError(
42-
`Invalid 'install.command' property for application ${applicationName}: expected string, got ${typeof applicationConfig.install.command}`
43-
);
64+
throw new InvalidInstallCommandError(applicationName, applicationConfig.install.command);
4465
}
4566

4667
if (
4768
'timeout' in applicationConfig.install &&
4869
(typeof applicationConfig.install.timeout !== 'number' || applicationConfig.install.timeout < 0)
4970
) {
50-
throw new TypeError(
51-
`Invalid 'install.timeout' property for application ${applicationName}: expected non-negativenumber, got ${typeof applicationConfig.install.timeout}`
52-
);
71+
throw new InvalidInstallTimeoutError(applicationName, applicationConfig.install.timeout);
5372
}
5473
}
5574
}
@@ -343,7 +362,7 @@ export class Application {
343362
* during the installation process in order to actually resolve what the user specifies for a
344363
* component matching some of npm's package resolution rules.
345364
*/
346-
function derivePackageIdentifier(packageIdentifier: string) {
365+
export function derivePackageIdentifier(packageIdentifier: string) {
347366
if (packageIdentifier.includes(':')) {
348367
return packageIdentifier;
349368
}
@@ -477,7 +496,6 @@ export function nonInteractiveSpawn(
477496
env.GIT_SSH_COMMAND = gitSSHCommand;
478497
}
479498

480-
// eslint-disable-next-line sonarjs/os-command
481499
const childProcess = spawn(command, args, {
482500
shell: true,
483501
cwd,

package-lock.json

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"description": "Harper is an open-source Node.js performance platform that unifies database, cache, application, and messaging layers into one in-memory process.",
44
"version": "5.0.0-unreleased",
55
"private": true,
6-
"type": "commonjs",
6+
"type": "commonjs",
77
"license": "Apache-2.0",
88
"homepage": "https://harper.fast",
99
"bugs": {
@@ -37,7 +37,8 @@
3737
"format:check": "prettier --check .",
3838
"format:write": "prettier --write .",
3939
"test:integration": "node --test integrationTests/apiTests/tests/testSuite.mjs",
40-
"test:unit": "npm run build || true; npx mocha unitTests --config unitTests/.mocharc.json"
40+
"test:unit": "TSX_TSCONFIG_PATH=./unitTests/tsconfig.json npx mocha --config unitTests/.mocharc.json",
41+
"test:unit:all": "npm run test:unit unitTests"
4142
},
4243
"engines": {
4344
"node": ">=20",
@@ -56,9 +57,11 @@
5657
},
5758
"devDependencies": {
5859
"@harperdb/code-guidelines": "^0.0.5",
60+
"@types/gunzip-maybe": "^1.4.3",
5961
"@types/micromatch": "^4.0.9",
6062
"@types/mocha": "^10.0.10",
6163
"@types/sinon": "^17.0.4",
64+
"@types/tar-fs": "^2.0.4",
6265
"axios": "^1.12.2",
6366
"chai": "^6.2.0",
6467
"eslint": "^9.36.0",

tsconfig.json

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
// Allow JS files for mixed codebase
2222
"allowJs": true,
2323

24-
// Type checking only - no emit (esbuild handles transpilation)
24+
// Type checking only by default; use `tsconfig.build.json` for emitting
2525
"noEmit": true,
2626

2727
// Target Node 20+ with ES2022 features
@@ -38,13 +38,6 @@
3838
"allowImportingTsExtensions": true,
3939

4040
// Enforce erasable syntax only so we can use Node.js type-stripping
41-
"erasableSyntaxOnly": true,
42-
43-
// Provide convenient path alias for compiled output
44-
// Change this to ./* once we're fully in TypeStrip land
45-
"baseUrl": ".",
46-
"paths": {
47-
"@/*": ["./dist/*"]
48-
}
41+
"erasableSyntaxOnly": true
4942
}
5043
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { describe, it } from 'mocha';
2+
import {
3+
assertApplicationConfig,
4+
InvalidPackageIdentifierError,
5+
InvalidInstallPropertyError,
6+
InvalidInstallCommandError,
7+
InvalidInstallTimeoutError,
8+
} from '@/components/Application';
9+
import assert from 'node:assert/strict';
10+
11+
describe('Application', () => {
12+
describe('assertApplicationConfig', () => {
13+
const applicationName = 'test-application';
14+
15+
it('should pass for valid minimal config', () => {
16+
assert.doesNotThrow(() => {
17+
assertApplicationConfig(applicationName, { package: 'my-package' });
18+
});
19+
});
20+
21+
it('should pass for valid config with install options', () => {
22+
assert.doesNotThrow(() => {
23+
assertApplicationConfig(applicationName, {
24+
package: 'my-package',
25+
install: {
26+
command: 'npm ci',
27+
timeout: 60000,
28+
},
29+
});
30+
});
31+
});
32+
33+
it('should pass for valid config with partial install options', () => {
34+
assert.doesNotThrow(() => {
35+
assertApplicationConfig(applicationName, {
36+
package: 'my-package',
37+
install: { command: 'npm ci' },
38+
});
39+
});
40+
41+
assert.doesNotThrow(() => {
42+
assertApplicationConfig(applicationName, {
43+
package: 'my-package',
44+
install: { timeout: 60000 },
45+
});
46+
});
47+
48+
assert.doesNotThrow(() => {
49+
assertApplicationConfig(applicationName, {
50+
package: 'my-package',
51+
install: {},
52+
});
53+
});
54+
});
55+
56+
it('should pass for config with additional, arbitrary options', () => {
57+
assert.doesNotThrow(() => {
58+
assertApplicationConfig(applicationName, {
59+
package: 'my-package',
60+
foo: 'bar',
61+
baz: 42,
62+
fuzz: { buzz: true }
63+
});
64+
});
65+
});
66+
67+
it('should fail for invalid package identifiers', () => {
68+
const invalidValues = [null, undefined, 42, {}, [], true, false];
69+
70+
for (const invalidValue of invalidValues) {
71+
assert.throws(
72+
() => {
73+
assertApplicationConfig(applicationName, {
74+
package: invalidValue,
75+
});
76+
},
77+
(error: Error) => {
78+
return (
79+
error instanceof InvalidPackageIdentifierError &&
80+
error.message === `Invalid 'package' property for application ${applicationName}: expected string, got ${typeof invalidValue}`
81+
);
82+
}
83+
);
84+
}
85+
});
86+
87+
it('should fail for invalid install property', () => {
88+
const invalidValues = [null, 42, 'string', [], true, false];
89+
90+
for (const invalidValue of invalidValues) {
91+
assert.throws(
92+
() => {
93+
assertApplicationConfig(applicationName, {
94+
package: 'my-package',
95+
install: invalidValue,
96+
});
97+
},
98+
(error: Error) => {
99+
return (
100+
error instanceof InvalidInstallPropertyError &&
101+
error.message === `Invalid 'install' property for application ${applicationName}: expected object, got ${typeof invalidValue}`
102+
);
103+
}
104+
);
105+
}
106+
});
107+
108+
it('should fail for invalid install.command', () => {
109+
const invalidValues = [null, undefined, 42, {}, [], true, false];
110+
111+
for (const invalidValue of invalidValues) {
112+
assert.throws(
113+
() => {
114+
assertApplicationConfig(applicationName, {
115+
package: 'my-package',
116+
install: { command: invalidValue },
117+
});
118+
},
119+
(error: Error) => {
120+
return (
121+
error instanceof InvalidInstallCommandError &&
122+
error.message === `Invalid 'install.command' property for application ${applicationName}: expected string, got ${typeof invalidValue}`
123+
);
124+
}
125+
);
126+
}
127+
});
128+
129+
it('should fail for invalid install.timeout', () => {
130+
const invalidValues = [null, undefined, 'string', {}, [], true, false, -1, -100];
131+
132+
for (const invalidValue of invalidValues) {
133+
assert.throws(
134+
() => {
135+
assertApplicationConfig(applicationName, {
136+
package: 'my-package',
137+
install: { timeout: invalidValue },
138+
});
139+
},
140+
(error: Error) => {
141+
return (
142+
error instanceof InvalidInstallTimeoutError &&
143+
error.message === `Invalid 'install.timeout' property for application ${applicationName}: expected non-negative number, got ${typeof invalidValue}`
144+
);
145+
}
146+
);
147+
}
148+
});
149+
150+
it('should pass for valid timeout of 0', () => {
151+
assert.doesNotThrow(() => {
152+
assertApplicationConfig(applicationName, {
153+
package: 'my-package',
154+
install: { timeout: 0 },
155+
});
156+
});
157+
});
158+
});
159+
});

unitTests/tsconfig.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"include": [
4+
"./**/*",
5+
],
6+
"compilerOptions": {
7+
// Provide convenient path alias for compiled output
8+
// Change this to ./* once we're fully in TypeStrip land
9+
"baseUrl": "..",
10+
"paths": {
11+
"@/*": ["./dist/*"]
12+
}
13+
}
14+
}

0 commit comments

Comments
 (0)