This commit is contained in:
2026-09-02 09:19:43 +03:00
parent 74cb5b6176
commit 962f489142
34 changed files with 1190 additions and 124 deletions
@@ -7,6 +7,7 @@ import type { ManyToMany } from '@adonisjs/lucid/types/relations'
import { DateTime } from 'luxon'
import Role from '#base_modules/Role/model/role'
import Permission from '#base_modules/Permission/model/permission'
import Tenant from '#base_modules/Tenant/model/tenant'
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
uids: ['email'],
@@ -52,6 +53,13 @@ export default class Admins extends ComposedModel {
})
declare roles: ManyToMany<typeof Role>
@manyToMany(() => Tenant, {
pivotTable: 'admin_tenants',
pivotForeignKey: 'admin_id',
pivotRelatedForeignKey: 'tenant_id',
})
declare tenants: ManyToMany<typeof Tenant>
declare permissions: Permission[]
static accessTokens = DbAccessTokensProvider.forModel(Admins)
@@ -0,0 +1,88 @@
import type { HttpContext } from '@adonisjs/core/http'
import Contact from '#base_modules/Contact/model/contact'
import { createContactValidator } from '#base_modules/Contact/validators/contact_create_validator'
import { updateContactValidator } from '#base_modules/Contact/validators/contact_update_validator'
export default class ContactsController {
/**
* @summary List contacts in the current tenant
* @responseBody 200 - {"data":[{"id":1,"display_name":"Ada Lovelace","email":"ada@example.test","phone":null,"status":"active"}]}
* @responseBody 401 - {"message":"Unauthorized"}
* @responseBody 400 - {"code":"E_TENANT_REQUIRED","message":"X-Tenant-Id header is required"}
* @responseBody 403 - {"code":"E_TENANT_ACCESS_DENIED","message":"You do not have access to this tenant"}
*/
async index({ response, tenant }: HttpContext) {
const contacts = await Contact.query().where('tenant_id', tenant.id).orderBy('id', 'asc')
return response.ok({ data: contacts })
}
/**
* @summary Show a contact in the current tenant
* @responseBody 200 - {"data":{"id":1,"display_name":"Katherine Johnson","email":"katherine@example.test","phone":null,"status":"active"}}
* @responseBody 404 - {"message":"Not found."}
*/
async show({ request, response, tenant }: HttpContext) {
const contact = await Contact.query()
.where('id', request.param('id'))
.where('tenant_id', tenant.id)
.first()
if (!contact) return response.notFound({ message: 'Not found.' })
return response.ok({ data: contact })
}
/**
* @summary Update a contact in the current tenant
* @responseBody 200 - {"data":{"id":1,"display_name":"Updated Name","email":"updated@example.test","phone":null,"status":"active"}}
* @responseBody 404 - {"message":"Not found."}
*/
async update({ request, response, tenant }: HttpContext) {
const contact = await Contact.query()
.where('id', request.param('id'))
.where('tenant_id', tenant.id)
.first()
if (!contact) return response.notFound({ message: 'Not found.' })
const data = await updateContactValidator.validate({
displayName: request.input('display_name'),
email: request.input('email'),
phone: request.input('phone'),
status: request.input('status'),
})
contact.merge(data)
await contact.save()
return response.ok({ data: contact })
}
/**
* @summary Delete a contact in the current tenant
* @responseBody 200 - {"message":"Deleted."}
* @responseBody 404 - {"message":"Not found."}
*/
async destroy({ request, response, tenant }: HttpContext) {
const contact = await Contact.query()
.where('id', request.param('id'))
.where('tenant_id', tenant.id)
.first()
if (!contact) return response.notFound({ message: 'Not found.' })
await contact.delete()
return response.ok({ message: 'Deleted.' })
}
/**
* @summary Create a contact in the current tenant
* @responseBody 201 - {"data":{"id":1,"display_name":"Grace Hopper","email":"grace@example.test","phone":"+1-555-0100","status":"active"}}
* @responseBody 401 - {"message":"Unauthorized"}
* @responseBody 400 - {"code":"E_TENANT_REQUIRED","message":"X-Tenant-Id header is required"}
* @responseBody 403 - {"code":"E_TENANT_ACCESS_DENIED","message":"You do not have access to this tenant"}
*/
async store({ request, response, tenant }: HttpContext) {
const input = await createContactValidator.validate(request.all())
const contact = await Contact.create({
tenantId: tenant.id,
displayName: input.display_name,
email: input.email,
phone: input.phone,
status: 'active',
})
return response.created({ data: contact })
}
}
@@ -0,0 +1,33 @@
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
export default class Contact extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column({ columnName: 'tenant_id', serializeAs: null })
declare tenantId: number
@column({ columnName: 'display_name', serializeAs: 'display_name' })
declare displayName: string
@column()
declare email: string | null
@column()
declare phone: string | null
@column()
declare status: 'active' | 'inactive'
@column.dateTime({ autoCreate: true, columnName: 'created_at', serializeAs: null })
declare createdAt: DateTime
@column.dateTime({
autoCreate: true,
autoUpdate: true,
columnName: 'updated_at',
serializeAs: null,
})
declare updatedAt: DateTime | null
}
@@ -0,0 +1,15 @@
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
const ContactsController = () => import('#base_modules/Contact/controller/contacts_controller')
router
.group(() => {
router.get('/', [ContactsController, 'index'])
router.get('/:id', [ContactsController, 'show'])
router.post('/', [ContactsController, 'store'])
router.put('/:id', [ContactsController, 'update'])
router.delete('/:id', [ContactsController, 'destroy'])
})
.prefix('/api/contacts')
.use([middleware.auth(), middleware.tenant()])
@@ -0,0 +1,9 @@
import vine from '@vinejs/vine'
export const createContactValidator = vine.compile(
vine.object({
display_name: vine.string().trim().minLength(1),
email: vine.string().trim().email().optional(),
phone: vine.string().trim().optional(),
})
)
@@ -0,0 +1,10 @@
import vine from '@vinejs/vine'
export const updateContactValidator = vine.compile(
vine.object({
displayName: vine.string().trim().minLength(1).optional(),
email: vine.string().trim().email().optional(),
phone: vine.string().trim().optional(),
status: vine.enum(['active', 'inactive']).optional(),
})
)
@@ -0,0 +1,16 @@
import type { HttpContext } from '@adonisjs/core/http'
import CustomField from '#base_modules/CustomField/model/custom_field'
export default class CustomFieldsController {
/**
* @summary List custom fields in the current tenant
* @responseBody 200 - {"data":[]}
* @responseBody 401 - {"message":"Unauthorized"}
* @responseBody 400 - {"code":"E_TENANT_REQUIRED","message":"X-Tenant-Id header is required"}
* @responseBody 403 - {"code":"E_TENANT_ACCESS_DENIED","message":"You do not have access to this tenant"}
*/
public async index({ response, tenant }: HttpContext) {
const fields = await CustomField.query().where('tenant_id', tenant.id).orderBy('id', 'asc')
return response.ok({ data: fields })
}
}
@@ -0,0 +1,17 @@
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
export default class CustomField extends BaseModel {
@column({ isPrimary: true }) declare id: number
@column({ columnName: 'tenant_id', serializeAs: null }) declare tenantId: number
@column({ columnName: 'entity_type', serializeAs: 'entity_type' }) declare entityType: string
@column() declare name: string
@column() declare key: string
@column({ columnName: 'field_type', serializeAs: 'field_type' }) declare fieldType: string
@column() declare required: boolean
@column() declare options: Record<string, unknown> | null
@column() declare status: string
@column.dateTime({ autoCreate: true, serializeAs: null }) declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true, serializeAs: null })
declare updatedAt: DateTime
}
@@ -0,0 +1,12 @@
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
const CustomFieldsController = () =>
import('#base_modules/CustomField/controller/custom_fields_controller')
router
.group(() => {
router.get('/', [CustomFieldsController, 'index'])
})
.prefix('/api/custom-fields')
.use([middleware.auth(), middleware.tenant()])
@@ -0,0 +1,13 @@
import type { HttpContext } from '@adonisjs/core/http'
export default class TenantController {
/**
* @summary Resolve current tenant context
* @responseBody 200 - {"tenant":{"id":42,"name":"Acme GmbH","slug":"acme-gmbh"}}
* @responseBody 400 - {"code":"E_TENANT_REQUIRED","message":"The X-Tenant-Id header is required."}
* @responseBody 403 - {"code":"E_TENANT_ACCESS_DENIED","message":"You do not have access to this tenant."}
*/
async current({ response, tenant }: HttpContext) {
return response.ok({ tenant })
}
}
@@ -0,0 +1,31 @@
import { BaseModel, column, manyToMany } from '@adonisjs/lucid/orm'
import type { ManyToMany } from '@adonisjs/lucid/types/relations'
import { DateTime } from 'luxon'
import Admin from '#base_modules/Admin/model/admin'
export default class Tenant extends BaseModel {
@column({ isPrimary: true, columnName: 'id' })
declare id: number
@column({ columnName: 'name' })
declare name: string
@column({ columnName: 'slug' })
declare slug: string
@column({ columnName: 'status' })
declare status: 'active' | 'inactive'
@column.dateTime({ autoCreate: true, columnName: 'created_at' })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true, columnName: 'updated_at' })
declare updatedAt: DateTime | null
@manyToMany(() => Admin, {
pivotTable: 'admin_tenants',
pivotForeignKey: 'tenant_id',
pivotRelatedForeignKey: 'admin_id',
})
declare admins: ManyToMany<typeof Admin>
}
@@ -0,0 +1,8 @@
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
const TenantController = () => import('#base_modules/Tenant/controller/tenant_controller')
router
.get('/tenants/current', [TenantController, 'current'])
.use([middleware.auth(), middleware.tenant()])
@@ -0,0 +1,15 @@
import Tenant from '#base_modules/Tenant/model/tenant'
export type ResolvedTenant = { id: number; name: string; slug: string }
export default class TenantAccessService {
async resolveForAdmin(adminId: number, tenantId: number): Promise<ResolvedTenant | null> {
const tenant = await Tenant.query()
.where('id', tenantId)
.where('status', 'active')
.whereHas('admins', (admins) => admins.where('admins.id', adminId))
.first()
return tenant ? { id: tenant.id, name: tenant.name, slug: tenant.slug } : null
}
}
@@ -0,0 +1,43 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import TenantAccessService, {
type ResolvedTenant,
} from '#base_modules/Tenant/services/tenant_access_service'
type TenantContext = ResolvedTenant
export default class ResolveTenantMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
const header = ctx.request.header('x-tenant-id')
if (!header)
return ctx.response.badRequest({
code: 'E_TENANT_REQUIRED',
message: 'The X-Tenant-Id header is required.',
})
const tenantId = Number(header)
if (!Number.isSafeInteger(tenantId) || tenantId < 1) {
return ctx.response.badRequest({
code: 'E_INVALID_TENANT_ID',
message: 'The X-Tenant-Id header must be a positive integer.',
})
}
const tenant = await new TenantAccessService().resolveForAdmin(ctx.auth.user!.id, tenantId)
if (!tenant)
return ctx.response.forbidden({
code: 'E_TENANT_ACCESS_DENIED',
message: 'You do not have access to this tenant.',
})
ctx.tenant = tenant
return next()
}
}
declare module '@adonisjs/core/http' {
export interface HttpContext {
tenant: TenantContext
}
}
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { modelToSchema } from '#services/swagger_model'
import { modelToExample } from '#services/swagger_model'
import { ModuleContext } from '#services/module_generator/module_context'
import {
capitalize,
@@ -139,7 +139,7 @@ export async function writeBackendModule(options: WriteBackendModuleOptions) {
snakeCaseName: snake,
name,
snake,
swaggerModel: await modelToSchema(genModel),
swaggerExample: await modelToExample(genModel),
}
writeFileSync(
@@ -150,7 +150,9 @@ export async function writeBackendModule(options: WriteBackendModuleOptions) {
const apiSpec = renderTemplate(join(backendTplDir, 'tests', 'api.spec.hbs'), {
snakeCaseName: snake,
name,
createFields,
updateFields,
})
writeFileSync(paths.testApi, apiSpec, 'utf8')
+32 -4
View File
@@ -1,18 +1,46 @@
const typeMap: Record<string, any> = {
const typeMap: Record<string, { type: string; format?: string }> = {
number: { type: 'integer', format: 'int64' },
string: { type: 'string' },
Date: { type: 'string', format: 'date-time' },
boolean: { type: 'boolean' },
}
/** Legacy schema helper retained for callers that need a property map. */
export async function modelToSchema(model: any) {
const properties: Record<string, any> = {}
const properties: Record<string, unknown> = {}
for (const [key, meta] of model.$columnsDefinitions.entries()) {
const tsType = meta.meta?.designType?.name || 'string'
const mapped = typeMap[tsType] ?? { type: 'string' }
properties[meta?.serializeAs || key] = {
...mapped,
...(typeMap[tsType] ?? { type: 'string' }),
description: key.replace(/([A-Z])/g, ' $1').trim(),
}
}
return JSON.stringify(properties)
}
/**
* Produces a deterministic, concrete JSON object for AutoSwagger response
* annotations. It intentionally omits Lucid columns hidden from serialization.
*/
export async function modelToExample(model: any) {
const example: Record<string, string | number | boolean> = {}
for (const [key, meta] of model.$columnsDefinitions.entries()) {
if (meta.serializeAs === null) continue
const serializedKey = meta.serializeAs || key
const type = meta.meta?.designType?.name || 'string'
example[serializedKey] = sampleValue(serializedKey, type)
}
return JSON.stringify(example)
}
function sampleValue(key: string, type: string): string | number | boolean {
if (type === 'number') return key === 'id' || key.endsWith('_id') ? 42 : 100
if (type === 'boolean') return true
if (type === 'Date') return '2025-01-15T10:30:00.000Z'
if (key.includes('email')) return 'user@example.test'
if (key.includes('url')) return 'https://example.test/resource'
return `Example ${key.replace(/_/g, ' ')}`
}
@@ -1,102 +1,91 @@
import type { HttpContext } from '@adonisjs/core/http'
import {{pascalCaseName}} from '#modules/{{capName}}/model/{{snakeCaseName}}'
import {{pascalCaseName}}Policy from '#modules/{{capName}}/policy/{{snakeCaseName}}_policy'
import { create{{pascalCaseName}}ApiValidator } from '#modules/{{capName}}/api/validators/{{snakeCaseName}}_create_api_validator'
import { update{{pascalCaseName}}ApiValidator } from '#modules/{{capName}}/api/validators/{{snakeCaseName}}_update_api_validator'
export default class {{pascalCaseName}}ApiController {
/**
* GET /api/{{name}}
*/
/**
* @index
* @operationId get{{pascalCaseName}}
* @description Returns array of records
* @responseBody 200 - [{{{swaggerModel}}}]
* @responseHeader 200 - X-pages - A description of the header - @example(test)
* @description Returns records visible to an authorized user.
* @responseBody 200 - [{{{swaggerExample}}}]
* @responseBody 401 - { "message": "Unauthorized" }
* @responseBody 403 - { "message": "Forbidden" }
*/
async index({ response }: HttpContext) {
const result = await {{pascalCaseName}}.all()
return response.ok(result)
async index({ response, bouncer }: HttpContext) {
await bouncer.with({{pascalCaseName}}Policy).authorize('view{{pascalCaseName}}List')
return response.ok(await {{pascalCaseName}}.all())
}
/**
* GET /api/{{name}}/:id
*/
/**
* @show
* @operationId show{{pascalCaseName}}
* @description Returns record
* @responseBody 200 - {{{swaggerModel}}}
* @description Returns one record visible to an authorized user.
* @paramPath id - Record ID - @required
* @responseBody 200 - {{{swaggerExample}}}
* @responseBody 401 - { "message": "Unauthorized" }
* @responseBody 403 - { "message": "Forbidden" }
* @responseBody 404 - { "message": "Not found" }
* @responseHeader 200 - X-pages - A description of the header - @example(test)
*/
async show({ params, response }: HttpContext) {
async show({ params, response, bouncer }: HttpContext) {
await bouncer.with({{pascalCaseName}}Policy).authorize('view{{pascalCaseName}}')
const record = await {{pascalCaseName}}.find(params.id)
if (!record) return response.notFound({ message: 'Not found' })
return response.ok(record)
}
/**
* POST /api/{{name}}
*/
/**
* @store
* @operationId store{{pascalCaseName}}
* @description Creates and Returns record
* @description Creates and returns a record.
* @requestBody <create{{pascalCaseName}}ApiValidator>
* @responseBody 200 - {{{swaggerModel}}}
* @responseBody 404 - { "message": "Not found" }
* @responseBody 422 - <Error422> - Returns an error if the request body fails validation.
* @responseHeader 200 - X-pages - A description of the header - @example(test)
* @responseBody 201 - {{{swaggerExample}}}
* @responseBody 401 - { "message": "Unauthorized" }
* @responseBody 403 - { "message": "Forbidden" }
* @responseBody 422 - { "errors": [{ "message": "Validation failed", "rule": "required", "field": "name" }] }
*/
async store({ request, response }: HttpContext) {
async store({ request, response, bouncer }: HttpContext) {
await bouncer.with({{pascalCaseName}}Policy).authorize('create{{pascalCaseName}}')
const payload = await create{{pascalCaseName}}ApiValidator.validate(request.all())
const record = await {{pascalCaseName}}.create(payload)
return response.created(record)
return response.created(await {{pascalCaseName}}.create(payload))
}
/**
* PUT /api/{{name}}/:id
*/
/**
* @update
* @operationId update{{pascalCaseName}}
* @description Updates and Returns record
* @description Updates and returns a record.
* @paramPath id - Record ID - @required
* @requestBody <update{{pascalCaseName}}ApiValidator>
* @responseBody 200 - {{{swaggerModel}}}
* @responseBody 200 - {{{swaggerExample}}}
* @responseBody 401 - { "message": "Unauthorized" }
* @responseBody 403 - { "message": "Forbidden" }
* @responseBody 404 - { "message": "Not found" }
* @responseBody 422 - <Error422> - Returns an error if the request body fails validation.
* @responseHeader 200 - X-pages - A description of the header - @example(test)
* @responseBody 422 - { "errors": [{ "message": "Validation failed", "rule": "required", "field": "name" }] }
*/
async update({ params, request, response }: HttpContext) {
async update({ params, request, response, bouncer }: HttpContext) {
await bouncer.with({{pascalCaseName}}Policy).authorize('update{{pascalCaseName}}')
const record = await {{pascalCaseName}}.find(params.id)
if (!record) return response.notFound({ message: 'Not found' })
const payload = await update{{pascalCaseName}}ApiValidator.validate(request.all())
record.merge(payload)
record.merge(await update{{pascalCaseName}}ApiValidator.validate(request.all()))
await record.save()
return response.ok(record)
}
/**
* DELETE /api/{{name}}/:id
*/
/**
* @destroy
* @operationId destroy{{pascalCaseName}}
* @description Destroys record
* @paramPath id - The ID of the source - @required
* @description Deletes a record.
* @paramPath id - Record ID - @required
* @responseBody 200 - { "message": "Deleted" }
* @responseHeader 200 - X-pages - A description of the header - @example(test)
* @responseBody 401 - { "message": "Unauthorized" }
* @responseBody 403 - { "message": "Forbidden" }
* @responseBody 404 - { "message": "Not found" }
*/
async destroy({ params, response }: HttpContext) {
async destroy({ params, response, bouncer }: HttpContext) {
await bouncer.with({{pascalCaseName}}Policy).authorize('delete{{pascalCaseName}}')
const record = await {{pascalCaseName}}.find(params.id)
if (!record) return response.notFound({ message: 'Not found' })
await record.delete()
return response.ok({ message: 'Deleted' })
}
@@ -1,4 +1,6 @@
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
const {{pascalCaseName}}ApiController = () => import('#modules/{{moduleName}}/api/controllers/{{snakeCaseName}}_api_controller')
router
@@ -10,3 +12,4 @@ router
router.delete('/:id', [{{pascalCaseName}}ApiController, 'destroy'])
})
.prefix('/api/{{snakeCaseName}}')
.use([middleware.auth()])
@@ -1,24 +1,104 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import { createAdminWithToken } from '#tests/utils/auth'
const ALL_PERMISSIONS = ['{{name}}s.view', '{{name}}s.create', '{{name}}s.update', '{{name}}s.delete']
const ROUTES = [
['get', '/api/{{snakeCaseName}}'],
['get', '/api/{{snakeCaseName}}/999999'],
['post', '/api/{{snakeCaseName}}'],
['put', '/api/{{snakeCaseName}}/999999'],
['delete', '/api/{{snakeCaseName}}/999999'],
] as const
test.group('API /api/{{snakeCaseName}}', (group) => {
group.each.setup(async () => {
await db.beginGlobalTransaction()
return () => db.rollbackGlobalTransaction()
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
test('rejects unauthenticated requests', async ({ client }) => {
for (const [method, url] of ROUTES) {
const response = await client[method](url)
response.assertStatus(401)
}
})
test('rejects a token without the required permission', async ({ client }) => {
const { token } = await createAdminWithToken([])
for (const [method, url] of ROUTES) {
const response = await client[method](url).bearerToken(token)
response.assertStatus(403)
}
})
test('GET /api/{{snakeCaseName}} -> list', async ({ client }) => {
const res = await client.get('/api/{{snakeCaseName}}')
res.assertStatus(200)
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const response = await client.get('/api/{{snakeCaseName}}').bearerToken(token)
response.assertStatus(200)
})
test('POST /api/{{snakeCaseName}} -> create', async ({ client }) => {
test('POST /api/{{snakeCaseName}} -> create', async ({ assert, client }) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const payload = {
{{#each createFields}}
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
{{/each}}
}
const res = await client.post('/api/{{snakeCaseName}}').json(payload)
res.assertStatus(201)
const response = await client.post('/api/{{snakeCaseName}}').bearerToken(token).json(payload)
response.assertStatus(201)
assert.exists(response.body().id)
})
test('GET /api/{{snakeCaseName}}/:id -> show and 404', async ({ assert, client }) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const created = await client.post('/api/{{snakeCaseName}}').bearerToken(token).json({
{{#each createFields}}
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
{{/each}}
})
created.assertStatus(201)
const id = created.body().id
const response = await client.get(`/api/{{snakeCaseName}}/${id}`).bearerToken(token)
response.assertStatus(200)
assert.equal(response.body().id, id)
const missing = await client.get('/api/{{snakeCaseName}}/999999').bearerToken(token)
missing.assertStatus(404)
})
test('PUT /api/{{snakeCaseName}}/:id -> update and 404', async ({ client }) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const created = await client.post('/api/{{snakeCaseName}}').bearerToken(token).json({
{{#each createFields}}
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
{{/each}}
})
created.assertStatus(201)
const updated = await client.put(`/api/{{snakeCaseName}}/${created.body().id}`).bearerToken(token).json({
{{#each updateFields}}
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
{{/each}}
})
updated.assertStatus(200)
const missing = await client.put('/api/{{snakeCaseName}}/999999').bearerToken(token).json({})
missing.assertStatus(404)
})
test('DELETE /api/{{snakeCaseName}}/:id -> destroy and 404', async ({ client }) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const created = await client.post('/api/{{snakeCaseName}}').bearerToken(token).json({
{{#each createFields}}
{{column_name}}: {{{sampleValue}}}{{#unless @last}},{{/unless}}
{{/each}}
})
created.assertStatus(201)
const deleted = await client.delete(`/api/{{snakeCaseName}}/${created.body().id}`).bearerToken(token)
deleted.assertStatus(200)
const missing = await client.delete('/api/{{snakeCaseName}}/999999').bearerToken(token)
missing.assertStatus(404)
})
})