feat: mirror v-can guard chains

This commit is contained in:
stanig2106
2025-11-14 19:23:18 +01:00
parent 2c4fe3f353
commit 7d823307fa
16 changed files with 573 additions and 138 deletions

21
test/chains.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
import { setup, $fetch } from '@nuxt/test-utils/e2e'
describe('chains fixture', async () => {
await setup({
rootDir: fileURLToPath(new URL('./fixtures/chains', import.meta.url)),
})
it('hides every branch when permission is missing and falls back to v-cannot', async () => {
const html = await $fetch('/')
expect(html).not.toContain('id="branch-pending"')
expect(html).toContain('id="branch-denied"')
})
it('renders explicit v-cannot blocks even when detached from the chain', async () => {
const html = await $fetch('/')
expect(html).toContain('id="explicit-denied"')
expect(html).not.toContain('id="approve-action"')
})
})

View File

@@ -4,7 +4,7 @@ const granted = new Set([
'contract.create',
])
export function __can__(path: string[]) {
const key = Array.isArray(path) ? path.join('.') : String(path)
export function __can__(...path: string[]) {
const key = path.join('.')
return granted.has(key)
}

31
test/fixtures/chains/app.vue vendored Normal file
View File

@@ -0,0 +1,31 @@
<template>
<main>
<section>
<div id="branch-draft" v-if="phase === 'draft'" v-can="can.employee.view">
Draft
</div>
<div id="branch-pending" v-else-if="phase === 'pending'">
Pending
</div>
<div id="branch-fallback" v-else>
Fallback
</div>
<p id="branch-denied" v-cannot>
Permission missing
</p>
</section>
<section>
<button id="approve-action" v-can="can.contract.approve">
Approve
</button>
<p id="explicit-denied" v-cannot="can.contract.approve">
Need contract.approve
</p>
</section>
</main>
</template>
<script setup lang="ts">
const phase = 'pending'
</script>

12
test/fixtures/chains/nuxt.config.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import NuxtCan from '../../../src/module'
export default defineNuxtConfig({
modules: [NuxtCan],
nuxtCan: {
permissions: {
employee: ['view'],
contract: ['approve'],
},
canFunctionImport: '~/permissions/__can__',
},
})

5
test/fixtures/chains/package.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"private": true,
"name": "chains",
"type": "module"
}

View File

@@ -0,0 +1,6 @@
const granted = new Set<string>([])
export function __can__(...path: string[]) {
const key = path.join('.')
return granted.has(key)
}

View File

@@ -18,8 +18,8 @@ describe('transformCan', () => {
<p v-cannot>Refus</p>
`)
expect(code).toContain(`v-if="__can__(['employee', 'view'])"`)
expect(code).toContain(`v-if="!(__can__(['employee', 'view']))"`)
expect(code).toContain(`v-if="__can__('employee', 'view')"`)
expect(code).toContain(`v-if="!(__can__('employee', 'view'))"`)
})
it('merges existing v-if expressions with the generated guard', () => {
@@ -27,7 +27,7 @@ describe('transformCan', () => {
<div v-if="isReady" v-can="can.contract.create" />
`)
expect(code).toContain(`v-if="(isReady) && __can__(['contract', 'create'])"`)
expect(code).toContain(`v-if="(isReady) && __can__('contract', 'create')"`)
})
it('throws when v-cannot is used without a preceding v-can', () => {
@@ -36,7 +36,7 @@ describe('transformCan', () => {
id: TEST_FILE,
})
expect(exec).toThrow(/must immediately follow its `v-can`/)
expect(exec).toThrow(/without an expression must immediately follow/)
})
it('throws when the expression does not start with can.*', () => {
@@ -48,15 +48,61 @@ describe('transformCan', () => {
expect(exec).toThrow(/expressions must start with `can\.`/)
})
it('throws when v-can is added to a v-else branch', () => {
it('mirrors guards across v-if / v-else-if / v-else chains when the first branch uses v-can', () => {
const code = runTransform(`
<div v-if="ready" v-can="can.employee.view"></div>
<div v-else-if="later"></div>
<div v-else></div>
<p v-cannot>Denied</p>
`)
expect(code).toContain(`v-if="(ready) && __can__('employee', 'view')"`)
expect(code).toContain(`v-else-if="(later) && __can__('employee', 'view')"`)
expect(code).toContain(`v-else-if="__can__('employee', 'view')"`)
expect(code).toContain(`v-if="!(__can__('employee', 'view'))"`)
})
it('throws when branches declare different v-can expressions', () => {
const exec = () => transformCan({
code: buildSFC(`
<div v-if="ready"></div>
<div v-else v-can="can.employee.view"></div>
<div v-if="ready" v-can="can.employee.view"></div>
<div v-else v-can="can.contract.create"></div>
`),
id: TEST_FILE,
})
expect(exec).toThrow(/cannot be used on `v-else`/)
expect(exec).toThrow(/must match across every branch/)
})
it('allows explicit expressions on v-cannot without a preceding v-can', () => {
const code = runTransform('<p v-cannot="can.employee.view">Denied</p>')
expect(code).toContain(`v-if="!(__can__('employee', 'view'))"`)
})
it('requires adjacent placement for v-cannot without an expression', () => {
const exec = () => transformCan({
code: buildSFC(`
<div v-can="can.employee.view">Allowed</div>
<div>
<p v-cannot>Denied</p>
</div>
`),
id: TEST_FILE,
})
expect(exec).toThrow(/without an expression must immediately follow/)
})
it('requires adjacency even when only empty siblings exist between v-can and v-cannot', () => {
const exec = () => transformCan({
code: buildSFC(`
<div v-can="can.employee.view">Allowed</div>
<div></div>
<div v-cannot>Denied</div>
`),
id: TEST_FILE,
})
expect(exec).toThrow(/without an expression must immediately follow/)
})
})