ui-steps-api-servers.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. /**
  2. * `GET /api/steps` on servers: an Express API, a NestJS API, a FastAPI service
  3. * and a Spring controller, in one indexed fixture, shaped to cross every
  4. * boundary an endpoint's picture has — the request and what runs before the
  5. * handler, the database, a queue, an email, and the responses with their
  6. * status codes. Mirrors `ui-steps-api.test.ts` (the mobile app).
  7. */
  8. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  9. import * as fs from 'fs';
  10. import * as os from 'os';
  11. import * as path from 'path';
  12. import { CodeGraph } from '../src';
  13. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  14. import { buildSteps, projectKind } from '../src/ui-server/api/steps';
  15. import type { WireBlock } from '../src/ui-server/api/program';
  16. import { routeRoots } from '../src/ui-server/api/route-roots';
  17. let tmpDir: string;
  18. let cg: CodeGraph;
  19. function write(rel: string, content: string): void {
  20. const full = path.join(tmpDir, rel);
  21. fs.mkdirSync(path.dirname(full), { recursive: true });
  22. fs.writeFileSync(full, content);
  23. }
  24. beforeAll(async () => {
  25. await initGrammars();
  26. await loadAllGrammars();
  27. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-steps-servers-'));
  28. write(
  29. 'package.json',
  30. JSON.stringify({ name: 'api', dependencies: { express: '4', '@nestjs/core': '10', '@nestjs/common': '10', bullmq: '5', '@prisma/client': '5', typeorm: '0.3' } })
  31. );
  32. // ---- Express: a named handler behind middleware, and an inline handler.
  33. write('src/server/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n");
  34. write('src/server/queue.ts', "import { Queue } from 'bullmq'\nexport const emailQueue = new Queue('email')\n");
  35. write('src/server/errors.ts', 'export class NotFoundError extends Error {}\n');
  36. write('src/server/auth.ts', 'export function authenticate(req, res, next) {\n next()\n}\n');
  37. write('src/server/validate.ts', 'export function validate(schema) {\n return (req, res, next) => next()\n}\n');
  38. write(
  39. 'src/server/users.service.ts',
  40. "import { prisma } from './db'\n" +
  41. "import { emailQueue } from './queue'\n" +
  42. "import { NotFoundError } from './errors'\n" +
  43. 'export async function createUser(req, res) {\n' +
  44. ' const user = await prisma.user.create({ data: { email: req.body.email, name: req.body.name } })\n' +
  45. " await emailQueue.add('welcome', { userId: user.id })\n" +
  46. ' if (!user.verified) {\n' +
  47. ' await sendVerification(user)\n' +
  48. ' }\n' +
  49. ' res.status(201).json({\n' +
  50. ' id: user.id,\n' +
  51. ' token: signToken(user.id),\n' +
  52. ' })\n' +
  53. '}\n' +
  54. 'function signToken(id) {\n' +
  55. " return jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: '1d' })\n" +
  56. '}\n' +
  57. 'export async function getUser(id: string) {\n' +
  58. ' const user = await prisma.user.findUnique({ where: { id } })\n' +
  59. " if (!user) throw new NotFoundError('no such user')\n" +
  60. ' return user\n' +
  61. '}\n' +
  62. 'async function sendVerification(user) {\n' +
  63. ' await transporter.sendMail({ to: user.email })\n' +
  64. '}\n' +
  65. 'export async function acceptUser(req, res) {\n' +
  66. ' const user = await prisma.user.update({ where: { id: req.params.id }, data: { accepted: true } })\n' +
  67. ' res.status(202)\n' +
  68. ' res.json(user)\n' +
  69. '}\n'
  70. );
  71. write(
  72. 'src/server/users.routes.ts',
  73. "import { Router } from 'express'\n" +
  74. "import { authenticate } from './auth'\n" +
  75. "import { validate } from './validate'\n" +
  76. "import { createUser, getUser, acceptUser } from './users.service'\n" +
  77. 'const router = Router()\n' +
  78. "router.post('/users', authenticate, validate(userSchema), createUser)\n" +
  79. "router.post('/users/:id/accept', authenticate, acceptUser)\n" +
  80. "router.get('/users/:id', authenticate, async (req, res) => {\n" +
  81. ' const user = await getUser(req.params.id)\n' +
  82. ' res.json(user)\n' +
  83. '})\n' +
  84. 'export default router\n'
  85. );
  86. // ---- NestJS: guards on the class and the method, DI into a service, a queue consumer.
  87. write(
  88. 'src/nest/cats.service.ts',
  89. "import { Injectable } from '@nestjs/common'\n" +
  90. "import { InjectRepository } from '@nestjs/typeorm'\n" +
  91. "import { Repository } from 'typeorm'\n" +
  92. "import { InjectQueue } from '@nestjs/bullmq'\n" +
  93. "import { Queue } from 'bullmq'\n" +
  94. "import { Cat } from './cat.entity'\n" +
  95. '@Injectable()\n' +
  96. 'export class CatsService {\n' +
  97. ' constructor(\n' +
  98. ' @InjectRepository(Cat) private readonly catsRepository: Repository<Cat>,\n' +
  99. " @InjectQueue('cats') private readonly catsQueue: Queue\n" +
  100. ' ) {}\n' +
  101. ' async create(dto) {\n' +
  102. ' const cat = await this.catsRepository.save(dto)\n' +
  103. " await this.catsQueue.add('index', { id: cat.id })\n" +
  104. ' return cat\n' +
  105. ' }\n' +
  106. ' async findOne(id: string) {\n' +
  107. ' return this.catsRepository.findOne({ where: { id } })\n' +
  108. ' }\n' +
  109. '}\n'
  110. );
  111. write('src/nest/cat.entity.ts', "import { Entity } from 'typeorm'\n@Entity()\nexport class Cat {\n id: string\n}\n");
  112. write(
  113. 'src/nest/cats.controller.ts',
  114. "import { Controller, Get, Post, Body, Param, UseGuards, NotFoundException } from '@nestjs/common'\n" +
  115. "import { AuthGuard } from '@nestjs/passport'\n" +
  116. "import { CatsService } from './cats.service'\n" +
  117. "import { RolesGuard } from './roles.guard'\n" +
  118. "@Controller('cats')\n" +
  119. "@UseGuards(AuthGuard('jwt'))\n" +
  120. 'export class CatsController {\n' +
  121. ' constructor(private readonly catsService: CatsService) {}\n' +
  122. ' @Post()\n' +
  123. ' @UseGuards(RolesGuard)\n' +
  124. ' async create(@Body() dto: CreateCatDto) {\n' +
  125. ' return this.catsService.create(dto)\n' +
  126. ' }\n' +
  127. " @Get(':id')\n" +
  128. " async findOne(@Param('id') id: string) {\n" +
  129. ' const cat = await this.catsService.findOne(id)\n' +
  130. " if (!cat) throw new NotFoundException('no cat')\n" +
  131. ' return cat\n' +
  132. ' }\n' +
  133. '}\n'
  134. );
  135. write('src/nest/roles.guard.ts', "import { Injectable } from '@nestjs/common'\n@Injectable()\nexport class RolesGuard {\n canActivate() { return true }\n}\n");
  136. write(
  137. 'src/nest/cats.processor.ts',
  138. "import { Processor, Process } from '@nestjs/bull'\n" +
  139. "@Processor('cats')\n" +
  140. 'export class CatsProcessor {\n' +
  141. " @Process('index')\n" +
  142. ' async handleIndex(job) {\n' +
  143. ' await searchClient.index(job.data)\n' +
  144. ' }\n' +
  145. '}\n'
  146. );
  147. // ---- FastAPI: a dependency on the route, SQLModel, an HTTPException, a Celery task.
  148. write(
  149. 'api/items.py',
  150. 'from fastapi import APIRouter, Depends, HTTPException\n' +
  151. 'from sqlmodel import select\n' +
  152. 'from .deps import get_current_user, SessionDep\n' +
  153. 'from .models import Item, ItemCreate\n' +
  154. 'from .tasks import send_welcome\n' +
  155. '\n' +
  156. 'router = APIRouter()\n' +
  157. '\n' +
  158. '@router.post("/items", dependencies=[Depends(get_current_user)])\n' +
  159. 'def create_item(session: SessionDep, item_in: ItemCreate):\n' +
  160. ' item = Item.model_validate(item_in)\n' +
  161. ' session.add(item)\n' +
  162. ' session.commit()\n' +
  163. ' if item.price < 0:\n' +
  164. ' raise HTTPException(status_code=422, detail="bad price")\n' +
  165. ' send_welcome.delay(item.id)\n' +
  166. ' return item\n'
  167. );
  168. write('api/deps.py', 'def get_current_user():\n return None\n\nSessionDep = None\n');
  169. write('api/models.py', 'class Item:\n pass\n\nclass ItemCreate:\n pass\n');
  170. write('api/tasks.py', 'from celery import shared_task\n\n@shared_task\ndef send_welcome(item_id):\n return item_id\n');
  171. // A router with its own prefix, included by an aggregate router, mounted at a literal prefix — and one at a computed one.
  172. write(
  173. 'api/orders.py',
  174. 'from fastapi import APIRouter\n' +
  175. '\n' +
  176. 'router = APIRouter(prefix="/orders", tags=["orders"])\n' +
  177. '\n' +
  178. '@router.get("/")\n' +
  179. 'def list_orders():\n' +
  180. ' return []\n' +
  181. '\n' +
  182. '@router.get("/{order_id}")\n' +
  183. 'def get_order(order_id: int):\n' +
  184. ' return order_id\n'
  185. );
  186. write('api/v1.py', 'from fastapi import APIRouter\nfrom .orders import router as orders_router\napi_router = APIRouter()\napi_router.include_router(orders_router)\n');
  187. write(
  188. 'api/main.py',
  189. 'from fastapi import FastAPI\nfrom .items import router\nfrom .v1 import api_router\nfrom .config import settings\napp = FastAPI()\napp.include_router(router)\napp.include_router(api_router, prefix="/api/v1")\napp.include_router(api_router, prefix=settings.LEGACY)\n'
  190. );
  191. write('api/config.py', 'settings = None\n');
  192. write('requirements.txt', 'fastapi\nsqlmodel\ncelery\n');
  193. // ---- Spring: a repository typed on a field, ResponseEntity replies, a guard annotation.
  194. write(
  195. 'src/main/java/demo/OwnerController.java',
  196. 'package demo;\n' +
  197. 'import org.springframework.web.bind.annotation.*;\n' +
  198. 'import org.springframework.http.*;\n' +
  199. '@RestController\n' +
  200. '@RequestMapping("/owners")\n' +
  201. 'public class OwnerController {\n' +
  202. ' private final OwnerRepository owners;\n' +
  203. ' public OwnerController(OwnerRepository owners) { this.owners = owners; }\n' +
  204. ' @PostMapping("/new")\n' +
  205. ' @PreAuthorize("hasRole(\'ADMIN\')")\n' +
  206. ' public ResponseEntity<Owner> create(@RequestBody Owner owner) {\n' +
  207. ' if (owner.getName() == null) {\n' +
  208. ' return ResponseEntity.badRequest().build();\n' +
  209. ' }\n' +
  210. ' Owner saved = owners.save(owner);\n' +
  211. ' return ResponseEntity.status(HttpStatus.CREATED).body(saved);\n' +
  212. ' }\n' +
  213. '}\n'
  214. );
  215. write(
  216. 'src/main/java/demo/OwnerRepository.java',
  217. 'package demo;\nimport org.springframework.data.jpa.repository.JpaRepository;\npublic interface OwnerRepository extends JpaRepository<Owner, Integer> {\n}\n'
  218. );
  219. write('src/main/java/demo/Owner.java', 'package demo;\npublic class Owner {\n private String name;\n public String getName() { return name; }\n}\n');
  220. // ---- ASP.NET Minimal API, endpoint-group style: the class is the group,
  221. // the handler is the first argument, the app's extension supplies `/api/`.
  222. write(
  223. 'src/Web/Endpoints/TodoItems.cs',
  224. 'using Microsoft.AspNetCore.Http.HttpResults;\n' +
  225. 'namespace Demo.Web.Endpoints;\n' +
  226. 'public class TodoItems : IEndpointGroup\n' +
  227. '{\n' +
  228. ' public static void Map(RouteGroupBuilder groupBuilder)\n' +
  229. ' {\n' +
  230. ' groupBuilder.RequireAuthorization();\n' +
  231. ' groupBuilder.MapPost(CreateTodoItem);\n' +
  232. ' groupBuilder.MapPut(UpdateTodoItem, "{id}");\n' +
  233. ' }\n' +
  234. ' public static async Task<Created<int>> CreateTodoItem(ISender sender, CreateTodoItemCommand command)\n' +
  235. ' {\n' +
  236. ' var id = await sender.Send(command);\n' +
  237. ' return TypedResults.Created($"/{nameof(TodoItems)}/{id}", id);\n' +
  238. ' }\n' +
  239. ' public static async Task<Results<NoContent, BadRequest>> UpdateTodoItem(ISender sender, int id, UpdateTodoItemCommand command)\n' +
  240. ' {\n' +
  241. ' if (id != command.Id)\n' +
  242. ' return TypedResults.BadRequest();\n' +
  243. ' await sender.Send(command);\n' +
  244. ' return TypedResults.NoContent();\n' +
  245. ' }\n' +
  246. '}\n'
  247. );
  248. write(
  249. 'src/Web/Infrastructure/WebApplicationExtensions.cs',
  250. 'using Microsoft.AspNetCore.Builder;\n' +
  251. 'namespace Demo.Web.Infrastructure;\n' +
  252. 'public static class WebApplicationExtensions\n' +
  253. '{\n' +
  254. ' public static WebApplication MapEndpoints(this WebApplication app)\n' +
  255. ' {\n' +
  256. ' var groupName = "x";\n' +
  257. ' var group = app.MapGroup($"/api/{groupName}").WithTags(groupName);\n' +
  258. ' return app;\n' +
  259. ' }\n' +
  260. '}\n'
  261. );
  262. cg = CodeGraph.initSync(tmpDir);
  263. await cg.indexAll();
  264. });
  265. afterAll(() => {
  266. cg?.close();
  267. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  268. });
  269. const q = (params: Record<string, string>) => new URLSearchParams(params);
  270. const route = (name: string) => {
  271. const r = cg.getNodesByKind('route').find((r) => r.name === name);
  272. if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`);
  273. return r;
  274. };
  275. const effect = (p: Awaited<ReturnType<typeof buildSteps>>, category: string) => p.steps.find((s) => s.kind === 'effect' && s.effect?.category === category);
  276. /**
  277. * The `program` reading, one line per item, indented — the rail as a reader
  278. * meets it. A step prints its label, a fork its condition and each arm's, a
  279. * bracketed run its words.
  280. */
  281. function inOrder(p: Awaited<ReturnType<typeof buildSteps>>): string[] {
  282. const label = (id: string) => p.steps.find((s) => s.id === id)?.label ?? id;
  283. const out: string[] = [];
  284. const walk = (block: WireBlock, indent: string): void => {
  285. for (const item of block) {
  286. if (item.kind === 'step') {
  287. out.push(`${indent}${label(item.step)}${item.again ? ' (again)' : ''}`);
  288. if (item.body) walk(item.body, `${indent} `);
  289. } else if (item.kind === 'fork') {
  290. out.push(`${indent}${item.form} ${item.on}`);
  291. for (const arm of item.arms) {
  292. out.push(`${indent} ${arm.when}${arm.ends ? ` → ${arm.ends}` : ''}`);
  293. walk(arm.body, `${indent} `);
  294. }
  295. } else if (item.kind === 'block') {
  296. out.push(`${indent}${item.block} ${item.via?.name ?? item.by ?? ''}`.trimEnd());
  297. walk(item.body, `${indent} `);
  298. } else out.push(`${indent}cut ${item.why}`);
  299. }
  300. };
  301. if (p.program) walk(p.program.root, '');
  302. return out;
  303. }
  304. describe('in the code’s order', () => {
  305. it('reads an Express handler as it is written, helper and all', async () => {
  306. // `create` → `emailQueue.add` → `if (!user.verified) sendVerification(user)`
  307. // → `res.status(201).json({ token: signToken(user.id) })`. The token is
  308. // signed INSIDE the reply, so it is drawn before the 201, not beside it.
  309. const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /users').id }));
  310. expect(p.defaultView).toBe('order');
  311. expect(inOrder(p)).toEqual([
  312. 'prisma.user.create({ data })',
  313. "emailQueue.add('welcome', { userId })",
  314. 'if !user.verified',
  315. ' !user.verified',
  316. ' inline sendVerification',
  317. ' transporter.sendMail({ to })',
  318. 'inline signToken',
  319. ' jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn })',
  320. '201',
  321. ]);
  322. });
  323. it('reads a Python handler, its raise ending the arm it is in', async () => {
  324. // `session.add` / `session.commit` are one database box drawn at both its
  325. // sites; `raise HTTPException(422)` is an early exit, so the arm that
  326. // raises answers there and the Celery job is on the other one.
  327. const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /items').id }));
  328. expect(inOrder(p)).toEqual([
  329. 'session.add +1',
  330. 'session.add +1',
  331. 'if item.price < 0',
  332. ' item.price < 0 → reply',
  333. ' 422',
  334. ' !(item.price < 0)',
  335. ' send_welcome.delay(item.id)',
  336. ]);
  337. });
  338. it('reads a Java handler, its early return as the fork’s other arm', async () => {
  339. // `if (owner.getName() == null) return badRequest();` — one comparison
  340. // flips rather than wrapping, so the arm that runs on says `!= null`.
  341. const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /owners/new').id }));
  342. expect(inOrder(p)).toEqual([
  343. 'if owner.getName() == null',
  344. ' owner.getName() == null → reply',
  345. ' 400',
  346. ' owner.getName() != null → reply',
  347. ' owners.save(owner)',
  348. ' 201',
  349. ]);
  350. });
  351. it('reads a C# handler’s two outcomes', async () => {
  352. const p = await buildSteps(cg, tmpDir, q({ anchor: route('PUT /api/TodoItems/{id}').id }));
  353. expect(inOrder(p)).toEqual([
  354. 'if id != command.Id',
  355. ' id != command.Id → reply',
  356. ' 400',
  357. ' id == command.Id → reply',
  358. ' 204',
  359. ]);
  360. });
  361. it('reads a Nest handler through the service it delegates to', async () => {
  362. const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /cats').id }));
  363. expect(inOrder(p)).toEqual(['inline create', ' this.catsRepository.save(dto)', ' handleIndex']);
  364. });
  365. it('has no order to read for a screen, and says so', async () => {
  366. // A route with an inline handler still has a body; what has none is a
  367. // symbol nothing in the picture happens inside.
  368. const p = await buildSteps(cg, tmpDir, q({ symbol: 'RolesGuard' }));
  369. expect(p.program).toBeNull();
  370. expect(p.defaultView).toBe('tree');
  371. });
  372. });
  373. describe('route roots', () => {
  374. it('names the handler an API route runs, the route itself for an inline handler', () => {
  375. const roots = routeRoots(cg, cg.getNodesByKind('route'));
  376. expect(roots.get(route('POST /users').id)).toMatchObject({ inline: false, node: { name: 'createUser' } });
  377. expect(roots.get(route('GET /users/:id').id)).toMatchObject({ inline: true });
  378. expect(roots.get(route('POST /cats').id)?.node.qualifiedName).toContain('CatsController');
  379. expect(roots.get(route('POST /items').id)?.node.name).toBe('create_item');
  380. expect(roots.get(route('POST /owners/new').id)?.node.name).toBe('create');
  381. });
  382. it('calls the project an API', () => {
  383. expect(projectKind(cg.getNodesByKind('route'), 0)).toBe('api');
  384. });
  385. });
  386. describe('Express', () => {
  387. it('draws the handler’s database write, the queue job, the email, and the 201 — after the middleware', async () => {
  388. const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /users').id }));
  389. expect(p.project).toBe('api');
  390. const anchor = p.steps.find((s) => s.anchor)!;
  391. expect(anchor.kind).toBe('screen');
  392. expect(anchor.sub).toBe('createUser');
  393. expect(anchor.screen).toMatchObject({ path: 'POST /users', endpoint: true, inline: false, component: { name: 'createUser' } });
  394. expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/users', in: 'users.routes.ts', after: ['authenticate', 'validate(…)'] });
  395. const db = effect(p, 'database')!;
  396. expect(db.label).toBe('prisma.user.create({ data })');
  397. expect(db.effect).toMatchObject({ model: 'user', access: 'write', by: { name: 'createUser' } });
  398. expect(db.sub).toBe('database · user · write · createUser');
  399. const queue = effect(p, 'queue')!;
  400. expect(queue.label).toBe("emailQueue.add('welcome', { userId })");
  401. const mail = effect(p, 'email')!;
  402. expect(mail.label).toBe('transporter.sendMail({ to })');
  403. const mailLink = p.links.find((l) => l.to === mail.id)!;
  404. expect(mailLink.via.map((v) => v.name)).toEqual(['sendVerification']);
  405. expect(mailLink.when).toBe('!user.verified');
  406. const res = effect(p, 'response')!;
  407. expect(res.label).toBe('201');
  408. expect(res.effect?.statuses).toEqual([201]);
  409. const resLink = p.links.find((l) => l.to === res.id)!;
  410. expect(resLink.sites[0]).toMatchObject({ text: 'res.status(201).json', args: '{ id, token }', status: 201 });
  411. // The token is signed while the reply is built: the link says so, and
  412. // the row reads in the code's order — the write, the job, the mail, the
  413. // signing (inside the reply's arguments), then the reply.
  414. const auth = effect(p, 'auth')!;
  415. expect(auth.label).toBe('jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn })');
  416. const authLink = p.links.find((l) => l.to === auth.id)!;
  417. expect(authLink.via.map((v) => v.name)).toEqual(['signToken']);
  418. expect(authLink.within).toBe('res.status(201).json');
  419. expect(resLink.within).toBeUndefined();
  420. const row = p.steps.filter((s) => s.depth === 1).sort((a, b) => a.order! - b.order!).map((s) => s.label);
  421. expect(row).toEqual([
  422. 'prisma.user.create({ data })',
  423. "emailQueue.add('welcome', { userId })",
  424. 'transporter.sendMail({ to })',
  425. 'jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn })',
  426. '201',
  427. ]);
  428. });
  429. it('walks an inline handler as the route itself, into the service’s read and its 404', async () => {
  430. const p = await buildSteps(cg, tmpDir, q({ anchor: route('GET /users/:id').id }));
  431. const anchor = p.steps.find((s) => s.anchor)!;
  432. expect(anchor.sub).toBe('inline handler · users.routes.ts');
  433. expect(anchor.trigger).toMatchObject({ kind: 'request', name: 'GET', of: '/users/:id', after: ['authenticate'] });
  434. const db = effect(p, 'database')!;
  435. expect(db.effect).toMatchObject({ model: 'user', access: 'read', by: { name: 'getUser' } });
  436. // `res.json(user)` in the inline handler sets no status: a 200, the
  437. // route's own reply box; the service's `NotFoundError` is `getUser`'s box.
  438. const replies = p.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'response');
  439. expect(replies.map((s) => [s.effect!.by.name, s.label]).sort()).toEqual([
  440. ['GET /users/:id', '200'],
  441. ['getUser', '404'],
  442. ]);
  443. const own = replies.find((s) => s.effect!.by.name === 'GET /users/:id')!;
  444. expect(p.links.find((l) => l.to === own.id)!.sites[0]).toMatchObject({ text: 'res.json', args: 'user', status: 200 });
  445. const notFound = replies.find((s) => s.effect!.by.name === 'getUser')!;
  446. const link = p.links.find((l) => l.to === notFound.id)!;
  447. expect(link.sites[0]).toMatchObject({ text: 'NotFoundError', status: 404, when: '!user' });
  448. expect(link.via.map((v) => v.name)).toEqual(['getUser']);
  449. });
  450. it('a status set by the statement before the reply is that reply’s, not a 200', async () => {
  451. const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /users/:id/accept').id }));
  452. const res = effect(p, 'response')!;
  453. expect(res.label).toBe('202');
  454. expect(p.links.find((l) => l.to === res.id)!.sites.map((x) => [x.text, x.status])).toEqual([
  455. ['res.status', 202],
  456. ['res.json', 202],
  457. ]);
  458. });
  459. });
  460. describe('NestJS', () => {
  461. it('reads the guards on the class and the method, follows DI into the repository and the queue', async () => {
  462. const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /cats').id }));
  463. const anchor = p.steps.find((s) => s.anchor)!;
  464. expect(anchor.sub).toBe('create');
  465. expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/cats', in: 'cats.controller.ts', after: ["UseGuards(AuthGuard('jwt'))", 'UseGuards(RolesGuard)'] });
  466. const db = effect(p, 'database')!;
  467. expect(db.label).toBe('this.catsRepository.save(dto)');
  468. expect(db.effect).toMatchObject({ model: 'cats', access: 'write', by: { name: 'create' } });
  469. const dbLink = p.links.find((l) => l.to === db.id)!;
  470. expect(dbLink.via.map((v) => v.name)).toEqual(['create']);
  471. // The job put on the `cats` queue lands on the processor that consumes it
  472. // — an arrival, not a call outside the index; the site is the `add` as written.
  473. expect(effect(p, 'queue')).toBeUndefined();
  474. const landing = p.steps.find((s) => s.kind === 'event' && s.node?.name === 'handleIndex')!;
  475. expect(landing).toBeDefined();
  476. expect(landing.event).toBe('index');
  477. expect(landing.trigger).toEqual({ kind: 'decorator', name: 'Process', of: "'index'", in: 'cats.processor.ts' });
  478. const toLanding = p.links.find((l) => l.to === landing.id)!;
  479. expect(toLanding.kind).toBe('event');
  480. expect(toLanding.synthesized).toBe(true);
  481. expect(toLanding.sites[0]).toMatchObject({ text: 'this.catsQueue.add', args: "'index', { id }" });
  482. expect(toLanding.label).toBe('via queue-job · job index · queue cats · registered at src/nest/cats.processor.ts:4');
  483. });
  484. it('a thrown exception is the 404 the request gets', async () => {
  485. const p = await buildSteps(cg, tmpDir, q({ anchor: route('GET /cats/:id').id }));
  486. const res = effect(p, 'response')!;
  487. expect(res.label).toBe('404');
  488. const resLink = p.links.find((l) => l.to === res.id)!;
  489. expect(resLink.sites[0]).toMatchObject({ text: 'NotFoundException', args: "'no cat'", status: 404, when: '!cat' });
  490. expect(effect(p, 'database')?.effect).toMatchObject({ access: 'read' });
  491. });
  492. it('a queue consumer says the job that fires it', async () => {
  493. const p = await buildSteps(cg, tmpDir, q({ symbol: 'handleIndex' }));
  494. expect(p.steps.find((s) => s.anchor)?.trigger).toEqual({ kind: 'decorator', name: 'Process', of: "'index'", in: 'cats.processor.ts' });
  495. });
  496. });
  497. describe('FastAPI', () => {
  498. it('names a mounted router’s routes by the path a request takes — the include prefix, then the router’s own', () => {
  499. const names = cg.getNodesByKind('route').map((r) => r.name);
  500. expect(names).toContain('GET /api/v1/orders');
  501. expect(names).toContain('GET /api/v1/orders/{order_id}');
  502. expect(names).toContain('POST /items');
  503. expect(names).not.toContain('GET /');
  504. });
  505. it('reads the dependency on the route, the session writes, the 422 and the Celery task', async () => {
  506. const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /items').id }));
  507. const anchor = p.steps.find((s) => s.anchor)!;
  508. expect(anchor.sub).toBe('create_item');
  509. expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/items', in: 'items.py', after: ['Depends(get_current_user)'] });
  510. const db = effect(p, 'database')!;
  511. expect(db.effect?.apis).toEqual(['session.add', 'session.commit']);
  512. expect(db.effect).toMatchObject({ access: 'write' });
  513. const res = effect(p, 'response')!;
  514. expect(res.label).toBe('422');
  515. const resLink = p.links.find((l) => l.to === res.id)!;
  516. expect(resLink.sites[0]).toMatchObject({ text: 'HTTPException', args: 'status_code=422, detail="bad price"', status: 422, when: 'item.price < 0' });
  517. const queue = effect(p, 'queue')!;
  518. expect(queue.label).toBe('send_welcome.delay(item.id)');
  519. });
  520. });
  521. describe('ASP.NET endpoint groups', () => {
  522. it('names the group’s routes under the app’s /api/ head and starts the walk at the handler, with its replies', async () => {
  523. const names = cg.getNodesByKind('route').map((r) => r.name);
  524. expect(names).toContain('POST /api/TodoItems');
  525. expect(names).toContain('PUT /api/TodoItems/{id}');
  526. const p = await buildSteps(cg, tmpDir, q({ anchor: route('PUT /api/TodoItems/{id}').id }));
  527. const anchor = p.steps.find((s) => s.anchor)!;
  528. expect(anchor.sub).toBe('UpdateTodoItem');
  529. expect(anchor.trigger).toMatchObject({ kind: 'request', name: 'PUT', of: '/api/TodoItems/{id}' });
  530. // One box per outcome, each line carrying its own condition.
  531. const replies = p.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'response');
  532. const outcomes = replies.map((s) => [s.label, p.links.find((l) => l.to === s.id)!.when]).sort();
  533. expect(outcomes).toEqual([
  534. ['204', 'id == command.Id'],
  535. ['400', 'id != command.Id'],
  536. ]);
  537. });
  538. });
  539. describe('Spring', () => {
  540. it('types the repository off the field, reads the annotation guard, and both replies with their codes', async () => {
  541. const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /owners/new').id }));
  542. const anchor = p.steps.find((s) => s.anchor)!;
  543. expect(anchor.sub).toBe('create');
  544. expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/owners/new', in: 'OwnerController.java', after: ["PreAuthorize(\"hasRole('ADMIN')\")"] });
  545. const db = effect(p, 'database')!;
  546. expect(db.label).toBe('owners.save(owner)');
  547. expect(db.effect).toMatchObject({ model: 'Owner', access: 'write' });
  548. const dbLink = p.links.find((l) => l.to === db.id)!;
  549. expect(dbLink.when).toBe('owner.getName() != null');
  550. const replies = p.steps.filter((s) => s.kind === 'effect' && s.effect?.category === 'response');
  551. const outcomes = replies.map((s) => [s.label, s.effect!.statuses, p.links.find((l) => l.to === s.id)!.when]).sort();
  552. expect(outcomes).toEqual([
  553. ['201', [201], 'owner.getName() != null'],
  554. ['400', [400], 'owner.getName() == null'],
  555. ]);
  556. });
  557. });