@rhino-dev/rhino-nestjs 0.2.5
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/LICENSE +21 -0
- package/README.md +427 -0
- package/dist/blueprint/blueprint-parser.d.ts +130 -0
- package/dist/blueprint/blueprint-parser.js +276 -0
- package/dist/blueprint/blueprint-parser.js.map +1 -0
- package/dist/blueprint/blueprint-runner.d.ts +51 -0
- package/dist/blueprint/blueprint-runner.js +238 -0
- package/dist/blueprint/blueprint-runner.js.map +1 -0
- package/dist/blueprint/blueprint-validator.d.ts +19 -0
- package/dist/blueprint/blueprint-validator.js +163 -0
- package/dist/blueprint/blueprint-validator.js.map +1 -0
- package/dist/blueprint/generators/policy-generator.d.ts +40 -0
- package/dist/blueprint/generators/policy-generator.js +157 -0
- package/dist/blueprint/generators/policy-generator.js.map +1 -0
- package/dist/blueprint/generators/prisma-schema-generator.d.ts +43 -0
- package/dist/blueprint/generators/prisma-schema-generator.js +197 -0
- package/dist/blueprint/generators/prisma-schema-generator.js.map +1 -0
- package/dist/blueprint/generators/resource-definition-generator.d.ts +52 -0
- package/dist/blueprint/generators/resource-definition-generator.js +229 -0
- package/dist/blueprint/generators/resource-definition-generator.js.map +1 -0
- package/dist/blueprint/generators/seeder-generator.d.ts +21 -0
- package/dist/blueprint/generators/seeder-generator.js +135 -0
- package/dist/blueprint/generators/seeder-generator.js.map +1 -0
- package/dist/blueprint/generators/test-generator.d.ts +28 -0
- package/dist/blueprint/generators/test-generator.js +275 -0
- package/dist/blueprint/generators/test-generator.js.map +1 -0
- package/dist/blueprint/manifest-manager.d.ts +58 -0
- package/dist/blueprint/manifest-manager.js +137 -0
- package/dist/blueprint/manifest-manager.js.map +1 -0
- package/dist/cli/commands/blueprint.command.d.ts +16 -0
- package/dist/cli/commands/blueprint.command.js +35 -0
- package/dist/cli/commands/blueprint.command.js.map +1 -0
- package/dist/cli/commands/export-postman.command.d.ts +5 -0
- package/dist/cli/commands/export-postman.command.js +39 -0
- package/dist/cli/commands/export-postman.command.js.map +1 -0
- package/dist/cli/commands/export-types.command.d.ts +4 -0
- package/dist/cli/commands/export-types.command.js +36 -0
- package/dist/cli/commands/export-types.command.js.map +1 -0
- package/dist/cli/commands/generate.command.d.ts +6 -0
- package/dist/cli/commands/generate.command.js +212 -0
- package/dist/cli/commands/generate.command.js.map +1 -0
- package/dist/cli/commands/install.command.d.ts +8 -0
- package/dist/cli/commands/install.command.js +205 -0
- package/dist/cli/commands/install.command.js.map +1 -0
- package/dist/cli/index.d.ts +17 -0
- package/dist/cli/index.js +123 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/utils/io.d.ts +12 -0
- package/dist/cli/utils/io.js +74 -0
- package/dist/cli/utils/io.js.map +1 -0
- package/dist/cli/utils/prompt.d.ts +12 -0
- package/dist/cli/utils/prompt.js +78 -0
- package/dist/cli/utils/prompt.js.map +1 -0
- package/dist/constants/tokens.d.ts +3 -0
- package/dist/constants/tokens.js +7 -0
- package/dist/constants/tokens.js.map +1 -0
- package/dist/controllers/auth.controller.d.ts +59 -0
- package/dist/controllers/auth.controller.js +121 -0
- package/dist/controllers/auth.controller.js.map +1 -0
- package/dist/controllers/global.controller.d.ts +44 -0
- package/dist/controllers/global.controller.js +357 -0
- package/dist/controllers/global.controller.js.map +1 -0
- package/dist/controllers/invitation.controller.d.ts +30 -0
- package/dist/controllers/invitation.controller.js +111 -0
- package/dist/controllers/invitation.controller.js.map +1 -0
- package/dist/controllers/nested.controller.d.ts +12 -0
- package/dist/controllers/nested.controller.js +69 -0
- package/dist/controllers/nested.controller.js.map +1 -0
- package/dist/decorators/index.d.ts +33 -0
- package/dist/decorators/index.js +79 -0
- package/dist/decorators/index.js.map +1 -0
- package/dist/errors/rhino-exception.d.ts +24 -0
- package/dist/errors/rhino-exception.js +47 -0
- package/dist/errors/rhino-exception.js.map +1 -0
- package/dist/exporters/postman-exporter.d.ts +21 -0
- package/dist/exporters/postman-exporter.js +396 -0
- package/dist/exporters/postman-exporter.js.map +1 -0
- package/dist/exporters/typescript-exporter.d.ts +23 -0
- package/dist/exporters/typescript-exporter.js +129 -0
- package/dist/exporters/typescript-exporter.js.map +1 -0
- package/dist/guards/jwt-auth.guard.d.ts +16 -0
- package/dist/guards/jwt-auth.guard.js +61 -0
- package/dist/guards/jwt-auth.guard.js.map +1 -0
- package/dist/guards/resource-policy.guard.d.ts +12 -0
- package/dist/guards/resource-policy.guard.js +111 -0
- package/dist/guards/resource-policy.guard.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +171 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptors/hidden-columns.interceptor.d.ts +18 -0
- package/dist/interceptors/hidden-columns.interceptor.js +66 -0
- package/dist/interceptors/hidden-columns.interceptor.js.map +1 -0
- package/dist/interceptors/response.interceptor.d.ts +19 -0
- package/dist/interceptors/response.interceptor.js +47 -0
- package/dist/interceptors/response.interceptor.js.map +1 -0
- package/dist/interfaces/rhino-config.interface.d.ts +103 -0
- package/dist/interfaces/rhino-config.interface.js +3 -0
- package/dist/interfaces/rhino-config.interface.js.map +1 -0
- package/dist/interfaces/rhino-request.interface.d.ts +12 -0
- package/dist/interfaces/rhino-request.interface.js +3 -0
- package/dist/interfaces/rhino-request.interface.js.map +1 -0
- package/dist/middleware/resolve-organization.middleware.d.ts +15 -0
- package/dist/middleware/resolve-organization.middleware.js +38 -0
- package/dist/middleware/resolve-organization.middleware.js.map +1 -0
- package/dist/middleware/route-group.middleware.d.ts +17 -0
- package/dist/middleware/route-group.middleware.js +60 -0
- package/dist/middleware/route-group.middleware.js.map +1 -0
- package/dist/middleware/tenant-route-rewrite.d.ts +76 -0
- package/dist/middleware/tenant-route-rewrite.js +124 -0
- package/dist/middleware/tenant-route-rewrite.js.map +1 -0
- package/dist/policies/resource-policy.d.ts +25 -0
- package/dist/policies/resource-policy.js +77 -0
- package/dist/policies/resource-policy.js.map +1 -0
- package/dist/prisma/prisma-soft-delete.extension.d.ts +16 -0
- package/dist/prisma/prisma-soft-delete.extension.js +57 -0
- package/dist/prisma/prisma-soft-delete.extension.js.map +1 -0
- package/dist/prisma/prisma-uuid.extension.d.ts +9 -0
- package/dist/prisma/prisma-uuid.extension.js +28 -0
- package/dist/prisma/prisma-uuid.extension.js.map +1 -0
- package/dist/prisma/prisma.service.d.ts +38 -0
- package/dist/prisma/prisma.service.js +100 -0
- package/dist/prisma/prisma.service.js.map +1 -0
- package/dist/rhino.config.d.ts +43 -0
- package/dist/rhino.config.js +130 -0
- package/dist/rhino.config.js.map +1 -0
- package/dist/rhino.module.d.ts +61 -0
- package/dist/rhino.module.js +268 -0
- package/dist/rhino.module.js.map +1 -0
- package/dist/services/audit.service.d.ts +35 -0
- package/dist/services/audit.service.js +92 -0
- package/dist/services/audit.service.js.map +1 -0
- package/dist/services/auth.service.d.ts +33 -0
- package/dist/services/auth.service.js +157 -0
- package/dist/services/auth.service.js.map +1 -0
- package/dist/services/invitation.service.d.ts +37 -0
- package/dist/services/invitation.service.js +143 -0
- package/dist/services/invitation.service.js.map +1 -0
- package/dist/services/nested.service.d.ts +34 -0
- package/dist/services/nested.service.js +163 -0
- package/dist/services/nested.service.js.map +1 -0
- package/dist/services/organization.service.d.ts +13 -0
- package/dist/services/organization.service.js +60 -0
- package/dist/services/organization.service.js.map +1 -0
- package/dist/services/query-builder.service.d.ts +31 -0
- package/dist/services/query-builder.service.js +151 -0
- package/dist/services/query-builder.service.js.map +1 -0
- package/dist/services/resource.service.d.ts +38 -0
- package/dist/services/resource.service.js +230 -0
- package/dist/services/resource.service.js.map +1 -0
- package/dist/services/route-registration.service.d.ts +40 -0
- package/dist/services/route-registration.service.js +68 -0
- package/dist/services/route-registration.service.js.map +1 -0
- package/dist/services/scope.service.d.ts +16 -0
- package/dist/services/scope.service.js +32 -0
- package/dist/services/scope.service.js.map +1 -0
- package/dist/services/serializer.service.d.ts +36 -0
- package/dist/services/serializer.service.js +90 -0
- package/dist/services/serializer.service.js.map +1 -0
- package/dist/services/validation.service.d.ts +50 -0
- package/dist/services/validation.service.js +203 -0
- package/dist/services/validation.service.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/dist/utils/fk-chain-walker.d.ts +30 -0
- package/dist/utils/fk-chain-walker.js +23 -0
- package/dist/utils/fk-chain-walker.js.map +1 -0
- package/dist/utils/format.d.ts +7 -0
- package/dist/utils/format.js +28 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/model-builder.d.ts +64 -0
- package/dist/utils/model-builder.js +67 -0
- package/dist/utils/model-builder.js.map +1 -0
- package/dist/utils/permission-matcher.d.ts +46 -0
- package/dist/utils/permission-matcher.js +120 -0
- package/dist/utils/permission-matcher.js.map +1 -0
- package/dist/utils/scope-discovery.d.ts +34 -0
- package/dist/utils/scope-discovery.js +102 -0
- package/dist/utils/scope-discovery.js.map +1 -0
- package/package.json +64 -0
- package/stubs/skills/rhino-audit.md +184 -0
- package/stubs/skills/rhino-bugfix.md +117 -0
- package/stubs/skills/rhino-deploy.md +5 -0
- package/stubs/skills/rhino-docs.md +147 -0
- package/stubs/skills/rhino-feature.md +146 -0
- package/stubs/skills/rhino-migrate.md +181 -0
- package/stubs/skills/rhino-model.md +180 -0
- package/stubs/skills/rhino-plan.md +5 -0
- package/stubs/skills/rhino-policy.md +183 -0
- package/stubs/skills/rhino-refactor.md +147 -0
- package/stubs/skills/rhino-review.md +113 -0
- package/stubs/skills/rhino-scope.md +140 -0
- package/stubs/skills/rhino-test.md +233 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rhino-scope
|
|
3
|
+
description: Create a custom query scope for an Rhino NestJS model — filters additional records via a Prisma where clause applied globally to all queries.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are creating a custom scope for an Rhino NestJS model. Scopes add automatic `where` conditions to every query for the model, similar to Eloquent global scopes in Laravel.
|
|
7
|
+
|
|
8
|
+
## Step 1: Understand When to Use a Scope
|
|
9
|
+
|
|
10
|
+
Use a custom scope when:
|
|
11
|
+
- Every query for a model should automatically include a filter (e.g., `isVisible: true`)
|
|
12
|
+
- The filter depends on request context (e.g., a driver only sees their own trips)
|
|
13
|
+
- You need to apply soft-visibility flags or tenant-adjacent filtering
|
|
14
|
+
|
|
15
|
+
Do NOT use a scope when:
|
|
16
|
+
- The filter is already handled by `belongsToOrganization: true` (org scoping is built in)
|
|
17
|
+
- The filter is something users can control via `?filter[field]=value`
|
|
18
|
+
|
|
19
|
+
## Step 2: Read Context
|
|
20
|
+
|
|
21
|
+
Read:
|
|
22
|
+
- `src/app.module.ts` — the model's current registration config
|
|
23
|
+
- `src/services/scope.service.ts` — how scopes are applied to Prisma queries
|
|
24
|
+
- `prisma/schema.prisma` — the model's fields
|
|
25
|
+
|
|
26
|
+
## Step 3: Define the Scope Class
|
|
27
|
+
|
|
28
|
+
Create `src/scopes/[model]-scope.ts`. A scope is a plain class with an `apply` method that returns Prisma `where` conditions:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// src/scopes/post.scope.ts
|
|
32
|
+
|
|
33
|
+
export class PublishedPostScope {
|
|
34
|
+
/**
|
|
35
|
+
* Applies to every findMany/findFirst/count query for this model.
|
|
36
|
+
* Return a Prisma where fragment to merge into the query.
|
|
37
|
+
*/
|
|
38
|
+
apply(context: { user?: any; organization?: any }): Record<string, any> {
|
|
39
|
+
// Only show published posts to non-admin users
|
|
40
|
+
if (!context.user) {
|
|
41
|
+
return { status: 'published' };
|
|
42
|
+
}
|
|
43
|
+
// Admins see everything — return empty object to add no filter
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
For a context-aware scope that filters by user:
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// src/scopes/driver-trip.scope.ts
|
|
53
|
+
|
|
54
|
+
export class DriverTripScope {
|
|
55
|
+
apply(context: { user?: any; organization?: any }): Record<string, any> {
|
|
56
|
+
if (!context.user?.driverId) return {};
|
|
57
|
+
return { driverId: context.user.driverId };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Step 4: Register the Scope
|
|
63
|
+
|
|
64
|
+
Add the scope class to the model's `scopes` array in `RhinoModule.forRoot()`:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { PublishedPostScope } from './scopes/post.scope';
|
|
68
|
+
|
|
69
|
+
// In app.module.ts
|
|
70
|
+
posts: {
|
|
71
|
+
model: 'post',
|
|
72
|
+
scopes: [PublishedPostScope], // ← Add here
|
|
73
|
+
// ... rest of config
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Multiple scopes are merged with AND logic:
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
trips: {
|
|
81
|
+
model: 'trip',
|
|
82
|
+
scopes: [ActiveTripScope, DriverTripScope],
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Step 5: Write Tests
|
|
87
|
+
|
|
88
|
+
Create `src/scopes/post.scope.spec.ts`:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
import { PublishedPostScope } from './post.scope';
|
|
92
|
+
|
|
93
|
+
describe('PublishedPostScope', () => {
|
|
94
|
+
const scope = new PublishedPostScope();
|
|
95
|
+
|
|
96
|
+
it('filters to published posts for unauthenticated users', () => {
|
|
97
|
+
const where = scope.apply({ user: undefined });
|
|
98
|
+
expect(where).toEqual({ status: 'published' });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns empty filter for admin users', () => {
|
|
102
|
+
const adminUser = { id: 1, permissions: ['*'] };
|
|
103
|
+
const where = scope.apply({ user: adminUser });
|
|
104
|
+
expect(where).toEqual({});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Write an e2e test that verifies the scope is applied during HTTP requests:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
it('excludes draft posts from the public listing', async () => {
|
|
113
|
+
await prisma.post.createMany({
|
|
114
|
+
data: [
|
|
115
|
+
{ title: 'Published', status: 'published' },
|
|
116
|
+
{ title: 'Draft', status: 'draft' },
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const res = await request(app.getHttpServer())
|
|
121
|
+
.get('/api/posts')
|
|
122
|
+
.expect(200);
|
|
123
|
+
|
|
124
|
+
const titles = res.body.data.map((p: any) => p.title);
|
|
125
|
+
expect(titles).toContain('Published');
|
|
126
|
+
expect(titles).not.toContain('Draft');
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Run:
|
|
131
|
+
```bash
|
|
132
|
+
npm test
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Common Mistakes
|
|
136
|
+
|
|
137
|
+
- Scopes apply to ALL queries including `findOne` and `count`. Make sure the scope does not accidentally hide records that users expect to find by ID.
|
|
138
|
+
- Returning `{}` from `apply()` adds no filter — safe default for "no restriction needed".
|
|
139
|
+
- Scopes are AND-merged with the org filter and user-supplied filters. Do not add an `AND: [...]` wrapper yourself.
|
|
140
|
+
- Do not use `req.user` or `request()` inside a scope class — the user context is passed as a parameter.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rhino-test
|
|
3
|
+
description: Write comprehensive tests for an Rhino NestJS model — CRUD, authorization, validation, multi-tenant isolation, soft deletes, and audit trail.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are writing tests for an Rhino NestJS model. This skill covers the full test matrix that all models must satisfy.
|
|
7
|
+
|
|
8
|
+
## Step 1: Read Before Testing
|
|
9
|
+
|
|
10
|
+
Read:
|
|
11
|
+
- `src/app.module.ts` — model registration, policies, and route groups
|
|
12
|
+
- `prisma/schema.prisma` — model fields and relations
|
|
13
|
+
- The policy file for this model
|
|
14
|
+
- Any existing spec files for this model
|
|
15
|
+
|
|
16
|
+
## Step 2: Test Setup Pattern
|
|
17
|
+
|
|
18
|
+
Use NestJS `TestingModule` with a real (or seeded) Prisma client. Keep test setup DRY with helper functions.
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
// test/helpers.ts
|
|
22
|
+
import { INestApplication } from '@nestjs/common';
|
|
23
|
+
import { Test } from '@nestjs/testing';
|
|
24
|
+
import * as request from 'supertest';
|
|
25
|
+
import { AppModule } from '../src/app.module';
|
|
26
|
+
import { PrismaService } from '@rhino-dev/rhino-nestjs';
|
|
27
|
+
import { AuthService } from '@rhino-dev/rhino-nestjs';
|
|
28
|
+
|
|
29
|
+
export async function createTestApp(): Promise<INestApplication> {
|
|
30
|
+
const module = await Test.createTestingModule({
|
|
31
|
+
imports: [AppModule],
|
|
32
|
+
}).compile();
|
|
33
|
+
const app = module.createNestApplication();
|
|
34
|
+
await app.init();
|
|
35
|
+
return app;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function seedOrg(prisma: PrismaService, slug: string) {
|
|
39
|
+
return prisma.client.organization.create({ data: { name: slug, slug } });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function seedUserWithRole(
|
|
43
|
+
prisma: PrismaService,
|
|
44
|
+
auth: AuthService,
|
|
45
|
+
org: any,
|
|
46
|
+
roleSlug: string,
|
|
47
|
+
permissions: string[],
|
|
48
|
+
) {
|
|
49
|
+
const user = await prisma.client.user.create({
|
|
50
|
+
data: { email: `${roleSlug}@test.com`, password: await auth.hashPassword('password') },
|
|
51
|
+
});
|
|
52
|
+
await prisma.client.userRole.create({
|
|
53
|
+
data: { userId: user.id, organizationId: org.id, roleSlug, permissions },
|
|
54
|
+
});
|
|
55
|
+
const { token } = await auth.login(user.email, 'password', undefined);
|
|
56
|
+
return { user, token };
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Step 3: CRUD Test Matrix
|
|
61
|
+
|
|
62
|
+
Write these tests for every model. Replace `posts` with the actual slug and `Post`/`post` with the model name.
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
describe('Posts API', () => {
|
|
66
|
+
let app: INestApplication;
|
|
67
|
+
let prisma: PrismaService;
|
|
68
|
+
let org: any;
|
|
69
|
+
let adminToken: string;
|
|
70
|
+
let editorToken: string;
|
|
71
|
+
let viewerToken: string;
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
app = await createTestApp();
|
|
75
|
+
prisma = app.get(PrismaService);
|
|
76
|
+
org = await seedOrg(prisma, 'test-org');
|
|
77
|
+
({ token: adminToken } = await seedUserWithRole(prisma, ..., org, 'admin', ['*']));
|
|
78
|
+
({ token: editorToken } = await seedUserWithRole(prisma, ..., org, 'editor', [
|
|
79
|
+
'posts.index', 'posts.show', 'posts.store', 'posts.update',
|
|
80
|
+
]));
|
|
81
|
+
({ token: viewerToken } = await seedUserWithRole(prisma, ..., org, 'viewer', [
|
|
82
|
+
'posts.index', 'posts.show',
|
|
83
|
+
]));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterAll(async () => {
|
|
87
|
+
await prisma.client.post.deleteMany();
|
|
88
|
+
await app.close();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── INDEX ─────────────────────────────────────────────────────
|
|
92
|
+
describe('GET /api/:org/posts', () => {
|
|
93
|
+
it('returns 200 with paginated list for admin', async () => {
|
|
94
|
+
await request(app.getHttpServer())
|
|
95
|
+
.get('/api/test-org/posts')
|
|
96
|
+
.set('Authorization', `Bearer ${adminToken}`)
|
|
97
|
+
.expect(200)
|
|
98
|
+
.expect((res) => {
|
|
99
|
+
expect(Array.isArray(res.body.data)).toBe(true);
|
|
100
|
+
expect(res.headers['x-current-page']).toBeDefined();
|
|
101
|
+
expect(res.headers['x-total']).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns 403 when user lacks posts.index permission', async () => {
|
|
106
|
+
const { token: noPermToken } = await seedUserWithRole(prisma, ..., org, 'none', []);
|
|
107
|
+
await request(app.getHttpServer())
|
|
108
|
+
.get('/api/test-org/posts')
|
|
109
|
+
.set('Authorization', `Bearer ${noPermToken}`)
|
|
110
|
+
.expect(403);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns 401 when not authenticated', async () => {
|
|
114
|
+
await request(app.getHttpServer())
|
|
115
|
+
.get('/api/test-org/posts')
|
|
116
|
+
.expect(401);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── SHOW ──────────────────────────────────────────────────────
|
|
121
|
+
describe('GET /api/:org/posts/:id', () => {
|
|
122
|
+
it('returns 200 with the post for admin', async () => { /* ... */ });
|
|
123
|
+
it('returns 404 for a non-existent id', async () => { /* ... */ });
|
|
124
|
+
it('returns 404 for a post from another org', async () => { /* ... multi-tenant isolation */ });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── STORE ─────────────────────────────────────────────────────
|
|
128
|
+
describe('POST /api/:org/posts', () => {
|
|
129
|
+
it('returns 201 for admin with valid payload', async () => {
|
|
130
|
+
const res = await request(app.getHttpServer())
|
|
131
|
+
.post('/api/test-org/posts')
|
|
132
|
+
.set('Authorization', `Bearer ${adminToken}`)
|
|
133
|
+
.send({ title: 'Hello World', content: 'Body text', status: 'draft' })
|
|
134
|
+
.expect(201);
|
|
135
|
+
expect(res.body.data.title).toBe('Hello World');
|
|
136
|
+
// organizationId must NOT come from the request body
|
|
137
|
+
expect(res.body.data.organizationId).toBeDefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns 403 when viewer tries to create', async () => {
|
|
141
|
+
await request(app.getHttpServer())
|
|
142
|
+
.post('/api/test-org/posts')
|
|
143
|
+
.set('Authorization', `Bearer ${viewerToken}`)
|
|
144
|
+
.send({ title: 'Try' })
|
|
145
|
+
.expect(403);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('returns 403 when editor submits a forbidden field', async () => {
|
|
149
|
+
await request(app.getHttpServer())
|
|
150
|
+
.post('/api/test-org/posts')
|
|
151
|
+
.set('Authorization', `Bearer ${editorToken}`)
|
|
152
|
+
.send({ title: 'Try', status: 'published' }) // 'status' not in editor's permittedAttrs
|
|
153
|
+
.expect(403);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('returns 422 when required field is missing', async () => {
|
|
157
|
+
const res = await request(app.getHttpServer())
|
|
158
|
+
.post('/api/test-org/posts')
|
|
159
|
+
.set('Authorization', `Bearer ${adminToken}`)
|
|
160
|
+
.send({}) // empty body
|
|
161
|
+
.expect(422);
|
|
162
|
+
expect(res.body.errors).toBeDefined();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── UPDATE ────────────────────────────────────────────────────
|
|
167
|
+
describe('PUT /api/:org/posts/:id', () => {
|
|
168
|
+
it('returns 200 for admin with partial update', async () => { /* ... */ });
|
|
169
|
+
it('returns 404 for a post from another org', async () => { /* ... */ });
|
|
170
|
+
it('ignores organizationId in the body', async () => { /* ... */ });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── DESTROY ───────────────────────────────────────────────────
|
|
174
|
+
describe('DELETE /api/:org/posts/:id', () => {
|
|
175
|
+
it('returns 204 for admin', async () => { /* ... */ });
|
|
176
|
+
it('soft deletes (sets deletedAt) when softDeletes is enabled', async () => {
|
|
177
|
+
// After DELETE, verify the record still exists but deletedAt is set
|
|
178
|
+
});
|
|
179
|
+
it('returns 403 for viewer', async () => { /* ... */ });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── SOFT DELETES ──────────────────────────────────────────────
|
|
183
|
+
describe('GET /api/:org/posts/trashed', () => {
|
|
184
|
+
it('lists only trashed posts for admin', async () => { /* ... */ });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('POST /api/:org/posts/:id/restore', () => {
|
|
188
|
+
it('restores a trashed post for admin', async () => { /* ... */ });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('DELETE /api/:org/posts/:id/force-delete', () => {
|
|
192
|
+
it('permanently deletes a post for admin', async () => { /* ... */ });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── MULTI-TENANT ISOLATION ────────────────────────────────────
|
|
196
|
+
describe('Multi-tenant isolation', () => {
|
|
197
|
+
it('does not return posts from org B when requesting as org A user', async () => {
|
|
198
|
+
const orgB = await seedOrg(prisma, 'org-b');
|
|
199
|
+
await prisma.client.post.create({ data: { title: 'OrgB Post', organizationId: orgB.id } });
|
|
200
|
+
|
|
201
|
+
const res = await request(app.getHttpServer())
|
|
202
|
+
.get('/api/test-org/posts')
|
|
203
|
+
.set('Authorization', `Bearer ${adminToken}`)
|
|
204
|
+
.expect(200);
|
|
205
|
+
|
|
206
|
+
const titles = res.body.data.map((p: any) => p.title);
|
|
207
|
+
expect(titles).not.toContain('OrgB Post');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Step 4: Filtering and Query Tests
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
describe('Querying', () => {
|
|
217
|
+
it('filters posts by status', async () => { /* ?filter[status]=published */ });
|
|
218
|
+
it('sorts posts by createdAt descending', async () => { /* ?sort=-createdAt */ });
|
|
219
|
+
it('searches posts by title', async () => { /* ?search=hello */ });
|
|
220
|
+
it('paginates posts', async () => { /* ?page=2&per_page=5 */ });
|
|
221
|
+
it('includes related user', async () => { /* ?include=user */ });
|
|
222
|
+
it('rejects disallowed filters', async () => { /* filter[internalNotes]=x → 400 */ });
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Step 5: Run and Fix
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
npm test -- --testPathPattern=posts
|
|
230
|
+
npm test # full suite
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
All pre-existing tests must remain green.
|