@hed-hog/core 0.0.400 → 0.0.402

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 (274) hide show
  1. package/dist/access-log/access-log.interceptor.d.ts +10 -0
  2. package/dist/access-log/access-log.interceptor.d.ts.map +1 -1
  3. package/dist/access-log/access-log.interceptor.js +15 -1
  4. package/dist/access-log/access-log.interceptor.js.map +1 -1
  5. package/dist/ai/ai.controller.d.ts +2 -0
  6. package/dist/ai/ai.controller.d.ts.map +1 -1
  7. package/dist/ai/ai.service.d.ts +86 -2
  8. package/dist/ai/ai.service.d.ts.map +1 -1
  9. package/dist/ai/ai.service.js +304 -23
  10. package/dist/ai/ai.service.js.map +1 -1
  11. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
  12. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
  13. package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
  14. package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
  15. package/dist/core.module.d.ts.map +1 -1
  16. package/dist/core.module.js +3 -0
  17. package/dist/core.module.js.map +1 -1
  18. package/dist/document-kit/document-kit.module.d.ts +3 -0
  19. package/dist/document-kit/document-kit.module.d.ts.map +1 -0
  20. package/dist/document-kit/document-kit.module.js +24 -0
  21. package/dist/document-kit/document-kit.module.js.map +1 -0
  22. package/dist/document-kit/document-kit.registry.d.ts +55 -0
  23. package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
  24. package/dist/document-kit/document-kit.registry.js +91 -0
  25. package/dist/document-kit/document-kit.registry.js.map +1 -0
  26. package/dist/document-kit/document-kit.service.d.ts +49 -0
  27. package/dist/document-kit/document-kit.service.d.ts.map +1 -0
  28. package/dist/document-kit/document-kit.service.js +135 -0
  29. package/dist/document-kit/document-kit.service.js.map +1 -0
  30. package/dist/file/file-upload-ticket.service.d.ts +1 -1
  31. package/dist/file/file.controller.d.ts +3 -3
  32. package/dist/file/file.service.d.ts +136 -13
  33. package/dist/file/file.service.d.ts.map +1 -1
  34. package/dist/file/file.service.js +297 -27
  35. package/dist/file/file.service.js.map +1 -1
  36. package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
  37. package/dist/file/mcp-file-source.schema.js +8 -5
  38. package/dist/file/mcp-file-source.schema.js.map +1 -1
  39. package/dist/file/provider/abstract.provider.d.ts +33 -0
  40. package/dist/file/provider/abstract.provider.d.ts.map +1 -1
  41. package/dist/file/provider/abstract.provider.js +9 -0
  42. package/dist/file/provider/abstract.provider.js.map +1 -1
  43. package/dist/file/provider/s3-multipart.util.d.ts +17 -0
  44. package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
  45. package/dist/file/provider/s3-multipart.util.js +88 -0
  46. package/dist/file/provider/s3-multipart.util.js.map +1 -0
  47. package/dist/file/provider/s3.provider.d.ts +9 -1
  48. package/dist/file/provider/s3.provider.d.ts.map +1 -1
  49. package/dist/file/provider/s3.provider.js +16 -0
  50. package/dist/file/provider/s3.provider.js.map +1 -1
  51. package/dist/file/provider/spaces.provider.d.ts +10 -1
  52. package/dist/file/provider/spaces.provider.d.ts.map +1 -1
  53. package/dist/file/provider/spaces.provider.js +17 -0
  54. package/dist/file/provider/spaces.provider.js.map +1 -1
  55. package/dist/index.d.ts +5 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +5 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
  60. package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
  61. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  62. package/dist/integration-profile/integration-profile.secrets.js +26 -0
  63. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  64. package/dist/integration-profile/integration-profile.service.d.ts +1 -0
  65. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
  66. package/dist/integration-profile/integration-profile.service.js +3 -3
  67. package/dist/integration-profile/integration-profile.service.js.map +1 -1
  68. package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
  69. package/dist/integration-profile/testers/ai.testers.js +46 -8
  70. package/dist/integration-profile/testers/ai.testers.js.map +1 -1
  71. package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
  72. package/dist/integration-profile/testers/http-probe.js +5 -3
  73. package/dist/integration-profile/testers/http-probe.js.map +1 -1
  74. package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
  75. package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
  76. package/dist/mail/mail.service.d.ts.map +1 -1
  77. package/dist/mail/mail.service.js +16 -1
  78. package/dist/mail/mail.service.js.map +1 -1
  79. package/dist/mcp/mcp-server.service.d.ts.map +1 -1
  80. package/dist/mcp/mcp-server.service.js +10 -1
  81. package/dist/mcp/mcp-server.service.js.map +1 -1
  82. package/dist/mcp/mcp.module.d.ts.map +1 -1
  83. package/dist/mcp/mcp.module.js +2 -0
  84. package/dist/mcp/mcp.module.js.map +1 -1
  85. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
  86. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
  87. package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
  88. package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
  89. package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
  90. package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
  91. package/dist/meeting/meeting-provider.registry.d.ts +72 -0
  92. package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
  93. package/dist/meeting/meeting-provider.registry.js.map +1 -1
  94. package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
  95. package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
  96. package/dist/oauth/oauth-token-broker.service.js +105 -3
  97. package/dist/oauth/oauth-token-broker.service.js.map +1 -1
  98. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
  99. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  100. package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
  101. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  102. package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
  103. package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
  104. package/dist/pdf/document/charts/chart-bar.js +117 -0
  105. package/dist/pdf/document/charts/chart-bar.js.map +1 -0
  106. package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
  107. package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
  108. package/dist/pdf/document/charts/chart-donut.js +89 -0
  109. package/dist/pdf/document/charts/chart-donut.js.map +1 -0
  110. package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
  111. package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
  112. package/dist/pdf/document/charts/chart-gauge.js +60 -0
  113. package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
  114. package/dist/pdf/document/charts/chart-line.d.ts +29 -0
  115. package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
  116. package/dist/pdf/document/charts/chart-line.js +89 -0
  117. package/dist/pdf/document/charts/chart-line.js.map +1 -0
  118. package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
  119. package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
  120. package/dist/pdf/document/charts/chart-primitives.js +110 -0
  121. package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
  122. package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
  123. package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
  124. package/dist/pdf/document/charts/chart-radar.js +83 -0
  125. package/dist/pdf/document/charts/chart-radar.js.map +1 -0
  126. package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
  127. package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
  128. package/dist/pdf/document/charts/chart-scale.js +118 -0
  129. package/dist/pdf/document/charts/chart-scale.js.map +1 -0
  130. package/dist/pdf/document/charts/index.d.ts +8 -0
  131. package/dist/pdf/document/charts/index.d.ts.map +1 -0
  132. package/dist/pdf/document/charts/index.js +24 -0
  133. package/dist/pdf/document/charts/index.js.map +1 -0
  134. package/dist/pdf/document/index.d.ts +8 -0
  135. package/dist/pdf/document/index.d.ts.map +1 -0
  136. package/dist/pdf/document/index.js +24 -0
  137. package/dist/pdf/document/index.js.map +1 -0
  138. package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
  139. package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
  140. package/dist/pdf/document/pdf-document.chrome.js +36 -0
  141. package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
  142. package/dist/pdf/document/pdf-document.css.d.ts +20 -0
  143. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
  144. package/dist/pdf/document/pdf-document.css.js +327 -0
  145. package/dist/pdf/document/pdf-document.css.js.map +1 -0
  146. package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
  147. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
  148. package/dist/pdf/document/pdf-document.renderer.js +226 -0
  149. package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
  150. package/dist/pdf/document/pdf-document.service.d.ts +27 -0
  151. package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
  152. package/dist/pdf/document/pdf-document.service.js +81 -0
  153. package/dist/pdf/document/pdf-document.service.js.map +1 -0
  154. package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
  155. package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
  156. package/dist/pdf/document/pdf-document.theme.js +105 -0
  157. package/dist/pdf/document/pdf-document.theme.js.map +1 -0
  158. package/dist/pdf/document/pdf-document.types.d.ts +164 -0
  159. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
  160. package/dist/pdf/document/pdf-document.types.js +18 -0
  161. package/dist/pdf/document/pdf-document.types.js.map +1 -0
  162. package/dist/pdf/pdf.module.d.ts.map +1 -1
  163. package/dist/pdf/pdf.module.js +3 -2
  164. package/dist/pdf/pdf.module.js.map +1 -1
  165. package/dist/pdf/pdf.service.d.ts +75 -1
  166. package/dist/pdf/pdf.service.d.ts.map +1 -1
  167. package/dist/pdf/pdf.service.js +167 -63
  168. package/dist/pdf/pdf.service.js.map +1 -1
  169. package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
  170. package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
  171. package/dist/pdf/templates/commercial-proposal.template.js +276 -0
  172. package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
  173. package/dist/setting/favicon.controller.d.ts +26 -0
  174. package/dist/setting/favicon.controller.d.ts.map +1 -0
  175. package/dist/setting/favicon.controller.js +130 -0
  176. package/dist/setting/favicon.controller.js.map +1 -0
  177. package/dist/setting/setting.module.d.ts.map +1 -1
  178. package/dist/setting/setting.module.js +2 -1
  179. package/dist/setting/setting.module.js.map +1 -1
  180. package/dist/setting/setting.service.d.ts.map +1 -1
  181. package/dist/setting/setting.service.js +3 -0
  182. package/dist/setting/setting.service.js.map +1 -1
  183. package/dist/task/task.service.d.ts +6 -0
  184. package/dist/task/task.service.d.ts.map +1 -1
  185. package/dist/task/task.service.js +44 -13
  186. package/dist/task/task.service.js.map +1 -1
  187. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
  188. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  189. package/dist/whatsapp-sender/whatsapp-sender.service.js +107 -22
  190. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  191. package/hedhog/data/integration_type.yaml +6 -0
  192. package/hedhog/data/route.yaml +8 -0
  193. package/hedhog/data/setting_group.yaml +52 -0
  194. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
  195. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
  196. package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
  197. package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
  198. package/hedhog/frontend/messages/en.json +81 -2
  199. package/hedhog/frontend/messages/pt.json +81 -2
  200. package/hedhog/table/file.yaml +3 -1
  201. package/package.json +5 -5
  202. package/src/access-log/access-log.interceptor.spec.ts +50 -0
  203. package/src/access-log/access-log.interceptor.ts +18 -0
  204. package/src/ai/ai-attachment.fragments.spec.ts +56 -3
  205. package/src/ai/ai.service.responses.spec.ts +429 -0
  206. package/src/ai/ai.service.ts +409 -27
  207. package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
  208. package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
  209. package/src/core.module.ts +3 -0
  210. package/src/document-kit/document-kit.module.ts +12 -0
  211. package/src/document-kit/document-kit.registry.ts +125 -0
  212. package/src/document-kit/document-kit.service.spec.ts +156 -0
  213. package/src/document-kit/document-kit.service.ts +173 -0
  214. package/src/file/file-direct-upload.spec.ts +344 -0
  215. package/src/file/file.service.spec.ts +168 -1
  216. package/src/file/file.service.ts +435 -36
  217. package/src/file/mcp-file-source.schema.ts +8 -5
  218. package/src/file/provider/abstract.provider.ts +57 -0
  219. package/src/file/provider/s3-multipart.spec.ts +123 -0
  220. package/src/file/provider/s3-multipart.util.ts +126 -0
  221. package/src/file/provider/s3.provider.ts +65 -1
  222. package/src/file/provider/spaces.provider.ts +67 -1
  223. package/src/index.ts +5 -0
  224. package/src/integration-profile/integration-profile.secrets.ts +26 -0
  225. package/src/integration-profile/integration-profile.service.ts +1 -0
  226. package/src/integration-profile/testers/ai.testers.ts +70 -5
  227. package/src/integration-profile/testers/http-probe.ts +5 -3
  228. package/src/integration-profile/testers/integration-tester.types.ts +5 -0
  229. package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
  230. package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
  231. package/src/integration-profile/testers/tester-coverage.spec.ts +12 -0
  232. package/src/mail/mail.service.spec.ts +25 -0
  233. package/src/mail/mail.service.ts +18 -1
  234. package/src/mcp/mcp-server.service.spec.ts +16 -0
  235. package/src/mcp/mcp-server.service.ts +10 -1
  236. package/src/mcp/mcp.module.ts +2 -0
  237. package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
  238. package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
  239. package/src/meeting/meeting-provider.registry.ts +80 -0
  240. package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
  241. package/src/oauth/oauth-token-broker.service.ts +129 -2
  242. package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
  243. package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
  244. package/src/pdf/document/charts/chart-bar.ts +217 -0
  245. package/src/pdf/document/charts/chart-donut.ts +163 -0
  246. package/src/pdf/document/charts/chart-gauge.ts +94 -0
  247. package/src/pdf/document/charts/chart-line.ts +159 -0
  248. package/src/pdf/document/charts/chart-primitives.ts +188 -0
  249. package/src/pdf/document/charts/chart-radar.ts +136 -0
  250. package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
  251. package/src/pdf/document/charts/chart-scale.ts +141 -0
  252. package/src/pdf/document/charts/charts.spec.ts +157 -0
  253. package/src/pdf/document/charts/index.ts +7 -0
  254. package/src/pdf/document/index.ts +7 -0
  255. package/src/pdf/document/pdf-document.chrome.ts +38 -0
  256. package/src/pdf/document/pdf-document.css.ts +326 -0
  257. package/src/pdf/document/pdf-document.renderer.spec.ts +172 -0
  258. package/src/pdf/document/pdf-document.renderer.ts +317 -0
  259. package/src/pdf/document/pdf-document.service.ts +77 -0
  260. package/src/pdf/document/pdf-document.theme.ts +146 -0
  261. package/src/pdf/document/pdf-document.types.ts +149 -0
  262. package/src/pdf/pdf.module.ts +3 -2
  263. package/src/pdf/pdf.service.spec.ts +131 -0
  264. package/src/pdf/pdf.service.ts +256 -82
  265. package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
  266. package/src/pdf/templates/commercial-proposal.template.ts +400 -0
  267. package/src/setting/favicon.controller.spec.ts +92 -0
  268. package/src/setting/favicon.controller.ts +135 -0
  269. package/src/setting/setting.module.ts +2 -1
  270. package/src/setting/setting.service.ts +3 -0
  271. package/src/task/task.service.spec.ts +77 -1
  272. package/src/task/task.service.ts +58 -14
  273. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +263 -0
  274. package/src/whatsapp-sender/whatsapp-sender.service.ts +154 -16
