Repository: cuttle-cards/cuttle (js)

Issue:
[DevEx]: Configure backend unit tests
### Improvement Summary

We should enable backend unit testing that verifies helper functionality and database writes. This requires a test setup that can boot the server, with ability to execute helpers and query the database in test code.

### Detailed Description

The [predecessor repo used mocha](https://github.com/TeasingSisyphus/cuttleV2/blob/main/tests/bootstrap.test.js) to test backend functionality. This was somewhat clumsily implemented, but it had the essential features of being able to boot the server, access backend utilities, and execute requests via test code.

We should find a setup that makes these things possible to test, ideally using `vitest`, which we are already using for configuration tests, or alternatively using mocha and a similar setup to the previous repo.

Regardless, we should use this as a foundation from which to test the helpers to be created in #940, #941 and #943 for managing game states.

Pull request description:
Feat/backend unit tests
<!-- Thanks for contributing to Cuttle! 🎉 -->

## Issue number

Relevant [issue number](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)
- Resolves #975 
Configures Vitest to boot the backend during testing, with direct access to both helpers and sails globals.

## Please check the following

- [ ] Do the tests still pass? (see [Run the Tests](https://github.com/cuttle-cards/cuttle/blob/main/docs/setup-development.md#run-the-tests))
- [ ] Is the code formatted properly? (see [Linting (Formatting)](https://github.com/cuttle-cards/cuttle/blob/main/docs/setup-development.md#linting-formatting))
- For New Features:
  - [ ] Have tests been added to cover any new features or fixes?
  - [ ] Has the documentation been updated accordingly?

## Please describe additional details for testing this change


Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index 22be93cd..d1c56f8d 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,6 +1,23 @@
 const sharedTestRules = {
   'import/no-unresolved': ['off'],
 };
+
+const sailsGlobals = {
+  _: true,
+  sails: true,
+  cardService: true,
+  gameService: true,
+  userService: true,
+  // Models
+  Card: true,
+  Season: true,
+  Match: true,
+  User: true,
+  Game: true,
+  UserSpectatingGame: true,
+  GameStateRow: true,
+};
+
 module.exports = {
   root: true,
   env: {
@@ -94,20 +111,7 @@ module.exports = {
     // Sails specific rules
     {
       files: ['**/api/**/*.{j,t}s?(x)'],
-      globals: {
-        _: true,
-        sails: true,
-        cardService: true,
-        gameService: true,
-        userService: true,
-        //
-        Card: true,
-        Season: true,
-        Match: true,
-        User: true,
-        Game: true,
-        UserSpectatingGame: true,
-      },
+      globals: sailsGlobals,
       rules: {
         'no-undef': 'error',
         'no-prototype-builtins': 'error',
@@ -116,7 +120,10 @@ module.exports = {
     // Vittest specific rules
     {
       files: ['**/tests/unit/**/*.{j,t}s?(x)'],
-      rules: sharedTestRules,
+      globals: sailsGlobals,
+      rules: {
+        ...sharedTestRules,
+      },
     },
     // Cypress specific rules
     {
diff --git a/api/helpers/wipe-database.js b/api/helpers/wipe-database.js
new file mode 100644
index 00000000..eae64e0a
--- /dev/null
+++ b/api/helpers/wipe-database.js
@@ -0,0 +1,28 @@
+module.exports = {
+  friendlyName: 'Wipe all records in the database',
+
+  description: 'Deletes all records. Throws error if not in development or staging mode',
+
+  fn: async (_, exits) => {
+    try {
+      if (!['development', 'staging'].includes(sails.config.environment)) {
+        return exits.error({message: 'Error: This action is only permitted in development or staging'});
+      }
+
+      await Promise.all([
+        Game.destroy({}),
+        User.destroy({}),
+        Card.destroy({}),
+        Season.destroy({}),
+        Match.destroy({}),
+        UserSpectatingGame.destroy({}),
+        GameStateRow.destroy({}),
+      ]);
+
+    } catch (err) {
+      return exits.success(false);
+    }
+
+    return exits.success(true);
+  },
+};
diff --git a/package.json b/package.json
index 642d3359..529eea9b 100644
--- a/package.json
+++ b/package.json
@@ -97,7 +97,7 @@
     "lint:fix": "eslint --fix --ext .js,.vue --ignore-path .gitignore .",
     "storybook": "storybook dev -p 6006",
     "storybook:build": "storybook build",
-    "test:unit": "vitest run",
+    "test:unit": "NODE_ENV=development vitest run",
     "prepare": "husky install",
     "version:patch": "npm version patch",
     "version:minor": "npm version minor",
diff --git a/vite.config.js b/vite.config.js
index cb34c999..9c58e863 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -58,7 +58,11 @@ export default defineConfig(({ mode }) => {
       },
     },
     test: {
-      include: ['**/tests/unit/**/*.{j,t}s?(x)'],
+      include: ['**/tests/unit/specs/**/*.spec.{j,t}s?(x)'],
+      threads: false,
+      globals: true,
+      environment: 'node',
+      setupFiles: ['tests/unit/setup.vitest.js'],
     },
     build: {
       outDir: 'assets',


Test patch:
diff --git a/api/controllers/TestController.js b/api/controllers/TestController.js
index 3ba8f552..37094ea8 100644
--- a/api/controllers/TestController.js
+++ b/api/controllers/TestController.js
@@ -10,15 +10,8 @@ const utc = require('dayjs/plugin/utc');
 dayjs.extend(utc);
 
 module.exports = {
-  wipeDatabase: function (req, res) {
-    return Promise.all([
-      Game.destroy({}),
-      User.destroy({}),
-      Card.destroy({}),
-      Season.destroy({}),
-      Match.destroy({}),
-      UserSpectatingGame.destroy({}),
-    ])
+  wipeDatabase: function (_req, res) {
+    return sails.helpers.wipeDatabase()
       .then(() => {
         return res.ok();
       })
diff --git a/tests/unit/setup.vitest.js b/tests/unit/setup.vitest.js
new file mode 100644
index 00000000..41b61141
--- /dev/null
+++ b/tests/unit/setup.vitest.js
@@ -0,0 +1,12 @@
+import { beforeAll, afterAll } from 'vitest';
+import { bootServer, shutDownServer } from './util/server.util';
+
+let sailsApp;
+
+beforeAll(async () => {
+  sailsApp = await bootServer();
+});
+
+afterAll(async () => {
+  await shutDownServer(sailsApp);
+});
\ No newline at end of file
diff --git a/tests/unit/utils/config-utils.spec.js b/tests/unit/specs/client-config.spec.js
similarity index 91%
rename from tests/unit/utils/config-utils.spec.js
rename to tests/unit/specs/client-config.spec.js
index f72fac48..ebbb40eb 100644
--- a/tests/unit/utils/config-utils.spec.js
+++ b/tests/unit/specs/client-config.spec.js
@@ -1,5 +1,5 @@
+import { beforeEach, describe, it, expect, vi, afterEach } from 'vitest';
 import devtools from '@vue/devtools';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import { version } from '_/package.json';
 import { initCuttleGlobals } from '_/utils/config-utils';
 
@@ -21,6 +21,11 @@ describe('initCuttleGlobals', () => {
     vi.restoreAllMocks();
   });
 
+  it('should execute sails helpers', async () => {
+    const serverIsUp = await sails.helpers.getApiHealth();
+    expect(serverIsUp).toBe(true);
+  });
+
   it('should add version to window.cuttle', () => {
     initCuttleGlobals();
     expect(window.cuttle.version).toEqual(version);
diff --git a/tests/unit/specs/sails-basics.spec.js b/tests/unit/specs/sails-basics.spec.js
new file mode 100644
index 00000000..28d22967
--- /dev/null
+++ b/tests/unit/specs/sails-basics.spec.js
@@ -0,0 +1,22 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+describe('Sails basics', () => {
+
+  beforeEach(async () => {
+    await sails.helpers.wipeDatabase();
+  });
+
+  it('should read from and write to the db', async () => {
+    let users = await User.find();
+    expect(users.length).toBe(0);
+    await User.create({username: 'foo', encryptedPassword: 'xyz123ABCABK7KAL'});
+  
+    users = await User.find({});
+    expect(users.length).toBe(1);
+  });
+  
+  it('should execute sails helpers', async () => {
+    const serverIsUp = await sails.helpers.getApiHealth();
+    expect(serverIsUp).toBe(true);
+  });
+});
diff --git a/tests/unit/test.spec.js b/tests/unit/test.spec.js
deleted file mode 100644
index 8c00b481..00000000
--- a/tests/unit/test.spec.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { it, expect } from 'vitest';
-
-it('should run vitest', () => {
-  expect(true).toBeTruthy();
-});
diff --git a/tests/unit/util/server.util.js b/tests/unit/util/server.util.js
new file mode 100644
index 00000000..3ff964da
--- /dev/null
+++ b/tests/unit/util/server.util.js
@@ -0,0 +1,48 @@
+const Sails = require('sails').constructor;
+
+export function bootServer() {
+  return new Promise((resolve, reject) => {
+
+    const sailsApp = new Sails();
+    sailsApp.lift(
+      {
+        environment: 'development',
+        port: 1337,
+        log: {
+          level: 'error',
+        },
+        hooks: {
+          grunt: false,
+        },
+      },
+      (err, server) => {
+        if (err) {
+          console.log('Sails error on bootwith error');
+          console.log('\n\n', err, '\n\n');
+          return reject(err);
+        }
+
+        return resolve(server);
+      },
+    );
+  });
+}
+
+export function shutDownServer(server) {
+  return new Promise((resolve, reject) => {
+
+    if (!server) {
+      return resolve();
+    }
+
+    server.lower((err) => {
+      if (err) {
+        console.log('\nFailed to lower sails\n');
+        console.log(err, '\n\n');
+        return reject(err);
+      }
+
+      return resolve();
+    });
+  });
+}