create-easy-stack 1.0.3

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 (327) hide show
  1. package/bin/create.mjs +244 -0
  2. package/infra/email/files/src/core/config/email.env.ts +73 -0
  3. package/infra/email/files/src/core/infrastructure/email/email-dispatcher.port.ts +13 -0
  4. package/infra/email/files/src/core/infrastructure/email/email-layout.spec.ts +83 -0
  5. package/infra/email/files/src/core/infrastructure/email/email-layout.ts +88 -0
  6. package/infra/email/files/src/core/infrastructure/email/email-sender.factory.spec.ts +87 -0
  7. package/infra/email/files/src/core/infrastructure/email/email-sender.factory.ts +35 -0
  8. package/infra/email/files/src/core/infrastructure/email/email-sender.port.ts +19 -0
  9. package/infra/email/files/src/core/infrastructure/email/email.job.spec.ts +101 -0
  10. package/infra/email/files/src/core/infrastructure/email/email.job.ts +64 -0
  11. package/infra/email/files/src/core/infrastructure/email/email.module.spec.ts +60 -0
  12. package/infra/email/files/src/core/infrastructure/email/email.module.ts +34 -0
  13. package/infra/email/files/src/core/infrastructure/email/log-email-sender.spec.ts +34 -0
  14. package/infra/email/files/src/core/infrastructure/email/log-email-sender.ts +17 -0
  15. package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.spec.ts +99 -0
  16. package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.ts +63 -0
  17. package/infra/email/infra.json +58 -0
  18. package/infra/prisma-postgres/docker.service.yml +12 -0
  19. package/infra/prisma-postgres/files/prisma/schema.prisma +10 -0
  20. package/infra/prisma-postgres/files/prisma.config.mjs +10 -0
  21. package/infra/prisma-postgres/files/src/core/config/database.env.ts +13 -0
  22. package/infra/prisma-postgres/files/src/core/infrastructure/database/database.module.ts +9 -0
  23. package/infra/prisma-postgres/files/src/core/infrastructure/database/prisma.service.ts +26 -0
  24. package/infra/prisma-postgres/infra.json +60 -0
  25. package/infra/rabbitmq/docker.service.yml +16 -0
  26. package/infra/rabbitmq/files/src/core/config/messaging.env.ts +29 -0
  27. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.spec.ts +60 -0
  28. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.ts +33 -0
  29. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.spec.ts +23 -0
  30. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.ts +19 -0
  31. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.spec.ts +80 -0
  32. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.ts +66 -0
  33. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.boundary.spec.ts +34 -0
  34. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.factory.ts +21 -0
  35. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.spec.ts +75 -0
  36. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.ts +30 -0
  37. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.port.ts +59 -0
  38. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.spec.ts +510 -0
  39. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.ts +353 -0
  40. package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.module.ts +9 -0
  41. package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.spec.ts +289 -0
  42. package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.ts +207 -0
  43. package/infra/rabbitmq/infra.json +55 -0
  44. package/infra/redis/docker.service.yml +10 -0
  45. package/infra/redis/files/src/core/http/decorators/rate-limit.decorator.ts +26 -0
  46. package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.spec.ts +180 -0
  47. package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.ts +105 -0
  48. package/infra/redis/files/src/core/infrastructure/redis/cache.constants.ts +67 -0
  49. package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.module.ts +9 -0
  50. package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.spec.ts +146 -0
  51. package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.ts +131 -0
  52. package/infra/redis/files/src/core/infrastructure/redis/lock/lock.module.ts +9 -0
  53. package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.integration-spec.ts +111 -0
  54. package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.ts +82 -0
  55. package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.module.ts +15 -0
  56. package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.spec.ts +45 -0
  57. package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.ts +38 -0
  58. package/infra/redis/files/src/core/infrastructure/redis/redis.module.ts +9 -0
  59. package/infra/redis/files/src/core/infrastructure/redis/redis.service.ts +196 -0
  60. package/infra/redis/infra.json +53 -0
  61. package/infra/storage-cloudinary/files/src/core/config/storage.env.ts +43 -0
  62. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.spec.ts +275 -0
  63. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.ts +158 -0
  64. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.factory.ts +30 -0
  65. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.module.ts +17 -0
  66. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.port.ts +38 -0
  67. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.spec.ts +48 -0
  68. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.ts +25 -0
  69. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/unconfigured-image-storage.ts +19 -0
  70. package/infra/storage-cloudinary/infra.json +73 -0
  71. package/lib/fs-utils.mjs +115 -0
  72. package/lib/registry.mjs +50 -0
  73. package/lib/scaffold.mjs +157 -0
  74. package/package.json +37 -0
  75. package/templates/nest-ddd/files/.env.example +5 -0
  76. package/templates/nest-ddd/files/.husky/commit-msg +1 -0
  77. package/templates/nest-ddd/files/.husky/pre-commit +1 -0
  78. package/templates/nest-ddd/files/.oxlintrc.json +10 -0
  79. package/templates/nest-ddd/files/.prettierrc +4 -0
  80. package/templates/nest-ddd/files/_gitignore +40 -0
  81. package/templates/nest-ddd/files/commitlint.config.js +3 -0
  82. package/templates/nest-ddd/files/nest-cli.json +8 -0
  83. package/templates/nest-ddd/files/package.json +66 -0
  84. package/templates/nest-ddd/files/plop-templates/command/command.ts.hbs +5 -0
  85. package/templates/nest-ddd/files/plop-templates/command/handler.ts.hbs +30 -0
  86. package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.command.ts.hbs +5 -0
  87. package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.spec.ts.hbs +35 -0
  88. package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.ts.hbs +25 -0
  89. package/templates/nest-ddd/files/plop-templates/module/application/index.ts.hbs +5 -0
  90. package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.handler.ts.hbs +29 -0
  91. package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.query.ts.hbs +5 -0
  92. package/templates/nest-ddd/files/plop-templates/module/domain/entities/{{kebab}}.entity.ts.hbs +37 -0
  93. package/templates/nest-ddd/files/plop-templates/module/domain/events/{{kebab}}-created.event.ts.hbs +10 -0
  94. package/templates/nest-ddd/files/plop-templates/module/domain/exceptions/{{kebab}}.exception.ts.hbs +11 -0
  95. package/templates/nest-ddd/files/plop-templates/module/domain/index.ts.hbs +4 -0
  96. package/templates/nest-ddd/files/plop-templates/module/domain/repositories/{{kebab}}.repository.ts.hbs +7 -0
  97. package/templates/nest-ddd/files/plop-templates/module/presentation/controllers/{{kebab}}.controller.ts.hbs +53 -0
  98. package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/create-{{kebab}}.dto.ts.hbs +10 -0
  99. package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/{{kebab}}-response.dto.ts.hbs +15 -0
  100. package/templates/nest-ddd/files/plop-templates/module/presentation/index.ts.hbs +4 -0
  101. package/templates/nest-ddd/files/plop-templates/module/presentation/mappers/{{kebab}}.mapper.ts.hbs +13 -0
  102. package/templates/nest-ddd/files/plop-templates/module/{{kebab}}.module.ts.hbs +40 -0
  103. package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/database/repositories/in-memory-{{kebab}}.repository.ts.hbs +17 -0
  104. package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/index.ts.hbs +1 -0
  105. package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/mappers/{{kebab}}.mapper.ts.hbs +29 -0
  106. package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/repositories/prisma-{{kebab}}.repository.ts.hbs +28 -0
  107. package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/index.ts.hbs +2 -0
  108. package/templates/nest-ddd/files/plop-templates/query/handler.ts.hbs +28 -0
  109. package/templates/nest-ddd/files/plop-templates/query/query.ts.hbs +5 -0
  110. package/templates/nest-ddd/files/plop-templates/snippets/error-codes.hbs +14 -0
  111. package/templates/nest-ddd/files/plop-templates/snippets/prisma-model.hbs +7 -0
  112. package/templates/nest-ddd/files/plopfile.mjs +186 -0
  113. package/templates/nest-ddd/files/src/app.controller.ts +13 -0
  114. package/templates/nest-ddd/files/src/app.module.ts +26 -0
  115. package/templates/nest-ddd/files/src/core/config/app.config.ts +9 -0
  116. package/templates/nest-ddd/files/src/core/config/env.validation.ts +30 -0
  117. package/templates/nest-ddd/files/src/core/domain/base/aggregate-root.base.ts +18 -0
  118. package/templates/nest-ddd/files/src/core/domain/base/domain-event.base.ts +7 -0
  119. package/templates/nest-ddd/files/src/core/domain/base/entity.base.ts +36 -0
  120. package/templates/nest-ddd/files/src/core/domain/base/value-object.base.ts +17 -0
  121. package/templates/nest-ddd/files/src/core/domain/exceptions/domain.exception.ts +16 -0
  122. package/templates/nest-ddd/files/src/core/domain/exceptions/error-codes.ts +28 -0
  123. package/templates/nest-ddd/files/src/core/domain/types/pagination.ts +9 -0
  124. package/templates/nest-ddd/files/src/core/http/decorators/client-info.decorator.ts +24 -0
  125. package/templates/nest-ddd/files/src/core/http/decorators/current-user.decorator.ts +19 -0
  126. package/templates/nest-ddd/files/src/core/http/dtos/paginated-response.dto.ts +29 -0
  127. package/templates/nest-ddd/files/src/core/http/dtos/pagination-query.dto.ts +17 -0
  128. package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.spec.ts +163 -0
  129. package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.ts +97 -0
  130. package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.spec.ts +47 -0
  131. package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.ts +31 -0
  132. package/templates/nest-ddd/files/src/core/http/interceptors/logging.interceptor.ts +37 -0
  133. package/templates/nest-ddd/files/src/core/http/interceptors/response.interceptor.ts +53 -0
  134. package/templates/nest-ddd/files/src/core/http/logging/app.logger.ts +34 -0
  135. package/templates/nest-ddd/files/src/core/index.ts +33 -0
  136. package/templates/nest-ddd/files/src/core/utils/correlation-id.util.spec.ts +29 -0
  137. package/templates/nest-ddd/files/src/core/utils/correlation-id.util.ts +15 -0
  138. package/templates/nest-ddd/files/src/core/utils/hash.util.spec.ts +61 -0
  139. package/templates/nest-ddd/files/src/core/utils/hash.util.ts +47 -0
  140. package/templates/nest-ddd/files/src/core/utils/snowflake.util.spec.ts +40 -0
  141. package/templates/nest-ddd/files/src/core/utils/snowflake.util.ts +99 -0
  142. package/templates/nest-ddd/files/src/core/utils/string.util.spec.ts +20 -0
  143. package/templates/nest-ddd/files/src/core/utils/string.util.ts +21 -0
  144. package/templates/nest-ddd/files/src/core/utils/time.util.spec.ts +25 -0
  145. package/templates/nest-ddd/files/src/core/utils/time.util.ts +25 -0
  146. package/templates/nest-ddd/files/src/core/utils/ttl.util.ts +22 -0
  147. package/templates/nest-ddd/files/src/main.ts +61 -0
  148. package/templates/nest-ddd/files/test/setup.ts +6 -0
  149. package/templates/nest-ddd/files/tsconfig.build.json +9 -0
  150. package/templates/nest-ddd/files/tsconfig.json +28 -0
  151. package/templates/nest-ddd/files/vitest.config.e2e.ts +15 -0
  152. package/templates/nest-ddd/files/vitest.config.ts +16 -0
  153. package/templates/nest-ddd/template.json +8 -0
  154. package/templates/node-express/files/.env.example +28 -0
  155. package/templates/node-express/files/docker-compose.yml +52 -0
  156. package/templates/node-express/files/package-lock.json +11880 -0
  157. package/templates/node-express/files/package.json +43 -0
  158. package/templates/node-express/files/server.js +13 -0
  159. package/templates/node-express/files/src/app.js +72 -0
  160. package/templates/node-express/files/src/auth/authUtils.js +116 -0
  161. package/templates/node-express/files/src/auth/checkAuth.js +61 -0
  162. package/templates/node-express/files/src/bucket_pattern/comment.model.js +27 -0
  163. package/templates/node-express/files/src/bucket_pattern/comment.service.js +50 -0
  164. package/templates/node-express/files/src/config/config.aws-s3.js +28 -0
  165. package/templates/node-express/files/src/config/config.cloudinary.js +13 -0
  166. package/templates/node-express/files/src/config/config.mongodb.js +25 -0
  167. package/templates/node-express/files/src/config/config.multer.js +25 -0
  168. package/templates/node-express/files/src/config/config.nodemailer.js +16 -0
  169. package/templates/node-express/files/src/controllers/access.controller.js +49 -0
  170. package/templates/node-express/files/src/controllers/cart.controller.js +38 -0
  171. package/templates/node-express/files/src/controllers/checkout.controller.js +15 -0
  172. package/templates/node-express/files/src/controllers/comment.controller.js +29 -0
  173. package/templates/node-express/files/src/controllers/discount.controller.js +68 -0
  174. package/templates/node-express/files/src/controllers/email.controller.js +16 -0
  175. package/templates/node-express/files/src/controllers/inventory.controller.js +15 -0
  176. package/templates/node-express/files/src/controllers/notification.controller.js +15 -0
  177. package/templates/node-express/files/src/controllers/product.controller.js +175 -0
  178. package/templates/node-express/files/src/controllers/profile.controller.js +65 -0
  179. package/templates/node-express/files/src/controllers/rbac.controller.js +41 -0
  180. package/templates/node-express/files/src/controllers/template.controller.js +20 -0
  181. package/templates/node-express/files/src/controllers/upload.controller.js +56 -0
  182. package/templates/node-express/files/src/controllers/user.controller.js +36 -0
  183. package/templates/node-express/files/src/core/error.response.js +84 -0
  184. package/templates/node-express/files/src/core/success.response.js +44 -0
  185. package/templates/node-express/files/src/database/init.mongodb.level0.js +16 -0
  186. package/templates/node-express/files/src/database/init.mongodb.level1.js +36 -0
  187. package/templates/node-express/files/src/database/init.redis.js +119 -0
  188. package/templates/node-express/files/src/helpers/asyncHandler.js +7 -0
  189. package/templates/node-express/files/src/helpers/check.connect.js +29 -0
  190. package/templates/node-express/files/src/keys/private_key.pem +28 -0
  191. package/templates/node-express/files/src/keys/public_key.pem +9 -0
  192. package/templates/node-express/files/src/logger/discord.log.js +26 -0
  193. package/templates/node-express/files/src/logger/discord.log.v2.js +56 -0
  194. package/templates/node-express/files/src/logger/mylogger.log.js +130 -0
  195. package/templates/node-express/files/src/logger/winston.log.js +27 -0
  196. package/templates/node-express/files/src/middlewares/index.js +21 -0
  197. package/templates/node-express/files/src/middlewares/rbac.js +37 -0
  198. package/templates/node-express/files/src/middlewares/role.middleware.js +12 -0
  199. package/templates/node-express/files/src/models/KeyToken.model.js +37 -0
  200. package/templates/node-express/files/src/models/apiKey.model.js +31 -0
  201. package/templates/node-express/files/src/models/cart.model.js +47 -0
  202. package/templates/node-express/files/src/models/comment.model.js +43 -0
  203. package/templates/node-express/files/src/models/discount.model.js +92 -0
  204. package/templates/node-express/files/src/models/inventory.model.js +42 -0
  205. package/templates/node-express/files/src/models/notification.model.js +52 -0
  206. package/templates/node-express/files/src/models/order.model.js +68 -0
  207. package/templates/node-express/files/src/models/otp.model.js +32 -0
  208. package/templates/node-express/files/src/models/product.model.js +144 -0
  209. package/templates/node-express/files/src/models/repository/cart.repo.js +71 -0
  210. package/templates/node-express/files/src/models/repository/discount.repo.js +63 -0
  211. package/templates/node-express/files/src/models/repository/inventory.repo.js +45 -0
  212. package/templates/node-express/files/src/models/repository/product.repo.js +145 -0
  213. package/templates/node-express/files/src/models/repository/shop.repo.js +18 -0
  214. package/templates/node-express/files/src/models/repository/spu.repo.js +18 -0
  215. package/templates/node-express/files/src/models/repository/user.repo.js +20 -0
  216. package/templates/node-express/files/src/models/resource.model.js +32 -0
  217. package/templates/node-express/files/src/models/role.model.js +57 -0
  218. package/templates/node-express/files/src/models/shop.model.js +43 -0
  219. package/templates/node-express/files/src/models/sku.model.js +81 -0
  220. package/templates/node-express/files/src/models/spu.model.js +101 -0
  221. package/templates/node-express/files/src/models/template.model.js +25 -0
  222. package/templates/node-express/files/src/models/user.model.js +82 -0
  223. package/templates/node-express/files/src/postman/access.post.http +22 -0
  224. package/templates/node-express/files/src/routes/access/index.route.js +21 -0
  225. package/templates/node-express/files/src/routes/cart/index.route.js +20 -0
  226. package/templates/node-express/files/src/routes/checkout/index.route.js +10 -0
  227. package/templates/node-express/files/src/routes/comment/index.route.js +16 -0
  228. package/templates/node-express/files/src/routes/discount/index.route.js +26 -0
  229. package/templates/node-express/files/src/routes/index.route.js +27 -0
  230. package/templates/node-express/files/src/routes/inventory/index.route.js +11 -0
  231. package/templates/node-express/files/src/routes/notification/index.route.js +14 -0
  232. package/templates/node-express/files/src/routes/product/index.route.js +38 -0
  233. package/templates/node-express/files/src/routes/profile/index.route.js +15 -0
  234. package/templates/node-express/files/src/routes/rbac/index.route.js +21 -0
  235. package/templates/node-express/files/src/routes/template/index.route.js +11 -0
  236. package/templates/node-express/files/src/routes/upload/index.route.js +28 -0
  237. package/templates/node-express/files/src/routes/user/index.route.js +13 -0
  238. package/templates/node-express/files/src/services/access.service.js +257 -0
  239. package/templates/node-express/files/src/services/apiKey.service.js +15 -0
  240. package/templates/node-express/files/src/services/cart.service.js +115 -0
  241. package/templates/node-express/files/src/services/checkout.service.js +218 -0
  242. package/templates/node-express/files/src/services/comment.service.js +185 -0
  243. package/templates/node-express/files/src/services/discount.service.js +282 -0
  244. package/templates/node-express/files/src/services/email.service.js +71 -0
  245. package/templates/node-express/files/src/services/inventory.service.js +35 -0
  246. package/templates/node-express/files/src/services/keyToken.service.js +77 -0
  247. package/templates/node-express/files/src/services/notification.service.js +58 -0
  248. package/templates/node-express/files/src/services/otp.service.js +36 -0
  249. package/templates/node-express/files/src/services/product.service.js +228 -0
  250. package/templates/node-express/files/src/services/rbac.service.js +143 -0
  251. package/templates/node-express/files/src/services/redis.service.js +57 -0
  252. package/templates/node-express/files/src/services/redisPubSub.service.js +72 -0
  253. package/templates/node-express/files/src/services/shop.service.js +21 -0
  254. package/templates/node-express/files/src/services/sku.service.js +52 -0
  255. package/templates/node-express/files/src/services/spu.service.js +89 -0
  256. package/templates/node-express/files/src/services/template.service.js +41 -0
  257. package/templates/node-express/files/src/services/upload.s3.service.js +55 -0
  258. package/templates/node-express/files/src/services/upload.service.js +74 -0
  259. package/templates/node-express/files/src/services/user.service.js +92 -0
  260. package/templates/node-express/files/src/temp/1734420604996-ci-cd.png +0 -0
  261. package/templates/node-express/files/src/temp/1734421222213-cover-fb.jpg +0 -0
  262. package/templates/node-express/files/src/temp/1734421222214-cover-fb-01.jpg +0 -0
  263. package/templates/node-express/files/src/temp/1734421293437-cover-fb-01.jpg +0 -0
  264. package/templates/node-express/files/src/temp/1734421293437-cover-fb.jpg +0 -0
  265. package/templates/node-express/files/src/tests/inventory.test.js +21 -0
  266. package/templates/node-express/files/src/tests/message_queue/kafka/consumer.js +23 -0
  267. package/templates/node-express/files/src/tests/message_queue/kafka/producer.js +21 -0
  268. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/consumer.js +28 -0
  269. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.consumer.js +26 -0
  270. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.producer.js +31 -0
  271. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producer.js +26 -0
  272. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producerDLX.js +44 -0
  273. package/templates/node-express/files/src/tests/mysql/connect.js +59 -0
  274. package/templates/node-express/files/src/tests/product.test.js +14 -0
  275. package/templates/node-express/files/src/utils/index.js +78 -0
  276. package/templates/node-express/files/src/utils/reasonPhrases.js +344 -0
  277. package/templates/node-express/files/src/utils/statusCodes.js +344 -0
  278. package/templates/node-express/files/src/utils/template.html.js +38 -0
  279. package/templates/node-express/template.json +11 -0
  280. package/templates/react-fsd/files/index.html +13 -0
  281. package/templates/react-fsd/files/package.json +15 -0
  282. package/templates/react-fsd/files/public/favicon.svg +1 -0
  283. package/templates/react-fsd/files/public/icons.svg +24 -0
  284. package/templates/react-fsd/files/src/assets/hero.png +0 -0
  285. package/templates/react-fsd/files/src/assets/typescript.svg +1 -0
  286. package/templates/react-fsd/files/src/assets/vite.svg +1 -0
  287. package/templates/react-fsd/files/src/counter.ts +9 -0
  288. package/templates/react-fsd/files/src/main.ts +60 -0
  289. package/templates/react-fsd/files/src/style.css +296 -0
  290. package/templates/react-fsd/files/tsconfig.json +24 -0
  291. package/templates/react-fsd/template.json +11 -0
  292. package/templates/react-monolithic/files/index.html +13 -0
  293. package/templates/react-monolithic/files/package.json +15 -0
  294. package/templates/react-monolithic/files/public/favicon.svg +1 -0
  295. package/templates/react-monolithic/files/public/icons.svg +24 -0
  296. package/templates/react-monolithic/files/src/assets/hero.png +0 -0
  297. package/templates/react-monolithic/files/src/assets/typescript.svg +1 -0
  298. package/templates/react-monolithic/files/src/assets/vite.svg +1 -0
  299. package/templates/react-monolithic/files/src/counter.ts +9 -0
  300. package/templates/react-monolithic/files/src/main.ts +60 -0
  301. package/templates/react-monolithic/files/src/style.css +296 -0
  302. package/templates/react-monolithic/files/tsconfig.json +24 -0
  303. package/templates/react-monolithic/template.json +11 -0
  304. package/templates/vue-fsd/files/index.html +13 -0
  305. package/templates/vue-fsd/files/package.json +15 -0
  306. package/templates/vue-fsd/files/public/favicon.svg +1 -0
  307. package/templates/vue-fsd/files/public/icons.svg +24 -0
  308. package/templates/vue-fsd/files/src/assets/hero.png +0 -0
  309. package/templates/vue-fsd/files/src/assets/typescript.svg +1 -0
  310. package/templates/vue-fsd/files/src/assets/vite.svg +1 -0
  311. package/templates/vue-fsd/files/src/counter.ts +9 -0
  312. package/templates/vue-fsd/files/src/main.ts +60 -0
  313. package/templates/vue-fsd/files/src/style.css +296 -0
  314. package/templates/vue-fsd/files/tsconfig.json +24 -0
  315. package/templates/vue-fsd/template.json +11 -0
  316. package/templates/vue-monolithic/files/index.html +13 -0
  317. package/templates/vue-monolithic/files/package.json +15 -0
  318. package/templates/vue-monolithic/files/public/favicon.svg +1 -0
  319. package/templates/vue-monolithic/files/public/icons.svg +24 -0
  320. package/templates/vue-monolithic/files/src/assets/hero.png +0 -0
  321. package/templates/vue-monolithic/files/src/assets/typescript.svg +1 -0
  322. package/templates/vue-monolithic/files/src/assets/vite.svg +1 -0
  323. package/templates/vue-monolithic/files/src/counter.ts +9 -0
  324. package/templates/vue-monolithic/files/src/main.ts +60 -0
  325. package/templates/vue-monolithic/files/src/style.css +296 -0
  326. package/templates/vue-monolithic/files/tsconfig.json +24 -0
  327. package/templates/vue-monolithic/template.json +11 -0