@@ -0,0 +1,344 @@
1
+ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
2
+ import { BadRequestException, ConflictException } from '@nestjs/common';
3
+ import { JwtService } from '@nestjs/jwt';
4
+ import { FileService } from './file.service';
5
+
6
+ /**
7
+ * Upload direto em partes assinadas: o cliente manda os bytes ao storage e a API so
8
+ * abre, assina e fecha. O que estes testes protegem:
9
+ *
10
+ * - a chave segue o `location`/`path` que o `uploadFromPath` grava;
11
+ * - a regra de parte do Studio (>= 16 MiB, <= 9000 partes);
12
+ * - no complete, nada vira linha `file` sem conferir tamanho, teto, allowlist e os
13
+ * primeiros bytes — e o que for recusado sai do bucket;
14
+ * - o token só é aceito com a assinatura e o tipo certos.
15
+ */
16
+ const MIB = 1024 * 1024;
17
+
18
+ describe('FileService — upload direto', () => {
19
+ let deps: any;
20
+ let service: FileService;
21
+ let provider: any;
22
+ let settingValues: Record<string, unknown>;
23
+
24
+ beforeEach(() => {
25
+ process.env.JWT_SECRET = 'spec-secret';
26
+ settingValues = {
27
+ 'storage-max-size': String(1024 * MIB),
28
+ 'storage-accept-mimetype': ['video/mp4', 'image/jpeg'],
29
+ };
30
+
31
+ deps = {
32
+ prisma: {
33
+ file: {
34
+ create: jest.fn().mockImplementation((args: any) => Promise.resolve({ id: 42, ...args.data })),
35
+ },
36
+ },
37
+ pagination: {},
38
+ setting: {
39
+ getSettingValues: jest.fn().mockImplementation(async (slugs: any) =>
40
+ Object.fromEntries((slugs as string[]).map((slug) => [slug, settingValues[slug]])),
41
+ ),
42
+ },
43
+ jwt: new JwtService(),
44
+ imageOptimization: {},
45
+ };
46
+
47
+ service = new FileService(
48
+ deps.prisma,
49
+ deps.pagination,
50
+ deps.setting,
51
+ deps.jwt,
52
+ deps.imageOptimization,
53
+ );
54
+
55
+ provider = {
56
+ getFilename: jest.fn().mockReturnValue('uuid-1.mp4'),
57
+ createMultipartUpload: jest.fn().mockResolvedValue('upload-1' as never),
58
+ signUploadParts: jest.fn().mockImplementation(async (_k: any, _u: any, numbers: any) =>
59
+ (numbers as number[]).map((partNumber) => ({ partNumber, url: `https://put/${partNumber}` })),
60
+ ),
61
+ completeMultipartUpload: jest.fn().mockResolvedValue(undefined as never),
62
+ abortMultipartUpload: jest.fn().mockResolvedValue(undefined as never),
63
+ readHead: jest.fn().mockResolvedValue(Buffer.from('head') as never),
64
+ metaData: jest.fn().mockResolvedValue({ ContentLength: 100 * MIB } as never),
65
+ delete: jest.fn().mockResolvedValue(true as never),
66
+ };
67
+
68
+ jest.spyOn(service, 'getProvider').mockImplementation(async () => {
69
+ (service as any).providerId = 3;
70
+ return provider;
71
+ });
72
+ jest.spyOn(service, 'getMimeType').mockResolvedValue(7 as never);
73
+ });
74
+
75
+ afterEach(() => jest.restoreAllMocks());
76
+
77
+ const complete = (overrides: Record<string, unknown> = {}) =>
78
+ service.completeDirectUpload({
79
+ key: 'social/brands/1/uuid-1.mp4',
80
+ uploadId: 'upload-1',
81
+ destination: 'social/brands/1',
82
+ parts: [{ partNumber: 1, etag: '"a"' }],
83
+ size: 100 * MIB,
84
+ mimetype: 'video/mp4',
85
+ originalname: 'demo.mp4',
86
+ isPrivate: true,
87
+ createdByUserId: 9,
88
+ ...overrides,
89
+ } as any);
90
+
91
+ describe('supportsDirectUpload', () => {
92
+ it('provider com os cinco métodos suporta', async () => {
93
+ await expect(service.supportsDirectUpload()).resolves.toBe(true);
94
+ });
95
+
96
+ it('provider sem multipart (local, gcs, azure) não suporta', async () => {
97
+ delete provider.readHead;
98
+ await expect(service.supportsDirectUpload()).resolves.toBe(false);
99
+ });
100
+
101
+ it('sem storage configurado é false, não erro', async () => {
102
+ (service.getProvider as any).mockRejectedValueOnce(new Error('no profile'));
103
+ await expect(service.supportsDirectUpload()).resolves.toBe(false);
104
+ });
105
+ });
106
+
107
+ describe('startDirectUpload', () => {
108
+ it('abre o multipart com a chave destination/uuid.ext e a regra de partes', async () => {
109
+ const result = await service.startDirectUpload('social/brands/1', {
110
+ originalname: 'demo.mp4',
111
+ mimetype: 'video/mp4',
112
+ size: 216_000_000,
113
+ });
114
+
115
+ expect(provider.getFilename).toHaveBeenCalledWith('demo.mp4');
116
+ expect(provider.createMultipartUpload).toHaveBeenCalledWith(
117
+ 'social/brands/1/uuid-1.mp4',
118
+ 'video/mp4',
119
+ );
120
+ expect(result).toEqual({
121
+ key: 'social/brands/1/uuid-1.mp4',
122
+ uploadId: 'upload-1',
123
+ partSize: 16 * MIB,
124
+ partCount: 13,
125
+ });
126
+ });
127
+
128
+ it('arquivo enorme: a parte cresce para caber em 9000 partes', async () => {
129
+ settingValues['storage-max-size'] = String(2_147_483_647);
130
+ const size = 2_000_000_000;
131
+ const { partSize, partCount } = await service.startDirectUpload('x', {
132
+ originalname: 'a.mp4',
133
+ mimetype: 'video/mp4',
134
+ size,
135
+ });
136
+ // 2 GB / 16 MiB = 120 partes: ainda vale o mínimo.
137
+ expect(partSize).toBe(16 * MIB);
138
+ expect(partCount).toBe(Math.ceil(size / (16 * MIB)));
139
+ });
140
+
141
+ it('acima da storage-max-size é recusado antes de abrir nada', async () => {
142
+ settingValues['storage-max-size'] = String(10 * MIB);
143
+ await expect(
144
+ service.startDirectUpload('x', { originalname: 'a.mp4', mimetype: 'video/mp4', size: 11 * MIB }),
145
+ ).rejects.toThrow('File too large');
146
+ expect(provider.createMultipartUpload).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('skipSizeValidation pula a storage-max-size, mas não o teto de file.size', async () => {
150
+ settingValues['storage-max-size'] = String(10 * MIB);
151
+ const info = { originalname: 'a.mp4', mimetype: 'video/mp4', skipSizeValidation: true };
152
+
153
+ await expect(service.startDirectUpload('x', { ...info, size: 11 * MIB })).resolves.toMatchObject({
154
+ uploadId: 'upload-1',
155
+ });
156
+ await expect(
157
+ service.startDirectUpload('x', { ...info, size: Number.MAX_SAFE_INTEGER + 1 }),
158
+ ).rejects.toThrow('File too large');
159
+ });
160
+
161
+ it('fora da allowlist é recusado, a não ser com skipMimeValidation', async () => {
162
+ await expect(
163
+ service.startDirectUpload('x', { originalname: 'a.mov', mimetype: 'video/quicktime', size: MIB }),
164
+ ).rejects.toThrow('Invalid file type');
165
+
166
+ await expect(
167
+ service.startDirectUpload('x', {
168
+ originalname: 'a.mov',
169
+ mimetype: 'video/quicktime',
170
+ size: MIB,
171
+ skipMimeValidation: true,
172
+ }),
173
+ ).resolves.toMatchObject({ uploadId: 'upload-1' });
174
+ });
175
+
176
+ it('acima de 2 GB passa (file.size é BIGINT); acima do number exato é recusado', async () => {
177
+ settingValues['storage-max-size'] = String(10 * 1024 * MIB);
178
+ const info = { originalname: 'a.mp4', mimetype: 'video/mp4' };
179
+
180
+ await expect(service.startDirectUpload('x', { ...info, size: 3 * 1024 * MIB })).resolves.toMatchObject({
181
+ uploadId: 'upload-1',
182
+ });
183
+ await expect(
184
+ service.startDirectUpload('x', { ...info, size: Number.MAX_SAFE_INTEGER + 1, skipSizeValidation: true }),
185
+ ).rejects.toThrow('File too large');
186
+ });
187
+
188
+ it('storage sem upload direto é 409', async () => {
189
+ delete provider.createMultipartUpload;
190
+ await expect(
191
+ service.startDirectUpload('x', { originalname: 'a.mp4', mimetype: 'video/mp4', size: MIB }),
192
+ ).rejects.toBeInstanceOf(ConflictException);
193
+ });
194
+ });
195
+
196
+ describe('signDirectUploadParts / abortDirectUpload', () => {
197
+ it('assina as partes pedidas, sem repetir', async () => {
198
+ const parts = await service.signDirectUploadParts('k', 'u', [1, 2, 2], 3600);
199
+ expect(provider.signUploadParts).toHaveBeenCalledWith('k', 'u', [1, 2], 3600);
200
+ expect(parts).toHaveLength(2);
201
+ });
202
+
203
+ it('número de parte inválido é 400', async () => {
204
+ await expect(service.signDirectUploadParts('k', 'u', [0])).rejects.toBeInstanceOf(
205
+ BadRequestException,
206
+ );
207
+ await expect(service.signDirectUploadParts('k', 'u', [9001])).rejects.toBeInstanceOf(
208
+ BadRequestException,
209
+ );
210
+ });
211
+
212
+ it('abort de upload que já não existe não é erro', async () => {
213
+ provider.abortMultipartUpload.mockRejectedValueOnce(
214
+ Object.assign(new Error('gone'), { name: 'NoSuchUpload' }),
215
+ );
216
+ await expect(service.abortDirectUpload('k', 'u')).resolves.toBeUndefined();
217
+ });
218
+ });
219
+
220
+ describe('completeDirectUpload', () => {
221
+ it('fecha, confere e grava a linha file como o uploadFromPath', async () => {
222
+ const file: any = await complete();
223
+
224
+ expect(provider.completeMultipartUpload).toHaveBeenCalledWith(
225
+ 'social/brands/1/uuid-1.mp4',
226
+ 'upload-1',
227
+ [{ partNumber: 1, etag: '"a"' }],
228
+ );
229
+ expect(deps.prisma.file.create).toHaveBeenCalledWith({
230
+ data: {
231
+ filename: 'demo.mp4',
232
+ path: 'uuid-1.mp4',
233
+ provider_id: 3,
234
+ location: 'social/brands/1',
235
+ mimetype_id: 7,
236
+ size: 100 * MIB,
237
+ is_private: true,
238
+ created_by_user_id: 9,
239
+ },
240
+ });
241
+ expect(file.id).toBe(42);
242
+ expect(provider.delete).not.toHaveBeenCalled();
243
+ });
244
+
245
+ it('tamanho gravado diferente do declarado: 400 e objeto apagado', async () => {
246
+ provider.metaData.mockResolvedValueOnce({ ContentLength: 5 });
247
+ await expect(complete()).rejects.toThrow('differs from the declared size');
248
+ expect(provider.delete).toHaveBeenCalledWith('social/brands/1/uuid-1.mp4');
249
+ expect(deps.prisma.file.create).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it('acima da storage-max-size: 400 e objeto apagado', async () => {
253
+ settingValues['storage-max-size'] = String(10 * MIB);
254
+ await expect(complete()).rejects.toThrow('File too large');
255
+ expect(provider.delete).toHaveBeenCalled();
256
+ expect(deps.prisma.file.create).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it('acima da storage-max-size com skipSizeValidation: grava a linha', async () => {
260
+ settingValues['storage-max-size'] = String(10 * MIB);
261
+ await complete({ skipSizeValidation: true });
262
+ expect(provider.delete).not.toHaveBeenCalled();
263
+ expect(deps.prisma.file.create).toHaveBeenCalled();
264
+ });
265
+
266
+ it('fora da allowlist: 400 e objeto apagado', async () => {
267
+ await expect(complete({ mimetype: 'application/zip' })).rejects.toThrow('Invalid file type');
268
+ expect(provider.delete).toHaveBeenCalled();
269
+ });
270
+
271
+ it('verifyHead recusa: objeto apagado e o erro sobe como veio', async () => {
272
+ const check = jest.fn(() => {
273
+ throw new BadRequestException('not a video');
274
+ });
275
+ await expect(complete({ verifyHead: { bytes: 32, check } })).rejects.toThrow('not a video');
276
+ expect(provider.readHead).toHaveBeenCalledWith('social/brands/1/uuid-1.mp4', 32);
277
+ expect(provider.delete).toHaveBeenCalled();
278
+ expect(deps.prisma.file.create).not.toHaveBeenCalled();
279
+ });
280
+
281
+ it('verifyHead que devolve o tipo real: é ele que vai para a linha', async () => {
282
+ const getMimeType = service.getMimeType as any;
283
+ await complete({
284
+ mimetype: 'video/mp4',
285
+ skipMimeValidation: true,
286
+ verifyHead: { bytes: 32, check: () => 'video/quicktime' },
287
+ });
288
+ expect(getMimeType).toHaveBeenCalledWith('video/quicktime');
289
+ });
290
+
291
+ it('partes que o storage não monta (4xx): aborta e responde 400', async () => {
292
+ provider.completeMultipartUpload.mockRejectedValueOnce(
293
+ Object.assign(new Error('InvalidPart'), { $metadata: { httpStatusCode: 400 } }),
294
+ );
295
+ await expect(complete()).rejects.toThrow('could not be assembled');
296
+ expect(provider.abortMultipartUpload).toHaveBeenCalledWith(
297
+ 'social/brands/1/uuid-1.mp4',
298
+ 'upload-1',
299
+ );
300
+ });
301
+
302
+ it('chave fora do destination é 400, sem tocar no storage', async () => {
303
+ await expect(complete({ key: 'outra/pasta/uuid-1.mp4' })).rejects.toThrow(
304
+ 'Invalid direct upload key',
305
+ );
306
+ await expect(complete({ key: 'social/brands/1/sub/uuid-1.mp4' })).rejects.toThrow(
307
+ 'Invalid direct upload key',
308
+ );
309
+ expect(provider.completeMultipartUpload).not.toHaveBeenCalled();
310
+ });
311
+
312
+ it('sem partes é 400', async () => {
313
+ await expect(complete({ parts: [] })).rejects.toThrow('Invalid upload parts');
314
+ });
315
+ });
316
+
317
+ describe('token', () => {
318
+ it('ida e volta com as claims', () => {
319
+ const token = service.signDirectUploadToken({ key: 'k', brandId: 1 }, 60);
320
+ expect(service.readDirectUploadToken(token)).toMatchObject({ key: 'k', brandId: 1 });
321
+ });
322
+
323
+ it('claims adulteradas (sem reassinar) são 400', () => {
324
+ const token = service.signDirectUploadToken({ key: 'k', size: 10 }, 60);
325
+ const [header, payload, signature] = token.split('.');
326
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString());
327
+ const forged = Buffer.from(JSON.stringify({ ...claims, size: 1 })).toString('base64url');
328
+
329
+ expect(() => service.readDirectUploadToken(`${header}.${forged}.${signature}`)).toThrow(
330
+ 'Invalid or expired upload token',
331
+ );
332
+ });
333
+
334
+ it('assinado com outro segredo é 400', () => {
335
+ const token = new JwtService().sign({ t: 'file-direct-upload' }, { secret: 'other' });
336
+ expect(() => service.readDirectUploadToken(token)).toThrow('Invalid or expired upload token');
337
+ });
338
+
339
+ it('JWT válido de outro tipo (ex.: download) é 400', () => {
340
+ const token = service.createDownloadToken('a/b.mp4', 60);
341
+ expect(() => service.readDirectUploadToken(token)).toThrow('not a direct upload token');
342
+ });
343
+ });
344
+ });
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
2
- import { NotFoundException } from '@nestjs/common';
2
+ import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
3
3
  import { FileService } from './file.service';
4
4
  import { ProviderFactory } from './provider/provider.factory';
5
5
 
@@ -274,6 +274,80 @@ describe('FileService — arquivo privado nas rotas por id', () => {
274
274
  });
275
275
  });
276
276
 
277
+ /**
278
+ * Um 503 do Spaces com corpo que não é XML estourava no parse do SDK
279
+ * (`char 'u' is not expected`) e virava 500 na rota de segmento HLS. O SDK ainda
280
+ * carimba o status em `$metadata`, e é por ele que a falha vira 503 passageiro.
281
+ */
282
+ describe('FileService — falha do storage na leitura', () => {
283
+ const buildService = (readError: unknown, size: number | null = 1024) => {
284
+ const prisma = {
285
+ file: {
286
+ findUnique: jest.fn().mockResolvedValue({
287
+ id: 9,
288
+ filename: 'seg037.ts',
289
+ location: 'lms/lessons/hls/2274/stream_0',
290
+ path: 'lms/lessons/hls/2274/stream_0/abc.ts',
291
+ size,
292
+ file_mimetype: { name: 'video/MP2T' },
293
+ } as never),
294
+ },
295
+ };
296
+ const service = new FileService(prisma as any, {} as any, {} as any, {} as any, {} as any);
297
+ const provider = {
298
+ metaData: jest.fn().mockResolvedValue({ ContentLength: 1024 } as never),
299
+ readStream: jest.fn().mockRejectedValue(readError as never),
300
+ };
301
+ jest.spyOn(service, 'getProvider').mockResolvedValue(provider as never);
302
+ return { service, provider };
303
+ };
304
+
305
+ afterEach(() => jest.restoreAllMocks());
306
+
307
+ it('503 do storage com corpo ilegível vira ServiceUnavailable com code STORAGE_UNAVAILABLE', async () => {
308
+ const sdkError = Object.assign(new Error("char 'u' is not expected.:1:1"), {
309
+ $metadata: { httpStatusCode: 503 },
310
+ });
311
+ const { service } = buildService(sdkError);
312
+
313
+ const erro: any = await service.openReadStreamById(9).catch((e) => e);
314
+
315
+ expect(erro).toBeInstanceOf(ServiceUnavailableException);
316
+ expect(erro.getResponse()).toMatchObject({ code: 'STORAGE_UNAVAILABLE' });
317
+ expect(erro.cause).toBe(sdkError);
318
+ });
319
+
320
+ it('throttle (SlowDown) também é indisponibilidade, inclusive na leitura por faixa', async () => {
321
+ const { service } = buildService(Object.assign(new Error('Slow down'), { name: 'SlowDown' }));
322
+
323
+ await expect(service.openReadStreamWithRange(9, 0, 99)).rejects.toBeInstanceOf(
324
+ ServiceUnavailableException,
325
+ );
326
+ });
327
+
328
+ it('NoSuchKey continua 404', async () => {
329
+ const { service } = buildService(Object.assign(new Error('missing'), { name: 'NoSuchKey' }));
330
+
331
+ await expect(service.openReadStreamById(9)).rejects.toBeInstanceOf(NotFoundException);
332
+ });
333
+
334
+ it('erro sem status de storage é repassado como veio', async () => {
335
+ const original = new Error('boom');
336
+ const { service } = buildService(original);
337
+
338
+ await expect(service.openReadStreamById(9)).rejects.toBe(original);
339
+ });
340
+
341
+ it('não faz HEAD no storage quando o tamanho já está na tabela', async () => {
342
+ const { service, provider } = buildService(null);
343
+ provider.readStream.mockResolvedValue({} as never);
344
+
345
+ await service.openReadStreamById(9);
346
+
347
+ expect(provider.metaData).not.toHaveBeenCalled();
348
+ });
349
+ });
350
+
277
351
  /**
278
352
  * A varredura de reotimização do acervo passa em todo arquivo, e recomprimir WebP sobre
279
353
  * WebP sempre devolve alguns bytes a menos. Sem um ganho mínimo, cada passada trocaria o
@@ -367,3 +441,96 @@ describe('FileService.reoptimizeFile — ganho mínimo', () => {
367
441
  expect(result.mimetypeChanged).toBe(true);
368
442
  });
369
443
  });
444
+
445
+ /**
446
+ * O Instagram recusa WebP, e toda image/* que passa pela otimizacao vira WebP. Quem
447
+ * entrega o arquivo a um consumidor externo pede os bytes intactos.
448
+ */
449
+ describe('FileService — skipImageOptimization', () => {
450
+ const makeService = () => {
451
+ const deps: any = {
452
+ prisma: { file: { create: jest.fn().mockImplementation((args: any) => Promise.resolve(args.data)) } },
453
+ pagination: {},
454
+ setting: {},
455
+ jwt: {},
456
+ imageOptimization: {
457
+ optimizeBuffer: jest.fn().mockResolvedValue({
458
+ buffer: Buffer.from('webp'),
459
+ mimetype: 'image/webp',
460
+ originalname: 'foto.webp',
461
+ } as never),
462
+ },
463
+ };
464
+ const service = new FileService(
465
+ deps.prisma,
466
+ deps.pagination,
467
+ deps.setting,
468
+ deps.jwt,
469
+ deps.imageOptimization,
470
+ );
471
+ const provider = {
472
+ upload: jest.fn().mockResolvedValue('social/foto.jpg' as never),
473
+ uploadFromPath: jest.fn().mockResolvedValue('social/foto.jpg' as never),
474
+ };
475
+ jest.spyOn(service as any, 'getProvider').mockResolvedValue(provider as never);
476
+ jest.spyOn(service as any, 'getMimeType').mockResolvedValue(7 as never);
477
+ return { service, deps, provider };
478
+ };
479
+
480
+ it('upload grava a imagem sem otimizar quando o chamador pede', async () => {
481
+ const { service, deps, provider } = makeService();
482
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]);
483
+
484
+ const file: any = await service.upload(
485
+ 'social/brands/1',
486
+ { buffer: jpeg, mimetype: 'image/jpeg', originalname: 'foto.jpg', size: jpeg.length } as any,
487
+ undefined,
488
+ { skipMimeValidation: true, skipSizeValidation: true, skipImageOptimization: true },
489
+ );
490
+
491
+ expect(deps.imageOptimization.optimizeBuffer).not.toHaveBeenCalled();
492
+ const sent = (provider.upload.mock.calls[0] as any[])[1];
493
+ expect(sent.mimetype).toBe('image/jpeg');
494
+ expect(Buffer.compare(sent.buffer, jpeg)).toBe(0);
495
+ expect(file.size).toBe(jpeg.length);
496
+ });
497
+
498
+ it('upload sem a opcao continua otimizando', async () => {
499
+ const { service, deps } = makeService();
500
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]);
501
+
502
+ await service.upload(
503
+ 'social/brands/1',
504
+ { buffer: jpeg, mimetype: 'image/jpeg', originalname: 'foto.jpg', size: jpeg.length } as any,
505
+ undefined,
506
+ { skipMimeValidation: true, skipSizeValidation: true },
507
+ );
508
+
509
+ expect(deps.imageOptimization.optimizeBuffer).toHaveBeenCalledTimes(1);
510
+ });
511
+
512
+ it('uploadFromPath manda a imagem pelo caminho de streaming, intacta', async () => {
513
+ const { service, deps, provider } = makeService();
514
+ const os = await import('node:os');
515
+ const fs = await import('node:fs/promises');
516
+ const path = await import('node:path');
517
+ const tmp = path.join(os.tmpdir(), `skip-opt-${process.pid}.jpg`);
518
+ await fs.writeFile(tmp, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]));
519
+
520
+ try {
521
+ await service.uploadFromPath('social/brands/1', tmp, {
522
+ mimetype: 'image/jpeg',
523
+ originalname: 'foto.jpg',
524
+ skipMimeValidation: true,
525
+ skipSizeValidation: true,
526
+ skipImageOptimization: true,
527
+ });
528
+ } finally {
529
+ await fs.rm(tmp, { force: true });
530
+ }
531
+
532
+ expect(deps.imageOptimization.optimizeBuffer).not.toHaveBeenCalled();
533
+ expect(provider.uploadFromPath).toHaveBeenCalledTimes(1);
534
+ expect(provider.upload).not.toHaveBeenCalled();
535
+ });
536
+ });