Skip to content

Commit

Permalink
fix: JSDoc null values being interpreted as strings, and added test…
Browse files Browse the repository at this point in the history
… README (#1377)
  • Loading branch information
adam-coster committed Sep 5, 2022
1 parent 465f450 commit 277b89a
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 8 deletions.
9 changes: 6 additions & 3 deletions src/AnnotationsReader/BasicAnnotationsReader.ts
Expand Up @@ -93,10 +93,13 @@ export class BasicAnnotationsReader implements AnnotationsReader {

if (isTextTag) {
return text;
} else if (BasicAnnotationsReader.jsonTags.has(jsDocTag.name)) {
return this.parseJson(text) ?? text;
}
let parsed = this.parseJson(text);
parsed = parsed === undefined ? text : parsed;
if (BasicAnnotationsReader.jsonTags.has(jsDocTag.name)) {
return parsed;
} else if (this.extraTags?.has(jsDocTag.name)) {
return this.parseJson(text) ?? text;
return parsed;
} else {
// Unknown jsDoc tag.
return undefined;
Expand Down
13 changes: 13 additions & 0 deletions test/README.md
@@ -0,0 +1,13 @@
# Testing

## Schema Generation

To add/update a test case for generating a valid schema from a Typescript file:

- Look in `test/valid-data` for a sample related to your change. If you don't find one, create your own following the naming convention. For example, when adding the new sample `annotation-default`:
- Create folder `test/valid-data/annotation-default`
- Add `main.ts` to that folder with the type sample
- Update the corresponding `main.ts` file with your changes.
- Run `yarn test:update` to compile the JSON schema
- Add a test to `test/valid-data-annotations.test.ts`, matching a similar pattern to the existing tests.
- Run tests via `yarn jest test/valid-data-annotations.test.ts` (this only runs the subset of tests related to schema validation)
57 changes: 52 additions & 5 deletions test/utils.ts
@@ -1,8 +1,8 @@
import Ajv from "ajv";
import Ajv, { Options as AjvOptions } from "ajv";
import addFormats from "ajv-formats";
import { readFileSync, writeFileSync } from "fs";
import stringify from "safe-stable-stringify";
import { resolve } from "path";
import stringify from "safe-stable-stringify";
import ts from "typescript";
import { createFormatter } from "../factory/formatter";
import { createParser } from "../factory/parser";
Expand All @@ -25,7 +25,29 @@ export function assertValidSchema(
type?: string,
jsDoc: Config["jsDoc"] = "none",
extraTags?: Config["extraTags"],
schemaId?: Config["schemaId"]
schemaId?: Config["schemaId"],
options?: {
/**
* Array of sample data
* that should
* successfully validate.
*/
validSamples?: any[];
/**
* Array of sample data
* that should
* fail to validate.
*/
invalidSamples?: any[];
/**
* Options to pass to Ajv
* when creating the Ajv
* instance.
*
* @default {strict:false}
*/
ajvOptions?: AjvOptions;
}
) {
return (): void => {
const config: Config = {
Expand Down Expand Up @@ -56,12 +78,37 @@ export function assertValidSchema(

let localValidator = validator;
if (extraTags) {
localValidator = new Ajv({ strict: false });
localValidator = new Ajv(options?.ajvOptions || { strict: false });
addFormats(localValidator);
}

localValidator.validateSchema(actual);
expect(localValidator.errors).toBeNull();
localValidator.compile(actual); // Will find MissingRef errors

// Compile in all cases to detect MissingRef errors
const validate = localValidator.compile(actual);

// Use the compiled validator if there
// are any samples.
if (options?.invalidSamples) {
for (const sample of options.invalidSamples) {
const isValid = validate(sample);
if (isValid) {
console.log("Unexpectedly Valid:", sample);
}
expect(isValid).toBe(false);
}
}
if (options?.validSamples) {
for (const sample of options.validSamples) {
const isValid = validate(sample);
if (!isValid) {
console.log("Unexpectedly Invalid:", sample);

console.log("AJV Errors:", validate.errors);
}
expect(isValid).toBe(true);
}
}
};
}
29 changes: 29 additions & 0 deletions test/valid-data-annotations.test.ts
@@ -1,4 +1,5 @@
import { assertValidSchema } from "./utils";
import * as annotationDefaultSamples from "./valid-data/annotation-default/samples";

describe("valid-data-annotations", () => {
it(
Expand Down Expand Up @@ -33,6 +34,34 @@ describe("valid-data-annotations", () => {

it("annotation-comment", assertValidSchema("annotation-comment", "MyObject", "extended"));

it("annotation-default", function () {
// Without actually using the defaults.
assertValidSchema("annotation-default", "MyObject", "extended", [], undefined, {
validSamples: annotationDefaultSamples.validSamples,
invalidSamples: annotationDefaultSamples.invalidSamplesUnlessDefaults,
})();

// Having AJV use the defaults.

// Since AJV will mutate, make
// shallow copies.
const validWithDefaults = annotationDefaultSamples.invalidSamplesUnlessDefaults.map((sample) => ({
...sample,
}));

assertValidSchema("annotation-default", "MyObject", "extended", [], undefined, {
validSamples: validWithDefaults,
ajvOptions: { useDefaults: true },
})();

// The previously-invalid samples
// should now match the expected
// structure when all defaults are applied.
validWithDefaults.forEach((sample) => {
expect(sample).toEqual(annotationDefaultSamples.expectedAfterDefaults);
});
});

it("annotation-example", assertValidSchema("annotation-example", "MyObject", "extended"));

it("annotation-id", assertValidSchema("annotation-id", "MyObject", "extended", [], "Test"));
Expand Down
31 changes: 31 additions & 0 deletions test/valid-data/annotation-default/main.ts
@@ -0,0 +1,31 @@
export interface MyObject {
/**
* @default {extra: {field:"value"}}
*/
nestedField: MyNestedObject;
/**
* @default 10
*/
numberField: number;
/**
* @default "hello"
*/
stringField: string;
/**
* @default true
*/
booleanField?: boolean;
/**
* @default null
*/
nullField: null;
/**
* @default [{ numberField2: 10, stringField2: "yes", anyField: null }]
*/
arrayField: Array<{ numberField2: number; stringField2: string; anyField?: any }>;
}

/**
* @default {}
*/
export type MyNestedObject = Record<string, any>;
48 changes: 48 additions & 0 deletions test/valid-data/annotation-default/samples.ts
@@ -0,0 +1,48 @@
import type { MyObject } from "./main.js";

export const validSamples: MyObject[] = [
{
nullField: null,
numberField: 100,
stringField: "goodbye",
arrayField: [],
booleanField: false,
nestedField: {},
},
];

/**
* Samples that should be *invalid* if
* AJV is not using `useDefaults: true`,
* and otherwise valid. The resulting
* mutated object should be the same
* in all cases.
*/
export const invalidSamplesUnlessDefaults: any[] = [
{
nullField: null,
numberField: 10,
stringField: "hello",
},
{},
];

/**
* The resulting data structure after
* `useDefaults` is used with the
* {@link invalidSamples} entries.
*
* We aren't testing AJV's behavior here.
* We're assuming that if AJV populates
* defaults, and those defaults are of
* the expected values, then this project
* must be working correctly.
*/
export const expectedAfterDefaults: MyObject = {
nullField: null,
numberField: 10,
stringField: "hello",
arrayField: [{ numberField2: 10, stringField2: "yes", anyField: null }],
booleanField: true,
nestedField: { extra: { field: "value" } },
};
74 changes: 74 additions & 0 deletions test/valid-data/annotation-default/schema.json
@@ -0,0 +1,74 @@
{
"$ref": "#/definitions/MyObject",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"MyNestedObject": {
"default": {},
"type": "object"
},
"MyObject": {
"additionalProperties": false,
"properties": {
"arrayField": {
"default": [
{
"anyField": null,
"numberField2": 10,
"stringField2": "yes"
}
],
"items": {
"additionalProperties": false,
"properties": {
"anyField": {},
"numberField2": {
"type": "number"
},
"stringField2": {
"type": "string"
}
},
"required": [
"numberField2",
"stringField2"
],
"type": "object"
},
"type": "array"
},
"booleanField": {
"default": true,
"type": "boolean"
},
"nestedField": {
"$ref": "#/definitions/MyNestedObject",
"default": {
"extra": {
"field": "value"
}
}
},
"nullField": {
"default": null,
"type": "null"
},
"numberField": {
"default": 10,
"type": "number"
},
"stringField": {
"default": "hello",
"type": "string"
}
},
"required": [
"nestedField",
"numberField",
"stringField",
"nullField",
"arrayField"
],
"type": "object"
}
}
}

0 comments on commit 277b89a

Please sign in to comment.