@@ -0,0 +1,353 @@
1
+ import {
2
+ BeforeApplicationShutdown,
3
+ Logger,
4
+ OnModuleInit,
5
+ ServiceUnavailableException,
6
+ } from '@nestjs/common';
7
+ import { randomUUID } from 'crypto';
8
+ import type * as amqp from 'amqplib';
9
+ import { RabbitMQService } from '../rabbitmq/rabbitmq.service.js';
10
+ import type {
11
+ IJobQueue,
12
+ JobDefinition,
13
+ JobHandler,
14
+ JobQueueOptions,
15
+ } from './job-queue.port.js';
16
+ import { describeFailure } from './job-failure.js';
17
+
18
+ const DRAIN_TIMEOUT_MS = 30_000;
19
+ const REQUEUE_PAUSE_MS = 1_000;
20
+ const NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
21
+
22
+ interface KnownJob {
23
+ job: JobDefinition<any>;
24
+ handler?: JobHandler<any>;
25
+ consumerTag?: string;
26
+ channel?: amqp.ConfirmChannel;
27
+ }
28
+
29
+ /**
30
+ * Job queue on RabbitMQ. One exchange (`jobs`) and, per kind of job:
31
+ *
32
+ * jobs.<name> main queue; workers consume here
33
+ * jobs.<name>.retry.N.<ms>ms one queue per retry, with a fixed TTL; when it expires the job is
34
+ * dead-lettered back to the main queue. The TTL is in the name, because
35
+ * RabbitMQ refuses to redeclare a queue with different arguments: changing a
36
+ * retry delay creates a new queue instead of breaking every start-up. The TTL is fixed per queue
37
+ * because a message with a long TTL at the head of a queue holds back
38
+ * the shorter ones behind it.
39
+ * jobs.<name>.dlq jobs that failed after the last retry, kept for people to look at
40
+ *
41
+ * A failing job is retried by publishing a copy to the next retry queue and only then
42
+ * acknowledging the original, so it is never lost; dying between the two steps can at worst
43
+ * run it twice. Nothing sleeps inside a consumer, so a waiting retry holds no worker.
44
+ */
45
+ export class RabbitMqJobQueue
46
+ implements IJobQueue, OnModuleInit, BeforeApplicationShutdown
47
+ {
48
+ private readonly logger = new Logger(RabbitMqJobQueue.name);
49
+ private readonly jobs = new Map<string, KnownJob>();
50
+ private readonly asserted = new WeakMap<amqp.ConfirmChannel, Set<string>>();
51
+ private readonly inFlight = new Set<Promise<void>>();
52
+ private readonly namespace: string;
53
+ private readonly delayScale: number;
54
+ private draining = false;
55
+ // prefetch and consume are two calls; two jobs set up at the same time must not interleave them
56
+ private setUpChain: Promise<unknown> = Promise.resolve();
57
+
58
+ constructor(
59
+ private readonly rabbit: RabbitMQService,
60
+ options: JobQueueOptions = {},
61
+ ) {
62
+ this.namespace = options.namespace ?? '';
63
+ this.delayScale = options.retryDelayScale ?? 1;
64
+ }
65
+
66
+ onModuleInit(): void {
67
+ this.rabbit.onConnected((channel) => this.setUp(channel));
68
+ this.rabbit.start();
69
+ }
70
+
71
+ register<T>(job: JobDefinition<T>, handler: JobHandler<T>): void {
72
+ const known = this.know(job);
73
+ known.handler = handler;
74
+ const channel = this.rabbit.getChannel();
75
+ if (channel) {
76
+ this.serially(() => this.setUpJob(channel, known)).catch((error: Error) =>
77
+ this.logger.error(
78
+ `Could not start workers for "${job.name}": ${error.message}`,
79
+ ),
80
+ );
81
+ }
82
+ }
83
+
84
+ async enqueue<T>(job: JobDefinition<T>, data: T): Promise<void> {
85
+ this.know(job);
86
+ const channel = this.rabbit.getChannel();
87
+ if (!channel) {
88
+ throw new ServiceUnavailableException('Message broker is unavailable');
89
+ }
90
+ try {
91
+ await this.assertTopology(channel, job);
92
+ } catch (error) {
93
+ this.logger.error(
94
+ `Could not prepare queues for "${job.name}": ${(error as Error).message}`,
95
+ );
96
+ throw new ServiceUnavailableException('Message broker is unavailable');
97
+ }
98
+ await this.rabbit.publish(
99
+ this.exchange,
100
+ job.name,
101
+ Buffer.from(JSON.stringify(data)),
102
+ {
103
+ contentType: 'application/json',
104
+ messageId: randomUUID(),
105
+ headers: { 'x-attempt': 0 },
106
+ },
107
+ );
108
+ }
109
+
110
+ /** Stop taking new jobs and let the ones running finish (up to 30 s) before the connection closes. */
111
+ async beforeApplicationShutdown(): Promise<void> {
112
+ this.draining = true;
113
+ for (const known of this.jobs.values()) {
114
+ if (known.consumerTag && known.channel) {
115
+ await known.channel.cancel(known.consumerTag).catch(() => undefined);
116
+ }
117
+ }
118
+ let timer: NodeJS.Timeout | undefined;
119
+ await Promise.race([
120
+ Promise.allSettled(this.inFlight),
121
+ new Promise((resolve) => {
122
+ timer = setTimeout(resolve, DRAIN_TIMEOUT_MS);
123
+ }),
124
+ ]);
125
+ clearTimeout(timer);
126
+ }
127
+
128
+ private get exchange(): string {
129
+ return `${this.namespace}jobs`;
130
+ }
131
+
132
+ private queueNames(job: JobDefinition<any>) {
133
+ const main = `${this.exchange}.${job.name}`;
134
+ return {
135
+ main,
136
+ retry: (n: number) => `${main}.retry.${n}.${this.retryTtl(job, n)}ms`,
137
+ dlq: `${main}.dlq`,
138
+ };
139
+ }
140
+
141
+ /** How long a message waits in retry queue `n` (1-based) before it goes back to the main queue. */
142
+ private retryTtl(job: JobDefinition<any>, n: number): number {
143
+ return Math.max(1, Math.round(job.retryDelaysMs[n - 1] * this.delayScale));
144
+ }
145
+
146
+ private know(job: JobDefinition<any>): KnownJob {
147
+ let known = this.jobs.get(job.name);
148
+ if (!known) {
149
+ if (!NAME_PATTERN.test(job.name)) {
150
+ throw new Error(`Invalid job name "${job.name}"`);
151
+ }
152
+ const concurrency = job.concurrency ?? 1;
153
+ if (
154
+ !Number.isInteger(concurrency) ||
155
+ concurrency < 1 ||
156
+ concurrency > 1000
157
+ ) {
158
+ throw new Error(`Job "${job.name}" needs a concurrency of 1 to 1000`);
159
+ }
160
+ if (job.retryDelaysMs.some((ms) => !(ms > 0))) {
161
+ throw new Error(`Job "${job.name}" has a non-positive retry delay`);
162
+ }
163
+ known = { job };
164
+ this.jobs.set(job.name, known);
165
+ }
166
+ return known;
167
+ }
168
+
169
+ /** Runs after every (re)connection: declare all queues and start all workers again. */
170
+ private setUp(channel: amqp.ConfirmChannel): Promise<void> {
171
+ return this.serially(async () => {
172
+ for (const known of this.jobs.values()) {
173
+ known.consumerTag = undefined;
174
+ await this.setUpJob(channel, known);
175
+ }
176
+ });
177
+ }
178
+
179
+ private serially<T>(task: () => Promise<T>): Promise<T> {
180
+ const result = this.setUpChain.then(task);
181
+ this.setUpChain = result.catch(() => undefined);
182
+ return result;
183
+ }
184
+
185
+ private async setUpJob(
186
+ channel: amqp.ConfirmChannel,
187
+ known: KnownJob,
188
+ ): Promise<void> {
189
+ await this.assertTopology(channel, known.job);
190
+ if (!known.handler || known.consumerTag || this.draining) return;
191
+
192
+ const { job, handler } = known;
193
+ await channel.prefetch(job.concurrency ?? 1);
194
+ const { consumerTag } = await channel.consume(
195
+ this.queueNames(job).main,
196
+ (message) => {
197
+ if (!message) {
198
+ // The broker cancelled this consumer (e.g. the queue was deleted): listen again
199
+ this.logger.warn(
200
+ `Workers for "${job.name}" were cancelled by the broker; restarting them`,
201
+ );
202
+ known.consumerTag = undefined;
203
+ if (!this.draining) {
204
+ this.serially(() => this.setUpJob(channel, known)).catch(
205
+ (error: Error) =>
206
+ this.logger.error(
207
+ `Could not restart workers for "${job.name}": ${error.message}`,
208
+ ),
209
+ );
210
+ }
211
+ return;
212
+ }
213
+ const running = this.handle(channel, job, handler, message);
214
+ this.inFlight.add(running);
215
+ void running.finally(() => this.inFlight.delete(running));
216
+ },
217
+ { noAck: false },
218
+ );
219
+ known.consumerTag = consumerTag;
220
+ known.channel = channel;
221
+ }
222
+
223
+ private async assertTopology(
224
+ channel: amqp.ConfirmChannel,
225
+ job: JobDefinition<any>,
226
+ ): Promise<void> {
227
+ let done = this.asserted.get(channel);
228
+ if (!done) {
229
+ done = new Set();
230
+ this.asserted.set(channel, done);
231
+ }
232
+ if (done.has(job.name)) return;
233
+
234
+ const names = this.queueNames(job);
235
+ await channel.assertExchange(this.exchange, 'direct', { durable: true });
236
+ await channel.assertQueue(names.main, { durable: true });
237
+ await channel.bindQueue(names.main, this.exchange, job.name);
238
+ for (const index of job.retryDelaysMs.keys()) {
239
+ await channel.assertQueue(names.retry(index + 1), {
240
+ durable: true,
241
+ arguments: {
242
+ 'x-message-ttl': this.retryTtl(job, index + 1),
243
+ 'x-dead-letter-exchange': this.exchange,
244
+ 'x-dead-letter-routing-key': job.name,
245
+ },
246
+ });
247
+ }
248
+ await channel.assertQueue(names.dlq, { durable: true });
249
+ done.add(job.name);
250
+ }
251
+
252
+ private async handle(
253
+ channel: amqp.ConfirmChannel,
254
+ job: JobDefinition<any>,
255
+ handler: JobHandler<any>,
256
+ message: amqp.ConsumeMessage,
257
+ ): Promise<void> {
258
+ const attempt = Number(message.properties.headers?.['x-attempt']) || 0;
259
+
260
+ let data: unknown;
261
+ try {
262
+ data = JSON.parse(message.content.toString());
263
+ } catch {
264
+ this.logger.error(`Job "${job.name}": unreadable message; dead-lettered`);
265
+ await this.deadLetter(channel, job, message, attempt, 'UNREADABLE');
266
+ return;
267
+ }
268
+
269
+ try {
270
+ await handler(data);
271
+ this.settle(() => channel.ack(message));
272
+ } catch (error) {
273
+ const reason = describeFailure(error);
274
+ if (attempt < job.retryDelaysMs.length) {
275
+ this.logger.warn(
276
+ `Job "${job.name}" (${message.properties.messageId}) failed on attempt ${attempt + 1}: ${reason}; retrying`,
277
+ );
278
+ await this.republish(
279
+ channel,
280
+ message,
281
+ this.queueNames(job).retry(attempt + 1),
282
+ attempt + 1,
283
+ reason,
284
+ );
285
+ } else {
286
+ this.logger.error(
287
+ `Job "${job.name}" (${message.properties.messageId}) failed for good after ${attempt + 1} attempts: ${reason}`,
288
+ );
289
+ await this.deadLetter(channel, job, message, attempt, reason);
290
+ }
291
+ }
292
+ }
293
+
294
+ private deadLetter(
295
+ channel: amqp.ConfirmChannel,
296
+ job: JobDefinition<any>,
297
+ message: amqp.ConsumeMessage,
298
+ attempt: number,
299
+ reason: string,
300
+ ): Promise<void> {
301
+ return this.republish(
302
+ channel,
303
+ message,
304
+ this.queueNames(job).dlq,
305
+ attempt + 1,
306
+ reason,
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Publishes a copy to `queue`, and only after the broker confirmed it acknowledges the
312
+ * original. If the copy cannot be published the original goes back to its queue instead.
313
+ */
314
+ private async republish(
315
+ channel: amqp.ConfirmChannel,
316
+ message: amqp.ConsumeMessage,
317
+ queue: string,
318
+ attempt: number,
319
+ reason: string,
320
+ ): Promise<void> {
321
+ try {
322
+ await this.rabbit.publish(
323
+ '',
324
+ queue,
325
+ message.content,
326
+ {
327
+ contentType: message.properties.contentType,
328
+ messageId: message.properties.messageId,
329
+ headers: { 'x-attempt': attempt, 'x-failure': reason },
330
+ },
331
+ // The channel the message came on: its ack must follow the publish on the same channel
332
+ channel,
333
+ );
334
+ } catch {
335
+ // Without a pause a refusing broker would turn this into a tight redelivery loop
336
+ await new Promise((resolve) => setTimeout(resolve, REQUEUE_PAUSE_MS));
337
+ this.settle(() => channel.nack(message, false, true));
338
+ return;
339
+ }
340
+ this.settle(() => channel.ack(message));
341
+ }
342
+
343
+ /** Ack or nack on a channel that may have closed meanwhile; the broker redelivers anyway. */
344
+ private settle(action: () => void): void {
345
+ try {
346
+ action();
347
+ } catch (error) {
348
+ this.logger.warn(
349
+ `Could not acknowledge a job (${(error as Error).message}); the broker will redeliver it`,
350
+ );
351
+ }
352
+ }
353
+ }
@@ -0,0 +1,9 @@
1
+ import { Global, Module } from '@nestjs/common';
2
+ import { RabbitMQService } from './rabbitmq.service.js';
3
+
4
+ @Global()
5
+ @Module({
6
+ providers: [RabbitMQService],
7
+ exports: [RabbitMQService],
8
+ })
9
+ export class RabbitMQModule {}
@@ -0,0 +1,289 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { EventEmitter } from 'events';
3
+ import { Logger, ServiceUnavailableException } from '@nestjs/common';
4
+ import * as amqp from 'amqplib';
5
+ import { RabbitMQService } from './rabbitmq.service.js';
6
+
7
+ vi.mock('amqplib', () => ({ connect: vi.fn() }));
8
+
9
+ class FakeChannel extends EventEmitter {
10
+ // Confirms by default; a test can change what the broker answers
11
+ confirm: (cb: (error: Error | null) => void) => void = (cb) => cb(null);
12
+ published: any[] = [];
13
+ close = vi.fn(async () => undefined);
14
+ publish = vi.fn(
15
+ (
16
+ exchange: string,
17
+ key: string,
18
+ content: Buffer,
19
+ options: any,
20
+ cb: (error: Error | null) => void,
21
+ ) => {
22
+ this.published.push({ exchange, key, content, options });
23
+ this.confirm(cb);
24
+ return true;
25
+ },
26
+ );
27
+ }
28
+
29
+ class FakeConnection extends EventEmitter {
30
+ channel = new FakeChannel();
31
+ close = vi.fn(async () => {
32
+ this.emit('close');
33
+ });
34
+ createConfirmChannel = vi.fn(async () => this.channel);
35
+ }
36
+
37
+ const connectMock = vi.mocked(amqp.connect) as unknown as ReturnType<
38
+ typeof vi.fn
39
+ >;
40
+
41
+ /** Let pending promise callbacks run (without moving fake timers) */
42
+ const flush = async () => {
43
+ for (let i = 0; i < 10; i++) await Promise.resolve();
44
+ };
45
+
46
+ describe('RabbitMQService', () => {
47
+ let service: RabbitMQService;
48
+
49
+ beforeEach(() => {
50
+ vi.useFakeTimers();
51
+ for (const level of ['log', 'warn', 'error'] as const) {
52
+ vi.spyOn(Logger.prototype, level).mockImplementation(() => undefined);
53
+ }
54
+ connectMock.mockReset();
55
+ service = new RabbitMQService();
56
+ });
57
+ afterEach(async () => {
58
+ await service.onApplicationShutdown();
59
+ vi.useRealTimers();
60
+ vi.restoreAllMocks();
61
+ });
62
+
63
+ describe('connecting', () => {
64
+ it('does not block the caller while the broker is away: start returns at once (AC-8)', async () => {
65
+ connectMock.mockRejectedValue(new Error('ECONNREFUSED'));
66
+
67
+ service.start();
68
+ await flush();
69
+
70
+ expect(service.isConnected()).toBe(false);
71
+ });
72
+
73
+ it('retries with a growing pause (1 s, 2 s, 4 s) until the broker answers (AC-8)', async () => {
74
+ const connection = new FakeConnection();
75
+ connectMock
76
+ .mockRejectedValueOnce(new Error('down'))
77
+ .mockRejectedValueOnce(new Error('down'))
78
+ .mockRejectedValueOnce(new Error('down'))
79
+ .mockResolvedValue(connection);
80
+
81
+ service.start();
82
+ await flush();
83
+ expect(connectMock).toHaveBeenCalledTimes(1);
84
+
85
+ await vi.advanceTimersByTimeAsync(1_000);
86
+ expect(connectMock).toHaveBeenCalledTimes(2);
87
+ await vi.advanceTimersByTimeAsync(2_000);
88
+ expect(connectMock).toHaveBeenCalledTimes(3);
89
+ await vi.advanceTimersByTimeAsync(4_000);
90
+ expect(connectMock).toHaveBeenCalledTimes(4);
91
+
92
+ expect(service.isConnected()).toBe(true);
93
+ });
94
+
95
+ it('caps the pause at 30 seconds', async () => {
96
+ connectMock.mockRejectedValue(new Error('down'));
97
+
98
+ service.start();
99
+ // 1 + 2 + 4 + 8 + 16 = 31 s of pauses, then every further pause is 30 s
100
+ await vi.advanceTimersByTimeAsync(31_000);
101
+ const callsBefore = connectMock.mock.calls.length;
102
+ await vi.advanceTimersByTimeAsync(29_000);
103
+ expect(connectMock.mock.calls.length).toBe(callsBefore);
104
+ await vi.advanceTimersByTimeAsync(1_000);
105
+ expect(connectMock.mock.calls.length).toBe(callsBefore + 1);
106
+ });
107
+
108
+ it('starts only one connection loop however often start is called', async () => {
109
+ connectMock.mockResolvedValue(new FakeConnection());
110
+
111
+ service.start();
112
+ service.start();
113
+ await flush();
114
+
115
+ expect(connectMock).toHaveBeenCalledTimes(1);
116
+ });
117
+ });
118
+
119
+ describe('hooks (AC-8)', () => {
120
+ it('runs a hook registered before connecting, with the new channel', async () => {
121
+ const connection = new FakeConnection();
122
+ connectMock.mockResolvedValue(connection);
123
+ const hook = vi.fn().mockResolvedValue(undefined);
124
+ service.onConnected(hook);
125
+
126
+ service.start();
127
+ await flush();
128
+
129
+ expect(hook).toHaveBeenCalledWith(connection.channel);
130
+ });
131
+
132
+ it('runs a hook registered after connecting straight away', async () => {
133
+ connectMock.mockResolvedValue(new FakeConnection());
134
+ service.start();
135
+ await flush();
136
+ const hook = vi.fn().mockResolvedValue(undefined);
137
+
138
+ service.onConnected(hook);
139
+
140
+ expect(hook).toHaveBeenCalledOnce();
141
+ });
142
+
143
+ it('reconnects when the connection drops and runs the hook again on the new channel', async () => {
144
+ const first = new FakeConnection();
145
+ const second = new FakeConnection();
146
+ connectMock.mockResolvedValueOnce(first).mockResolvedValueOnce(second);
147
+ const hook = vi.fn().mockResolvedValue(undefined);
148
+ service.onConnected(hook);
149
+ service.start();
150
+ await flush();
151
+ expect(hook).toHaveBeenCalledTimes(1);
152
+
153
+ first.emit('close');
154
+ await flush();
155
+ await vi.advanceTimersByTimeAsync(0);
156
+ await flush();
157
+
158
+ expect(hook).toHaveBeenCalledTimes(2);
159
+ expect(hook).toHaveBeenLastCalledWith(second.channel);
160
+ expect(service.isConnected()).toBe(true);
161
+ });
162
+
163
+ it('drops the connection and tries again when a hook fails, rather than run half set up', async () => {
164
+ const first = new FakeConnection();
165
+ const second = new FakeConnection();
166
+ connectMock.mockResolvedValueOnce(first).mockResolvedValueOnce(second);
167
+ const hook = vi
168
+ .fn()
169
+ .mockRejectedValueOnce(new Error('PRECONDITION_FAILED'))
170
+ .mockResolvedValue(undefined);
171
+ service.onConnected(hook);
172
+
173
+ service.start();
174
+ await flush();
175
+ expect(service.isConnected()).toBe(false);
176
+ expect(first.close).toHaveBeenCalled();
177
+
178
+ await vi.advanceTimersByTimeAsync(1_000);
179
+
180
+ expect(hook).toHaveBeenCalledTimes(2);
181
+ expect(service.isConnected()).toBe(true);
182
+ });
183
+ });
184
+
185
+ describe('publish', () => {
186
+ it('answers 503 at once when there is no connection (AC-7)', async () => {
187
+ await expect(
188
+ service.publish('jobs', 'send-email', Buffer.from('x')),
189
+ ).rejects.toBeInstanceOf(ServiceUnavailableException);
190
+ });
191
+
192
+ it('waits for the broker to confirm, and publishes persistent messages (AC-1)', async () => {
193
+ const connection = new FakeConnection();
194
+ connectMock.mockResolvedValue(connection);
195
+ service.start();
196
+ await flush();
197
+
198
+ await service.publish('jobs', 'send-email', Buffer.from('x'), {
199
+ headers: { 'x-attempt': 0 },
200
+ });
201
+
202
+ const sent = connection.channel.published[0];
203
+ expect(sent.exchange).toBe('jobs');
204
+ expect(sent.key).toBe('send-email');
205
+ expect(sent.options).toEqual({
206
+ persistent: true,
207
+ headers: { 'x-attempt': 0 },
208
+ });
209
+ });
210
+
211
+ it('does not return before the confirmation arrives', async () => {
212
+ const connection = new FakeConnection();
213
+ let confirm!: () => void;
214
+ connection.channel.confirm = (cb) => {
215
+ confirm = () => cb(null);
216
+ };
217
+ connectMock.mockResolvedValue(connection);
218
+ service.start();
219
+ await flush();
220
+
221
+ let done = false;
222
+ const publishing = service
223
+ .publish('jobs', 'k', Buffer.from('x'))
224
+ .then(() => (done = true));
225
+ await flush();
226
+ expect(done).toBe(false);
227
+
228
+ confirm();
229
+ await publishing;
230
+ expect(done).toBe(true);
231
+ });
232
+
233
+ it('answers 503 when the broker refuses the message (AC-7)', async () => {
234
+ const connection = new FakeConnection();
235
+ connection.channel.confirm = (cb) => cb(new Error('nack'));
236
+ connectMock.mockResolvedValue(connection);
237
+ service.start();
238
+ await flush();
239
+
240
+ await expect(
241
+ service.publish('jobs', 'k', Buffer.from('x')),
242
+ ).rejects.toBeInstanceOf(ServiceUnavailableException);
243
+ });
244
+
245
+ it('answers 503 instead of hanging when no confirmation ever comes (AC-7)', async () => {
246
+ const connection = new FakeConnection();
247
+ connection.channel.confirm = () => undefined;
248
+ connectMock.mockResolvedValue(connection);
249
+ service.start();
250
+ await flush();
251
+
252
+ const publishing = service.publish('jobs', 'k', Buffer.from('x'));
253
+ const outcome = expect(publishing).rejects.toBeInstanceOf(
254
+ ServiceUnavailableException,
255
+ );
256
+ await vi.advanceTimersByTimeAsync(5_000);
257
+
258
+ await outcome;
259
+ });
260
+ });
261
+
262
+ describe('shutting down', () => {
263
+ it('closes the channel and the connection and stops reconnecting (AC-10)', async () => {
264
+ const connection = new FakeConnection();
265
+ connectMock.mockResolvedValue(connection);
266
+ service.start();
267
+ await flush();
268
+
269
+ await service.onApplicationShutdown();
270
+ await vi.advanceTimersByTimeAsync(60_000);
271
+
272
+ expect(connection.channel.close).toHaveBeenCalled();
273
+ expect(connection.close).toHaveBeenCalled();
274
+ expect(connectMock).toHaveBeenCalledTimes(1);
275
+ expect(service.isConnected()).toBe(false);
276
+ });
277
+
278
+ it('stops waiting to reconnect when the application shuts down', async () => {
279
+ connectMock.mockRejectedValue(new Error('down'));
280
+ service.start();
281
+ await flush();
282
+
283
+ await service.onApplicationShutdown();
284
+ await vi.advanceTimersByTimeAsync(60_000);
285
+
286
+ expect(connectMock).toHaveBeenCalledTimes(1);
287
+ });
288
+ });
289
+ });