diff --git a/.github/workflows/sdk-build-validation.yml b/.github/workflows/sdk-build-validation.yml index d33b3b5bbd..20a1ede63b 100644 --- a/.github/workflows/sdk-build-validation.yml +++ b/.github/workflows/sdk-build-validation.yml @@ -153,7 +153,7 @@ jobs: working-directory: examples/${{ matrix.sdk }} run: | case "${{ matrix.sdk }}" in - web|node) + web) npm install npm run build ;; @@ -200,6 +200,11 @@ jobs: ruby) bundle install ;; + node) + npm install + npm run build + npm run test + ;; dart) dart pub get dart analyze --no-fatal-warnings diff --git a/src/SDK/Language/Node.php b/src/SDK/Language/Node.php index 34518b85d7..fbbbea9246 100644 --- a/src/SDK/Language/Node.php +++ b/src/SDK/Language/Node.php @@ -145,31 +145,61 @@ public function getFiles(): array 'destination' => 'src/models.ts', 'template' => 'web/src/models.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/permission.test.js', + 'template' => 'node/test/permission.test.js.twig', + ], [ 'scope' => 'default', 'destination' => 'src/permission.ts', 'template' => 'web/src/permission.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/role.test.js', + 'template' => 'node/test/role.test.js.twig', + ], [ 'scope' => 'default', 'destination' => 'src/role.ts', 'template' => 'web/src/role.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/id.test.js', + 'template' => 'node/test/id.test.js.twig', + ], [ 'scope' => 'default', 'destination' => 'src/id.ts', 'template' => 'web/src/id.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/query.test.js', + 'template' => 'node/test/query.test.js.twig', + ], [ 'scope' => 'default', 'destination' => 'src/query.ts', 'template' => 'web/src/query.ts.twig', ], + [ + 'scope' => 'default', + 'destination' => 'test/operator.test.js', + 'template' => 'node/test/operator.test.js.twig', + ], [ 'scope' => 'default', 'destination' => 'src/operator.ts', 'template' => 'node/src/operator.ts.twig', ], + [ + 'scope' => 'service', + 'destination' => 'test/services/{{service.name | caseDash}}.test.js', + 'template' => 'node/test/services/service.test.js.twig', + ], [ 'scope' => 'default', 'destination' => 'README.md', @@ -198,7 +228,7 @@ public function getFiles(): array [ 'scope' => 'default', 'destination' => 'tsconfig.json', - 'template' => '/node/tsconfig.json.twig', + 'template' => 'node/tsconfig.json.twig', ], [ 'scope' => 'default', diff --git a/src/SDK/SDK.php b/src/SDK/SDK.php index a537e066f6..028c4a50cd 100644 --- a/src/SDK/SDK.php +++ b/src/SDK/SDK.php @@ -176,7 +176,7 @@ public function __construct(Language $language, Spec $spec) return implode("\n", $value); }, ['is_safe' => ['html']])); $this->twig->addFilter(new TwigFilter('escapeDollarSign', function ($value) { - $value = str_replace('\\', '\\\\', $value); // Escape backslashes first + $value = str_replace('\\', '\\\\', $value ?? ''); // Escape backslashes first $value = str_replace('"', '\\"', $value); // Escape double quotes $value = str_replace('$', '\\$', $value); // Escape dollar signs return $value; diff --git a/templates/node/package.json.twig b/templates/node/package.json.twig index 39f1faa5ea..88b26fb459 100644 --- a/templates/node/package.json.twig +++ b/templates/node/package.json.twig @@ -7,7 +7,8 @@ "main": "dist/index.js", "type": "commonjs", "scripts": { - "build": "tsup" + "build": "tsup", + "test": "jest" }, "exports": { ".": { @@ -46,7 +47,8 @@ "tsup": "7.2.0", "esbuild-plugin-file-path-extensions": "^2.0.0", "tslib": "2.6.2", - "typescript": "5.4.2" + "typescript": "5.4.2", + "jest": "^29.7.0" }, "dependencies": { "json-bigint": "1.0.0", diff --git a/templates/node/test/id.test.js.twig b/templates/node/test/id.test.js.twig new file mode 100644 index 0000000000..0a648bc1b8 --- /dev/null +++ b/templates/node/test/id.test.js.twig @@ -0,0 +1,6 @@ +const { ID } = require("../dist/id"); + +describe("ID", () => { + test('unique', () => expect(ID.unique()).toHaveLength(20)); + test('custom', () => expect(ID.custom('custom')).toEqual('custom')); +}); diff --git a/templates/node/test/operator.test.js.twig b/templates/node/test/operator.test.js.twig new file mode 100644 index 0000000000..408723a318 --- /dev/null +++ b/templates/node/test/operator.test.js.twig @@ -0,0 +1,99 @@ +const { Condition, Operator } = require("../dist/operator"); + +describe('Operator', () => { + test('returns increment', () => { + expect(Operator.increment(1)).toEqual(`{"method":"increment","values":[1]}`); + }); + + test('returns increment with max', () => { + expect(Operator.increment(5, 100)).toEqual(`{"method":"increment","values":[5,100]}`); + }); + + test('returns decrement', () => { + expect(Operator.decrement(1)).toEqual(`{"method":"decrement","values":[1]}`); + }); + + test('returns decrement with min', () => { + expect(Operator.decrement(3, 0)).toEqual(`{"method":"decrement","values":[3,0]}`); + }); + + test('returns multiply', () => { + expect(Operator.multiply(2)).toEqual(`{"method":"multiply","values":[2]}`); + }); + + test('returns multiply with max', () => { + expect(Operator.multiply(3, 1000)).toEqual(`{"method":"multiply","values":[3,1000]}`); + }); + + test('returns divide', () => { + expect(Operator.divide(2)).toEqual(`{"method":"divide","values":[2]}`); + }); + + test('returns divide with min', () => { + expect(Operator.divide(4, 1)).toEqual(`{"method":"divide","values":[4,1]}`); + }); + + test('returns modulo', () => { + expect(Operator.modulo(5)).toEqual(`{"method":"modulo","values":[5]}`); + }); + + test('returns power', () => { + expect(Operator.power(2)).toEqual(`{"method":"power","values":[2]}`); + }); + + test('returns arrayAppend', () => { + expect(Operator.arrayAppend(['item1', 'item2'])).toEqual('{"method":"arrayAppend","values":["item1","item2"]}'); + }); + + test('returns arrayPrepend', () => { + expect(Operator.arrayPrepend(['first', 'second'])).toEqual('{"method":"arrayPrepend","values":["first","second"]}'); + }); + + test('returns arrayInsert', () => { + expect(Operator.arrayInsert(0, 'newItem')).toEqual('{"method":"arrayInsert","values":[0,"newItem"]}'); + }); + + test('returns arrayRemove', () => { + expect(Operator.arrayRemove('oldItem')).toEqual('{"method":"arrayRemove","values":["oldItem"]}'); + }); + + test('returns arrayUnique', () => { + expect(Operator.arrayUnique()).toEqual('{"method":"arrayUnique","values":[]}'); + }); + + test('returns arrayIntersect', () => { + expect(Operator.arrayIntersect(['a', 'b', 'c'])).toEqual('{"method":"arrayIntersect","values":["a","b","c"]}'); + }); + + test('returns arrayDiff', () => { + expect(Operator.arrayDiff(['x', 'y'])).toEqual('{"method":"arrayDiff","values":["x","y"]}'); + }); + + test('returns arrayFilter', () => { + expect(Operator.arrayFilter(Condition.Equal, 'test')).toEqual('{"method":"arrayFilter","values":["equal","test"]}'); + }); + + test('returns stringConcat', () => { + expect(Operator.stringConcat('suffix')).toEqual('{"method":"stringConcat","values":["suffix"]}'); + }); + + test('returns stringReplace', () => { + expect(Operator.stringReplace('old', 'new')).toEqual('{"method":"stringReplace","values":["old","new"]}'); + }); + + test('returns toggle', () => { + expect(Operator.toggle()).toEqual('{"method":"toggle","values":[]}'); + }); + + test('returns dateAddDays', () => { + expect(Operator.dateAddDays(7)).toEqual('{"method":"dateAddDays","values":[7]}'); + }); + + test('returns dateSubDays', () => { + expect(Operator.dateSubDays(7)).toEqual('{"method":"dateSubDays","values":[7]}'); + }); + + test('returns dateSetNow', () => { + expect(Operator.dateSetNow()).toEqual('{"method":"dateSetNow","values":[]}'); + }); +}); diff --git a/templates/node/test/permission.test.js.twig b/templates/node/test/permission.test.js.twig new file mode 100644 index 0000000000..7f972d3b2d --- /dev/null +++ b/templates/node/test/permission.test.js.twig @@ -0,0 +1,10 @@ +const { Permission } = require("../dist/permission"); +const { Role } = require("../dist/role"); + +describe('Permission', () => { + test('read', () => expect(Permission.read(Role.any())).toEqual('read("any")')); + test('write', () => expect(Permission.write(Role.any())).toEqual('write("any")')); + test('create', () => expect(Permission.create(Role.any())).toEqual('create("any")')); + test('update', () => expect(Permission.update(Role.any())).toEqual('update("any")')); + test('delete', () => expect(Permission.delete(Role.any())).toEqual('delete("any")')); +}) diff --git a/templates/node/test/query.test.js.twig b/templates/node/test/query.test.js.twig new file mode 100644 index 0000000000..e539a1f606 --- /dev/null +++ b/templates/node/test/query.test.js.twig @@ -0,0 +1,155 @@ +const { Query } = require("../dist/query"); + +const tests = [ + { + description: 'with a string', + value: 's', + expectedValues: '["s"]' + }, + { + description: 'with a integer', + value: 1, + expectedValues: '[1]' + }, + { + description: 'with a double', + value: 1.2, + expectedValues: '[1.2]' + }, + { + description: 'with a whole number double', + value: 1.0, + expectedValues: '[1]' + }, + { + description: 'with a bool', + value: false, + expectedValues: '[false]' + }, + { + description: 'with a list', + value: ['a', 'b', 'c'], + expectedValues: '["a","b","c"]' + } +]; + +describe('Query', () => { + describe('basic filter equal', () => { + for (const t of tests) { + test(t.description, () => + expect(Query.equal("attr", t.value)) + .toEqual(`{"method":"equal","attribute":"attr","values":${t.expectedValues}}`) + ) + } + }) + + describe('basic filter notEqual', () => { + for (const t of tests) { + test(t.description, () => + expect(Query.notEqual("attr", t.value)) + .toEqual(`{"method":"notEqual","attribute":"attr","values":${t.expectedValues}}`) + ) + } + }); + + describe('basic filter lessThan', () => { + for (const t of tests) { + test(t.description, () => + expect(Query.lessThan("attr", t.value)) + .toEqual(`{"method":"lessThan","attribute":"attr","values":${t.expectedValues}}`) + ) + } + }); + + describe('basic filter lessThanEqual', () => { + for (const t of tests) { + test(t.description, () => + expect(Query.lessThanEqual("attr", t.value)) + .toEqual(`{"method":"lessThanEqual","attribute":"attr","values":${t.expectedValues}}`) + ) + } + }); + + describe('basic filter greaterThan', () => { + for (const t of tests) { + test(t.description, () => + expect(Query.greaterThan("attr", t.value)) + .toEqual(`{"method":"greaterThan","attribute":"attr","values":${t.expectedValues}}`) + ) + } + }); + + describe('basic filter greaterThanEqual', () => { + for (const t of tests) { + test(t.description, () => + expect(Query.greaterThanEqual("attr", t.value)) + .toEqual(`{"method":"greaterThanEqual","attribute":"attr","values":${t.expectedValues}}`) + ) + } + }); + + test('search', () => + expect(Query.search('attr', 'keyword1 keyword2')) + .toEqual(`{"method":"search","attribute":"attr","values":["keyword1 keyword2"]}`) + ); + + test('isNull', () => + expect(Query.isNull('attr')) + .toEqual(`{"method":"isNull","attribute":"attr"}`) + ); + + test('isNotNull', () => + expect(Query.isNotNull('attr')) + .toEqual(`{"method":"isNotNull","attribute":"attr"}`) + ); + + describe('between', () => { + test('with integers', () => + expect(Query.between('attr', 1, 2)) + .toEqual(`{"method":"between","attribute":"attr","values":[1,2]}`) + ); + test('with doubles', () => + expect(Query.between('attr', 1.2, 2.2)) + .toEqual(`{"method":"between","attribute":"attr","values":[1.2,2.2]}`) + ); + test('with strings', () => + expect(Query.between('attr',"a","z")) + .toEqual(`{"method":"between","attribute":"attr","values":["a","z"]}`) + ); + }); + + test('select', () => + expect(Query.select(['attr1', 'attr2'])) + .toEqual(`{"method":"select","values":["attr1","attr2"]}`) + ); + + test('orderAsc', () => + expect(Query.orderAsc('attr')) + .toEqual(`{"method":"orderAsc","attribute":"attr"}`) + ); + + test('orderDesc', () => + expect(Query.orderDesc('attr')) + .toEqual(`{"method":"orderDesc","attribute":"attr"}`) + ); + + test('cursorBefore', () => + expect(Query.cursorBefore('attr')) + .toEqual('{"method":"cursorBefore","values":["attr"]}') + ); + + test('cursorAfter', () => + expect(Query.cursorAfter('attr')) + .toEqual('{"method":"cursorAfter","values":["attr"]}') + ); + + test('limit', () => + expect(Query.limit(1)) + .toEqual('{"method":"limit","values":[1]}') + ); + + test('offset', () => + expect(Query.offset(1)) + .toEqual('{"method":"offset","values":[1]}') + ); +}) diff --git a/templates/node/test/role.test.js.twig b/templates/node/test/role.test.js.twig new file mode 100644 index 0000000000..8f7420bda5 --- /dev/null +++ b/templates/node/test/role.test.js.twig @@ -0,0 +1,14 @@ +const { Role } = require("../dist/role"); + +describe('Role', () => { + test('any', () => expect(Role.any()).toEqual('any')); + test('user without status', () => expect(Role.user('custom')).toEqual('user:custom')); + test('user with status', () => expect(Role.user('custom', 'verified')).toEqual('user:custom/verified')); + test('users without status', () => expect(Role.users()).toEqual('users')); + test('users with status', () => expect(Role.users('verified')).toEqual('users/verified')); + test('guests', () => expect(Role.guests()).toEqual('guests')); + test('team without role', () => expect(Role.team('custom')).toEqual('team:custom')) + test('team with role', () => expect(Role.team('custom', 'owner')).toEqual('team:custom/owner')) + test('member', () => expect(Role.member('custom')).toEqual('member:custom')) + test('label', () => expect(Role.label('admin')).toEqual('label:admin')) +}) diff --git a/templates/node/test/services/service.test.js.twig b/templates/node/test/services/service.test.js.twig new file mode 100644 index 0000000000..fd842cd90b --- /dev/null +++ b/templates/node/test/services/service.test.js.twig @@ -0,0 +1,42 @@ +const { Client } = require("../../dist/client"); +const { InputFile } = require("../../dist/inputFile"); +const { {{ service.name | caseUcfirst }} } = require("../../dist/services/{{ service.name | caseKebab }}"); + +const { fetch: mockedFetch, Response } = require("node-fetch-native-with-agent"); +jest.mock('node-fetch-native-with-agent', () => ({ ...jest.requireActual('node-fetch-native-with-agent'), fetch: jest.fn() })); + +describe('{{ service.name | caseUcfirst }}', () => { + const client = new Client(); + const {{ service.name | caseCamel }} = new {{ service.name | caseUcfirst }}(client); + + {% for method in service.methods ~%} + test('test method {{ method.name | caseCamel }}()', async () => { + {%~ if method.type == 'webAuth' %} + const data = 'https://example.com/'; + mockedFetch.mockImplementation(() => Response.redirect(data)); + {%~ elseif method.type == 'location' %} + const data = new ArrayBuffer(0); + mockedFetch.mockImplementation(() => new Response(data)); + {%~ else %} + {%- if method.responseModel and method.responseModel != 'any' %} + const data = { + {%- for definition in spec.definitions ~%}{%~ if definition.name == method.responseModel -%}{%~ for property in definition.properties | filter((param) => param.required) ~%} + '{{ property.name | escapeDollarSign }}': {% if property.type == 'object' %}{}{% elseif property.type == 'array' %}[]{% elseif property.type == 'string' %}'{{ property.example | escapeDollarSign }}'{% elseif property.type == 'boolean' %}true{% else %}{{ property.example }}{% endif %},{%~ endfor ~%}{% set break = true %}{%- else -%}{% set continue = true %}{%- endif -%}{%~ endfor -%} + }; + {%~ else %} + const data = {message: ""}; + {%~ endif %} + mockedFetch.mockImplementation(() => Response.json(data)); + {%~ endif %} + + const response = await {{ service.name | caseCamel }}.{{ method.name | caseCamel }}({%~ for parameter in method.parameters.all | filter((param) => param.required) ~%} + {% if parameter.type == 'object' %}{}{% elseif parameter.type == 'array' %}[]{% elseif parameter.type == 'file' %}InputFile.fromBuffer(new Uint8Array(0), 'image.png'){% elseif parameter.type == 'boolean' %}true{% elseif parameter.type == 'string' %}'{% if parameter.example is not empty %}{{ parameter.example | escapeDollarSign }}{% endif %}'{% elseif parameter.type == 'integer' and parameter['x-example'] is empty %}1{% elseif parameter.type == 'number' and parameter['x-example'] is empty %}1.0{% else %}{{ parameter.example }}{%~ endif ~%},{%~ endfor ~%} + ); + + // Remove custom toString method on the objects to allow for clean data comparison. + delete response.toString; + + expect(response).toEqual(data); + }); + {% endfor %} +})