@quanticjs/create-app 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deps.d.ts +4 -0
- package/dist/deps.js +97 -0
- package/dist/deps.js.map +1 -0
- package/dist/generators/backend.d.ts +2 -0
- package/dist/generators/backend.js +20 -0
- package/dist/generators/backend.js.map +1 -0
- package/dist/generators/bff.d.ts +2 -0
- package/dist/generators/bff.js +9 -0
- package/dist/generators/bff.js.map +1 -0
- package/dist/generators/claude.d.ts +2 -0
- package/dist/generators/claude.js +45 -0
- package/dist/generators/claude.js.map +1 -0
- package/dist/generators/docker.d.ts +2 -0
- package/dist/generators/docker.js +10 -0
- package/dist/generators/docker.js.map +1 -0
- package/dist/generators/e2e.d.ts +2 -0
- package/dist/generators/e2e.js +8 -0
- package/dist/generators/e2e.js.map +1 -0
- package/dist/generators/frontend.d.ts +2 -0
- package/dist/generators/frontend.js +28 -0
- package/dist/generators/frontend.js.map +1 -0
- package/dist/generators/module.d.ts +2 -0
- package/dist/generators/module.js +35 -0
- package/dist/generators/module.js.map +1 -0
- package/dist/generators/root.d.ts +2 -0
- package/dist/generators/root.js +7 -0
- package/dist/generators/root.js.map +1 -0
- package/dist/generators/scripts.d.ts +2 -0
- package/dist/generators/scripts.js +10 -0
- package/dist/generators/scripts.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts.d.ts +8 -0
- package/dist/prompts.js +53 -0
- package/dist/prompts.js.map +1 -0
- package/dist/scaffold.d.ts +2 -0
- package/dist/scaffold.js +79 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/utils/exec.d.ts +2 -0
- package/dist/utils/exec.js +14 -0
- package/dist/utils/exec.js.map +1 -0
- package/dist/utils/template.d.ts +10 -0
- package/dist/utils/template.js +20 -0
- package/dist/utils/template.js.map +1 -0
- package/dist/utils/validate.d.ts +4 -0
- package/dist/utils/validate.js +27 -0
- package/dist/utils/validate.js.map +1 -0
- package/package.json +50 -0
- package/templates/backend/app.module.ts.ejs +61 -0
- package/templates/backend/bff.controller.ts.ejs +60 -0
- package/templates/backend/bff.module.ts.ejs +10 -0
- package/templates/backend/bff.service.ts.ejs +6 -0
- package/templates/backend/create-schema.migration.ts.ejs +15 -0
- package/templates/backend/data-source.ts.ejs +13 -0
- package/templates/backend/env.example.ejs +30 -0
- package/templates/backend/main.ts.ejs +43 -0
- package/templates/backend/module.ts.ejs +14 -0
- package/templates/backend/nest-cli.json.ejs +8 -0
- package/templates/backend/package.json.ejs +23 -0
- package/templates/backend/tsconfig.build.json.ejs +4 -0
- package/templates/backend/tsconfig.json.ejs +24 -0
- package/templates/claude/CLAUDE.md.ejs +86 -0
- package/templates/claude/hooks/auto-format.sh +22 -0
- package/templates/claude/hooks/check-secrets.sh +49 -0
- package/templates/claude/hooks/guard-destructive.sh +42 -0
- package/templates/claude/hooks/on-compaction.sh +29 -0
- package/templates/claude/mcp.json +10 -0
- package/templates/claude/rules/api-patterns.md +86 -0
- package/templates/claude/rules/auth-patterns.md +109 -0
- package/templates/claude/rules/backend-patterns.md +421 -0
- package/templates/claude/rules/database-patterns.md +96 -0
- package/templates/claude/rules/docker-patterns.md +86 -0
- package/templates/claude/rules/frontend-patterns.md +262 -0
- package/templates/claude/rules/observability-backend.md +132 -0
- package/templates/claude/rules/observability-frontend.md +49 -0
- package/templates/claude/rules/playwright-mcp.md +80 -0
- package/templates/claude/rules/resilience-ops.md +103 -0
- package/templates/claude/rules/testing-e2e-ui.md +190 -0
- package/templates/claude/rules/testing-patterns.md +94 -0
- package/templates/claude/rules/workflow-backend.md +64 -0
- package/templates/claude/rules/workflow-frontend.md +60 -0
- package/templates/claude/settings.json +68 -0
- package/templates/claude/skills/add-api-endpoint/SKILL.md +59 -0
- package/templates/claude/skills/add-auth-endpoint/SKILL.md +68 -0
- package/templates/claude/skills/add-entity/SKILL.md +56 -0
- package/templates/claude/skills/add-event/SKILL.md +127 -0
- package/templates/claude/skills/add-feature/SKILL.md +20 -0
- package/templates/claude/skills/add-frontend-page/SKILL.md +75 -0
- package/templates/claude/skills/add-handler/SKILL.md +105 -0
- package/templates/claude/skills/add-integration/SKILL.md +176 -0
- package/templates/claude/skills/add-migration/SKILL.md +20 -0
- package/templates/claude/skills/add-module/SKILL.md +89 -0
- package/templates/claude/skills/add-realtime/SKILL.md +119 -0
- package/templates/claude/skills/audit-rules/SKILL.md +120 -0
- package/templates/claude/skills/debugging/SKILL.md +105 -0
- package/templates/claude/skills/docker-dev/SKILL.md +86 -0
- package/templates/claude/skills/e2e-audit/SKILL.md +85 -0
- package/templates/claude/skills/e2e-full/SKILL.md +132 -0
- package/templates/claude/skills/e2e-scan/SKILL.md +171 -0
- package/templates/claude/skills/e2e-verify/SKILL.md +145 -0
- package/templates/claude/skills/fix-bug/SKILL.md +33 -0
- package/templates/claude/skills/implement-spec/SKILL.md +98 -0
- package/templates/claude/skills/review-code/SKILL.md +109 -0
- package/templates/claude/skills/review-spec/SKILL.md +216 -0
- package/templates/claude/skills/run-tests/SKILL.md +37 -0
- package/templates/claude/skills/specify/SKILL.md +87 -0
- package/templates/claude/skills/write-backend-tests/SKILL.md +182 -0
- package/templates/claude/skills/write-ui-tests/SKILL.md +118 -0
- package/templates/docker/Dockerfile.client.ejs +14 -0
- package/templates/docker/Dockerfile.ejs +28 -0
- package/templates/docker/docker-compose.test.yml.ejs +54 -0
- package/templates/docker/docker-compose.yml.ejs +76 -0
- package/templates/docker/nginx.conf.ejs +21 -0
- package/templates/frontend/App.tsx.ejs +64 -0
- package/templates/frontend/DashboardPage.tsx.ejs +37 -0
- package/templates/frontend/LoginPage.tsx.ejs +20 -0
- package/templates/frontend/NotFoundPage.tsx.ejs +15 -0
- package/templates/frontend/api-client.ts.ejs +15 -0
- package/templates/frontend/index.css.ejs +57 -0
- package/templates/frontend/index.html.ejs +13 -0
- package/templates/frontend/main.tsx.ejs +10 -0
- package/templates/frontend/package.json.ejs +16 -0
- package/templates/frontend/playwright.config.ts.ejs +20 -0
- package/templates/frontend/postcss.config.js.ejs +3 -0
- package/templates/frontend/smoke.spec.ts.ejs +37 -0
- package/templates/frontend/tailwind.config.ts.ejs +56 -0
- package/templates/frontend/tsconfig.json.ejs +25 -0
- package/templates/frontend/tsconfig.node.json.ejs +15 -0
- package/templates/frontend/utils.ts.ejs +6 -0
- package/templates/frontend/vite-env.d.ts.ejs +1 -0
- package/templates/frontend/vite.config.ts.ejs +20 -0
- package/templates/root/gitignore.ejs +9 -0
- package/templates/root/prettierrc.ejs +7 -0
- package/templates/scripts/init-db.sh.ejs +8 -0
- package/templates/scripts/save-auth-state.ts.ejs +24 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Write Tests — Backend
|
|
2
|
+
|
|
3
|
+
## Usage
|
|
4
|
+
```
|
|
5
|
+
/write-backend-tests src/project/commands/CreateItemHandler.ts
|
|
6
|
+
/write-backend-tests src/project/controllers/ItemsController.ts
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## 1. Handler Unit Test (`*.spec.ts`)
|
|
10
|
+
|
|
11
|
+
Test the handler directly — mock repositories, assert `Result<T>`.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { Test } from '@nestjs/testing';
|
|
15
|
+
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
16
|
+
import { createMockRepository, ErrorType } from '@quanticjs/core';
|
|
17
|
+
import { CreateItemHandler } from './CreateItemHandler';
|
|
18
|
+
import { CreateItemCommand } from './CreateItemCommand';
|
|
19
|
+
import { Item } from '../entities/Item.entity';
|
|
20
|
+
|
|
21
|
+
describe('CreateItemHandler', () => {
|
|
22
|
+
let handler: CreateItemHandler;
|
|
23
|
+
let itemRepo: ReturnType<typeof createMockRepository>;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
itemRepo = createMockRepository();
|
|
27
|
+
itemRepo.create.mockImplementation((dto: any) => ({
|
|
28
|
+
...dto,
|
|
29
|
+
id: 'item-1',
|
|
30
|
+
createdAt: new Date(),
|
|
31
|
+
updatedAt: new Date(),
|
|
32
|
+
}));
|
|
33
|
+
itemRepo.save.mockImplementation((entity: any) => Promise.resolve(entity));
|
|
34
|
+
|
|
35
|
+
const module = await Test.createTestingModule({
|
|
36
|
+
providers: [
|
|
37
|
+
CreateItemHandler,
|
|
38
|
+
{ provide: getRepositoryToken(Item), useValue: itemRepo },
|
|
39
|
+
],
|
|
40
|
+
}).compile();
|
|
41
|
+
|
|
42
|
+
handler = module.get(CreateItemHandler);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should create item and return success', async () => {
|
|
46
|
+
const command = new CreateItemCommand({ name: 'Widget', userId: 'user-1' });
|
|
47
|
+
const result = await handler.execute(command);
|
|
48
|
+
|
|
49
|
+
expect(result.isSuccess).toBe(true);
|
|
50
|
+
expect(result.value!.name).toBe('Widget');
|
|
51
|
+
expect(itemRepo.save).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return conflict when name already exists', async () => {
|
|
55
|
+
itemRepo.findOne.mockResolvedValue({ id: 'existing', name: 'Widget' });
|
|
56
|
+
const command = new CreateItemCommand({ name: 'Widget', userId: 'user-1' });
|
|
57
|
+
const result = await handler.execute(command);
|
|
58
|
+
|
|
59
|
+
expect(result.isSuccess).toBe(false);
|
|
60
|
+
expect(result.errorType).toBe(ErrorType.Conflict);
|
|
61
|
+
expect(itemRepo.save).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Key Rules
|
|
67
|
+
- Use `createMockRepository()` from `@quanticjs/core` — never hand-roll mocks
|
|
68
|
+
- Override specific methods with `.mockImplementation()` for test scenarios
|
|
69
|
+
- Assert via `result.isSuccess`, `result.value`, `result.errorType` — never try/catch
|
|
70
|
+
- Test both success AND failure paths (NotFound, Conflict, Forbidden, ValidationError)
|
|
71
|
+
|
|
72
|
+
## 2. Controller Integration Test (`*.spec.ts`)
|
|
73
|
+
|
|
74
|
+
Test HTTP request → validation → bus dispatch → response. Mock CommandBus/QueryBus.
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { Test } from '@nestjs/testing';
|
|
78
|
+
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
79
|
+
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
|
80
|
+
import { APP_GUARD } from '@nestjs/core';
|
|
81
|
+
import request from 'supertest';
|
|
82
|
+
import { Result } from '@quanticjs/core';
|
|
83
|
+
import { ItemsController } from './ItemsController';
|
|
84
|
+
|
|
85
|
+
class MockAuthGuard {
|
|
86
|
+
canActivate(context: any) {
|
|
87
|
+
const req = context.switchToHttp().getRequest();
|
|
88
|
+
req.user = {
|
|
89
|
+
keycloakId: 'user-1',
|
|
90
|
+
email: 'test@test.com',
|
|
91
|
+
roles: ['admin'],
|
|
92
|
+
};
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('ItemsController (integration)', () => {
|
|
98
|
+
let app: INestApplication;
|
|
99
|
+
let commandBus: { execute: jest.Mock };
|
|
100
|
+
let queryBus: { execute: jest.Mock };
|
|
101
|
+
|
|
102
|
+
beforeAll(async () => {
|
|
103
|
+
commandBus = { execute: jest.fn() };
|
|
104
|
+
queryBus = { execute: jest.fn() };
|
|
105
|
+
|
|
106
|
+
const module = await Test.createTestingModule({
|
|
107
|
+
controllers: [ItemsController],
|
|
108
|
+
providers: [
|
|
109
|
+
{ provide: CommandBus, useValue: commandBus },
|
|
110
|
+
{ provide: QueryBus, useValue: queryBus },
|
|
111
|
+
{ provide: APP_GUARD, useClass: MockAuthGuard },
|
|
112
|
+
],
|
|
113
|
+
}).compile();
|
|
114
|
+
|
|
115
|
+
app = module.createNestApplication();
|
|
116
|
+
app.useGlobalPipes(new ValidationPipe({
|
|
117
|
+
whitelist: true,
|
|
118
|
+
forbidNonWhitelisted: true,
|
|
119
|
+
transform: true,
|
|
120
|
+
}));
|
|
121
|
+
await app.init();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterAll(() => app.close());
|
|
125
|
+
|
|
126
|
+
it('should create item with valid payload', async () => {
|
|
127
|
+
commandBus.execute.mockResolvedValue(Result.success({ id: 'item-1' }));
|
|
128
|
+
|
|
129
|
+
const res = await request(app.getHttpServer())
|
|
130
|
+
.post('/api/items')
|
|
131
|
+
.send({ name: 'Widget', description: 'A widget' });
|
|
132
|
+
|
|
133
|
+
expect(res.status).toBe(201);
|
|
134
|
+
expect(commandBus.execute).toHaveBeenCalledTimes(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should reject invalid payload', async () => {
|
|
138
|
+
const res = await request(app.getHttpServer())
|
|
139
|
+
.post('/api/items')
|
|
140
|
+
.send({});
|
|
141
|
+
|
|
142
|
+
expect(res.status).toBe(400);
|
|
143
|
+
expect(commandBus.execute).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Key Rules
|
|
149
|
+
- MockAuthGuard sets `req.user` with `keycloakId`, `email`, `roles`
|
|
150
|
+
- ValidationPipe with `whitelist + forbidNonWhitelisted` — tests class-validator DTOs
|
|
151
|
+
- Mock CommandBus/QueryBus return `Result.success()` or `Result.failure()`
|
|
152
|
+
- Test validation (400), auth (401/403), success (200/201), and not-found (404) cases
|
|
153
|
+
- Use `beforeAll` / `afterAll` for app lifecycle (not beforeEach — too slow)
|
|
154
|
+
|
|
155
|
+
## 3. Validator Unit Test (`*.spec.ts`)
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
describe('CreateItemValidator', () => {
|
|
159
|
+
const validator = new CreateItemValidator();
|
|
160
|
+
|
|
161
|
+
it('should pass with valid input', () => {
|
|
162
|
+
const result = validator.validate(new CreateItemCommand({ name: 'Valid' }));
|
|
163
|
+
expect(result.isSuccess).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should fail when name is empty', () => {
|
|
167
|
+
const result = validator.validate(new CreateItemCommand({ name: '' }));
|
|
168
|
+
expect(result.isSuccess).toBe(false);
|
|
169
|
+
expect(result.errorType).toBe(ErrorType.ValidationError);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Test File Naming
|
|
175
|
+
| Type | Pattern | Location |
|
|
176
|
+
|------|---------|----------|
|
|
177
|
+
| Handler unit | `CreateItemHandler.spec.ts` | Next to handler file |
|
|
178
|
+
| Validator unit | `CreateItemValidator.spec.ts` | Next to validator file |
|
|
179
|
+
| Controller integration | `ItemsController.spec.ts` | Next to controller file |
|
|
180
|
+
|
|
181
|
+
## Mandatory Coverage
|
|
182
|
+
Every handler test must cover: happy path, validation failure, not found, conflict, permission check.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Write UI Tests
|
|
2
|
+
|
|
3
|
+
Write mocked E2E specs using Playwright. All APIs are mocked with `page.route()` — tests run fast, need no backend, and verify the UI renders correctly for all states.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
```
|
|
7
|
+
/write-ui-tests /projects
|
|
8
|
+
/write-ui-tests /settings/profile
|
|
9
|
+
/write-ui-tests client/src/pages/SettingsPage.tsx
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Steps
|
|
13
|
+
|
|
14
|
+
### Step 1: Write mocked E2E spec (`client/e2e/*.spec.ts`)
|
|
15
|
+
|
|
16
|
+
All 4 states are **mandatory**:
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import { test, expect } from '@playwright/test';
|
|
20
|
+
|
|
21
|
+
test.describe('Projects Page', () => {
|
|
22
|
+
test.beforeEach(async ({ page }) => {
|
|
23
|
+
// Mock auth
|
|
24
|
+
await page.route('**/auth/me', route => route.fulfill({
|
|
25
|
+
status: 200,
|
|
26
|
+
body: JSON.stringify({ keycloakId: 'kc-1', roles: ['user'] }),
|
|
27
|
+
}));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// HAPPY PATH
|
|
31
|
+
test('shows projects on success', async ({ page }) => {
|
|
32
|
+
await page.route('**/api/projects*', route => route.fulfill({
|
|
33
|
+
status: 200, json: { data: [{ id: '1', name: 'Project A' }] },
|
|
34
|
+
}));
|
|
35
|
+
await page.goto('/projects');
|
|
36
|
+
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ERROR STATE
|
|
40
|
+
test('shows error on API failure', async ({ page }) => {
|
|
41
|
+
await page.route('**/api/projects*', route => route.fulfill({ status: 500 }));
|
|
42
|
+
await page.goto('/projects');
|
|
43
|
+
await expect(page.getByText(/something went wrong|error|try again/i)).toBeVisible();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// EMPTY STATE
|
|
47
|
+
test('shows empty state when no projects', async ({ page }) => {
|
|
48
|
+
await page.route('**/api/projects*', route => route.fulfill({
|
|
49
|
+
status: 200, json: { data: [] },
|
|
50
|
+
}));
|
|
51
|
+
await page.goto('/projects');
|
|
52
|
+
await expect(page.getByText(/no projects/i)).toBeVisible();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// LOADING STATE
|
|
56
|
+
test('shows loading while fetching', async ({ page }) => {
|
|
57
|
+
await page.route('**/api/projects*', async route => {
|
|
58
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
59
|
+
await route.fulfill({ status: 200, json: { data: [] } });
|
|
60
|
+
});
|
|
61
|
+
await page.goto('/projects');
|
|
62
|
+
// Assert skeleton or spinner visible before data loads
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Step 2: Write component test (`client/src/<path>/__tests__/<Component>.test.tsx`)
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import { render, screen } from '@testing-library/react';
|
|
71
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
72
|
+
import { createClient, QuanticProvider } from '@quanticjs/react-core';
|
|
73
|
+
import { QuanticQueryProvider } from '@quanticjs/react-query';
|
|
74
|
+
import { ToastProvider, ErrorBoundary } from '@quanticjs/react-ui';
|
|
75
|
+
import { describe, it, expect } from 'vitest';
|
|
76
|
+
|
|
77
|
+
const testClient = createClient({ baseUrl: '/api' });
|
|
78
|
+
|
|
79
|
+
function TestWrapper({ children }: { children: React.ReactNode }) {
|
|
80
|
+
return (
|
|
81
|
+
<QuanticProvider client={testClient}>
|
|
82
|
+
<QuanticQueryProvider>
|
|
83
|
+
<ToastProvider>
|
|
84
|
+
<ErrorBoundary fallback={<div>Error</div>}>
|
|
85
|
+
<MemoryRouter>{children}</MemoryRouter>
|
|
86
|
+
</ErrorBoundary>
|
|
87
|
+
</ToastProvider>
|
|
88
|
+
</QuanticQueryProvider>
|
|
89
|
+
</QuanticProvider>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('ProjectsPage', () => {
|
|
94
|
+
it('renders heading', () => {
|
|
95
|
+
render(<ProjectsPage />, { wrapper: TestWrapper });
|
|
96
|
+
expect(screen.getByRole('heading', { name: 'Projects' })).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Selector Rules
|
|
102
|
+
- `getByRole` > `getByLabelText` > `getByText` > `getByPlaceholder` > `getByTestId`
|
|
103
|
+
- NEVER use CSS selectors (`.class`, `#id`, `div > span`)
|
|
104
|
+
- NEVER use `page.waitForTimeout()`
|
|
105
|
+
- NEVER use `fireEvent` — use `userEvent` in component tests
|
|
106
|
+
|
|
107
|
+
## Mock Rules
|
|
108
|
+
- Mock all APIs with `page.route()`
|
|
109
|
+
- Auth mocking: mock `/auth/me` endpoint — NEVER use `localStorage`
|
|
110
|
+
|
|
111
|
+
## Assertion Rules
|
|
112
|
+
- Assertions verify **behavior**, not just element existence
|
|
113
|
+
- Verify API calls were made with `waitForResponse` for mutation actions
|
|
114
|
+
- Test interactive outcomes: after clicking a filter, verify filtered results
|
|
115
|
+
- NEVER use `expect(await el.isVisible()).toBe(true)` — use `await expect(el).toBeVisible()`
|
|
116
|
+
|
|
117
|
+
## Responsive Testing
|
|
118
|
+
Test at mobile viewport (375x812) and desktop for all pages.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
FROM node:20-alpine AS builder
|
|
2
|
+
WORKDIR /app
|
|
3
|
+
COPY package*.json ./
|
|
4
|
+
RUN npm ci
|
|
5
|
+
COPY . .
|
|
6
|
+
RUN npm run build
|
|
7
|
+
|
|
8
|
+
FROM nginx:alpine AS production
|
|
9
|
+
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
10
|
+
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
11
|
+
RUN addgroup -g 1001 -S appgroup && adduser -u 1001 -S appuser -G appgroup
|
|
12
|
+
USER appuser
|
|
13
|
+
EXPOSE 80
|
|
14
|
+
CMD ["nginx", "-g", "daemon off;"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# ---- Development ----
|
|
2
|
+
FROM node:20-alpine AS development
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
COPY package*.json ./
|
|
5
|
+
RUN npm ci
|
|
6
|
+
COPY tsconfig*.json nest-cli.json ./
|
|
7
|
+
# src/ is volume-mounted at runtime
|
|
8
|
+
CMD ["npm", "run", "start:dev"]
|
|
9
|
+
|
|
10
|
+
# ---- Builder ----
|
|
11
|
+
FROM node:20-alpine AS builder
|
|
12
|
+
WORKDIR /app
|
|
13
|
+
COPY package*.json ./
|
|
14
|
+
RUN npm ci
|
|
15
|
+
COPY . .
|
|
16
|
+
RUN npm run build
|
|
17
|
+
|
|
18
|
+
# ---- Production ----
|
|
19
|
+
FROM node:20-alpine AS production
|
|
20
|
+
WORKDIR /app
|
|
21
|
+
ENV NODE_ENV=production
|
|
22
|
+
RUN addgroup -g 1001 -S appgroup && adduser -u 1001 -S appuser -G appgroup
|
|
23
|
+
COPY package*.json ./
|
|
24
|
+
RUN npm ci --only=production && npm cache clean --force
|
|
25
|
+
COPY --from=builder /app/dist ./dist
|
|
26
|
+
USER appuser
|
|
27
|
+
EXPOSE 3000
|
|
28
|
+
CMD ["node", "dist/main.js"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres-test:
|
|
3
|
+
image: postgres:16-alpine
|
|
4
|
+
environment:
|
|
5
|
+
POSTGRES_USER: postgres
|
|
6
|
+
POSTGRES_PASSWORD: postgres
|
|
7
|
+
POSTGRES_DB: <%= projectNameUnderscored %>_test
|
|
8
|
+
healthcheck:
|
|
9
|
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
10
|
+
interval: 5s
|
|
11
|
+
timeout: 5s
|
|
12
|
+
retries: 5
|
|
13
|
+
|
|
14
|
+
redis-test:
|
|
15
|
+
image: redis:7-alpine
|
|
16
|
+
healthcheck:
|
|
17
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
18
|
+
interval: 5s
|
|
19
|
+
timeout: 5s
|
|
20
|
+
retries: 5
|
|
21
|
+
|
|
22
|
+
keycloak-test:
|
|
23
|
+
image: quay.io/keycloak/keycloak:24.0
|
|
24
|
+
command: start-dev
|
|
25
|
+
environment:
|
|
26
|
+
KC_DB: dev-mem
|
|
27
|
+
KEYCLOAK_ADMIN: admin
|
|
28
|
+
KEYCLOAK_ADMIN_PASSWORD: admin
|
|
29
|
+
KC_HOSTNAME_STRICT: "false"
|
|
30
|
+
KC_HTTP_ENABLED: "true"
|
|
31
|
+
ports:
|
|
32
|
+
- "8099:8080"
|
|
33
|
+
|
|
34
|
+
backend-test:
|
|
35
|
+
build:
|
|
36
|
+
context: .
|
|
37
|
+
target: development
|
|
38
|
+
ports:
|
|
39
|
+
- "3099:3000"
|
|
40
|
+
environment:
|
|
41
|
+
NODE_ENV: test
|
|
42
|
+
PORT: 3000
|
|
43
|
+
DATABASE_HOST: postgres-test
|
|
44
|
+
DATABASE_PORT: 5432
|
|
45
|
+
DATABASE_USER: postgres
|
|
46
|
+
DATABASE_PASSWORD: postgres
|
|
47
|
+
DATABASE_NAME: <%= projectNameUnderscored %>_test
|
|
48
|
+
REDIS_URL: redis://redis-test:6379
|
|
49
|
+
KEYCLOAK_URL: http://keycloak-test:8080
|
|
50
|
+
depends_on:
|
|
51
|
+
postgres-test:
|
|
52
|
+
condition: service_healthy
|
|
53
|
+
redis-test:
|
|
54
|
+
condition: service_healthy
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:16-alpine
|
|
4
|
+
environment:
|
|
5
|
+
POSTGRES_USER: postgres
|
|
6
|
+
POSTGRES_PASSWORD: postgres
|
|
7
|
+
POSTGRES_DB: <%= projectNameUnderscored %>
|
|
8
|
+
volumes:
|
|
9
|
+
- postgres_data:/var/lib/postgresql/data
|
|
10
|
+
- ./scripts/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
|
|
11
|
+
healthcheck:
|
|
12
|
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
13
|
+
interval: 5s
|
|
14
|
+
timeout: 5s
|
|
15
|
+
retries: 5
|
|
16
|
+
|
|
17
|
+
redis:
|
|
18
|
+
image: redis:7-alpine
|
|
19
|
+
healthcheck:
|
|
20
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
21
|
+
interval: 5s
|
|
22
|
+
timeout: 5s
|
|
23
|
+
retries: 5
|
|
24
|
+
|
|
25
|
+
keycloak:
|
|
26
|
+
image: quay.io/keycloak/keycloak:24.0
|
|
27
|
+
command: start-dev
|
|
28
|
+
environment:
|
|
29
|
+
KC_DB: dev-mem
|
|
30
|
+
KEYCLOAK_ADMIN: admin
|
|
31
|
+
KEYCLOAK_ADMIN_PASSWORD: admin
|
|
32
|
+
KC_HOSTNAME_STRICT: "false"
|
|
33
|
+
KC_HTTP_ENABLED: "true"
|
|
34
|
+
ports:
|
|
35
|
+
- "8080:8080"
|
|
36
|
+
healthcheck:
|
|
37
|
+
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080; echo -e 'GET /health/ready HTTP/1.1\\r\\nhost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3; cat <&3 | grep -q '200'"]
|
|
38
|
+
interval: 10s
|
|
39
|
+
timeout: 5s
|
|
40
|
+
retries: 15
|
|
41
|
+
|
|
42
|
+
backend:
|
|
43
|
+
build:
|
|
44
|
+
context: .
|
|
45
|
+
target: development
|
|
46
|
+
ports:
|
|
47
|
+
- "3000:3000"
|
|
48
|
+
environment:
|
|
49
|
+
NODE_ENV: development
|
|
50
|
+
PORT: 3000
|
|
51
|
+
DATABASE_HOST: postgres
|
|
52
|
+
DATABASE_PORT: 5432
|
|
53
|
+
DATABASE_USER: postgres
|
|
54
|
+
DATABASE_PASSWORD: postgres
|
|
55
|
+
DATABASE_NAME: <%= projectNameUnderscored %>
|
|
56
|
+
REDIS_URL: redis://redis:6379
|
|
57
|
+
KEYCLOAK_URL: http://keycloak:8080
|
|
58
|
+
KEYCLOAK_REALM: <%= projectName %>
|
|
59
|
+
KEYCLOAK_CLIENT_ID: <%= projectName %>-backend
|
|
60
|
+
KEYCLOAK_CLIENT_SECRET: change-me
|
|
61
|
+
SESSION_SECRET: dev-secret-change-in-prod
|
|
62
|
+
volumes:
|
|
63
|
+
- ./src:/app/src:cached
|
|
64
|
+
- ./nest-cli.json:/app/nest-cli.json:ro
|
|
65
|
+
- ./tsconfig.json:/app/tsconfig.json:ro
|
|
66
|
+
- ./tsconfig.build.json:/app/tsconfig.build.json:ro
|
|
67
|
+
depends_on:
|
|
68
|
+
postgres:
|
|
69
|
+
condition: service_healthy
|
|
70
|
+
redis:
|
|
71
|
+
condition: service_healthy
|
|
72
|
+
keycloak:
|
|
73
|
+
condition: service_healthy
|
|
74
|
+
|
|
75
|
+
volumes:
|
|
76
|
+
postgres_data:
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
server {
|
|
2
|
+
listen 80;
|
|
3
|
+
root /usr/share/nginx/html;
|
|
4
|
+
index index.html;
|
|
5
|
+
|
|
6
|
+
location / {
|
|
7
|
+
try_files $uri $uri/ /index.html;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
location /api/ {
|
|
11
|
+
proxy_pass http://backend:3000;
|
|
12
|
+
proxy_set_header Host $host;
|
|
13
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
location /auth/ {
|
|
17
|
+
proxy_pass http://backend:3000;
|
|
18
|
+
proxy_set_header Host $host;
|
|
19
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { lazy, Suspense } from 'react';
|
|
2
|
+
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
|
3
|
+
import * as Sentry from '@sentry/react';
|
|
4
|
+
import { QuanticProvider, QuanticQueryProvider } from '@quanticjs/react-core';
|
|
5
|
+
import { ToastProvider, ErrorBoundary, Spinner } from '@quanticjs/react-ui';
|
|
6
|
+
import { apiClient } from './lib/api-client';
|
|
7
|
+
|
|
8
|
+
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
|
9
|
+
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
|
10
|
+
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
|
11
|
+
|
|
12
|
+
const router = createBrowserRouter([
|
|
13
|
+
{
|
|
14
|
+
path: '/login',
|
|
15
|
+
element: (
|
|
16
|
+
<Suspense fallback={<Spinner size="lg" />}>
|
|
17
|
+
<LoginPage />
|
|
18
|
+
</Suspense>
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
path: '/',
|
|
23
|
+
element: (
|
|
24
|
+
<Suspense fallback={<Spinner size="lg" />}>
|
|
25
|
+
<DashboardPage />
|
|
26
|
+
</Suspense>
|
|
27
|
+
),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
path: '*',
|
|
31
|
+
element: (
|
|
32
|
+
<Suspense fallback={<Spinner size="lg" />}>
|
|
33
|
+
<NotFoundPage />
|
|
34
|
+
</Suspense>
|
|
35
|
+
),
|
|
36
|
+
},
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export function App() {
|
|
40
|
+
return (
|
|
41
|
+
<Sentry.ErrorBoundary fallback={<div>Something went wrong.</div>}>
|
|
42
|
+
<QuanticProvider client={apiClient}>
|
|
43
|
+
<QuanticQueryProvider>
|
|
44
|
+
<ToastProvider>
|
|
45
|
+
<ErrorBoundary
|
|
46
|
+
fallback={(error, reset) => (
|
|
47
|
+
<div className="flex min-h-screen items-center justify-center">
|
|
48
|
+
<div className="text-center">
|
|
49
|
+
<h1 className="text-2xl font-bold">Something went wrong</h1>
|
|
50
|
+
<button onClick={reset} className="mt-4 underline">
|
|
51
|
+
Try again
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<RouterProvider router={router} />
|
|
58
|
+
</ErrorBoundary>
|
|
59
|
+
</ToastProvider>
|
|
60
|
+
</QuanticQueryProvider>
|
|
61
|
+
</QuanticProvider>
|
|
62
|
+
</Sentry.ErrorBoundary>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useApiQuery } from '@quanticjs/react-query';
|
|
2
|
+
import { Skeleton } from '@quanticjs/react-ui';
|
|
3
|
+
|
|
4
|
+
interface UserSession {
|
|
5
|
+
id: string;
|
|
6
|
+
email: string;
|
|
7
|
+
displayName: string;
|
|
8
|
+
role: string;
|
|
9
|
+
roles: string[];
|
|
10
|
+
permissions: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function DashboardPage() {
|
|
14
|
+
const { data: session, isLoading } = useApiQuery<UserSession>(
|
|
15
|
+
['auth', 'session'],
|
|
16
|
+
(api) => api.get('/auth/me'),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
if (isLoading) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="p-8 space-y-4">
|
|
22
|
+
<Skeleton className="h-8 w-48" />
|
|
23
|
+
<Skeleton className="h-4 w-64" />
|
|
24
|
+
<Skeleton className="h-32 w-full" />
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="p-8">
|
|
31
|
+
<h1 className="text-2xl font-bold text-foreground">
|
|
32
|
+
Welcome, {session?.displayName}
|
|
33
|
+
</h1>
|
|
34
|
+
<p className="text-muted-foreground mt-2">Dashboard content goes here.</p>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export default function LoginPage() {
|
|
2
|
+
const handleLogin = () => {
|
|
3
|
+
window.location.href = '/auth/login?provider=keycloak&returnTo=/';
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
return (
|
|
7
|
+
<div className="flex min-h-screen items-center justify-center bg-background">
|
|
8
|
+
<div className="w-full max-w-sm space-y-6 text-center">
|
|
9
|
+
<h1 className="text-3xl font-bold text-foreground">Welcome</h1>
|
|
10
|
+
<p className="text-muted-foreground">Sign in to continue</p>
|
|
11
|
+
<button
|
|
12
|
+
onClick={handleLogin}
|
|
13
|
+
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
|
14
|
+
>
|
|
15
|
+
Sign in with SSO
|
|
16
|
+
</button>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Link } from 'react-router-dom';
|
|
2
|
+
|
|
3
|
+
export default function NotFoundPage() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="flex min-h-screen items-center justify-center bg-background">
|
|
6
|
+
<div className="text-center">
|
|
7
|
+
<h1 className="text-6xl font-bold text-foreground">404</h1>
|
|
8
|
+
<p className="mt-4 text-muted-foreground">Page not found</p>
|
|
9
|
+
<Link to="/" className="mt-6 inline-block text-primary underline">
|
|
10
|
+
Go home
|
|
11
|
+
</Link>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createClient, correlationId } from '@quanticjs/react-core';
|
|
2
|
+
|
|
3
|
+
export const apiClient = createClient({
|
|
4
|
+
baseUrl: '/api',
|
|
5
|
+
interceptors: [correlationId()],
|
|
6
|
+
auth: {
|
|
7
|
+
refresh: () =>
|
|
8
|
+
fetch('/auth/refresh', { method: 'POST', credentials: 'include' }).then((r) => {
|
|
9
|
+
if (!r.ok) throw r;
|
|
10
|
+
}),
|
|
11
|
+
onRefreshFailure: () => {
|
|
12
|
+
window.location.href = '/auth/login?returnTo=' + encodeURIComponent(window.location.pathname);
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
});
|