ui-effects.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. /**
  2. * The effects table (`src/ui-server/api/effects.ts`): what a call is when it
  3. * leaves the index, by the call as written, per language; the model and the
  4. * access a database call names; the status a response site sends.
  5. */
  6. import { describe, it, expect } from 'vitest';
  7. import { classifyEffect, implicitResponseStatus, responseStatus } from '../src/ui-server/api/effects';
  8. const c = (text: string, language?: string, extra: Partial<Parameters<typeof classifyEffect>[0]> = {}) =>
  9. classifyEffect({ text, kind: 'calls', language: language as never, project: 'api', ...extra });
  10. const n = (text: string, language?: string, extra: Partial<Parameters<typeof classifyEffect>[0]> = {}) =>
  11. classifyEffect({ text, kind: 'instantiates', language: language as never, project: 'api', ...extra });
  12. describe('classifyEffect', () => {
  13. it('keeps the mobile app’s categories, with or without a language', () => {
  14. expect(c('client.post')?.category).toBe('network');
  15. expect(c('fetch', 'tsx')?.category).toBe('network');
  16. expect(c('AsyncStorage.setItem', 'tsx')?.category).toBe('storage');
  17. expect(c('Linking.openURL', 'tsx')?.category).toBe('device');
  18. expect(c('DdRum.addAction', 'tsx')?.category).toBe('telemetry');
  19. expect(c('Math.max', 'tsx')).toBeNull();
  20. expect(c('i18n.t', 'tsx')).toBeNull();
  21. expect(c('Object.create', 'typescript')).toBeNull();
  22. });
  23. it('TypeScript servers: the database by the chain, the model and the access', () => {
  24. expect(c('prisma.article.findFirst', 'typescript')).toEqual({ category: 'database', model: 'article', access: 'read' });
  25. expect(c('this.prisma.user.create', 'typescript')).toEqual({ category: 'database', model: 'user', access: 'write' });
  26. expect(c('this.usersRepository.save', 'typescript')).toEqual({ category: 'database', model: 'users', access: 'write' });
  27. expect(c('this.catModel.find', 'typescript')).toEqual({ category: 'database', model: 'cat', access: 'read' });
  28. expect(c('db.insert', 'typescript')).toEqual({ category: 'database', access: 'write' });
  29. expect(c('knex', 'typescript', { args: "'users'" })).toBeNull();
  30. expect(c('User.findOne', 'typescript')).toEqual({ category: 'database', model: 'User', access: 'read' });
  31. expect(c('Promise.all', 'typescript')).toBeNull();
  32. });
  33. it('TypeScript servers: responses, queues, email, payments, cache, auth', () => {
  34. expect(c('res.status(404).json', 'typescript')?.category).toBe('response');
  35. expect(c('res.json', 'typescript')?.category).toBe('response');
  36. expect(c('reply.code(201).send', 'typescript')?.category).toBe('response');
  37. expect(c('c.json', 'typescript')?.category).toBe('response');
  38. expect(n('NotFoundException', 'typescript')?.category).toBe('response');
  39. expect(n('UnprocessableEntityException', 'typescript')?.category).toBe('response');
  40. expect(n('HttpException', 'typescript')?.category).toBe('response');
  41. expect(n('Error', 'typescript')).toBeNull();
  42. expect(n('TypeError', 'typescript')).toBeNull();
  43. // In an app, an exception is an error, not a reply.
  44. expect(classifyEffect({ text: 'ValidationException', kind: 'instantiates', language: 'typescript', project: 'app' })).toBeNull();
  45. expect(c('this.emailQueue.add', 'typescript')?.category).toBe('queue');
  46. expect(c('queue.add', 'typescript')?.category).toBe('queue');
  47. expect(c('this.mailerService.sendMail', 'typescript')?.category).toBe('email');
  48. expect(c('resend.emails.send', 'typescript')?.category).toBe('email');
  49. expect(c('stripe.checkout.sessions.create', 'typescript')?.category).toBe('payments');
  50. expect(c('this.cacheManager.get', 'typescript')?.category).toBe('cache');
  51. expect(c('redis.setex', 'typescript')?.category).toBe('cache');
  52. expect(c('this.jwtService.signAsync', 'typescript')?.category).toBe('auth');
  53. expect(c('bcrypt.compare', 'typescript')?.category).toBe('auth');
  54. expect(c('jwt.verify', 'typescript')?.category).toBe('auth');
  55. expect(c('crypto.createHmac', 'typescript')?.category).toBe('auth');
  56. expect(c('crypto.createHash', 'typescript')).toBeNull();
  57. expect(c('crypto.randomBytes', 'typescript')).toBeNull();
  58. expect(c('s3.putObject', 'typescript')?.category).toBe('storage');
  59. expect(c('fs.writeFile', 'typescript')?.category).toBe('storage');
  60. expect(c('spawn', 'typescript')?.category).toBe('process');
  61. expect(c('process.exit', 'typescript')?.category).toBe('process');
  62. });
  63. it('Python: SQLAlchemy / Django, FastAPI / Flask / Django responses, celery, files, processes', () => {
  64. expect(c('session.exec', 'python')).toEqual({ category: 'database', access: 'read' });
  65. expect(c('session.add', 'python')).toEqual({ category: 'database', access: 'write' });
  66. expect(c('session.commit', 'python')).toEqual({ category: 'database', access: 'write' });
  67. expect(c('User.objects.filter', 'python')).toEqual({ category: 'database', model: 'User', access: 'read' });
  68. expect(c('db.session.add', 'python')?.category).toBe('database');
  69. expect(c('HTTPException', 'python')?.category).toBe('response');
  70. expect(c('JSONResponse', 'python')?.category).toBe('response');
  71. expect(c('jsonify', 'python')?.category).toBe('response');
  72. expect(c('abort', 'python')?.category).toBe('response');
  73. expect(c('render', 'python')?.category).toBe('response');
  74. expect(c('send_email.delay', 'python')?.category).toBe('queue');
  75. expect(c('send_mail', 'python')?.category).toBe('email');
  76. expect(c('requests.post', 'python')?.category).toBe('network');
  77. expect(c('httpx.AsyncClient', 'python')?.category).toBe('network');
  78. expect(c('open', 'python')?.category).toBe('storage');
  79. expect(c('s3.upload_file', 'python')?.category).toBe('storage');
  80. expect(c('subprocess.run', 'python')?.category).toBe('process');
  81. expect(c('jwt.encode', 'python')?.category).toBe('auth');
  82. expect(c('pwd_context.verify', 'python')?.category).toBe('auth');
  83. expect(c('print', 'python')).toBeNull();
  84. expect(c('len', 'python')).toBeNull();
  85. expect(c('item.model_dump', 'python')).toBeNull();
  86. });
  87. it('Java / Kotlin: repositories by name and by declared type, Spring responses, templates', () => {
  88. expect(c('owners.save', 'java', { receiverType: 'OwnerRepository' })).toEqual({ category: 'database', model: 'Owner', access: 'write' });
  89. expect(c('owners.findById', 'kotlin', { receiverType: 'OwnerRepository' })).toEqual({ category: 'database', model: 'Owner', access: 'read' });
  90. expect(c('this.ownerRepository.findAll', 'java')).toEqual({ category: 'database', model: 'owner', access: 'read' });
  91. expect(c('jdbcTemplate.update', 'java')?.category).toBe('database');
  92. expect(c('entityManager.persist', 'java')?.category).toBe('database');
  93. expect(c('ResponseEntity.ok', 'java')?.category).toBe('response');
  94. expect(c('ResponseEntity.status(HttpStatus.NOT_FOUND).body', 'java')?.category).toBe('response');
  95. expect(n('ResponseStatusException', 'java')?.category).toBe('response');
  96. expect(n('IllegalArgumentException', 'java')).toBeNull();
  97. expect(n('ResourceNotFoundException', 'java')?.category).toBe('response');
  98. expect(c('rabbitTemplate.convertAndSend', 'java')?.category).toBe('queue');
  99. expect(c('kafkaTemplate.send', 'java')?.category).toBe('queue');
  100. expect(c('applicationEventPublisher.publishEvent', 'java')?.category).toBe('queue');
  101. expect(c('mailSender.send', 'java')?.category).toBe('email');
  102. expect(c('restTemplate.getForObject', 'java')?.category).toBe('network');
  103. expect(c('webClient.get', 'java')?.category).toBe('network');
  104. expect(c('passwordEncoder.encode', 'java')?.category).toBe('auth');
  105. expect(c('redisTemplate.opsForValue', 'java')?.category).toBe('cache');
  106. expect(c('Files.write', 'java')?.category).toBe('storage');
  107. // Android: DataStore, SharedPreferences, Room DAOs, WorkManager.
  108. expect(c('userPreferences.updateData', 'kotlin')?.category).toBe('storage');
  109. expect(c('sharedPreferences.edit', 'kotlin')?.category).toBe('storage');
  110. expect(c('topicDao.upsertTopics', 'kotlin')).toEqual({ category: 'database', model: 'topic', access: 'write' });
  111. expect(c('workManager.enqueueUniqueWork', 'kotlin')?.category).toBe('queue');
  112. expect(c('viewModelScope.launch', 'kotlin')).toBeNull();
  113. expect(c('model.addAttribute', 'java')).toBeNull();
  114. expect(c('result.hasErrors', 'java')).toBeNull();
  115. expect(c('Objects.equals', 'java')).toBeNull();
  116. });
  117. it('C#: EF Core / repositories, controller responses, MassTransit, Identity', () => {
  118. expect(c('_context.TodoItems.Add', 'csharp')).toEqual({ category: 'database', model: 'TodoItems', access: 'write' });
  119. expect(c('_context.SaveChangesAsync', 'csharp')).toEqual({ category: 'database', access: 'write' });
  120. expect(c('_orderRepository.AddAsync', 'csharp')).toEqual({ category: 'database', model: 'order', access: 'write' });
  121. expect(c('_basketRepository.FirstOrDefaultAsync', 'csharp')).toEqual({ category: 'database', model: 'basket', access: 'read' });
  122. expect(c('NotFound', 'csharp')?.category).toBe('response');
  123. expect(c('Ok', 'csharp')?.category).toBe('response');
  124. expect(c('TypedResults.NoContent', 'csharp')?.category).toBe('response');
  125. expect(c('Results.Created', 'csharp')?.category).toBe('response');
  126. expect(n('NotFoundException', 'csharp')?.category).toBe('response');
  127. expect(n('ArgumentNullException', 'csharp')).toBeNull();
  128. expect(c('_bus.Publish', 'csharp')?.category).toBe('queue');
  129. expect(c('_publishEndpoint.Publish', 'csharp')?.category).toBe('queue');
  130. expect(c('BackgroundJob.Enqueue', 'csharp')?.category).toBe('queue');
  131. expect(c('_emailSender.SendEmailAsync', 'csharp')?.category).toBe('email');
  132. expect(c('_httpClient.GetAsync', 'csharp')?.category).toBe('network');
  133. expect(c('_userManager.CreateAsync', 'csharp')?.category).toBe('auth');
  134. expect(c('_signInManager.PasswordSignInAsync', 'csharp')?.category).toBe('auth');
  135. expect(c('_cache.GetOrCreateAsync', 'csharp')?.category).toBe('cache');
  136. expect(c('File.ReadAllText', 'csharp')?.category).toBe('storage');
  137. expect(c('Guard.Against.Null', 'csharp')).toBeNull();
  138. expect(c('nameof', 'csharp')).toBeNull();
  139. expect(c('sender.Send', 'csharp')).toBeNull();
  140. });
  141. it('Go: database/sql, gorm, gin responses, net/http, os', () => {
  142. expect(c('db.QueryRow', 'go')).toEqual({ category: 'database', access: 'read' });
  143. expect(c('db.Exec', 'go')).toEqual({ category: 'database', access: 'write' });
  144. expect(c('db.Create', 'go')?.category).toBe('database');
  145. expect(c('c.JSON', 'go')?.category).toBe('response');
  146. expect(c('c.AbortWithStatus', 'go')?.category).toBe('response');
  147. expect(c('http.Error', 'go')?.category).toBe('response');
  148. expect(c('w.WriteHeader', 'go')?.category).toBe('response');
  149. expect(c('http.Get', 'go')?.category).toBe('network');
  150. expect(c('client.Do', 'go')?.category).toBe('network');
  151. expect(c('os.ReadFile', 'go')?.category).toBe('storage');
  152. expect(c('exec.Command', 'go')?.category).toBe('process');
  153. expect(c('producer.Produce', 'go')?.category).toBe('queue');
  154. expect(c('jwt.NewWithClaims', 'go')?.category).toBe('auth');
  155. expect(c('fmt.Sprintf', 'go')).toBeNull();
  156. expect(c('errors.New', 'go')).toBeNull();
  157. });
  158. it('C: files, sockets, processes', () => {
  159. expect(c('fopen', 'c')?.category).toBe('storage');
  160. expect(c('fprintf', 'c')?.category).toBe('storage');
  161. expect(c('write', 'c')?.category).toBe('storage');
  162. expect(c('socket', 'c')?.category).toBe('network');
  163. expect(c('connect', 'c')?.category).toBe('network');
  164. expect(c('curl_easy_perform', 'c')?.category).toBe('network');
  165. expect(c('fork', 'c')?.category).toBe('process');
  166. expect(c('exit', 'c')?.category).toBe('process');
  167. expect(c('pthread_create', 'c')?.category).toBe('process');
  168. expect(c('strlen', 'c')).toBeNull();
  169. expect(c('malloc', 'c')).toBeNull();
  170. expect(c('memcpy', 'c')).toBeNull();
  171. expect(c('serverLog', 'c')).toBeNull();
  172. });
  173. it('Swift (Vapor), Ruby (Rails), PHP (Laravel)', () => {
  174. expect(c('Abort', 'swift')?.category).toBe('response');
  175. expect(c('Todo.query', 'swift')).toEqual({ category: 'database', model: 'Todo', access: 'read' });
  176. expect(c('todo.save', 'swift')?.category).toBe('database');
  177. expect(c('URLSession.shared.dataTask', 'swift')?.category).toBe('network');
  178. expect(c('render', 'ruby')?.category).toBe('response');
  179. expect(c('redirect_to', 'ruby')?.category).toBe('response');
  180. expect(c('User.find_by', 'ruby')).toEqual({ category: 'database', model: 'User', access: 'read' });
  181. expect(c('@user.save', 'ruby')?.category).toBe('database');
  182. expect(c('UserMailer.welcome', 'ruby')?.category).toBe('email');
  183. expect(c('HardJob.perform_later', 'ruby')?.category).toBe('queue');
  184. expect(c('User::find', 'php')).toEqual({ category: 'database', model: 'User', access: 'read' });
  185. expect(c('DB::table', 'php')?.category).toBe('database');
  186. expect(c('abort', 'php')?.category).toBe('response');
  187. expect(c('Mail::to', 'php')?.category).toBe('email');
  188. });
  189. it('a language without rows for a family stays quiet', () => {
  190. expect(c('foo.bar', 'ruby')).toBeNull();
  191. expect(c('save', 'python')).toBeNull();
  192. expect(c('render', 'java')).toBeNull();
  193. });
  194. });
  195. describe('responseStatus', () => {
  196. it('reads the literal code out of the chain, the arguments, or the name', () => {
  197. expect(responseStatus('res.status(404).json', '{ error }')).toBe(404);
  198. expect(responseStatus('res.status', '404')).toBe(404);
  199. expect(responseStatus('res.sendStatus', '204')).toBe(204);
  200. expect(responseStatus('res.json', '{ user }')).toBeNull();
  201. expect(responseStatus('reply.code(201).send', 'user')).toBe(201);
  202. expect(responseStatus('res.redirect', "'/login'")).toBe(302);
  203. expect(responseStatus('NotFoundException', "'no such user'", 'instantiates')).toBe(404);
  204. expect(responseStatus('UnprocessableEntityException', '{ errors }', 'instantiates')).toBe(422);
  205. expect(responseStatus('HttpException', "'x', HttpStatus.FORBIDDEN", 'instantiates')).toBe(403);
  206. expect(responseStatus('HttpException', "'x', 418", 'instantiates')).toBe(418);
  207. expect(responseStatus('HTTPException', 'status_code=404, detail="no title"')).toBe(404);
  208. expect(responseStatus('abort', '404')).toBe(404);
  209. expect(responseStatus('JsonResponse', '{ "error" }, status=400')).toBe(400);
  210. expect(responseStatus('Http404', '')).toBe(404);
  211. expect(responseStatus('ResponseEntity.ok', 'body')).toBe(200);
  212. expect(responseStatus('ResponseEntity.notFound().build', '')).toBe(404);
  213. expect(responseStatus('ResponseEntity.status(HttpStatus.CREATED).body', 'saved')).toBe(201);
  214. expect(responseStatus('ResponseStatusException', 'HttpStatus.NOT_FOUND, "x"', 'instantiates')).toBe(404);
  215. expect(responseStatus('ResponseEntity', 'body, HttpStatus.CREATED', 'instantiates')).toBe(201);
  216. expect(responseStatus('NotFound', '')).toBe(404);
  217. expect(responseStatus('Ok', 'item')).toBe(200);
  218. expect(responseStatus('CreatedAtAction', 'nameof(Get), item')).toBe(201);
  219. expect(responseStatus('TypedResults.NoContent', '')).toBe(204);
  220. expect(responseStatus('StatusCode', '500')).toBe(500);
  221. expect(responseStatus('Results.Problem', '')).toBe(500);
  222. expect(responseStatus('c.JSON', 'http.StatusCreated, u')).toBe(201);
  223. expect(responseStatus('c.String', '200, "ok"')).toBe(200);
  224. expect(responseStatus('http.Error', 'w, msg, http.StatusInternalServerError')).toBe(500);
  225. expect(responseStatus('w.WriteHeader', 'http.StatusNotFound')).toBe(404);
  226. expect(responseStatus('c.AbortWithStatus', '404')).toBe(404);
  227. expect(responseStatus('Abort', '.notFound')).toBe(404);
  228. expect(responseStatus('Abort', '.badRequest, reason: "x"')).toBe(400);
  229. expect(responseStatus('redirect_to', 'root_path')).toBe(302);
  230. expect(responseStatus('render', 'json: user, status: :created')).toBeNull();
  231. expect(responseStatus('res.status', 'code')).toBeNull();
  232. expect(responseStatus('res.json', '')).toBeNull();
  233. });
  234. });
  235. describe('implicitResponseStatus', () => {
  236. it('a body-sending reply that sets no status is a 200', () => {
  237. expect(implicitResponseStatus('res.json')).toBe(200);
  238. expect(implicitResponseStatus('res.send')).toBe(200);
  239. expect(implicitResponseStatus('res.render')).toBe(200);
  240. expect(implicitResponseStatus('reply.send')).toBe(200);
  241. expect(implicitResponseStatus('c.json')).toBe(200);
  242. expect(implicitResponseStatus('NextResponse.json')).toBe(200);
  243. expect(implicitResponseStatus('JSONResponse')).toBe(200);
  244. expect(implicitResponseStatus('jsonify')).toBe(200);
  245. });
  246. it('is null when the chain sets a status — literal or not — or ends without a body', () => {
  247. expect(implicitResponseStatus('res.status(404).json')).toBeNull();
  248. expect(implicitResponseStatus('res.status(code).json')).toBeNull();
  249. expect(implicitResponseStatus('res.sendStatus(204)')).toBeNull();
  250. expect(implicitResponseStatus('res.end')).toBeNull();
  251. expect(implicitResponseStatus('res.redirect')).toBeNull();
  252. expect(implicitResponseStatus('NotFoundException')).toBeNull();
  253. expect(implicitResponseStatus('prisma.user.create')).toBeNull();
  254. });
  255. });