Browse Source

add better-auth, testing, colada delay plugin

Jason Gorst 5 days ago
parent
commit
1dff58ae17

+ 20 - 0
app/middleware/admin.js

@@ -0,0 +1,20 @@
+export default defineNuxtRouteMiddleware(async () => {
+  const nuxtApp = useNuxtApp()
+  const { fetchSession, user } = useAuthClient()
+  await fetchSession()
+
+  if (user.value.role !== "admin") {
+    return nuxtApp.runWithContext(() => {
+      const toast = useToast()
+
+      toast.add({
+        severity: "danger",
+        summary: "Not An Admin.",
+        detail: "Only an admin can access that.",
+        life: 3000
+      })
+
+      return abortNavigation()
+    })
+  }
+})

+ 34 - 3
nuxt.config.js

@@ -1,11 +1,40 @@
 import tailwindcss from "@tailwindcss/vite"
+import { fileURLToPath } from "node:url"
 
 // noinspection JSUnresolvedReference
 export default defineNuxtConfig({
-  compatibilityDate: "2026-03-13",
+  app: {
+    head: {
+      link: [
+        { rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
+        { rel: "icon", href: "/theater-masks-solid.svg" },
+        { rel: "manifest", href: "/manifest.json" },
+        { rel: "mask-icon", href: "/theater-masks-solid.svg", color: "#9fe88d" }
+      ],
+
+      meta: [
+        { charset: "UTF-8" },
+        { name: "theme-color", content: "#9fe88d" },
+        { name: "viewport", content: "width=device-width, initial-scale=1.0" }
+      ],
+
+      title: "Dramatis Personae"
+    }
+  },
+
+  alias: {
+    test: fileURLToPath(new URL("./test", import.meta.url)),
+  },
+
+  compatibilityDate: "2026-04-13",
   css: ["~/assets/css/main.css"],
   // devServer: { port: 3000 },
-  devtools: { enabled: true, timeline: { enabled: true } },
+
+  devtools: {
+    enabled: true,
+    timeline: { enabled: true }
+  },
+
   experimental: { asyncContext: true },
   // future: { compatibilityVersion: 5 },
 
@@ -17,7 +46,8 @@ export default defineNuxtConfig({
     "@nuxt/icon",
     "@pinia/nuxt",
     "@pinia/colada-nuxt",
-    "@primevue/nuxt-module"
+    "@primevue/nuxt-module",
+    ...(process.env.NODE_ENV === "test" ? ["@nuxt/test-utils/module"] : [])
   ],
 
   nitro: {
@@ -45,6 +75,7 @@ export default defineNuxtConfig({
     optimizeDeps: {
       include: [
         "@pinia/colada-devtools",
+        "@pinia/colada-plugin-delay",
         "@primevue/core/api",
         "@primeuix/forms/resolvers/zod",
         "better-auth/client",

+ 28 - 19
package.json

@@ -12,54 +12,63 @@
     "postprisma:generate": "nuxt prepare",
     "prepare": "nuxt prepare",
     "preview": "nuxt preview -p 4001",
+    "auth:generate": "pnpx auth@latest generate --config ./server/utils/auth.js --output ./prisma/auth.prisma --yes",
     "prisma:generate": "prisma generate",
-    "prisma:push": "prisma db push"
+    "prisma:push": "prisma db push",
+    "test": "vitest"
   },
-  "packageManager": "pnpm@10.33.0",
+  "packageManager": "pnpm@10.33.2",
   "dependencies": {
     "nuxt": "^4.4.2",
-    "vue": "^3.5.32"
+    "vue": "^3.5.33"
   },
   "devDependencies": {
     "@iconify-json/fa6-solid": "^1.2.4",
     "@iconify-json/ph": "^1.2.2",
-    "@nuxt/devtools": "^3.2.4",
+    "@nuxt/devtools": "3.2.3",
     "@nuxt/icon": "^2.2.1",
     "@nuxt/kit": "^4.4.2",
-    "@pinia/colada": "^1.1.0",
-    "@pinia/colada-devtools": "^0.4.5",
-    "@pinia/colada-nuxt": "^0.3.2",
+    "@nuxt/test-utils": "^4.0.2",
+    "@pinia/colada-devtools": "^1.0.0",
+    "@pinia/colada-nuxt": "^1.0.0",
+    "@pinia/colada-plugin-delay": "^0.2.1",
     "@pinia/nuxt": "^0.11.3",
+    "@playwright/test": "^1.59.1",
     "@primeuix/forms": "^0.1.0",
     "@primevue/core": "^4.5.5",
     "@primevue/nuxt-module": "^4.5.5",
-    "@prisma/adapter-pg": "^7.7.0",
-    "@prisma/client": "^7.7.0",
+    "@prisma/adapter-pg": "^7.8.0",
+    "@prisma/client": "^7.8.0",
     "@tailwindcss/typography": "^0.5.19",
-    "@tailwindcss/vite": "^4.2.2",
-    "better-auth": "^1.6.2",
+    "@tailwindcss/vite": "^4.2.4",
+    "@vue/test-utils": "^2.4.8",
+    "better-auth": "^1.6.9",
     "defu": "^6.1.7",
-    "dotenv": "^17.4.1",
+    "dotenv": "^17.4.2",
     "engine.io": "^6.6.6",
+    "happy-dom": "^20.9.0",
     "html-to-text": "^9.0.5",
     "lodash-es": "^4.18.1",
-    "pinia": "^3.0.4",
-    "pinia-colada-plugin-normalizer": "^0.1.7",
-    "prettier": "^3.8.2",
+    "pinia-colada-plugin-normalizer": "^0.1.8",
+    "playwright-core": "^1.59.1",
+    "postmark": "^4.0.7",
+    "prettier": "^3.8.3",
     "prettier-plugin-classnames": "^0.10.1",
     "prettier-plugin-merge": "^0.10.1",
     "prettier-plugin-prisma": "^5.0.0",
-    "prettier-plugin-tailwindcss": "^0.7.2",
+    "prettier-plugin-tailwindcss": "^0.7.3",
     "primevue": "^4.5.5",
-    "prisma": "^7.7.0",
+    "prisma": "^7.8.0",
     "socket.io": "^4.8.3",
     "socket.io-client": "^4.8.3",
     "tailwind-merge": "^3.5.0",
-    "tailwindcss": "^4.2.2",
+    "tailwindcss": "^4.2.4",
     "tailwindcss-primeui": "^0.6.1",
     "trix": "^2.1.18",
     "tsx": "^4.21.0",
-    "vite": "^7.3.1",
+    "uuid": "^14.0.0",
+    "vite": "^7.3.2",
+    "vitest": "^4.1.5",
     "zod": "^4.3.6"
   }
 }

+ 24 - 0
playwright.config.js

@@ -0,0 +1,24 @@
+import { fileURLToPath } from "node:url"
+import { defineConfig, devices } from "@playwright/test"
+
+const devicesToTest = ["Desktop Chrome"]
+
+export default defineConfig({
+  testDir: "./test/e2e",
+  /* Run tests in files in parallel */
+  fullyParallel: true,
+  retries: 0,
+  workers: undefined,
+  timeout: undefined,
+  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+  reporter: "html",
+  use: {
+    trace: "on-first-retry",
+    nuxt: {
+      rootDir: fileURLToPath(new URL(".", import.meta.url))
+    }
+  },
+  projects: devicesToTest.map((p) =>
+    typeof p === "string" ? { name: p, use: devices[p] } : p
+  )
+})

File diff suppressed because it is too large
+ 279 - 381
pnpm-lock.yaml


+ 6 - 5
server/plugins/socketio.js

@@ -1,8 +1,8 @@
-// noinspection JSAccessibilityCheck,JSUndefinedPropertyAssignment,JSUnresolvedReference
+// noinspection JSAccessibilityCheck,JSUnresolvedReference
 
 import { Server as Engine } from "engine.io"
 import { Server } from "socket.io"
-// import * as middlewares from "../socketio/middlewares"
+import * as middlewares from "../socketio/middlewares"
 import * as handlers from "../socketio/handlers"
 
 const LOG_CONNECTIONS = false
@@ -23,9 +23,10 @@ export default defineNitroPlugin((nitroApp) => {
   io.bind(engine)
 
   // register middlewares
-  // _forOwn(middlewares, (middleware) => io.use(middleware))
+  _forOwn(middlewares, (middleware) => io.use(middleware))
 
   // expose server instance
+  // noinspection JSUndefinedPropertyAssignment
   nitroApp.hooks.hook("request", (event) => (event.context.io = io))
 
   io.on("connection", async (socket) => {
@@ -37,7 +38,7 @@ export default defineNitroPlugin((nitroApp) => {
       console.log(
         "[server socketio] [connection]",
         socket.id,
-        // socket.data.user?.username ?? "unauthenticated"
+        socket.data.user?.username ?? "unauthenticated"
       )
 
       console.log(
@@ -50,7 +51,7 @@ export default defineNitroPlugin((nitroApp) => {
           "[server socketio] [disconnect]",
           reason,
           socket.id,
-          // socket.data.user?.username ?? "unauthenticated"
+          socket.data.user?.username ?? "unauthenticated"
         )
       })
     }

+ 1 - 0
server/socketio/middlewares/authMiddleware.js

@@ -1,4 +1,5 @@
 export async function authMiddleware(socket, next) {
+  // noinspection JSUnresolvedReference
   const session = await auth.api.getSession({
     headers: socket.handshake.headers
   })

+ 3 - 1
server/utils/AuthError.js

@@ -1,4 +1,4 @@
-export default class AuthError extends Error {
+class AuthError extends Error {
   constructor(message, options) {
     super(message, options)
     this.name = "AuthError"
@@ -6,3 +6,5 @@ export default class AuthError extends Error {
     this.permissions = options?.permissions
   }
 }
+
+export default AuthError

+ 25 - 0
server/utils/authorize.js

@@ -0,0 +1,25 @@
+export default async function authorize(user, resource, permissions) {
+  if (_isNil(user)) {
+    throw new AuthError(`You must be signed in to modify ${resource}s.`, {
+      resource,
+      permissions
+    })
+  }
+
+  permissions = _flatten([permissions])
+
+  // noinspection JSUnresolvedReference
+  const result = await auth.api.userHasPermission({
+    body: {
+      userId: user.id,
+      permissions: { [resource]: permissions }
+    }
+  })
+
+  if (!result.success) {
+    throw new AuthError(
+      `${user.username} isn't allowed to modify ${resource}s.`,
+      { resource, permissions }
+    )
+  }
+}

+ 28 - 0
test/mocks/components/Icon.mock.vue

@@ -0,0 +1,28 @@
+<template>
+  <div data-testid="icon">
+    {{ name }}
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  name: {
+    type: String,
+    required: true
+  },
+
+  size: {
+    type: String,
+    required: false,
+    default: "1em"
+  },
+
+  mode: {
+    type: String,
+    required: false,
+    default: "svg"
+  }
+})
+</script>
+
+<style scoped></style>

+ 41 - 0
test/mocks/composables/useAuthClient.mock.js

@@ -0,0 +1,41 @@
+import { mockNuxtImport } from "@nuxt/test-utils/runtime"
+import { NIL as NIL_UUID } from "uuid"
+
+function inYearsFromNow(years) {
+  const now = new Date()
+  const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000
+
+  return new Date().setTime(now.getTime() + years * YEAR_IN_MS)
+}
+
+const user = {
+  name: "Test User",
+  email: "test@example.com",
+  role: "user",
+  username: "test",
+  id: NIL_UUID
+}
+
+const session = {
+  token: "0".repeat(32),
+  userId: user.id,
+  id: NIL_UUID,
+  createdAt: now,
+  updatedAt: now,
+  expiresAt: inYearsFromNow(20)
+}
+
+mockNuxtImport("useAuthClient", () => {
+  return () => {
+    return {
+      user: ref(user),
+      session: ref(session),
+      isSignedIn: ref(true),
+      signIn: () => null,
+      signUp: () => null,
+      signOut: () => null,
+      fetchSession: () => ({ session, user }),
+      authClient: {}
+    }
+  }
+})

+ 14 - 0
test/nuxt/app/components/ToastContainer.test.js

@@ -0,0 +1,14 @@
+import { mockComponent, mountSuspended } from "@nuxt/test-utils/runtime"
+import ToastContainer from "~/components/ToastContainer.vue"
+
+mockComponent("Icon", () => import("test/mocks/components/Icon.mock.vue"))
+
+let wrapper
+
+beforeEach(() => {
+  wrapper = mountSuspended(ToastContainer)
+})
+
+test("should render", () => {
+  expect(wrapper).toBeDefined
+})

+ 263 - 0
test/nuxt/app/stores/useFiltersStore.test.js

@@ -0,0 +1,263 @@
+import { setActivePinia, createPinia } from "pinia"
+import { FilterMatchMode } from "@primevue/core/api"
+
+let filtersStore
+
+beforeEach(() => {
+  setActivePinia(createPinia())
+  filtersStore = useFiltersStore()
+})
+
+describe("initializes state", () => {
+  describe("inits emptyFilters", () => {
+    it("has the correct shape", () => {
+      expect(_keys(filtersStore.emptyFilters)).toEqual([
+        "global",
+        ..._keys(nameFields),
+        ..._keys(optionsFields)
+      ])
+    })
+
+    it("has correct defaults for string fields", () => {
+      expect(
+        _every(
+          _values(
+            _pick(filtersStore.emptyFilters, ["global", ..._keys(nameFields)])
+          ),
+          { value: "", matchMode: FilterMatchMode.CONTAINS }
+        )
+      ).toBe(true)
+    })
+
+    it("has correct defaults for array fields", () => {
+      expect(
+        _every(
+          _values(_pick(filtersStore.emptyFilters, _keys(optionsFields))),
+          { value: [], matchMode: FilterMatchMode.IN }
+        )
+      ).toBe(true)
+    })
+  })
+
+  it("inits filters from emptyFilters", () => {
+    expect(filtersStore.filters).toEqual(filtersStore.emptyFilters)
+  })
+
+  it("inits showFilters", () => {
+    expect(filtersStore.showFilters).toBe(false)
+  })
+})
+
+describe("getters", () => {
+  describe("hasFilterByField", () => {
+    it("has the correct shape", () => {
+      expect(_keys(filtersStore.hasFilterByField)).toEqual(
+        _keys(filtersStore.filters)
+      )
+    })
+
+    it("returns true for non-empty string filter value", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasFilterByField.global).toBe(true)
+    })
+
+    it("returns true for non-empty array filter value", () => {
+      filtersStore.filters.player.value = ["foo"]
+      expect(filtersStore.hasFilterByField.player).toBe(true)
+    })
+
+    it("returns true for non-default matchMode", () => {
+      filtersStore.filters.faeName.matchMode = FilterMatchMode.STARTS_WITH
+      expect(filtersStore.hasFilterByField.faeName).toBe(true)
+    })
+  })
+})
+
+describe("actions", () => {
+  describe("hasAnyColumnFilters", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasAnyColumnFilters()).toBe(false)
+    })
+
+    it("returns true for non-empty column filter", () => {
+      filtersStore.filters.faeName.value = "foo"
+      expect(filtersStore.hasAnyColumnFilters()).toBe(true)
+    })
+  })
+
+  describe("hasAnyCategoryFilters", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasAnyCategoryFilters()).toBe(false)
+    })
+
+    it("returns true for non-empty column filter", () => {
+      filtersStore.filters.player.value = ["foo"]
+      expect(filtersStore.hasAnyCategoryFilters()).toBe(true)
+    })
+  })
+
+  describe("hasAnyFilters", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasAnyFilters()).toBe(false)
+    })
+
+    it("returns true for non-empty filter", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasAnyFilters()).toBe(true)
+    })
+  })
+
+  describe("hasAnyFiltersFor", () => {
+    it("returns false at initial state with a single field", () => {
+      expect(filtersStore.hasAnyFiltersFor("global")).toBe(false)
+    })
+
+    it("returns false at initial state with multiple fields", () => {
+      expect(
+        filtersStore.hasAnyFiltersFor(["global", "faeName", "player"])
+      ).toBe(false)
+    })
+
+    it("returns true for non-empty filter with a single field", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasAnyFiltersFor("global")).toBe(true)
+    })
+
+    it("returns true for non-empty filter with multiple fields", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(
+        filtersStore.hasAnyFiltersFor(["global", "faeName", "player"])
+      ).toBe(true)
+    })
+  })
+
+  describe("hasAnyNameFilters", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasAnyNameFilters()).toBe(false)
+    })
+
+    it("returns true for non-empty name filter", () => {
+      filtersStore.filters.faeName.value = "foo"
+      expect(filtersStore.hasAnyNameFilters()).toBe(true)
+    })
+  })
+
+  describe("hasFilterFor", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasFilterFor("global")).toBe(false)
+    })
+
+    it("returns false for non-empty filter with a different field", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasFilterFor("player")).toBe(false)
+    })
+
+    it("returns true for non-empty filter and same field", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasFilterFor("global")).toBe(true)
+    })
+  })
+
+  describe("hasGlobalFilter", () => {
+    it("returns false at initial state", () => {
+      expect(filtersStore.hasGlobalFilter()).toBe(false)
+    })
+
+    it("returns false for non-empty other filter", () => {
+      filtersStore.filters.faeName.value = "foo"
+      expect(filtersStore.hasGlobalFilter()).toBe(false)
+    })
+
+    it("returns true for non-empty global filter", () => {
+      filtersStore.filters.global.value = "foo"
+      expect(filtersStore.hasGlobalFilter()).toBe(true)
+    })
+  })
+
+  describe("removeFilterValueFrom", () => {
+    let oldFilters
+
+    beforeEach(() => {
+      filtersStore.filters.player.value = ["foo", "bar"]
+      oldFilters = _clone(filtersStore.filters)
+    })
+
+    it("returns false for non-array field", () => {
+      expect(filtersStore.removeFilterValueFrom("faeName", "foo")).toBe(false)
+    })
+
+    it("doesn't modify filters for non-array field", () => {
+      filtersStore.removeFilterValueFrom("faeName", "foo")
+      expect(filtersStore.filters).toEqual(oldFilters)
+    })
+
+    it("doesn't modify filter if it doesn't contain the value", () => {
+      filtersStore.removeFilterValueFrom("player", "qux")
+      expect(filtersStore.filters.player.value).toEqual(oldFilters.player.value)
+    })
+
+    it("removes correct value from array filter", () => {
+      filtersStore.removeFilterValueFrom("player", "foo")
+      expect(filtersStore.filters.player.value).toEqual(["bar"])
+    })
+  })
+
+  describe("resetFilters", () => {
+    it("resets all filters", () => {
+      filtersStore.filters.global = {
+        value: "foo",
+        matchMode: FilterMatchMode.STARTS_WITH
+      }
+      filtersStore.filters.faeName.value = "foo"
+      filtersStore.resetFilters()
+      expect(filtersStore.filters).toEqual(filtersStore.emptyFilters)
+    })
+  })
+
+  describe("resetFilterFor", () => {
+    it("resets the given filter", () => {
+      filtersStore.filters.player.value = ["foo", "bar"]
+      filtersStore.resetFilterFor("player")
+      expect(filtersStore.filters.player).toEqual(
+        filtersStore.emptyFilters.player
+      )
+    })
+
+    it("doesn't modify other filters", () => {
+      filtersStore.filters.faeName.value = "foo"
+      const filtersState = filtersStore.filters
+      filtersStore.resetFilterFor("player")
+      expect(filtersStore.filters).toEqual(filtersState)
+    })
+  })
+})
+
+describe("resetGlobalFilter", () => {
+  it("resets the global filter", () => {
+    filtersStore.filters.global = {
+      value: "foo",
+      matchMode: FilterMatchMode.STARTS_WITH
+    }
+    filtersStore.resetGlobalFilter()
+    expect(filtersStore.filters.global).toEqual(
+      filtersStore.emptyFilters.global
+    )
+  })
+
+  it("doesn't modify other filters", () => {
+    filtersStore.filters.faeName.value = "foo"
+    const filtersState = filtersStore.filters
+    filtersStore.resetGlobalFilter()
+    expect(filtersStore.filters.faeName).toEqual(filtersState.faeName)
+  })
+})
+
+describe("toggleShowFilters", () => {
+  it("toggles showFilters", () => {
+    filtersStore.toggleShowFilters()
+    expect(filtersStore.showFilters).toBe(true)
+
+    filtersStore.toggleShowFilters()
+    expect(filtersStore.showFilters).toBe(false)
+  })
+})

+ 0 - 0
test/nuxt/setup.js


+ 12 - 0
vitest.config.js

@@ -0,0 +1,12 @@
+import { defineVitestConfig } from "@nuxt/test-utils/config"
+
+export default defineVitestConfig({
+  test: {
+    environment: "nuxt",
+    include: "./test/**/*.test.js",
+    exclude: ["./test/e2e/**", "./test/mocks/**"],
+    setupFiles: "./test/nuxt/setup.js",
+    globals: true,
+    watch: false
+  }
+})

Some files were not shown because too many files changed in this diff