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
@@ -0,0 +1,170 @@
# ERP Foundation Next Steps Implementation Plan
> **For Hermes:** Implement this plan task-by-task using strict TDD.
**Goal:** Establish a testable, tenant-safe ERP foundation in the existing AdonisJS 7 + React + PostgreSQL modular monolith, then deliver the first reusable core modules.
**Architecture:** Preserve the existing `apps/backend/modules/<Module>` and `apps/admin/src/modules/<Module>` conventions. Core infrastructure becomes the only source of tenant scope, authorization, contacts, custom-field metadata, durable events, and internal service contracts. Business modules must not issue SQL joins into each other’s tables.
**Tech Stack:** AdonisJS 7, Lucid ORM, PostgreSQL 17, Japa, Bouncer, `@adonisjs/auth`, adonis-autoswagger, React 19, Vite, React Query, React Hook Form, React Intl, Docker Compose.
---
## Current verified context
- Compose stack is running with `zfg-*` container names.
- Backend lint, formatting, and typecheck run successfully in `zfg-backend-adonis`.
- Database-backed tests require `DB_DATABASE=adonis_test`; the default Compose development database is `adonis` and is deliberately rejected by the test bootstrap.
- The module generator now generates protected API CRUD routes, policy checks, concrete AutoSwagger response annotations, and CRUD/auth/permission test scaffolding.
- The generator must be used as a scaffolding baseline only. ERP domain logic must add a migration, service contract, tenant scope, event/outbox behavior, endpoint tests, and documentation before the module is complete.
## Definition of done for every ERP endpoint
1. Write a failing Japa HTTP test first; run it in Compose against `adonis_test` and confirm the expected failure.
2. Implement the smallest behavior needed to pass.
3. Cover success, authentication, authorization, validation, not-found/conflict, tenant isolation, and any idempotency behavior relevant to that endpoint.
4. Annotate every active controller action with concrete, valid JSON AutoSwagger request/response examples.
5. Run:
- `docker compose exec -T backend npm run lint`
- `docker compose exec -T backend npm run typecheck`
- `docker compose exec -T backend npm run format`
- `docker compose exec -T -e DB_DATABASE=adonis_test backend npm test -- --files <spec>`
6. Ensure `git diff --check` passes. For new API routes, inspect generated docs at `/swagger` or run the project’s Swagger command if one is added.
## Step 1: Make the Compose test database invocation first-class
**Objective:** Remove the need for ad-hoc environment overrides when running backend tests in Compose.
**Files likely to change:**
- Modify: `docker-compose.yml`
- Modify or create: `apps/backend/.env.test.example`
- Modify: `apps/backend/package.json`
- Test: `apps/backend/tests/bootstrap.ts`
**Tasks:**
1. Decide and document one supported Compose test command, preferably a dedicated `test:compose` backend script or Compose service/profile.
2. Ensure it injects `NODE_ENV=test` and `DB_DATABASE=adonis_test` without altering the development backend container’s `adonis` database.
3. Add a small test/bootstrap assertion that the test command receives an `*_test` database name.
4. Verify the generator test runs without manually supplying `-e DB_DATABASE=adonis_test`.
## Step 2: Design and implement tenant context
**Objective:** Resolve one authenticated/request-scoped tenant and prevent unscoped tenant data access.
**Files likely to change:**
- Create: `apps/backend/database/migrations/<timestamp>_create_tenants_table.ts`
- Create: `apps/backend/app/base_modules/Tenant/model/tenant.ts`
- Create: `apps/backend/app/services/tenant_context.ts`
- Create: `apps/backend/app/middleware/resolve_tenant_middleware.ts`
- Modify: `apps/backend/start/kernel.ts`
- Modify: `apps/backend/config/database.ts`
- Test: `apps/backend/tests/functional/tenant/tenant_context.spec.ts`
**Tasks:**
1. Write failing tests for subdomain/header/token tenant resolution, missing tenant rejection, and cross-tenant record invisibility.
2. Add a forward-only tenants migration and a tenant-owned record fixture.
3. Implement a request-scoped tenant context that is immutable after middleware resolution.
4. Add tenant middleware after authentication and before module controllers.
5. Introduce a reusable tenant query helper/service so every tenant-owned module uses the same scope.
6. Keep the initial approach PostgreSQL row isolation with `tenant_id`; defer per-schema/per-database selection until a measured SaaS scaling requirement exists.
## Step 3: Extend IAM for tenant-aware ERP permissions
**Objective:** Build authorization around tenant membership and namespaced ERP permissions.
**Files likely to change:**
- Create migration(s) for tenant memberships and role assignment scope.
- Modify: `apps/backend/app/services/permission_service.ts`
- Modify: `apps/backend/app/middleware/initialize_bouncer_middleware.ts`
- Create: `apps/backend/app/base_modules/Tenant/policy/tenant_policy.ts`
- Test: `apps/backend/tests/functional/iam/tenant_permissions.spec.ts`
**Tasks:**
1. Write failing endpoint tests for no token (401), membership absence (403), missing permission (403), and valid tenant role access (200/201).
2. Add tenant membership persistence and tenant-scoped role/permission lookup without breaking the existing administrator model.
3. Register core permission slugs: `tenant.view`, `tenant.manage`, `contacts.view`, `contacts.create`, `contacts.update`, and `contacts.delete`.
4. Add an idempotent migration to grant approved core permissions to the Super Admin role on existing installations.
5. Add concrete AutoSwagger examples for all IAM endpoints.
## Step 4: Implement Base Contacts as the first generated ERP module
**Objective:** Provide the canonical tenant-owned people and organization directory.
**Files likely to change:**
- Create: `apps/backend/database/migrations/<timestamp>_create_contacts_table.ts`
- Create: `apps/backend/modules/Contacts/...`
- Create: `apps/admin/src/modules/Contacts/...`
- Test: `apps/backend/tests/modules/Contacts/contacts.api.spec.ts`
**Tasks:**
1. Start with HTTP tests for list/create/show/update/delete, validation, permissions, and isolation across two tenants.
2. Create `contacts` with tenant ID, contact kind, legal/display name, parent organization, email/phone, address payload, status, timestamps, and `custom_fields JSONB`.
3. Generate CRUD assets through MakeModuleController only after the table migration exists; then replace generic controller logic with tenant-aware Contacts service calls.
4. Register the Contacts permission config and ensure menus/routes use `contacts.view`.
5. Add React module configuration, API client, list/form pages, and translations for `en`, `de`, `fr`, and `nl` with identical key sets.
6. Add concrete AutoSwagger examples matching the actual serializer wrappers and error shapes.
## Step 5: Implement metadata and custom-fields configuration
**Objective:** Allow tenant administrators to define validated dynamic fields for approved entities without schema migrations.
**Files likely to change:**
- Create migration(s) for `custom_field_definitions` and optional option records.
- Create: `apps/backend/app/base_modules/Metadata/...`
- Modify: `apps/backend/modules/Contacts/...`
- Test: `apps/backend/tests/functional/metadata/custom_fields.spec.ts`
- Create: `apps/admin/src/modules/Metadata/...`
**Tasks:**
1. Write failing tests for field creation, allowed target entity, invalid values, required-field enforcement, and tenant isolation.
2. Persist field key, entity type, scalar type, validation rules, required/visibility state, option values, and tenant ownership.
3. Build one reusable validator that reads metadata definitions and validates the `custom_fields` JSONB payload before module persistence.
4. Expose dynamic-form metadata through an authenticated endpoint with concrete AutoSwagger examples.
5. Render dynamic controls in the Contacts admin form, including all requested translations.
## Step 6: Establish the internal event/outbox contract before commercial modules
**Objective:** Make CRM, Sales, Inventory, Procurement, Invoicing, and Accounting communication durable and module-boundary-safe.
**Files likely to change:**
- Create migration: `apps/backend/database/migrations/<timestamp>_create_outbox_events_table.ts`
- Create: `apps/backend/app/services/outbox/...`
- Create: `apps/backend/app/services/module_contracts/...`
- Test: `apps/backend/tests/functional/outbox/outbox_delivery.spec.ts`
**Tasks:**
1. Write failing tests proving that transaction rollback creates neither business record nor outbox event, and retries do not process a duplicate event twice.
2. Persist event ID, tenant ID, name, version, aggregate ID, correlation ID, payload, occurred time, and processing state in an outbox table within the business transaction.
3. Implement an explicit publisher and idempotent consumer registry. The initial delivery mechanism may use the Adonis event emitter only after outbox persistence succeeds.
4. Define versioned service interfaces for cross-module reads, beginning with `ContactService` and future `ProductService`; prohibit SQL joins across module-owned tables.
5. Add tests and concrete OpenAPI examples for any operational event-monitoring endpoints.
## After the foundation
Proceed in this order:
1. Product Catalog.
2. CRM with `crm:lead_won` outbox event.
3. Sales Orders consuming `crm:lead_won` and emitting `sales:order_confirmed`.
4. Inventory/Logistics consuming confirmed orders and emitting `inventory:stock_shipped`.
5. Procurement and reorder rules emitting/consuming receiving events.
6. Invoicing and Accounting from immutable operational events.
## Risks and decisions
- Do not implement PostgreSQL-per-tenant schemas or separate databases until tenant row isolation, authorization, and test coverage are established; otherwise every migration and test path becomes unnecessarily complex.
- Do not add Vite Module Federation until the existing admin module registry is insufficient. The existing React module convention is the source of truth for the first ERP modules.
- Do not generate migrations from an already-existing table automatically. Migrations must be authored and reviewed before MakeModuleController scaffolds a module from that table.
- Do not generate generic Swagger schema objects. Every response annotation needs a concrete valid JSON example derived from the serializer.
+2
View File
@@ -5,8 +5,10 @@ WORKDIR /app
# Copy all workspaces before installation so npm can resolve their dependencies.
COPY package.json package-lock.json ./
COPY eslint.config.js ./
COPY apps ./apps
COPY packages ./packages
COPY scripts ./scripts
# Install workspace dependencies (monorepo-aware).
RUN npm ci --force
@@ -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)
})
})
@@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'tenants'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('name').notNullable()
table.string('slug').notNullable().unique()
table.string('status').notNullable().defaultTo('active')
table.timestamp('created_at').notNullable().defaultTo(this.now())
table.timestamp('updated_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,32 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'admin_tenants'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table
.integer('admin_id')
.unsigned()
.notNullable()
.references('id')
.inTable('admins')
.onDelete('CASCADE')
table
.integer('tenant_id')
.unsigned()
.notNullable()
.references('id')
.inTable('tenants')
.onDelete('CASCADE')
table.timestamp('created_at').notNullable().defaultTo(this.now())
table.timestamp('updated_at').nullable()
table.unique(['admin_id', 'tenant_id'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,29 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'contacts'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table
.integer('tenant_id')
.unsigned()
.notNullable()
.references('id')
.inTable('tenants')
.onDelete('CASCADE')
table.string('display_name').notNullable()
table.string('email').nullable()
table.string('phone').nullable()
table.string('status').notNullable().defaultTo('active')
table.timestamp('created_at').notNullable().defaultTo(this.now())
table.timestamp('updated_at').nullable()
table.index(['tenant_id', 'display_name'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -0,0 +1,33 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'custom_fields'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table
.integer('tenant_id')
.unsigned()
.notNullable()
.references('id')
.inTable('tenants')
.onDelete('CASCADE')
table.string('entity_type').notNullable()
table.string('name').notNullable()
table.string('key').notNullable()
table.string('field_type').notNullable()
table.boolean('required').notNullable().defaultTo(false)
table.jsonb('options').nullable()
table.string('status').notNullable().defaultTo('active')
table.timestamp('created_at').notNullable().defaultTo(this.now())
table.timestamp('updated_at').notNullable().defaultTo(this.now())
table.unique(['tenant_id', 'entity_type', 'key'])
table.index(['tenant_id', 'entity_type'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
+1
View File
@@ -44,4 +44,5 @@ router.use([
*/
export const middleware = router.named({
auth: () => import('#middleware/auth_middleware'),
tenant: () => import('#middleware/resolve_tenant_middleware'),
})
@@ -0,0 +1,197 @@
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
import Contact from '#base_modules/Contact/model/contact'
import Tenant from '#base_modules/Tenant/model/tenant'
import { createAdminWithToken } from '#tests/utils/auth'
test.group('Contacts API', (group) => {
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
test('rejects an unauthenticated list request', async ({ client }) => {
const response = await client.get('/api/contacts').header('X-Tenant-Id', '1')
response.assertStatus(401)
})
test('lists only contacts owned by the selected tenant', async ({ assert, client }) => {
const { admin, token } = await createAdminWithToken([])
const tenant = await Tenant.create({ name: 'Acme GmbH', slug: 'acme-gmbh', status: 'active' })
await tenant.related('admins').attach([admin.id])
await Contact.create({
tenantId: tenant.id,
displayName: 'Ada Lovelace',
email: 'ada@example.test',
phone: null,
status: 'active',
})
const response = await client
.get('/api/contacts')
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
response.assertStatus(200)
assert.deepEqual(response.body(), {
data: [
{
id: 1,
display_name: 'Ada Lovelace',
email: 'ada@example.test',
phone: null,
status: 'active',
},
],
})
})
test('creates a contact within the resolved tenant', async ({ assert, client }) => {
const { admin, token } = await createAdminWithToken([])
const tenant = await Tenant.create({
name: 'Create GmbH',
slug: 'create-gmbh',
status: 'active',
})
await tenant.related('admins').attach([admin.id])
const response = await client
.post('/api/contacts')
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
.json({ display_name: 'Grace Hopper', email: 'grace@example.test', phone: '+1-555-0100' })
response.assertStatus(201)
const created = response.body().data
assert.deepInclude(created, {
display_name: 'Grace Hopper',
email: 'grace@example.test',
phone: '+1-555-0100',
status: 'active',
})
const persistedContact = await Contact.find(created.id)
assert.equal(persistedContact?.tenantId, tenant.id)
})
test('rejects a contact without a display name', async ({ assert, client }) => {
const { admin, token } = await createAdminWithToken([])
const tenant = await Tenant.create({
name: 'Validation GmbH',
slug: 'validation-gmbh',
status: 'active',
})
await tenant.related('admins').attach([admin.id])
const response = await client
.post('/api/contacts')
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
.json({ email: 'missing-name@example.test' })
response.assertStatus(422)
assert.equal(response.body().errors[0].field, 'display_name')
})
test('shows a contact only inside the resolved tenant', async ({ assert, client }) => {
const { admin, token } = await createAdminWithToken([])
const tenant = await Tenant.create({ name: 'Show GmbH', slug: 'show-gmbh', status: 'active' })
await tenant.related('admins').attach([admin.id])
const contact = await Contact.create({
tenantId: tenant.id,
displayName: 'Katherine Johnson',
email: 'katherine@example.test',
phone: null,
status: 'active',
})
const response = await client
.get(`/api/contacts/${contact.id}`)
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
response.assertStatus(200)
assert.deepEqual(response.body(), {
data: {
id: contact.id,
display_name: 'Katherine Johnson',
email: 'katherine@example.test',
phone: null,
status: 'active',
},
})
const otherTenant = await Tenant.create({
name: 'Other GmbH',
slug: 'other-gmbh',
status: 'active',
})
const foreignContact = await Contact.create({
tenantId: otherTenant.id,
displayName: 'Tenant Two Contact',
email: null,
phone: null,
status: 'active',
})
const foreignResponse = await client
.get(`/api/contacts/${foreignContact.id}`)
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
foreignResponse.assertStatus(404)
})
test('updates a contact inside the resolved tenant', async ({ assert, client }) => {
const { admin, token } = await createAdminWithToken([])
const tenant = await Tenant.create({
name: 'Update GmbH',
slug: 'update-gmbh',
status: 'active',
})
await tenant.related('admins').attach([admin.id])
const contact = await Contact.create({
tenantId: tenant.id,
displayName: 'Original Name',
email: null,
phone: null,
status: 'active',
})
const response = await client
.put(`/api/contacts/${contact.id}`)
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
.json({ display_name: 'Updated Name', email: 'updated@example.test' })
response.assertStatus(200)
assert.deepEqual(response.body(), {
data: {
id: contact.id,
display_name: 'Updated Name',
email: 'updated@example.test',
phone: null,
status: 'active',
},
})
})
test('deletes a contact inside the resolved tenant', async ({ assert, client }) => {
const { admin, token } = await createAdminWithToken([])
const tenant = await Tenant.create({
name: 'Delete GmbH',
slug: 'delete-gmbh',
status: 'active',
})
await tenant.related('admins').attach([admin.id])
const contact = await Contact.create({
tenantId: tenant.id,
displayName: 'Delete Me',
email: null,
phone: null,
status: 'active',
})
const response = await client
.delete(`/api/contacts/${contact.id}`)
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
response.assertStatus(200)
assert.equal(await Contact.find(contact.id), null)
})
})
@@ -0,0 +1,43 @@
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
import CustomField from '#base_modules/CustomField/model/custom_field'
import Tenant from '#base_modules/Tenant/model/tenant'
import { createAdminWithToken } from '#tests/utils/auth'
test.group('Custom fields API', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('rejects an unauthenticated list request', async ({ client }) => {
const response = await client.get('/api/custom-fields')
response.assertStatus(401)
})
test('lists fields owned by the resolved tenant', async ({ assert, client }) => {
const { admin, token } = await createAdminWithToken([])
const tenant = await Tenant.create({
name: 'Metadata GmbH',
slug: 'metadata-gmbh',
status: 'active',
})
await tenant.related('admins').attach([admin.id])
await CustomField.create({
tenantId: tenant.id,
entityType: 'contact',
name: 'Customer tier',
key: 'customer_tier',
fieldType: 'text',
required: false,
options: null,
status: 'active',
})
const response = await client
.get('/api/custom-fields')
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
response.assertStatus(200)
assert.equal(response.body().data[0].key, 'customer_tier')
})
})
@@ -0,0 +1,70 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import Tenant from '#base_modules/Tenant/model/tenant'
import { createAdminWithToken } from '#tests/utils/auth'
test.group('Tenant context', (group) => {
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
test('rejects an authenticated tenant-scoped request without an X-Tenant-Id header', async ({
assert,
client,
}) => {
const { token } = await createAdminWithToken([])
const response = await client.get('/tenants/current').bearerToken(token)
response.assertStatus(400)
assert.equal(response.body().code, 'E_TENANT_REQUIRED')
})
test('resolves a tenant only for an authenticated member', async ({ assert, client }) => {
const { admin, token } = await createAdminWithToken([])
const tenant = await Tenant.create({ name: 'Acme GmbH', slug: 'acme-gmbh', status: 'active' })
await tenant.related('admins').attach([admin.id])
const response = await client
.get('/tenants/current')
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
response.assertStatus(200)
assert.deepEqual(response.body(), {
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug },
})
})
test('rejects a tenant selected by an admin who is not a member', async ({ assert, client }) => {
const { token } = await createAdminWithToken([])
const [tenant] = await db
.table('tenants')
.insert({ name: 'Other GmbH', slug: 'other-gmbh', status: 'active' })
.returning(['id'])
const response = await client
.get('/tenants/current')
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
response.assertStatus(403)
assert.equal(response.body().code, 'E_TENANT_ACCESS_DENIED')
})
test('rejects membership in an inactive tenant', async ({ assert, client }) => {
const { admin, token } = await createAdminWithToken([])
const [tenant] = await db
.table('tenants')
.insert({ name: 'Inactive GmbH', slug: 'inactive-gmbh', status: 'inactive' })
.returning(['id'])
await db.table('admin_tenants').insert({ admin_id: admin.id, tenant_id: tenant.id })
const response = await client
.get('/tenants/current')
.header('X-Tenant-Id', String(tenant.id))
.bearerToken(token)
response.assertStatus(403)
assert.equal(response.body().code, 'E_TENANT_ACCESS_DENIED')
})
})
@@ -0,0 +1,71 @@
import { test } from '@japa/runner'
import { join } from 'node:path'
import { renderTemplate } from '#services/module_generator/writers/template_renderer'
const TEMPLATE_DIR = join(process.cwd(), 'app', 'templates', 'backend')
const API_TEMPLATE_DIR = join(TEMPLATE_DIR, 'api')
const context = {
name: 'widget',
capName: 'Widget',
moduleName: 'Widget',
modelName: 'Widgets',
pascalCaseName: 'Widget',
snakeCaseName: 'widget',
snake: 'widget',
swaggerExample: '{ "id": 42, "name": "Example widget" }',
createFields: [{ column_name: 'name', sampleValue: "'Example widget'" }],
updateFields: [{ column_name: 'name', sampleValue: "'Updated widget'" }],
}
test.group('ERP module generator templates', () => {
test('generates authenticated, permission-authorized API CRUD routes', ({ assert }) => {
const routes = renderTemplate(join(API_TEMPLATE_DIR, 'routes.hbs'), context)
const controller = renderTemplate(join(API_TEMPLATE_DIR, 'controller.hbs'), context)
assert.include(routes, "import { middleware } from '#start/kernel'")
assert.include(routes, '.use([middleware.auth()])')
assert.include(controller, "import WidgetPolicy from '#modules/Widget/policy/widget_policy'")
assert.include(controller, "authorize('viewWidgetList')")
assert.include(controller, "authorize('viewWidget')")
assert.include(controller, "authorize('createWidget')")
assert.include(controller, "authorize('updateWidget')")
assert.include(controller, "authorize('deleteWidget')")
})
test('generates concrete AutoSwagger examples for every API response status', ({ assert }) => {
const controller = renderTemplate(join(API_TEMPLATE_DIR, 'controller.hbs'), context)
assert.include(controller, '@responseBody 200 - [{ "id": 42, "name": "Example widget" }]')
assert.include(controller, '@responseBody 201 - { "id": 42, "name": "Example widget" }')
assert.include(controller, '@responseBody 401 - { "message": "Unauthorized" }')
assert.include(controller, '@responseBody 403 - { "message": "Forbidden" }')
assert.include(
controller,
'@responseBody 422 - { "errors": [{ "message": "Validation failed", "rule": "required", "field": "name" }] }'
)
assert.notInclude(controller, '<Error422>')
})
test('generates API specs covering every CRUD endpoint plus auth and permission boundaries', ({
assert,
}) => {
const spec = renderTemplate(join(TEMPLATE_DIR, 'tests', 'api.spec.hbs'), context)
for (const methodAndPath of [
'GET /api/widget -> list',
'POST /api/widget -> create',
'GET /api/widget/:id -> show',
'PUT /api/widget/:id -> update',
'DELETE /api/widget/:id -> destroy',
]) {
assert.include(spec, methodAndPath)
}
assert.include(spec, 'rejects unauthenticated requests')
assert.include(spec, 'rejects a token without the required permission')
assert.include(spec, 'assertStatus(401)')
assert.include(spec, 'assertStatus(403)')
assert.include(spec, 'assertStatus(404)')
})
})
@@ -0,0 +1,23 @@
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
import Tenant from '#base_modules/Tenant/model/tenant'
import TenantAccessService from '#base_modules/Tenant/services/tenant_access_service'
import { createAdminWithToken } from '#tests/utils/auth'
test.group('TenantAccessService', (group) => {
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
test('resolves an active tenant for a member admin', async ({ assert }) => {
const { admin } = await createAdminWithToken([])
const tenant = await Tenant.create({
name: 'Service GmbH',
slug: 'service-gmbh',
status: 'active',
})
await tenant.related('admins').attach([admin.id])
const resolved = await new TenantAccessService().resolveForAdmin(admin.id, tenant.id)
assert.deepEqual(resolved, { id: tenant.id, name: tenant.name, slug: tenant.slug })
})
})
+8 -8
View File
@@ -5,7 +5,7 @@ services:
dockerfile: apps/backend/Dockerfile
env_file:
- ./apps/backend/.env
container_name: crux_backend_adonis
container_name: zfg-backend-adonis
ports:
- "3333:3333"
depends_on:
@@ -46,7 +46,7 @@ services:
postgres:
image: postgres:17
container_name: crux_postgres
container_name: zfg-postgres
ports:
- "5432:5432"
environment:
@@ -58,13 +58,13 @@ services:
redis:
image: redis:8.0-M03
container_name: crux_redis
container_name: zfg-redis
ports:
- "6379:6379"
minio:
image: minio/minio:latest
container_name: crux_minio
container_name: zfg-minio
ports:
- "9000:9000"
- "9001:9001"
@@ -77,7 +77,7 @@ services:
minio_create_bucket:
image: minio/mc:latest
container_name: crux_minio_create_bucket
container_name: zfg-minio-create-bucket
depends_on:
- minio
entrypoint: >
@@ -89,7 +89,7 @@ services:
pgadmin:
image: dpage/pgadmin4
container_name: crux_pgadmin
container_name: zfg-pgadmin
ports:
- "5050:80"
environment:
@@ -106,7 +106,7 @@ services:
dockerfile: apps/admin/Dockerfile
env_file:
- ./apps/admin/.env
container_name: crux_admin_react
container_name: zfg-admin-react
working_dir: /app/apps/admin
ports:
- "5173:5173"
@@ -123,7 +123,7 @@ services:
dockerfile: apps/web/Dockerfile
env_file:
- ./apps/web/.env
container_name: crux_web_next
container_name: zfg-web-next
working_dir: /app/apps/web
ports:
- "3000:3000"
+5 -51
View File
@@ -3634,7 +3634,6 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3651,7 +3650,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3668,7 +3666,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3685,7 +3682,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3702,7 +3698,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3719,7 +3714,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3736,7 +3730,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3753,7 +3746,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3770,7 +3762,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3787,7 +3778,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3804,7 +3794,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3821,7 +3810,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3838,7 +3826,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3855,7 +3842,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3872,7 +3858,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3889,7 +3874,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3906,7 +3890,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3923,7 +3906,6 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3940,7 +3922,6 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3957,7 +3938,6 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3974,7 +3954,6 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3991,7 +3970,6 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -4008,7 +3986,6 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -4025,7 +4002,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -4042,7 +4018,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -4059,7 +4034,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -4965,7 +4939,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@japa/assert/-/assert-4.2.0.tgz",
"integrity": "sha512-Krgrcee01BN1StlVwK5JQP6LL5t3DE3uFNbfFoDTfW7kQuHB0xh6yfaV0hrgcoiEjsqmm2OOsVWeju9aXK4vIA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@poppinss/macroable": "^1.1.0",
@@ -9628,7 +9602,7 @@
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
@@ -9646,7 +9620,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/estree": {
@@ -10321,7 +10295,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10338,7 +10311,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10355,7 +10327,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10372,7 +10343,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10389,7 +10359,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10406,7 +10375,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10423,7 +10391,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10440,7 +10407,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10457,7 +10423,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10474,7 +10439,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10491,7 +10455,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10508,7 +10471,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10525,7 +10487,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10542,7 +10503,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10559,7 +10519,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10576,7 +10535,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10593,7 +10551,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10610,7 +10567,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10627,7 +10583,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -10644,7 +10599,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -11673,7 +11627,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -12083,7 +12037,7 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
+1
View File
@@ -26,6 +26,7 @@
"test:admin": "npm run test --workspace @crux-kit/admin-app",
"test:backend": "npm run test --workspace @crux-kit/backend",
"test:web": "npm run test --workspace @crux-kit/web-app",
"test:backend:compose": "docker compose exec -T -e DB_DATABASE=adonis_test backend npm test --",
"version:patch": "npm version patch --no-git-tag-version && npm version patch --workspaces --no-git-tag-version",
"version:minor": "npm version minor --no-git-tag-version && npm version minor --workspaces --no-git-tag-version",
"version:major": "npm version major --no-git-tag-version && npm version major --workspaces --no-git-tag-version",