@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.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +427 -0
  3. package/dist/blueprint/blueprint-parser.d.ts +130 -0
  4. package/dist/blueprint/blueprint-parser.js +276 -0
  5. package/dist/blueprint/blueprint-parser.js.map +1 -0
  6. package/dist/blueprint/blueprint-runner.d.ts +51 -0
  7. package/dist/blueprint/blueprint-runner.js +238 -0
  8. package/dist/blueprint/blueprint-runner.js.map +1 -0
  9. package/dist/blueprint/blueprint-validator.d.ts +19 -0
  10. package/dist/blueprint/blueprint-validator.js +163 -0
  11. package/dist/blueprint/blueprint-validator.js.map +1 -0
  12. package/dist/blueprint/generators/policy-generator.d.ts +40 -0
  13. package/dist/blueprint/generators/policy-generator.js +157 -0
  14. package/dist/blueprint/generators/policy-generator.js.map +1 -0
  15. package/dist/blueprint/generators/prisma-schema-generator.d.ts +43 -0
  16. package/dist/blueprint/generators/prisma-schema-generator.js +197 -0
  17. package/dist/blueprint/generators/prisma-schema-generator.js.map +1 -0
  18. package/dist/blueprint/generators/resource-definition-generator.d.ts +52 -0
  19. package/dist/blueprint/generators/resource-definition-generator.js +229 -0
  20. package/dist/blueprint/generators/resource-definition-generator.js.map +1 -0
  21. package/dist/blueprint/generators/seeder-generator.d.ts +21 -0
  22. package/dist/blueprint/generators/seeder-generator.js +135 -0
  23. package/dist/blueprint/generators/seeder-generator.js.map +1 -0
  24. package/dist/blueprint/generators/test-generator.d.ts +28 -0
  25. package/dist/blueprint/generators/test-generator.js +275 -0
  26. package/dist/blueprint/generators/test-generator.js.map +1 -0
  27. package/dist/blueprint/manifest-manager.d.ts +58 -0
  28. package/dist/blueprint/manifest-manager.js +137 -0
  29. package/dist/blueprint/manifest-manager.js.map +1 -0
  30. package/dist/cli/commands/blueprint.command.d.ts +16 -0
  31. package/dist/cli/commands/blueprint.command.js +35 -0
  32. package/dist/cli/commands/blueprint.command.js.map +1 -0
  33. package/dist/cli/commands/export-postman.command.d.ts +5 -0
  34. package/dist/cli/commands/export-postman.command.js +39 -0
  35. package/dist/cli/commands/export-postman.command.js.map +1 -0
  36. package/dist/cli/commands/export-types.command.d.ts +4 -0
  37. package/dist/cli/commands/export-types.command.js +36 -0
  38. package/dist/cli/commands/export-types.command.js.map +1 -0
  39. package/dist/cli/commands/generate.command.d.ts +6 -0
  40. package/dist/cli/commands/generate.command.js +212 -0
  41. package/dist/cli/commands/generate.command.js.map +1 -0
  42. package/dist/cli/commands/install.command.d.ts +8 -0
  43. package/dist/cli/commands/install.command.js +205 -0
  44. package/dist/cli/commands/install.command.js.map +1 -0
  45. package/dist/cli/index.d.ts +17 -0
  46. package/dist/cli/index.js +123 -0
  47. package/dist/cli/index.js.map +1 -0
  48. package/dist/cli/utils/io.d.ts +12 -0
  49. package/dist/cli/utils/io.js +74 -0
  50. package/dist/cli/utils/io.js.map +1 -0
  51. package/dist/cli/utils/prompt.d.ts +12 -0
  52. package/dist/cli/utils/prompt.js +78 -0
  53. package/dist/cli/utils/prompt.js.map +1 -0
  54. package/dist/constants/tokens.d.ts +3 -0
  55. package/dist/constants/tokens.js +7 -0
  56. package/dist/constants/tokens.js.map +1 -0
  57. package/dist/controllers/auth.controller.d.ts +59 -0
  58. package/dist/controllers/auth.controller.js +121 -0
  59. package/dist/controllers/auth.controller.js.map +1 -0
  60. package/dist/controllers/global.controller.d.ts +44 -0
  61. package/dist/controllers/global.controller.js +357 -0
  62. package/dist/controllers/global.controller.js.map +1 -0
  63. package/dist/controllers/invitation.controller.d.ts +30 -0
  64. package/dist/controllers/invitation.controller.js +111 -0
  65. package/dist/controllers/invitation.controller.js.map +1 -0
  66. package/dist/controllers/nested.controller.d.ts +12 -0
  67. package/dist/controllers/nested.controller.js +69 -0
  68. package/dist/controllers/nested.controller.js.map +1 -0
  69. package/dist/decorators/index.d.ts +33 -0
  70. package/dist/decorators/index.js +79 -0
  71. package/dist/decorators/index.js.map +1 -0
  72. package/dist/errors/rhino-exception.d.ts +24 -0
  73. package/dist/errors/rhino-exception.js +47 -0
  74. package/dist/errors/rhino-exception.js.map +1 -0
  75. package/dist/exporters/postman-exporter.d.ts +21 -0
  76. package/dist/exporters/postman-exporter.js +396 -0
  77. package/dist/exporters/postman-exporter.js.map +1 -0
  78. package/dist/exporters/typescript-exporter.d.ts +23 -0
  79. package/dist/exporters/typescript-exporter.js +129 -0
  80. package/dist/exporters/typescript-exporter.js.map +1 -0
  81. package/dist/guards/jwt-auth.guard.d.ts +16 -0
  82. package/dist/guards/jwt-auth.guard.js +61 -0
  83. package/dist/guards/jwt-auth.guard.js.map +1 -0
  84. package/dist/guards/resource-policy.guard.d.ts +12 -0
  85. package/dist/guards/resource-policy.guard.js +111 -0
  86. package/dist/guards/resource-policy.guard.js.map +1 -0
  87. package/dist/index.d.ts +60 -0
  88. package/dist/index.js +171 -0
  89. package/dist/index.js.map +1 -0
  90. package/dist/interceptors/hidden-columns.interceptor.d.ts +18 -0
  91. package/dist/interceptors/hidden-columns.interceptor.js +66 -0
  92. package/dist/interceptors/hidden-columns.interceptor.js.map +1 -0
  93. package/dist/interceptors/response.interceptor.d.ts +19 -0
  94. package/dist/interceptors/response.interceptor.js +47 -0
  95. package/dist/interceptors/response.interceptor.js.map +1 -0
  96. package/dist/interfaces/rhino-config.interface.d.ts +103 -0
  97. package/dist/interfaces/rhino-config.interface.js +3 -0
  98. package/dist/interfaces/rhino-config.interface.js.map +1 -0
  99. package/dist/interfaces/rhino-request.interface.d.ts +12 -0
  100. package/dist/interfaces/rhino-request.interface.js +3 -0
  101. package/dist/interfaces/rhino-request.interface.js.map +1 -0
  102. package/dist/middleware/resolve-organization.middleware.d.ts +15 -0
  103. package/dist/middleware/resolve-organization.middleware.js +38 -0
  104. package/dist/middleware/resolve-organization.middleware.js.map +1 -0
  105. package/dist/middleware/route-group.middleware.d.ts +17 -0
  106. package/dist/middleware/route-group.middleware.js +60 -0
  107. package/dist/middleware/route-group.middleware.js.map +1 -0
  108. package/dist/middleware/tenant-route-rewrite.d.ts +76 -0
  109. package/dist/middleware/tenant-route-rewrite.js +124 -0
  110. package/dist/middleware/tenant-route-rewrite.js.map +1 -0
  111. package/dist/policies/resource-policy.d.ts +25 -0
  112. package/dist/policies/resource-policy.js +77 -0
  113. package/dist/policies/resource-policy.js.map +1 -0
  114. package/dist/prisma/prisma-soft-delete.extension.d.ts +16 -0
  115. package/dist/prisma/prisma-soft-delete.extension.js +57 -0
  116. package/dist/prisma/prisma-soft-delete.extension.js.map +1 -0
  117. package/dist/prisma/prisma-uuid.extension.d.ts +9 -0
  118. package/dist/prisma/prisma-uuid.extension.js +28 -0
  119. package/dist/prisma/prisma-uuid.extension.js.map +1 -0
  120. package/dist/prisma/prisma.service.d.ts +38 -0
  121. package/dist/prisma/prisma.service.js +100 -0
  122. package/dist/prisma/prisma.service.js.map +1 -0
  123. package/dist/rhino.config.d.ts +43 -0
  124. package/dist/rhino.config.js +130 -0
  125. package/dist/rhino.config.js.map +1 -0
  126. package/dist/rhino.module.d.ts +61 -0
  127. package/dist/rhino.module.js +268 -0
  128. package/dist/rhino.module.js.map +1 -0
  129. package/dist/services/audit.service.d.ts +35 -0
  130. package/dist/services/audit.service.js +92 -0
  131. package/dist/services/audit.service.js.map +1 -0
  132. package/dist/services/auth.service.d.ts +33 -0
  133. package/dist/services/auth.service.js +157 -0
  134. package/dist/services/auth.service.js.map +1 -0
  135. package/dist/services/invitation.service.d.ts +37 -0
  136. package/dist/services/invitation.service.js +143 -0
  137. package/dist/services/invitation.service.js.map +1 -0
  138. package/dist/services/nested.service.d.ts +34 -0
  139. package/dist/services/nested.service.js +163 -0
  140. package/dist/services/nested.service.js.map +1 -0
  141. package/dist/services/organization.service.d.ts +13 -0
  142. package/dist/services/organization.service.js +60 -0
  143. package/dist/services/organization.service.js.map +1 -0
  144. package/dist/services/query-builder.service.d.ts +31 -0
  145. package/dist/services/query-builder.service.js +151 -0
  146. package/dist/services/query-builder.service.js.map +1 -0
  147. package/dist/services/resource.service.d.ts +38 -0
  148. package/dist/services/resource.service.js +230 -0
  149. package/dist/services/resource.service.js.map +1 -0
  150. package/dist/services/route-registration.service.d.ts +40 -0
  151. package/dist/services/route-registration.service.js +68 -0
  152. package/dist/services/route-registration.service.js.map +1 -0
  153. package/dist/services/scope.service.d.ts +16 -0
  154. package/dist/services/scope.service.js +32 -0
  155. package/dist/services/scope.service.js.map +1 -0
  156. package/dist/services/serializer.service.d.ts +36 -0
  157. package/dist/services/serializer.service.js +90 -0
  158. package/dist/services/serializer.service.js.map +1 -0
  159. package/dist/services/validation.service.d.ts +50 -0
  160. package/dist/services/validation.service.js +203 -0
  161. package/dist/services/validation.service.js.map +1 -0
  162. package/dist/tsconfig.build.tsbuildinfo +1 -0
  163. package/dist/utils/fk-chain-walker.d.ts +30 -0
  164. package/dist/utils/fk-chain-walker.js +23 -0
  165. package/dist/utils/fk-chain-walker.js.map +1 -0
  166. package/dist/utils/format.d.ts +7 -0
  167. package/dist/utils/format.js +28 -0
  168. package/dist/utils/format.js.map +1 -0
  169. package/dist/utils/model-builder.d.ts +64 -0
  170. package/dist/utils/model-builder.js +67 -0
  171. package/dist/utils/model-builder.js.map +1 -0
  172. package/dist/utils/permission-matcher.d.ts +46 -0
  173. package/dist/utils/permission-matcher.js +120 -0
  174. package/dist/utils/permission-matcher.js.map +1 -0
  175. package/dist/utils/scope-discovery.d.ts +34 -0
  176. package/dist/utils/scope-discovery.js +102 -0
  177. package/dist/utils/scope-discovery.js.map +1 -0
  178. package/package.json +64 -0
  179. package/stubs/skills/rhino-audit.md +184 -0
  180. package/stubs/skills/rhino-bugfix.md +117 -0
  181. package/stubs/skills/rhino-deploy.md +5 -0
  182. package/stubs/skills/rhino-docs.md +147 -0
  183. package/stubs/skills/rhino-feature.md +146 -0
  184. package/stubs/skills/rhino-migrate.md +181 -0
  185. package/stubs/skills/rhino-model.md +180 -0
  186. package/stubs/skills/rhino-plan.md +5 -0
  187. package/stubs/skills/rhino-policy.md +183 -0
  188. package/stubs/skills/rhino-refactor.md +147 -0
  189. package/stubs/skills/rhino-review.md +113 -0
  190. package/stubs/skills/rhino-scope.md +140 -0
  191. 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.