From 6aa8b51adc6dee43cead5272af0e28b217c48108 Mon Sep 17 00:00:00 2001 From: Erin Millard Date: Sun, 24 Mar 2024 20:09:46 +1000 Subject: [PATCH] Add support for custom constraints --- CHANGELOG.md | 6 + README.md | 169 ++++++++++++++++ src/constraint.ts | 6 +- src/declaration/big-integer.ts | 13 +- src/declaration/binary.ts | 21 +- src/declaration/boolean.ts | 14 +- src/declaration/duration.ts | 13 +- src/declaration/enumeration.ts | 19 +- src/declaration/integer.ts | 13 +- src/declaration/kubernetes-address.ts | 6 +- src/declaration/network-port-number.ts | 15 +- src/declaration/number.ts | 13 +- src/declaration/string.ts | 36 ++-- src/declaration/url.ts | 20 +- .../explicit-constraint-violation.ansi | 0 test/suite/declaration/big-integer.spec.ts | 119 ++++++++++++ test/suite/declaration/binary.spec.ts | 129 +++++++++++++ test/suite/declaration/boolean.spec.ts | 121 +++++++++++- test/suite/declaration/duration.spec.ts | 121 ++++++++++++ test/suite/declaration/enumeration.spec.ts | 182 +++++++++++++++++- test/suite/declaration/integer.spec.ts | 114 +++++++++++ .../declaration/network-port-number.spec.ts | 116 +++++++++++ test/suite/declaration/number.spec.ts | 114 +++++++++++ test/suite/declaration/string.spec.ts | 130 +++++++++++++ test/suite/declaration/url.spec.ts | 126 ++++++++++++ test/suite/specification.spec.ts | 17 +- test/suite/summary.spec.ts | 17 +- 27 files changed, 1594 insertions(+), 76 deletions(-) create mode 100644 test/fixture/summary/explicit-constraint-violation.ansi diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c5b2c..7cbe36b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ The format is based on [Keep a Changelog], and this project adheres to ## Unreleased +### Added + +- Custom constraints can now be defined for `bigInteger`, `binary`, `boolean`, + `duration`, `enumeration`, `integer`, `networkPortNumber`, `number`, `string`, + and `url` declarations. + ## [v0.9.1] - 2024-03-25 [v0.9.1]: https://github.com/ezzatron/austenite/releases/tag/v0.9.1 diff --git a/README.md b/README.md index 59dfed2..0100892 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,23 @@ export const earthAtomCount = bigInteger( ], }, ); + +// constraints +export const earthAtomCount = bigInteger( + "EARTH_ATOM_COUNT", + "number of atoms on earth", + { + constraints: [ + { + description: "must be a multiple of 1000", + constrain: (v) => v % 1_000n === 0n || "must be a multiple of 1000", + }, + ], + examples: [ + { value: 5_972_200_000_000_000_000_000_000n, label: "5.9722 septillion" }, + ], + }, +); ``` ### `binary` @@ -183,6 +200,23 @@ export const sessionKey = binary("SESSION_KEY", "session token signing key", { }, ], }); + +// constraints +export const sessionKey = binary("SESSION_KEY", "session token signing key", { + constraints: [ + { + description: "must be 128 or 256 bits", + constrain: (v) => + [16, 32].includes(v.length) || "decoded length must be 16 or 32", + }, + ], + examples: [ + { + value: Buffer.from("SUPER_SECRET_256_BIT_SIGNING_KEY", "utf-8"), + label: "256-bit key", + }, + ], +}); ``` ### `boolean` @@ -226,6 +260,24 @@ export const isDebug = boolean( ], }, ); + +// constraints +export const isDebug = boolean( + "DEBUG", + "enable or disable debugging features", + { + constraints: [ + { + description: "must not be enabled on Windows", + constrain: (v) => + !v || + process.platform !== "win32" || + "must not be enabled on Windows", + }, + ], + examples: [{ value: false, label: "disabled" }], + }, +); ``` ### `duration` @@ -267,6 +319,23 @@ export const grpcTimeout = duration("GRPC_TIMEOUT", "gRPC request timeout", { }, ], }); + +// constraints +export const grpcTimeout = duration("GRPC_TIMEOUT", "gRPC request timeout", { + constraints: [ + { + description: "must be a multiple of 100 milliseconds", + constrain: (v) => + v.milliseconds % 100 === 0 || "must be a multiple of 100 milliseconds", + }, + ], + examples: [ + { + value: Temporal.Duration.from({ milliseconds: 100 }), + label: "100 milliseconds", + }, + ], +}); ``` ### `enumeration` @@ -326,6 +395,27 @@ export const logLevel = enumeration( ], }, ); + +// constraints +export const logLevel = enumeration( + "LOG_LEVEL", + "the minimum log level to record", + members, + { + constraints: [ + { + description: "must not be debug on Windows", + constrain: (v) => + v !== "debug" || + process.platform !== "win32" || + "must not be debug on Windows", + }, + ], + examples: [ + { value: "error", label: "if you only want to see when things go wrong" }, + ], + }, +); ``` ### `integer` @@ -359,6 +449,17 @@ export const weight = integer("WEIGHT", "weighting for this node", { { value: 1000, as: "1e3", label: "highest weight" }, ], }); + +// constraints +export const weight = integer("WEIGHT", "weighting for this node", { + constraints: [ + { + description: "must be a multiple of 10", + constrain: (v) => v % 10 === 0 || "must be a multiple of 10", + }, + ], + examples: [{ value: 100, label: "100" }], +}); ``` ### `kubernetesAddress` @@ -437,6 +538,21 @@ export const port = networkPortNumber( ], }, ); + +// constraints +export const port = networkPortNumber( + "PORT", + "listen port for the HTTP server", + { + constraints: [ + { + description: "must not be disallowed", + constrain: (v) => ![1337, 31337].includes(v) || "not allowed", + }, + ], + examples: [{ value: 8080, label: "standard" }], + }, +); ``` ### `number` @@ -482,6 +598,21 @@ export const sampleRatio = number( ], }, ); + +// constraints +export const sampleRatio = number( + "SAMPLE_RATIO", + "ratio of requests to sample", + { + constraints: [ + { + description: "must be a multiple of 0.01", + constrain: (v) => v % 0.01 === 0 || "must be a multiple of 0.01", + }, + ], + examples: [{ value: 0.01, label: "1%" }], + }, +); ``` ### `string` @@ -547,6 +678,27 @@ export const readDsn = string( ], }, ); + +// constraints +export const readDsn = string( + "READ_DSN", + "database connection string for read-models", + { + constraints: [ + { + description: "must not contain a password", + constrain: (v) => + !v.includes("password") || "must not contain a password", + }, + ], + examples: [ + { + value: "host=localhost dbname=readmodels user=projector", + label: "local", + }, + ], + }, +); ``` ### `url` @@ -592,6 +744,23 @@ export const cdnUrl = url("CDN_URL", "CDN to use when serving static assets", { }, ], }); + +// constraints +export const cdnUrl = url("CDN_URL", "CDN to use when serving static assets", { + constraints: [ + { + description: "must not be a local URL", + constrain: (v) => + !v.hostname.endsWith(".local") || "must not be a local URL", + }, + ], + examples: [ + { + value: new URL("https://host.example.org/path/to/resource"), + label: "absolute", + }, + ], +}); ``` ## See also diff --git a/src/constraint.ts b/src/constraint.ts index c2ae9ca..1735008 100644 --- a/src/constraint.ts +++ b/src/constraint.ts @@ -1,6 +1,10 @@ import { normalize } from "./error.js"; import { createConjunctionFormatter } from "./list.js"; +export type DeclarationConstraintOptions = { + readonly constraints?: ExtrinsicConstraint[]; +}; + export type Constraint = IntrinsicConstraint | ExtrinsicConstraint; export type IntrinsicConstraint = { @@ -12,7 +16,7 @@ export type ExtrinsicConstraint = { readonly constrain: Constrain; }; -export type Constrain = (v: T) => string | undefined | void; +export type Constrain = (v: T) => string | undefined | void | true; export function applyConstraints( constraints: Constraint[], diff --git a/src/declaration/big-integer.ts b/src/declaration/big-integer.ts index 19d8a69..51ec5a9 100644 --- a/src/declaration/big-integer.ts +++ b/src/declaration/big-integer.ts @@ -1,3 +1,7 @@ +import type { + Constraint, + DeclarationConstraintOptions, +} from "../constraint.js"; import { createRangeConstraint, hasBigintRangeConstraint, @@ -21,6 +25,7 @@ import { resolve } from "../maybe.js"; import { ScalarSchema, createScalar, toString } from "../schema.js"; export type Options = DeclarationOptions & + DeclarationConstraintOptions & DeclarationExampleOptions & Partial>; @@ -59,7 +64,8 @@ function createSchema(name: string, options: Options): ScalarSchema { } } - const constraints = []; + const { constraints: explicitConstraints = [] } = options; + const constraints: Constraint[] = []; try { if (hasBigintRangeConstraint(options)) { @@ -69,7 +75,10 @@ function createSchema(name: string, options: Options): ScalarSchema { throw new SpecError(name, normalize(error)); } - return createScalar("big integer", toString, unmarshal, constraints); + return createScalar("big integer", toString, unmarshal, [ + ...constraints, + ...explicitConstraints, + ]); } function buildExamples(): Example[] { diff --git a/src/declaration/binary.ts b/src/declaration/binary.ts index cf5f3f0..f2ecc34 100644 --- a/src/declaration/binary.ts +++ b/src/declaration/binary.ts @@ -1,4 +1,8 @@ import { Buffer } from "buffer"; +import type { + Constraint, + DeclarationConstraintOptions, +} from "../constraint.js"; import { createLengthConstraint, type LengthConstraintSpec, @@ -26,6 +30,7 @@ const PATTERNS: Partial> = { } as const; export type Options = DeclarationOptions & + DeclarationConstraintOptions & DeclarationExampleOptions & { readonly encoding?: BufferEncoding; readonly length?: LengthConstraintSpec; @@ -36,15 +41,10 @@ export function binary( description: string, options: ExactOptions = {} as ExactOptions, ): Declaration { - const { - encoding = "base64", - examples, - isSensitive = false, - length, - } = options; + const { encoding = "base64", examples, isSensitive = false } = options; const def = defaultFromOptions(options); - const schema = createSchema(name, encoding, length); + const schema = createSchema(name, encoding, options); const v = registerVariable({ name, @@ -70,13 +70,14 @@ export function binary( function createSchema( name: string, encoding: BufferEncoding, - length: LengthConstraintSpec | undefined, + options: Options, ): ScalarSchema { function marshal(v: Buffer): string { return v.toString(encoding); } - const constraints = []; + const { constraints: explicitConstraints = [], length } = options; + const constraints: Constraint[] = []; try { if (typeof length !== "undefined") { @@ -90,7 +91,7 @@ function createSchema( encoding, marshal, createUnmarshal(encoding, PATTERNS[encoding]), - constraints, + [...constraints, ...explicitConstraints], ); } diff --git a/src/declaration/boolean.ts b/src/declaration/boolean.ts index 6a0dcd0..3813749 100644 --- a/src/declaration/boolean.ts +++ b/src/declaration/boolean.ts @@ -1,3 +1,4 @@ +import type { DeclarationConstraintOptions } from "../constraint.js"; import { Declaration, Options as DeclarationOptions, @@ -16,6 +17,7 @@ import { resolve } from "../maybe.js"; import { EnumSchema, InvalidEnumError, createEnum } from "../schema.js"; export type Options = DeclarationOptions & + DeclarationConstraintOptions & DeclarationExampleOptions & { readonly literals?: Literals; }; @@ -34,7 +36,7 @@ export function boolean( ): Declaration { const { examples, isSensitive = false, literals = defaultLiterals } = options; - const schema = createSchema(name, literals); + const schema = createSchema(name, literals, options); const def = defaultFromOptions(options); const v = registerVariable({ @@ -58,7 +60,11 @@ export function boolean( }; } -function createSchema(name: string, literals: Literals): EnumSchema { +function createSchema( + name: string, + literals: Literals, + options: Options, +): EnumSchema { for (const literal of Object.keys(literals)) { if (literal.length < 1) throw new EmptyLiteralError(name); } @@ -78,7 +84,9 @@ function createSchema(name: string, literals: Literals): EnumSchema { throw new InvalidEnumError(literals); } - return createEnum(literals, marshal, unmarshal, []); + const { constraints: explicitConstraints = [] } = options; + + return createEnum(literals, marshal, unmarshal, [...explicitConstraints]); } function findLiteral( diff --git a/src/declaration/duration.ts b/src/declaration/duration.ts index 3e6c530..5761121 100644 --- a/src/declaration/duration.ts +++ b/src/declaration/duration.ts @@ -1,4 +1,8 @@ import { Temporal } from "@js-temporal/polyfill"; +import type { + Constraint, + DeclarationConstraintOptions, +} from "../constraint.js"; import { createDurationRangeConstraint, hasDurationRangeConstraint, @@ -25,6 +29,7 @@ const { Duration } = Temporal; type Duration = Temporal.Duration; export type Options = DeclarationOptions & + DeclarationConstraintOptions & DeclarationExampleOptions & Partial>; @@ -63,7 +68,8 @@ function createSchema(name: string, options: Options): ScalarSchema { } } - const constraints = []; + const { constraints: explicitConstraints = [] } = options; + const constraints: Constraint[] = []; try { if (hasDurationRangeConstraint(options)) { @@ -73,7 +79,10 @@ function createSchema(name: string, options: Options): ScalarSchema { throw new SpecError(name, normalize(error)); } - return createScalar("ISO 8601 duration", toString, unmarshal, constraints); + return createScalar("ISO 8601 duration", toString, unmarshal, [ + ...constraints, + ...explicitConstraints, + ]); } function buildExamples(): Example[] { diff --git a/src/declaration/enumeration.ts b/src/declaration/enumeration.ts index dbcfce3..9d75129 100644 --- a/src/declaration/enumeration.ts +++ b/src/declaration/enumeration.ts @@ -1,3 +1,4 @@ +import type { DeclarationConstraintOptions } from "../constraint.js"; import { Declaration, Options as DeclarationOptions, @@ -22,7 +23,9 @@ export type Member = { readonly description: string; }; -export type Options = DeclarationOptions & DeclarationExampleOptions; +export type Options = DeclarationOptions & + DeclarationConstraintOptions & + DeclarationExampleOptions; export function enumeration>( name: string, @@ -33,7 +36,7 @@ export function enumeration>( const { examples, isSensitive = false } = options; const def = defaultFromOptions(options); - const schema = createSchema(name, members); + const schema = createSchema(name, members, options); const v = registerVariable({ name, @@ -56,7 +59,11 @@ export function enumeration>( }; } -function createSchema(name: string, members: Members): EnumSchema { +function createSchema( + name: string, + members: Members, + options: Options, +): EnumSchema { const entries = Object.entries(members); if (entries.length < 2) throw new InsufficientMembersError(name); @@ -81,7 +88,11 @@ function createSchema(name: string, members: Members): EnumSchema { throw new InvalidEnumError(members); } - return createEnum(schemaMembers, marshal, unmarshal, []); + const { constraints: explicitConstraints = [] } = options; + + return createEnum(schemaMembers, marshal, unmarshal, [ + ...explicitConstraints, + ]); } function buildExamples(members: Members): Example[] { diff --git a/src/declaration/integer.ts b/src/declaration/integer.ts index f07d876..9c31768 100644 --- a/src/declaration/integer.ts +++ b/src/declaration/integer.ts @@ -1,3 +1,7 @@ +import type { + Constraint, + DeclarationConstraintOptions, +} from "../constraint.js"; import { createIntegerConstraint } from "../constraint/integer.js"; import { assertRangeSpec, @@ -23,6 +27,7 @@ import { resolve } from "../maybe.js"; import { ScalarSchema, createScalar, toString } from "../schema.js"; export type Options = DeclarationOptions & + DeclarationConstraintOptions & DeclarationExampleOptions & Partial>; @@ -57,7 +62,8 @@ function createSchema(name: string, options: Options): ScalarSchema { return Number(v); } - const constraints = [createIntegerConstraint()]; + const { constraints: explicitConstraints = [] } = options; + const constraints: Constraint[] = [createIntegerConstraint()]; try { if (hasNumberRangeConstraint(options)) { @@ -68,7 +74,10 @@ function createSchema(name: string, options: Options): ScalarSchema { throw new SpecError(name, normalize(error)); } - return createScalar("integer", toString, unmarshal, constraints); + return createScalar("integer", toString, unmarshal, [ + ...constraints, + ...explicitConstraints, + ]); } function buildExamples(): Example[] { diff --git a/src/declaration/kubernetes-address.ts b/src/declaration/kubernetes-address.ts index ff29fa5..8c27e3f 100644 --- a/src/declaration/kubernetes-address.ts +++ b/src/declaration/kubernetes-address.ts @@ -73,7 +73,7 @@ function registerHost( def: Maybe, ): Variable { const hostDef = map(def, (address) => address?.host); - const schema = createString("hostname", [createHostnameConstraint()]); + const schema = createHostSchema(); let envName: string; try { @@ -92,6 +92,10 @@ function registerHost( }); } +function createHostSchema(): ScalarSchema { + return createString("hostname", [createHostnameConstraint()]); +} + function buildHostExamples(): Example[] { return [ { diff --git a/src/declaration/network-port-number.ts b/src/declaration/network-port-number.ts index 21dd3a5..83900a9 100644 --- a/src/declaration/network-port-number.ts +++ b/src/declaration/network-port-number.ts @@ -1,3 +1,7 @@ +import type { + Constraint, + DeclarationConstraintOptions, +} from "../constraint.js"; import { createNetworkPortNumberConstraint } from "../constraint/network-port-number.js"; import { assertRangeSpec, @@ -23,6 +27,7 @@ import { resolve } from "../maybe.js"; import { ScalarSchema, createScalar, toString } from "../schema.js"; export type Options = DeclarationOptions & + DeclarationConstraintOptions & DeclarationExampleOptions & Partial>; @@ -62,7 +67,10 @@ function createSchema(name: string, options: Options): ScalarSchema { return Number(v); } - const constraints = [createNetworkPortNumberConstraint()]; + const { constraints: explicitConstraints = [] } = options; + const constraints: Constraint[] = [ + createNetworkPortNumberConstraint(), + ]; try { if (hasNumberRangeConstraint(options)) { @@ -73,7 +81,10 @@ function createSchema(name: string, options: Options): ScalarSchema { throw new SpecError(name, normalize(error)); } - return createScalar("port number", toString, unmarshal, constraints); + return createScalar("port number", toString, unmarshal, [ + ...constraints, + ...explicitConstraints, + ]); } function buildExamples(): Example[] { diff --git a/src/declaration/number.ts b/src/declaration/number.ts index df98224..b2ed959 100644 --- a/src/declaration/number.ts +++ b/src/declaration/number.ts @@ -1,3 +1,7 @@ +import type { + Constraint, + DeclarationConstraintOptions, +} from "../constraint.js"; import { createRangeConstraint, hasNumberRangeConstraint, @@ -21,6 +25,7 @@ import { resolve } from "../maybe.js"; import { ScalarSchema, createScalar, toString } from "../schema.js"; export type Options = DeclarationOptions & + DeclarationConstraintOptions & DeclarationExampleOptions & Partial>; @@ -59,7 +64,8 @@ function createSchema(name: string, options: Options): ScalarSchema { return n; } - const constraints = []; + const { constraints: explicitConstraints = [] } = options; + const constraints: Constraint[] = []; try { if (hasNumberRangeConstraint(options)) { @@ -69,7 +75,10 @@ function createSchema(name: string, options: Options): ScalarSchema { throw new SpecError(name, normalize(error)); } - return createScalar("number", toString, unmarshal, constraints); + return createScalar("number", toString, unmarshal, [ + ...constraints, + ...explicitConstraints, + ]); } function buildExamples(): Example[] { diff --git a/src/declaration/string.ts b/src/declaration/string.ts index 0e0514f..be4c95e 100644 --- a/src/declaration/string.ts +++ b/src/declaration/string.ts @@ -1,3 +1,7 @@ +import type { + Constraint, + DeclarationConstraintOptions, +} from "../constraint.js"; import { createLengthConstraint, type LengthConstraintSpec, @@ -17,9 +21,10 @@ import { type Example, } from "../example.js"; import { resolve } from "../maybe.js"; -import { createString } from "../schema.js"; +import { createString, type ScalarSchema } from "../schema.js"; export type Options = DeclarationOptions & + DeclarationConstraintOptions & DeclarationExampleOptions & { readonly length?: LengthConstraintSpec; }; @@ -29,20 +34,10 @@ export function string( description: string, options: ExactOptions = {} as ExactOptions, ): Declaration { - const { examples, isSensitive = false, length } = options; + const { examples, isSensitive = false } = options; const def = defaultFromOptions(options); - const constraints = []; - - try { - if (typeof length !== "undefined") { - constraints.push(createLengthConstraint("length", length)); - } - } catch (error) { - throw new SpecError(name, normalize(error)); - } - - const schema = createString("string", constraints); + const schema = createSchema(name, options); const v = registerVariable({ name, @@ -60,6 +55,21 @@ export function string( }; } +function createSchema(name: string, options: Options): ScalarSchema { + const { constraints: explicitConstraints = [], length } = options; + const constraints: Constraint[] = []; + + try { + if (typeof length !== "undefined") { + constraints.push(createLengthConstraint("length", length)); + } + } catch (error) { + throw new SpecError(name, normalize(error)); + } + + return createString("string", [...constraints, ...explicitConstraints]); +} + function buildExamples(): Example[] { return [ { diff --git a/src/declaration/url.ts b/src/declaration/url.ts index 911aedf..7a3bcca 100644 --- a/src/declaration/url.ts +++ b/src/declaration/url.ts @@ -1,4 +1,8 @@ -import { applyConstraints, type Constraint } from "../constraint.js"; +import { + applyConstraints, + type Constraint, + type DeclarationConstraintOptions, +} from "../constraint.js"; import { createURLProtocolConstraint } from "../constraint/url-protocol.js"; import { Declaration, @@ -21,6 +25,7 @@ import { createURL, toString, type URLSchema } from "../schema.js"; const VALID_PROTOCOL_PATTERN = /^[a-zA-Z][a-zA-Z0-9.+-]*:$/; export type Options = DeclarationOptions & + DeclarationConstraintOptions & DeclarationExampleOptions & { readonly base?: URL; readonly protocols?: string[]; @@ -35,7 +40,7 @@ export function url( assertProtocols(name, protocols); - const schema = createSchema(base, protocols); + const schema = createSchema(base, options); assertBase(name, schema.constraints, base); @@ -99,10 +104,7 @@ function assertBase( } } -function createSchema( - base: URL | undefined, - protocols: string[] | undefined, -): URLSchema { +function createSchema(base: URL | undefined, options: Options): URLSchema { function unmarshal(v: string): URL { try { return new URL(v, base); @@ -111,12 +113,16 @@ function createSchema( } } + const { constraints: explicitConstraints = [], protocols } = options; const constraints: Constraint[] = []; if (protocols != null) { constraints.push(createURLProtocolConstraint(protocols)); } - return createURL(base, protocols, toString, unmarshal, constraints); + return createURL(base, protocols, toString, unmarshal, [ + ...constraints, + ...explicitConstraints, + ]); } function buildExamples( diff --git a/test/fixture/summary/explicit-constraint-violation.ansi b/test/fixture/summary/explicit-constraint-violation.ansi new file mode 100644 index 0000000..e69de29 diff --git a/test/suite/declaration/big-integer.spec.ts b/test/suite/declaration/big-integer.spec.ts index de16aee..f9f503f 100644 --- a/test/suite/declaration/big-integer.spec.ts +++ b/test/suite/declaration/big-integer.spec.ts @@ -219,4 +219,123 @@ describe("Big integer declarations", () => { }); }); }); + + describe("when the declaration has constraints", () => { + beforeEach(() => { + declaration = bigInteger("AUSTENITE_INTEGER", "", { + constraints: [ + { + description: "", + constrain: (v) => v % 2n === 0n || "must be divisible by 2", + }, + { + description: "", + constrain: (v) => v % 3n === 0n || "must be divisible by 3", + }, + ], + examples: [{ value: 6n, label: "example" }], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.AUSTENITE_INTEGER = "6"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(6n); + }); + }); + }); + + describe("when the value violates the first constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_INTEGER = "3"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_INTEGER (3) is invalid: must be divisible by 2", + ); + }); + }); + }); + + describe("when the value violates the second constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_INTEGER = "2"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_INTEGER (2) is invalid: must be divisible by 3", + ); + }); + }); + }); + }); + + describe("when the declaration has the constraints from the README", () => { + beforeEach(() => { + declaration = bigInteger("EARTH_ATOM_COUNT", "number of atoms on earth", { + constraints: [ + { + description: "must be a multiple of 1000", + constrain: (v) => v % 1_000n === 0n || "must be a multiple of 1000", + }, + ], + examples: [ + { + value: 5_972_200_000_000_000_000_000_000n, + label: "5.9722 septillion", + }, + ], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.EARTH_ATOM_COUNT = "5972200000000000000000000"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(5_972_200_000_000_000_000_000_000n); + }); + }); + }); + + describe("when the value violates the constraints", () => { + beforeEach(() => { + process.env.EARTH_ATOM_COUNT = "5972200000000000000000001"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of EARTH_ATOM_COUNT (5972200000000000000000001) is invalid: must be a multiple of 1000", + ); + }); + }); + }); + }); }); diff --git a/test/suite/declaration/binary.spec.ts b/test/suite/declaration/binary.spec.ts index 6f46ba6..0e69e36 100644 --- a/test/suite/declaration/binary.spec.ts +++ b/test/suite/declaration/binary.spec.ts @@ -226,6 +226,135 @@ describe("Binary declarations", () => { }); }); }); + + describe("when the declaration has constraints", () => { + beforeEach(() => { + declaration = binary("AUSTENITE_BINARY", "", { + constraints: [ + { + description: "", + constrain: (v) => + v.length % 2 === 0 || "length must be divisible by 2", + }, + { + description: "", + constrain: (v) => + v.length % 3 === 0 || "length must be divisible by 3", + }, + ], + examples: [{ value: Buffer.from("abcdef", "utf-8"), label: "example" }], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.AUSTENITE_BINARY = "YWJjZGVm"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value().toString("utf-8")).toEqual("abcdef"); + }); + }); + }); + + describe("when the value violates the first constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_BINARY = "YWJj"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_BINARY (YWJj) is invalid: length must be divisible by 2", + ); + }); + }); + }); + + describe("when the value violates the second constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_BINARY = "YWI="; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_BINARY (YWI=) is invalid: length must be divisible by 3", + ); + }); + }); + }); + }); + + describe("when the declaration has the constraints from the README", () => { + beforeEach(() => { + declaration = binary("SESSION_KEY", "session token signing key", { + constraints: [ + { + description: "must be 128 or 256 bits", + constrain: (v) => + [16, 32].includes(v.length) || "decoded length must be 16 or 32", + }, + ], + examples: [ + { + value: Buffer.from("SUPER_SECRET_256_BIT_SIGNING_KEY", "utf-8"), + label: "256-bit key", + }, + ], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.SESSION_KEY = Buffer.from( + "SUPER_SECRET_256_BIT_SIGNING_KEY", + "utf-8", + ).toString("base64"); + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value().toString("utf-8")).toEqual( + "SUPER_SECRET_256_BIT_SIGNING_KEY", + ); + }); + }); + }); + + describe("when the value violates the constraints", () => { + beforeEach(() => { + process.env.SESSION_KEY = Buffer.from("INVALID", "utf-8").toString( + "base64", + ); + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of SESSION_KEY (SU5WQUxJRA==) is invalid: decoded length must be 16 or 32", + ); + }); + }); + }); + }); }); function toEncoding(encoding: BufferEncoding, value: string): string { diff --git a/test/suite/declaration/boolean.spec.ts b/test/suite/declaration/boolean.spec.ts index fefb405..b2e2e27 100644 --- a/test/suite/declaration/boolean.spec.ts +++ b/test/suite/declaration/boolean.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Declaration } from "../../../src/declaration.js"; import { Options } from "../../../src/declaration/boolean.js"; import { boolean, initialize } from "../../../src/index.js"; @@ -329,4 +329,123 @@ describe("Boolean declarations", () => { }); }); }); + + describe("when the declaration has constraints", () => { + beforeEach(() => { + declaration = boolean("AUSTENITE_BOOLEAN", "", { + constraints: [ + { + description: "", + constrain: (v) => v || "value must be true", + }, + ], + examples: [{ value: true, label: "example" }], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.AUSTENITE_BOOLEAN = "true"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(true); + }); + }); + }); + + describe("when the value violates a constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_BOOLEAN = "false"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_BOOLEAN (false) is invalid: value must be true", + ); + }); + }); + }); + }); + + describe("when the declaration has the constraints from the README", () => { + let realPlatform: NodeJS.Platform; + + beforeEach(() => { + declaration = boolean("DEBUG", "enable or disable debugging features", { + constraints: [ + { + description: "must not be enabled on Windows", + constrain: (v) => + !v || + process.platform !== "win32" || + "must not be enabled on Windows", + }, + ], + examples: [{ value: false, label: "disabled" }], + }); + + realPlatform = process.platform; + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { value: realPlatform }); + }); + + describe("when the value is false", () => { + beforeEach(() => { + process.env.DEBUG = "false"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(false); + }); + }); + }); + + describe("when the value is true and the platform is not Windows", () => { + beforeEach(() => { + process.env.DEBUG = "true"; + Object.defineProperty(process, "platform", { value: "darwin" }); + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(true); + }); + }); + }); + + describe("when the value is true and the platform is Windows", () => { + beforeEach(() => { + process.env.DEBUG = "true"; + Object.defineProperty(process, "platform", { value: "win32" }); + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of DEBUG (true) is invalid: must not be enabled on Windows", + ); + }); + }); + }); + }); }); diff --git a/test/suite/declaration/duration.spec.ts b/test/suite/declaration/duration.spec.ts index dbb9ff9..1c59d57 100644 --- a/test/suite/declaration/duration.spec.ts +++ b/test/suite/declaration/duration.spec.ts @@ -205,4 +205,125 @@ describe("Duration declarations", () => { }); }); }); + + describe("when the declaration has constraints", () => { + beforeEach(() => { + declaration = duration("AUSTENITE_DURATION", "", { + constraints: [ + { + description: "", + constrain: (v) => v.days % 2 === 0 || "days must be divisible by 2", + }, + { + description: "", + constrain: (v) => v.days % 3 === 0 || "days must be divisible by 3", + }, + ], + examples: [{ value: Duration.from("P6D"), label: "example" }], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.AUSTENITE_DURATION = "P6D"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toEqual(Duration.from("P6D")); + }); + }); + }); + + describe("when the value violates the first constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_DURATION = "P3D"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_DURATION (P3D) is invalid: days must be divisible by 2", + ); + }); + }); + }); + + describe("when the value violates the second constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_DURATION = "P2D"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_DURATION (P2D) is invalid: days must be divisible by 3", + ); + }); + }); + }); + }); + + describe("when the declaration has the constraints from the README", () => { + beforeEach(() => { + declaration = duration("GRPC_TIMEOUT", "gRPC request timeout", { + constraints: [ + { + description: "must be a multiple of 100 milliseconds", + constrain: (v) => + v.milliseconds % 100 === 0 || + "must be a multiple of 100 milliseconds", + }, + ], + examples: [ + { + value: Temporal.Duration.from({ milliseconds: 100 }), + label: "100 milliseconds", + }, + ], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.GRPC_TIMEOUT = "PT1S"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toEqual(Duration.from("PT1S")); + }); + }); + }); + + describe("when the value violates the constraints", () => { + beforeEach(() => { + process.env.GRPC_TIMEOUT = "PT0.01S"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of GRPC_TIMEOUT (PT0.01S) is invalid: must be a multiple of 100 milliseconds", + ); + }); + }); + }); + }); }); diff --git a/test/suite/declaration/enumeration.spec.ts b/test/suite/declaration/enumeration.spec.ts index bbd3629..780803b 100644 --- a/test/suite/declaration/enumeration.spec.ts +++ b/test/suite/declaration/enumeration.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Declaration } from "../../../src/declaration.js"; import { Options } from "../../../src/declaration/enumeration.js"; import { enumeration, initialize } from "../../../src/index.js"; @@ -20,7 +20,7 @@ describe("Enumeration declarations", () => { }, } as const; - let declaration: Declaration>; + let declaration: Declaration<0 | 1 | 2, Options<0 | 1 | 2>>; describe("when no options are supplied", () => { beforeEach(() => { @@ -268,4 +268,182 @@ describe("Enumeration declarations", () => { ); }); }); + + describe("when the declaration has constraints", () => { + beforeEach(() => { + declaration = enumeration( + "AUSTENITE_ENUMERATION", + "", + members, + { + constraints: [ + { + description: "", + constrain: (v) => [0, 1].includes(v) || "value must be 0 or 1", + }, + { + description: "", + constrain: (v) => [1, 2].includes(v) || "value must be 1 or 2", + }, + ], + examples: [{ value: 1, label: "example" }], + }, + ); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.AUSTENITE_ENUMERATION = ""; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(1); + }); + }); + }); + + describe("when the value violates the first constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_ENUMERATION = ""; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_ENUMERATION ('') is invalid: value must be 0 or 1", + ); + }); + }); + }); + + describe("when the value violates the second constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_ENUMERATION = ""; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_ENUMERATION ('') is invalid: value must be 1 or 2", + ); + }); + }); + }); + }); + + describe("when the declaration has the constraints from the README", () => { + let declaration: Declaration< + "debug" | "info" | "warn" | "error" | "fatal", + Options<"debug" | "info" | "warn" | "error" | "fatal"> + >; + let realPlatform: NodeJS.Platform; + + beforeEach(() => { + declaration = enumeration( + "LOG_LEVEL", + "the minimum log level to record", + { + debug: { + value: "debug", + description: "show information for developers", + }, + info: { value: "info", description: "standard log messages" }, + warn: { + value: "warn", + description: "important, but don't need individual human review", + }, + error: { + value: "error", + description: "a healthy application shouldn't produce any errors", + }, + fatal: { + value: "fatal", + description: "the application cannot proceed", + }, + }, + { + constraints: [ + { + description: "must not be debug on Windows", + constrain: (v) => + v !== "debug" || + process.platform !== "win32" || + "must not be debug on Windows", + }, + ], + examples: [ + { + value: "error", + label: "if you only want to see when things go wrong", + }, + ], + }, + ); + + realPlatform = process.platform; + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { value: realPlatform }); + }); + + describe("when the value is not debug", () => { + beforeEach(() => { + process.env.LOG_LEVEL = "error"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe("error"); + }); + }); + }); + + describe("when the value is debug and the platform is not Windows", () => { + beforeEach(() => { + process.env.LOG_LEVEL = "debug"; + Object.defineProperty(process, "platform", { value: "darwin" }); + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe("debug"); + }); + }); + }); + + describe("when the value is debug and the platform is Windows", () => { + beforeEach(() => { + process.env.LOG_LEVEL = "debug"; + Object.defineProperty(process, "platform", { value: "win32" }); + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of LOG_LEVEL (debug) is invalid: must not be debug on Windows", + ); + }); + }); + }); + }); }); diff --git a/test/suite/declaration/integer.spec.ts b/test/suite/declaration/integer.spec.ts index 4a009a1..1d44269 100644 --- a/test/suite/declaration/integer.spec.ts +++ b/test/suite/declaration/integer.spec.ts @@ -239,4 +239,118 @@ describe("Integer declarations", () => { ); }); }); + + describe("when the declaration has constraints", () => { + beforeEach(() => { + declaration = integer("AUSTENITE_INTEGER", "", { + constraints: [ + { + description: "", + constrain: (v) => v % 2 === 0 || "must be divisible by 2", + }, + { + description: "", + constrain: (v) => v % 3 === 0 || "must be divisible by 3", + }, + ], + examples: [{ value: 6, label: "example" }], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.AUSTENITE_INTEGER = "6"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(6); + }); + }); + }); + + describe("when the value violates the first constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_INTEGER = "3"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_INTEGER (3) is invalid: must be divisible by 2", + ); + }); + }); + }); + + describe("when the value violates the second constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_INTEGER = "2"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_INTEGER (2) is invalid: must be divisible by 3", + ); + }); + }); + }); + }); + + describe("when the declaration has the constraints from the README", () => { + beforeEach(() => { + declaration = integer("WEIGHT", "weighting for this node", { + constraints: [ + { + description: "must be a multiple of 10", + constrain: (v) => v % 10 === 0 || "must be a multiple of 10", + }, + ], + examples: [{ value: 100, label: "100" }], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.WEIGHT = "300"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(300); + }); + }); + }); + + describe("when the value violates the constraints", () => { + beforeEach(() => { + process.env.WEIGHT = "301"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of WEIGHT (301) is invalid: must be a multiple of 10", + ); + }); + }); + }); + }); }); diff --git a/test/suite/declaration/network-port-number.spec.ts b/test/suite/declaration/network-port-number.spec.ts index d9b6962..c50e66c 100644 --- a/test/suite/declaration/network-port-number.spec.ts +++ b/test/suite/declaration/network-port-number.spec.ts @@ -298,4 +298,120 @@ describe("Network port number declarations", () => { }); }, ); + + describe("when the declaration has constraints", () => { + beforeEach(() => { + declaration = networkPortNumber("AUSTENITE_PORT", "", { + constraints: [ + { + description: "", + constrain: (v) => v % 2 === 0 || "must be divisible by 2", + }, + { + description: "", + constrain: (v) => v % 3 === 0 || "must be divisible by 3", + }, + ], + examples: [{ value: 6, label: "example" }], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.AUSTENITE_PORT = "6"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(6); + }); + }); + }); + + describe("when the value violates the first constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_PORT = "3"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_PORT (3) is invalid: must be divisible by 2", + ); + }); + }); + }); + + describe("when the value violates the second constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_PORT = "2"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_PORT (2) is invalid: must be divisible by 3", + ); + }); + }); + }); + }); + + describe("when the declaration has the constraints from the README", () => { + beforeEach(() => { + declaration = networkPortNumber( + "PORT", + "listen port for the HTTP server", + { + constraints: [ + { + description: "must not be disallowed", + constrain: (v) => ![1337, 31337].includes(v) || "not allowed", + }, + ], + examples: [{ value: 8080, label: "standard" }], + }, + ); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.PORT = "8080"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(8080); + }); + }); + }); + + describe("when the value violates the constraints", () => { + beforeEach(() => { + process.env.PORT = "1337"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow("value of PORT (1337) is invalid: not allowed"); + }); + }); + }); + }); }); diff --git a/test/suite/declaration/number.spec.ts b/test/suite/declaration/number.spec.ts index c9cc03d..aaaacfb 100644 --- a/test/suite/declaration/number.spec.ts +++ b/test/suite/declaration/number.spec.ts @@ -210,4 +210,118 @@ describe("Number declarations", () => { }); }); }); + + describe("when the declaration has constraints", () => { + beforeEach(() => { + declaration = number("AUSTENITE_NUMBER", "", { + constraints: [ + { + description: "", + constrain: (v) => v % 2 === 0 || "must be divisible by 2", + }, + { + description: "", + constrain: (v) => v % 3 === 0 || "must be divisible by 3", + }, + ], + examples: [{ value: 6, label: "example" }], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.AUSTENITE_NUMBER = "6"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(6); + }); + }); + }); + + describe("when the value violates the first constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_NUMBER = "3"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_NUMBER (3) is invalid: must be divisible by 2", + ); + }); + }); + }); + + describe("when the value violates the second constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_NUMBER = "2"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_NUMBER (2) is invalid: must be divisible by 3", + ); + }); + }); + }); + }); + + describe("when the declaration has the constraints from the README", () => { + beforeEach(() => { + declaration = number("SAMPLE_RATIO", "ratio of requests to sample", { + constraints: [ + { + description: "must be a multiple of 0.01", + constrain: (v) => v % 0.01 === 0 || "must be a multiple of 0.01", + }, + ], + examples: [{ value: 0.01, label: "1%" }], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.SAMPLE_RATIO = "0.01"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe(0.01); + }); + }); + }); + + describe("when the value violates the constraints", () => { + beforeEach(() => { + process.env.SAMPLE_RATIO = "0.001"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of SAMPLE_RATIO (0.001) is invalid: must be a multiple of 0.01", + ); + }); + }); + }); + }); }); diff --git a/test/suite/declaration/string.spec.ts b/test/suite/declaration/string.spec.ts index 5472bc7..e323b2c 100644 --- a/test/suite/declaration/string.spec.ts +++ b/test/suite/declaration/string.spec.ts @@ -135,4 +135,134 @@ describe("String declarations", () => { }); }); }); + + describe("when the declaration has constraints", () => { + beforeEach(() => { + declaration = string("AUSTENITE_STRING", "", { + constraints: [ + { + description: "", + constrain: (v) => + v.length % 2 === 0 || "length must be divisible by 2", + }, + { + description: "", + constrain: (v) => + v.length % 3 === 0 || "length must be divisible by 3", + }, + ], + examples: [{ value: "abcdef", label: "example" }], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.AUSTENITE_STRING = "abcdef"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe("abcdef"); + }); + }); + }); + + describe("when the value violates the first constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_STRING = "abc"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_STRING (abc) is invalid: length must be divisible by 2", + ); + }); + }); + }); + + describe("when the value violates the second constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_STRING = "ab"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_STRING (ab) is invalid: length must be divisible by 3", + ); + }); + }); + }); + }); + + describe("when the declaration has the constraints from the README", () => { + beforeEach(() => { + declaration = string( + "READ_DSN", + "database connection string for read-models", + { + constraints: [ + { + description: "must not contain a password", + constrain: (v) => + !v.includes("password") || "must not contain a password", + }, + ], + examples: [ + { + value: "host=localhost dbname=readmodels user=projector", + label: "local", + }, + ], + }, + ); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.READ_DSN = + "host=localhost dbname=readmodels user=projector"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toBe( + "host=localhost dbname=readmodels user=projector", + ); + }); + }); + }); + + describe("when the value violates the constraint", () => { + beforeEach(() => { + process.env.READ_DSN = + "host=localhost dbname=readmodels user=projector password=secret"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of READ_DSN ('host=localhost dbname=readmodels user=projector password=secret') is invalid: must not contain a password", + ); + }); + }); + }); + }); }); diff --git a/test/suite/declaration/url.spec.ts b/test/suite/declaration/url.spec.ts index fc01107..2716e96 100644 --- a/test/suite/declaration/url.spec.ts +++ b/test/suite/declaration/url.spec.ts @@ -365,4 +365,130 @@ describe("URL declarations", () => { }); }); }); + + describe("when the declaration has constraints", () => { + beforeEach(() => { + declaration = url("AUSTENITE_URL", "", { + constraints: [ + { + description: "", + constrain: (v) => + v.hostname === "example.org" || "hostname must be example.org", + }, + { + description: "", + constrain: (v) => + v.protocol === "https:" || "protocol must be https:", + }, + ], + examples: [ + { value: new URL("https://example.org/"), label: "example" }, + ], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.AUSTENITE_URL = "https://example.org/"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toEqual(new URL("https://example.org/")); + }); + }); + }); + + describe("when the value violates the first constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_URL = "https://example.com/"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_URL (https://example.com/) is invalid: hostname must be example.org", + ); + }); + }); + }); + + describe("when the value violates the second constraint", () => { + beforeEach(() => { + process.env.AUSTENITE_URL = "http://example.org/"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of AUSTENITE_URL (http://example.org/) is invalid: protocol must be https:", + ); + }); + }); + }); + }); + + describe("when the declaration has the constraints from the README", () => { + beforeEach(() => { + declaration = url("CDN_URL", "CDN to use when serving static assets", { + constraints: [ + { + description: "must not be a local URL", + constrain: (v) => + !v.hostname.endsWith(".local") || "must not be a local URL", + }, + ], + examples: [ + { + value: new URL("https://host.example.org/path/to/resource"), + label: "absolute", + }, + ], + }); + }); + + describe("when the value satisfies the constraints", () => { + beforeEach(() => { + process.env.CDN_URL = "https://host.example.org/path/to/resource"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("returns the value", () => { + expect(declaration.value()).toEqual( + new URL("https://host.example.org/path/to/resource"), + ); + }); + }); + }); + + describe("when the value violates the constraints", () => { + beforeEach(() => { + process.env.CDN_URL = "https://host.local/path/to/resource"; + + initialize({ onInvalid: noop }); + }); + + describe(".value()", () => { + it("throws", () => { + expect(() => { + declaration.value(); + }).toThrow( + "value of CDN_URL (https://host.local/path/to/resource) is invalid: must not be a local URL", + ); + }); + }); + }); + }); }); diff --git a/test/suite/specification.spec.ts b/test/suite/specification.spec.ts index f676c9e..0719115 100644 --- a/test/suite/specification.spec.ts +++ b/test/suite/specification.spec.ts @@ -2,7 +2,6 @@ import { Temporal } from "@js-temporal/polyfill"; import { join } from "path"; import { fileURLToPath } from "url"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerVariable } from "../../src/environment.js"; import { bigInteger, binary, @@ -17,8 +16,6 @@ import { string, url, } from "../../src/index.js"; -import { undefinedValue } from "../../src/maybe.js"; -import { createString } from "../../src/schema.js"; import { MockConsole, createMockConsole } from "../helpers.js"; const fixturesPath = fileURLToPath( @@ -953,15 +950,11 @@ describe("Specification documents", () => { examples: [{ value: Duration.from("PT3S"), label: "example" }], }, ); - registerVariable({ - name: "AUSTENITE_CUSTOM", - description: "custom variable", - default: undefinedValue(), - isSensitive: false, - schema: createString("string", [ + string("AUSTENITE_CUSTOM", "custom variable", { + constraints: [ { description: "must start with a greeting", - constrain: function constrainGreeting(v) { + constrain(v) { if (!v.match(/^(Hi|Hello)\b/)) { return 'must start with "Hi" or "Hello"'; } @@ -969,13 +962,13 @@ describe("Specification documents", () => { }, { description: "must end with a subject", - constrain: function constrainSubject(v) { + constrain(v) { if (!v.match(/\b(world|universe)!$/i)) { return 'must end with "world!" or "universe!"'; } }, }, - ]), + ], examples: [{ value: "Hello, world!", label: "example" }], }); initialize(); diff --git a/test/suite/summary.spec.ts b/test/suite/summary.spec.ts index feb3b42..393df99 100644 --- a/test/suite/summary.spec.ts +++ b/test/suite/summary.spec.ts @@ -2,7 +2,6 @@ import { Temporal } from "@js-temporal/polyfill"; import { join } from "path"; import { fileURLToPath } from "url"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerVariable } from "../../src/environment.js"; import { bigInteger, binary, @@ -17,8 +16,6 @@ import { string, url, } from "../../src/index.js"; -import { undefinedValue } from "../../src/maybe.js"; -import { createString } from "../../src/schema.js"; import { MockConsole, createMockConsole } from "../helpers.js"; const fixturesPath = fileURLToPath( @@ -355,15 +352,11 @@ describe("Validation summary", () => { min: Duration.from("PT3S"), examples: [{ value: Duration.from("PT3S"), label: "example" }], }); - registerVariable({ - name: "AUSTENITE_CUSTOM", - description: "custom variable", - default: undefinedValue(), - isSensitive: false, - schema: createString("string", [ + string("AUSTENITE_CUSTOM", "custom variable", { + constraints: [ { description: "must start with a greeting", - constrain: function constrainGreeting(v) { + constrain(v) { if (!v.match(/^(Hi|Hello)\b/)) { return 'must start with "Hi" or "Hello"'; } @@ -371,13 +364,13 @@ describe("Validation summary", () => { }, { description: "must end with a subject", - constrain: function constrainSubject(v) { + constrain(v) { if (!v.match(/\b(world|universe)!$/i)) { return 'must end with "world!" or "universe!"'; } }, }, - ]), + ], examples: [{ value: "Hello, world!", label: "example" }], });