karaoke-gen 0.103.1__py3-none-any.whl → 0.107.0__py3-none-any.whl

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 (339) hide show
  1. backend/Dockerfile.base +1 -0
  2. backend/api/routes/admin.py +226 -3
  3. backend/api/routes/push.py +238 -0
  4. backend/api/routes/users.py +14 -3
  5. backend/config.py +12 -1
  6. backend/main.py +2 -1
  7. backend/models/job.py +4 -0
  8. backend/models/user.py +20 -2
  9. backend/services/encoding_interface.py +4 -0
  10. backend/services/gce_encoding/main.py +22 -8
  11. backend/services/job_manager.py +68 -11
  12. backend/services/job_notification_service.py +4 -21
  13. backend/services/push_notification_service.py +409 -0
  14. backend/services/stripe_service.py +2 -2
  15. backend/tests/conftest.py +2 -1
  16. backend/tests/test_admin_delete_outputs.py +352 -0
  17. backend/tests/test_gce_encoding_worker.py +229 -0
  18. backend/tests/test_impersonation.py +18 -3
  19. backend/tests/test_job_notification_service.py +24 -58
  20. backend/tests/test_push_notification_service.py +460 -0
  21. backend/tests/test_push_routes.py +357 -0
  22. backend/tests/test_stripe_service.py +205 -0
  23. backend/tests/test_video_worker_orchestrator.py +189 -0
  24. backend/workers/video_worker_orchestrator.py +23 -0
  25. karaoke_gen/instrumental_review/server.py +145 -35
  26. karaoke_gen/nextjs_frontend/__init__.py +98 -0
  27. karaoke_gen/nextjs_frontend/out/404/index.html +1 -0
  28. karaoke_gen/nextjs_frontend/out/404.html +1 -0
  29. karaoke_gen/nextjs_frontend/out/__next.__PAGE__.txt +9 -0
  30. karaoke_gen/nextjs_frontend/out/__next._full.txt +22 -0
  31. karaoke_gen/nextjs_frontend/out/__next._head.txt +8 -0
  32. karaoke_gen/nextjs_frontend/out/__next._index.txt +9 -0
  33. karaoke_gen/nextjs_frontend/out/__next._tree.txt +2 -0
  34. karaoke_gen/nextjs_frontend/out/_next/static/chunks/01a7f8fe40f1ff47.js +1 -0
  35. karaoke_gen/nextjs_frontend/out/_next/static/chunks/112f346e31f991df.js +4 -0
  36. karaoke_gen/nextjs_frontend/out/_next/static/chunks/16d1a4dd9d8a873a.js +3 -0
  37. karaoke_gen/nextjs_frontend/out/_next/static/chunks/1ab85c362b8b0e86.js +9 -0
  38. karaoke_gen/nextjs_frontend/out/_next/static/chunks/247eb132b7f7b574.js +1 -0
  39. karaoke_gen/nextjs_frontend/out/_next/static/chunks/2b80d15cc95e4818.js +1 -0
  40. karaoke_gen/nextjs_frontend/out/_next/static/chunks/32c7eba5cd46c1bc.js +7 -0
  41. karaoke_gen/nextjs_frontend/out/_next/static/chunks/483f26794eae53d0.js +1 -0
  42. karaoke_gen/nextjs_frontend/out/_next/static/chunks/550c3b02e85f196a.js +1 -0
  43. karaoke_gen/nextjs_frontend/out/_next/static/chunks/55c5ade44387bef8.js +1 -0
  44. karaoke_gen/nextjs_frontend/out/_next/static/chunks/5628d92b5893add2.css +1 -0
  45. karaoke_gen/nextjs_frontend/out/_next/static/chunks/56ebf7665e4341c8.js +7 -0
  46. karaoke_gen/nextjs_frontend/out/_next/static/chunks/5997132b61dec430.js +1 -0
  47. karaoke_gen/nextjs_frontend/out/_next/static/chunks/5ea55255bce3eb9e.js +5 -0
  48. karaoke_gen/nextjs_frontend/out/_next/static/chunks/5eda89a57490b3cd.js +1 -0
  49. karaoke_gen/nextjs_frontend/out/_next/static/chunks/692f5d9e0d700c76.js +3 -0
  50. karaoke_gen/nextjs_frontend/out/_next/static/chunks/71d7a05b14f9f0f4.js +1 -0
  51. karaoke_gen/nextjs_frontend/out/_next/static/chunks/81ac355749ef3302.js +1 -0
  52. karaoke_gen/nextjs_frontend/out/_next/static/chunks/95f7e5934dbb0e5d.js +1 -0
  53. karaoke_gen/nextjs_frontend/out/_next/static/chunks/9bce8f19eaa46940.js +1 -0
  54. karaoke_gen/nextjs_frontend/out/_next/static/chunks/a6dad97d9634a72d.js +1 -0
  55. karaoke_gen/nextjs_frontend/out/_next/static/chunks/a9ed54eed3e14c92.js +2 -0
  56. karaoke_gen/nextjs_frontend/out/_next/static/chunks/b35cd41238ecfb17.js +1 -0
  57. karaoke_gen/nextjs_frontend/out/_next/static/chunks/b5bc3c3d5ebd49eb.js +1 -0
  58. karaoke_gen/nextjs_frontend/out/_next/static/chunks/b5c078c08db5ae32.js +5 -0
  59. karaoke_gen/nextjs_frontend/out/_next/static/chunks/be9c44a178104187.js +1 -0
  60. karaoke_gen/nextjs_frontend/out/_next/static/chunks/c4c840e18cb4861c.js +1 -0
  61. karaoke_gen/nextjs_frontend/out/_next/static/chunks/c645af7d6b65f73e.js +1 -0
  62. karaoke_gen/nextjs_frontend/out/_next/static/chunks/d2c5e2575df784d4.js +1 -0
  63. karaoke_gen/nextjs_frontend/out/_next/static/chunks/d30af02b96d81462.js +1 -0
  64. karaoke_gen/nextjs_frontend/out/_next/static/chunks/d9bdf64f4ec1e9b7.js +7 -0
  65. karaoke_gen/nextjs_frontend/out/_next/static/chunks/dcde6ed684dacd0e.js +5 -0
  66. karaoke_gen/nextjs_frontend/out/_next/static/chunks/e422cbe931246000.js +1 -0
  67. karaoke_gen/nextjs_frontend/out/_next/static/chunks/e483af34fc792d38.js +1 -0
  68. karaoke_gen/nextjs_frontend/out/_next/static/chunks/e57422aad6b897da.js +1 -0
  69. karaoke_gen/nextjs_frontend/out/_next/static/chunks/ef02697fb404726a.js +1 -0
  70. karaoke_gen/nextjs_frontend/out/_next/static/chunks/ff1a16fafef87110.js +1 -0
  71. karaoke_gen/nextjs_frontend/out/_next/static/chunks/turbopack-2d9ca3017a9deedf.js +3 -0
  72. karaoke_gen/nextjs_frontend/out/_next/static/zpw_-rjFIDV5tlPPtnvRI/_buildManifest.js +11 -0
  73. karaoke_gen/nextjs_frontend/out/_next/static/zpw_-rjFIDV5tlPPtnvRI/_clientMiddlewareManifest.json +1 -0
  74. karaoke_gen/nextjs_frontend/out/_next/static/zpw_-rjFIDV5tlPPtnvRI/_ssgManifest.js +1 -0
  75. karaoke_gen/nextjs_frontend/out/_not-found/__next._full.txt +18 -0
  76. karaoke_gen/nextjs_frontend/out/_not-found/__next._head.txt +8 -0
  77. karaoke_gen/nextjs_frontend/out/_not-found/__next._index.txt +9 -0
  78. karaoke_gen/nextjs_frontend/out/_not-found/__next._not-found.__PAGE__.txt +5 -0
  79. karaoke_gen/nextjs_frontend/out/_not-found/__next._not-found.txt +4 -0
  80. karaoke_gen/nextjs_frontend/out/_not-found/__next._tree.txt +2 -0
  81. karaoke_gen/nextjs_frontend/out/_not-found/index.html +1 -0
  82. karaoke_gen/nextjs_frontend/out/_not-found/index.txt +18 -0
  83. karaoke_gen/nextjs_frontend/out/admin/__next._full.txt +25 -0
  84. karaoke_gen/nextjs_frontend/out/admin/__next._head.txt +8 -0
  85. karaoke_gen/nextjs_frontend/out/admin/__next._index.txt +9 -0
  86. karaoke_gen/nextjs_frontend/out/admin/__next._tree.txt +2 -0
  87. karaoke_gen/nextjs_frontend/out/admin/__next.admin.__PAGE__.txt +9 -0
  88. karaoke_gen/nextjs_frontend/out/admin/__next.admin.txt +7 -0
  89. karaoke_gen/nextjs_frontend/out/admin/beta/__next._full.txt +25 -0
  90. karaoke_gen/nextjs_frontend/out/admin/beta/__next._head.txt +8 -0
  91. karaoke_gen/nextjs_frontend/out/admin/beta/__next._index.txt +9 -0
  92. karaoke_gen/nextjs_frontend/out/admin/beta/__next._tree.txt +2 -0
  93. karaoke_gen/nextjs_frontend/out/admin/beta/__next.admin.beta.__PAGE__.txt +9 -0
  94. karaoke_gen/nextjs_frontend/out/admin/beta/__next.admin.beta.txt +4 -0
  95. karaoke_gen/nextjs_frontend/out/admin/beta/__next.admin.txt +7 -0
  96. karaoke_gen/nextjs_frontend/out/admin/beta/index.html +1 -0
  97. karaoke_gen/nextjs_frontend/out/admin/beta/index.txt +25 -0
  98. karaoke_gen/nextjs_frontend/out/admin/index.html +1 -0
  99. karaoke_gen/nextjs_frontend/out/admin/index.txt +25 -0
  100. karaoke_gen/nextjs_frontend/out/admin/jobs/__next._full.txt +25 -0
  101. karaoke_gen/nextjs_frontend/out/admin/jobs/__next._head.txt +8 -0
  102. karaoke_gen/nextjs_frontend/out/admin/jobs/__next._index.txt +9 -0
  103. karaoke_gen/nextjs_frontend/out/admin/jobs/__next._tree.txt +2 -0
  104. karaoke_gen/nextjs_frontend/out/admin/jobs/__next.admin.jobs.__PAGE__.txt +9 -0
  105. karaoke_gen/nextjs_frontend/out/admin/jobs/__next.admin.jobs.txt +4 -0
  106. karaoke_gen/nextjs_frontend/out/admin/jobs/__next.admin.txt +7 -0
  107. karaoke_gen/nextjs_frontend/out/admin/jobs/index.html +1 -0
  108. karaoke_gen/nextjs_frontend/out/admin/jobs/index.txt +25 -0
  109. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._full.txt +25 -0
  110. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._head.txt +8 -0
  111. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._index.txt +9 -0
  112. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next._tree.txt +2 -0
  113. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next.admin.rate-limits.__PAGE__.txt +9 -0
  114. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next.admin.rate-limits.txt +4 -0
  115. karaoke_gen/nextjs_frontend/out/admin/rate-limits/__next.admin.txt +7 -0
  116. karaoke_gen/nextjs_frontend/out/admin/rate-limits/index.html +1 -0
  117. karaoke_gen/nextjs_frontend/out/admin/rate-limits/index.txt +25 -0
  118. karaoke_gen/nextjs_frontend/out/admin/searches/__next._full.txt +25 -0
  119. karaoke_gen/nextjs_frontend/out/admin/searches/__next._head.txt +8 -0
  120. karaoke_gen/nextjs_frontend/out/admin/searches/__next._index.txt +9 -0
  121. karaoke_gen/nextjs_frontend/out/admin/searches/__next._tree.txt +2 -0
  122. karaoke_gen/nextjs_frontend/out/admin/searches/__next.admin.searches.__PAGE__.txt +9 -0
  123. karaoke_gen/nextjs_frontend/out/admin/searches/__next.admin.searches.txt +4 -0
  124. karaoke_gen/nextjs_frontend/out/admin/searches/__next.admin.txt +7 -0
  125. karaoke_gen/nextjs_frontend/out/admin/searches/index.html +1 -0
  126. karaoke_gen/nextjs_frontend/out/admin/searches/index.txt +25 -0
  127. karaoke_gen/nextjs_frontend/out/admin/users/__next._full.txt +25 -0
  128. karaoke_gen/nextjs_frontend/out/admin/users/__next._head.txt +8 -0
  129. karaoke_gen/nextjs_frontend/out/admin/users/__next._index.txt +9 -0
  130. karaoke_gen/nextjs_frontend/out/admin/users/__next._tree.txt +2 -0
  131. karaoke_gen/nextjs_frontend/out/admin/users/__next.admin.txt +7 -0
  132. karaoke_gen/nextjs_frontend/out/admin/users/__next.admin.users.__PAGE__.txt +9 -0
  133. karaoke_gen/nextjs_frontend/out/admin/users/__next.admin.users.txt +4 -0
  134. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._full.txt +25 -0
  135. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._head.txt +8 -0
  136. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._index.txt +9 -0
  137. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next._tree.txt +2 -0
  138. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.txt +7 -0
  139. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.users.detail.__PAGE__.txt +9 -0
  140. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.users.detail.txt +4 -0
  141. karaoke_gen/nextjs_frontend/out/admin/users/detail/__next.admin.users.txt +4 -0
  142. karaoke_gen/nextjs_frontend/out/admin/users/detail/index.html +1 -0
  143. karaoke_gen/nextjs_frontend/out/admin/users/detail/index.txt +25 -0
  144. karaoke_gen/nextjs_frontend/out/admin/users/index.html +1 -0
  145. karaoke_gen/nextjs_frontend/out/admin/users/index.txt +25 -0
  146. karaoke_gen/nextjs_frontend/out/app/__next._full.txt +22 -0
  147. karaoke_gen/nextjs_frontend/out/app/__next._head.txt +8 -0
  148. karaoke_gen/nextjs_frontend/out/app/__next._index.txt +9 -0
  149. karaoke_gen/nextjs_frontend/out/app/__next._tree.txt +2 -0
  150. karaoke_gen/nextjs_frontend/out/app/__next.app.__PAGE__.txt +9 -0
  151. karaoke_gen/nextjs_frontend/out/app/__next.app.txt +4 -0
  152. karaoke_gen/nextjs_frontend/out/app/index.html +1 -0
  153. karaoke_gen/nextjs_frontend/out/app/index.txt +22 -0
  154. karaoke_gen/nextjs_frontend/out/app/jobs/__next._full.txt +19 -0
  155. karaoke_gen/nextjs_frontend/out/app/jobs/__next._head.txt +8 -0
  156. karaoke_gen/nextjs_frontend/out/app/jobs/__next._index.txt +9 -0
  157. karaoke_gen/nextjs_frontend/out/app/jobs/__next._tree.txt +2 -0
  158. karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.jobs.$oc$slug.__PAGE__.txt +6 -0
  159. karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.jobs.$oc$slug.txt +4 -0
  160. karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.jobs.txt +4 -0
  161. karaoke_gen/nextjs_frontend/out/app/jobs/__next.app.txt +4 -0
  162. karaoke_gen/nextjs_frontend/out/app/jobs/index.html +1 -0
  163. karaoke_gen/nextjs_frontend/out/app/jobs/index.txt +19 -0
  164. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._full.txt +19 -0
  165. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._head.txt +8 -0
  166. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._index.txt +9 -0
  167. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next._tree.txt +2 -0
  168. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.jobs.$oc$slug.__PAGE__.txt +6 -0
  169. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.jobs.$oc$slug.txt +4 -0
  170. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.jobs.txt +4 -0
  171. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/__next.app.txt +4 -0
  172. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/index.html +1 -0
  173. karaoke_gen/nextjs_frontend/out/app/jobs/local/instrumental/index.txt +19 -0
  174. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._full.txt +19 -0
  175. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._head.txt +8 -0
  176. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._index.txt +9 -0
  177. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next._tree.txt +2 -0
  178. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.jobs.$oc$slug.__PAGE__.txt +6 -0
  179. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.jobs.$oc$slug.txt +4 -0
  180. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.jobs.txt +4 -0
  181. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/__next.app.txt +4 -0
  182. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/index.html +1 -0
  183. karaoke_gen/nextjs_frontend/out/app/jobs/local/review/index.txt +19 -0
  184. karaoke_gen/nextjs_frontend/out/auth/verify/__next._full.txt +22 -0
  185. karaoke_gen/nextjs_frontend/out/auth/verify/__next._head.txt +8 -0
  186. karaoke_gen/nextjs_frontend/out/auth/verify/__next._index.txt +9 -0
  187. karaoke_gen/nextjs_frontend/out/auth/verify/__next._tree.txt +2 -0
  188. karaoke_gen/nextjs_frontend/out/auth/verify/__next.auth.txt +4 -0
  189. karaoke_gen/nextjs_frontend/out/auth/verify/__next.auth.verify.__PAGE__.txt +9 -0
  190. karaoke_gen/nextjs_frontend/out/auth/verify/__next.auth.verify.txt +4 -0
  191. karaoke_gen/nextjs_frontend/out/auth/verify/index.html +1 -0
  192. karaoke_gen/nextjs_frontend/out/auth/verify/index.txt +22 -0
  193. karaoke_gen/nextjs_frontend/out/index.html +1 -0
  194. karaoke_gen/nextjs_frontend/out/index.txt +22 -0
  195. karaoke_gen/nextjs_frontend/out/manifest.webmanifest +31 -0
  196. karaoke_gen/nextjs_frontend/out/order/success/__next._full.txt +22 -0
  197. karaoke_gen/nextjs_frontend/out/order/success/__next._head.txt +8 -0
  198. karaoke_gen/nextjs_frontend/out/order/success/__next._index.txt +9 -0
  199. karaoke_gen/nextjs_frontend/out/order/success/__next._tree.txt +2 -0
  200. karaoke_gen/nextjs_frontend/out/order/success/__next.order.success.__PAGE__.txt +9 -0
  201. karaoke_gen/nextjs_frontend/out/order/success/__next.order.success.txt +4 -0
  202. karaoke_gen/nextjs_frontend/out/order/success/__next.order.txt +4 -0
  203. karaoke_gen/nextjs_frontend/out/order/success/index.html +1 -0
  204. karaoke_gen/nextjs_frontend/out/order/success/index.txt +22 -0
  205. karaoke_gen/nextjs_frontend/out/payment/success/__next._full.txt +22 -0
  206. karaoke_gen/nextjs_frontend/out/payment/success/__next._head.txt +8 -0
  207. karaoke_gen/nextjs_frontend/out/payment/success/__next._index.txt +9 -0
  208. karaoke_gen/nextjs_frontend/out/payment/success/__next._tree.txt +2 -0
  209. karaoke_gen/nextjs_frontend/out/payment/success/__next.payment.success.__PAGE__.txt +9 -0
  210. karaoke_gen/nextjs_frontend/out/payment/success/__next.payment.success.txt +4 -0
  211. karaoke_gen/nextjs_frontend/out/payment/success/__next.payment.txt +4 -0
  212. karaoke_gen/nextjs_frontend/out/payment/success/index.html +1 -0
  213. karaoke_gen/nextjs_frontend/out/payment/success/index.txt +22 -0
  214. karaoke_gen/nextjs_frontend/out/screenshots/email-action_reminder.png +0 -0
  215. karaoke_gen/nextjs_frontend/out/screenshots/email-beta_welcome.png +0 -0
  216. karaoke_gen/nextjs_frontend/out/screenshots/email-job_completion.png +0 -0
  217. karaoke_gen/nextjs_frontend/out/screenshots/example-output.avif +0 -0
  218. karaoke_gen/nextjs_frontend/out/screenshots/homepage-full.png +0 -0
  219. karaoke_gen/nextjs_frontend/out/screenshots/homepage-hero.png +0 -0
  220. karaoke_gen/nextjs_frontend/out/screenshots/instrumental-review.avif +0 -0
  221. karaoke_gen/nextjs_frontend/out/screenshots/instrumental-review.png +0 -0
  222. karaoke_gen/nextjs_frontend/out/screenshots/job-dashboard.avif +0 -0
  223. karaoke_gen/nextjs_frontend/out/screenshots/lyrics-review.avif +0 -0
  224. karaoke_gen/nextjs_frontend/out/screenshots/lyrics-review.png +0 -0
  225. karaoke_gen/nextjs_frontend/out/sw.js +183 -0
  226. karaoke_gen/utils/cli_args.py +3 -3
  227. karaoke_gen/utils/gen_cli.py +4 -0
  228. karaoke_gen/utils/remote_cli.py +8 -40
  229. {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/METADATA +2 -1
  230. {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/RECORD +244 -131
  231. {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/WHEEL +1 -1
  232. lyrics_transcriber/correction/agentic/agent.py +83 -60
  233. lyrics_transcriber/correction/anchor_sequence.py +48 -3
  234. lyrics_transcriber/correction/corrector.py +92 -58
  235. lyrics_transcriber/review/server.py +165 -33
  236. lyrics_transcriber/utils/tracing.py +214 -0
  237. karaoke_gen/instrumental_review/static/index.html +0 -1695
  238. lyrics_transcriber/frontend/.gitignore +0 -24
  239. lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +0 -935
  240. lyrics_transcriber/frontend/.yarnrc.yml +0 -3
  241. lyrics_transcriber/frontend/README.md +0 -50
  242. lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +0 -210
  243. lyrics_transcriber/frontend/__init__.py +0 -25
  244. lyrics_transcriber/frontend/e2e/agentic-corrections.spec.ts +0 -207
  245. lyrics_transcriber/frontend/e2e/fixtures/agentic-correction-data.json +0 -226
  246. lyrics_transcriber/frontend/eslint.config.js +0 -28
  247. lyrics_transcriber/frontend/index.html +0 -22
  248. lyrics_transcriber/frontend/package-lock.json +0 -4553
  249. lyrics_transcriber/frontend/package.json +0 -48
  250. lyrics_transcriber/frontend/playwright.config.ts +0 -69
  251. lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
  252. lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
  253. lyrics_transcriber/frontend/src/App.tsx +0 -243
  254. lyrics_transcriber/frontend/src/api.ts +0 -262
  255. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +0 -111
  256. lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +0 -114
  257. lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +0 -204
  258. lyrics_transcriber/frontend/src/components/AppHeader.tsx +0 -65
  259. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +0 -180
  260. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +0 -175
  261. lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +0 -359
  262. lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +0 -281
  263. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +0 -162
  264. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +0 -257
  265. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +0 -94
  266. lyrics_transcriber/frontend/src/components/EditModal.tsx +0 -720
  267. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +0 -592
  268. lyrics_transcriber/frontend/src/components/EditWordList.tsx +0 -431
  269. lyrics_transcriber/frontend/src/components/FileUpload.tsx +0 -77
  270. lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +0 -467
  271. lyrics_transcriber/frontend/src/components/Header.tsx +0 -520
  272. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +0 -1526
  273. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +0 -216
  274. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +0 -721
  275. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +0 -80
  276. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +0 -999
  277. lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +0 -51
  278. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +0 -127
  279. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +0 -67
  280. lyrics_transcriber/frontend/src/components/ModelSelector.tsx +0 -23
  281. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +0 -177
  282. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +0 -268
  283. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +0 -336
  284. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +0 -354
  285. lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +0 -64
  286. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +0 -383
  287. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +0 -131
  288. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +0 -266
  289. lyrics_transcriber/frontend/src/components/WordDivider.tsx +0 -191
  290. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +0 -466
  291. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +0 -56
  292. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +0 -89
  293. lyrics_transcriber/frontend/src/components/shared/constants.ts +0 -30
  294. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +0 -180
  295. lyrics_transcriber/frontend/src/components/shared/styles.ts +0 -13
  296. lyrics_transcriber/frontend/src/components/shared/types.js +0 -2
  297. lyrics_transcriber/frontend/src/components/shared/types.ts +0 -135
  298. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +0 -177
  299. lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +0 -78
  300. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +0 -75
  301. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +0 -360
  302. lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +0 -110
  303. lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +0 -22
  304. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +0 -537
  305. lyrics_transcriber/frontend/src/main.tsx +0 -11
  306. lyrics_transcriber/frontend/src/theme.ts +0 -406
  307. lyrics_transcriber/frontend/src/types/global.d.ts +0 -9
  308. lyrics_transcriber/frontend/src/types.js +0 -2
  309. lyrics_transcriber/frontend/src/types.ts +0 -199
  310. lyrics_transcriber/frontend/src/validation.ts +0 -132
  311. lyrics_transcriber/frontend/src/vite-env.d.ts +0 -1
  312. lyrics_transcriber/frontend/tsconfig.app.json +0 -26
  313. lyrics_transcriber/frontend/tsconfig.json +0 -25
  314. lyrics_transcriber/frontend/tsconfig.node.json +0 -23
  315. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +0 -1
  316. lyrics_transcriber/frontend/update_version.js +0 -11
  317. lyrics_transcriber/frontend/vite.config.d.ts +0 -2
  318. lyrics_transcriber/frontend/vite.config.js +0 -15
  319. lyrics_transcriber/frontend/vite.config.ts +0 -16
  320. lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
  321. lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
  322. lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
  323. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js +0 -44465
  324. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +0 -1
  325. lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
  326. lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
  327. lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
  328. lyrics_transcriber/frontend/web_assets/index.html +0 -22
  329. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
  330. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +0 -5
  331. lyrics_transcriber/frontend/yarn.lock +0 -3711
  332. {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/apple-touch-icon.png +0 -0
  333. {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/favicon-16x16.png +0 -0
  334. {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/favicon-32x32.png +0 -0
  335. {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/favicon.ico +0 -0
  336. {lyrics_transcriber/frontend/public → karaoke_gen/nextjs_frontend/out}/nomad-karaoke-logo.svg +0 -0
  337. /lyrics_transcriber/frontend/public/nomad-karaoke-logo.png → /karaoke_gen/nextjs_frontend/out/nomad-logo.png +0 -0
  338. {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/entry_points.txt +0 -0
  339. {karaoke_gen-0.103.1.dist-info → karaoke_gen-0.107.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,7 +3,6 @@ Unit tests for job notification service.
3
3
  """
4
4
  import pytest
5
5
  from unittest.mock import Mock, patch, AsyncMock
6
- import urllib.parse
7
6
 
8
7
  from backend.services.job_notification_service import (
9
8
  JobNotificationService,
@@ -20,82 +19,55 @@ class TestURLBuilding:
20
19
  """Test basic review URL building."""
21
20
  service = JobNotificationService()
22
21
  service.frontend_url = "https://gen.nomadkaraoke.com"
23
- service.backend_url = "https://api.nomadkaraoke.com"
24
22
 
25
23
  url = service._build_review_url("job-123")
26
24
 
27
- assert "gen.nomadkaraoke.com/lyrics/" in url
28
- assert "baseApiUrl=" in url
29
- # The API URL should be URL-encoded
30
- assert urllib.parse.quote("https://api.nomadkaraoke.com/api/review/job-123", safe='') in url
25
+ assert url == "https://gen.nomadkaraoke.com/app/jobs/job-123/review"
31
26
 
32
- def test_build_review_url_with_audio_hash(self):
33
- """Test review URL with audio hash parameter."""
27
+ def test_build_review_url_ignores_legacy_params(self):
28
+ """Test review URL ignores legacy audio_hash and review_token params."""
34
29
  service = JobNotificationService()
35
30
  service.frontend_url = "https://gen.nomadkaraoke.com"
36
- service.backend_url = "https://api.nomadkaraoke.com"
37
31
 
38
- url = service._build_review_url("job-123", audio_hash="abc123")
32
+ # Legacy params are still accepted but not used in the URL
33
+ url = service._build_review_url("job-123", audio_hash="abc123", review_token="token456")
39
34
 
40
- assert "audioHash=abc123" in url
35
+ # URL should be the simple consolidated route
36
+ assert url == "https://gen.nomadkaraoke.com/app/jobs/job-123/review"
37
+ # No query params
38
+ assert "?" not in url
41
39
 
42
- def test_build_review_url_with_review_token(self):
43
- """Test review URL with review token parameter."""
40
+ def test_build_review_url_preserves_job_id_characters(self):
41
+ """Test that job ID is used directly in URL path."""
44
42
  service = JobNotificationService()
45
43
  service.frontend_url = "https://gen.nomadkaraoke.com"
46
- service.backend_url = "https://api.nomadkaraoke.com"
47
44
 
48
- url = service._build_review_url("job-123", review_token="token456")
45
+ # Note: job IDs with slashes would be unusual in real usage
46
+ url = service._build_review_url("abc-def-123")
49
47
 
50
- assert "reviewToken=token456" in url
51
-
52
- def test_build_review_url_with_all_params(self):
53
- """Test review URL with all parameters."""
54
- service = JobNotificationService()
55
- service.frontend_url = "https://gen.nomadkaraoke.com"
56
- service.backend_url = "https://api.nomadkaraoke.com"
57
-
58
- url = service._build_review_url(
59
- "job-123",
60
- audio_hash="hash789",
61
- review_token="token456"
62
- )
63
-
64
- assert "baseApiUrl=" in url
65
- assert "audioHash=hash789" in url
66
- assert "reviewToken=token456" in url
67
-
68
- def test_build_review_url_encodes_special_chars(self):
69
- """Test that special characters in job ID are encoded."""
70
- service = JobNotificationService()
71
- service.frontend_url = "https://gen.nomadkaraoke.com"
72
- service.backend_url = "https://api.nomadkaraoke.com"
73
-
74
- url = service._build_review_url("job/with/slashes")
75
-
76
- # The baseApiUrl parameter should have encoded slashes
77
- assert "%2F" in url
48
+ assert url == "https://gen.nomadkaraoke.com/app/jobs/abc-def-123/review"
78
49
 
79
50
  def test_build_instrumental_url_basic(self):
80
51
  """Test basic instrumental URL building."""
81
52
  service = JobNotificationService()
82
53
  service.frontend_url = "https://gen.nomadkaraoke.com"
83
- service.backend_url = "https://api.nomadkaraoke.com"
84
54
 
85
55
  url = service._build_instrumental_url("job-123")
86
56
 
87
- assert "gen.nomadkaraoke.com/instrumental/" in url
88
- assert "baseApiUrl=" in url
57
+ assert url == "https://gen.nomadkaraoke.com/app/jobs/job-123/instrumental"
89
58
 
90
- def test_build_instrumental_url_with_token(self):
91
- """Test instrumental URL with token parameter."""
59
+ def test_build_instrumental_url_ignores_legacy_params(self):
60
+ """Test instrumental URL ignores legacy instrumental_token param."""
92
61
  service = JobNotificationService()
93
62
  service.frontend_url = "https://gen.nomadkaraoke.com"
94
- service.backend_url = "https://api.nomadkaraoke.com"
95
63
 
64
+ # Legacy param is still accepted but not used in the URL
96
65
  url = service._build_instrumental_url("job-123", instrumental_token="inst-token")
97
66
 
98
- assert "instrumentalToken=inst-token" in url
67
+ # URL should be the simple consolidated route
68
+ assert url == "https://gen.nomadkaraoke.com/app/jobs/job-123/instrumental"
69
+ # No query params
70
+ assert "?" not in url
99
71
 
100
72
 
101
73
  class TestCompletionEmail:
@@ -325,7 +297,6 @@ class TestActionReminderEmail:
325
297
  """Test that lyrics reminder includes correct review URL."""
326
298
  service = JobNotificationService()
327
299
  service.frontend_url = "https://gen.nomadkaraoke.com"
328
- service.backend_url = "https://api.nomadkaraoke.com"
329
300
  service.email_service = Mock()
330
301
  service.email_service.send_action_reminder.return_value = True
331
302
  service.template_service = Mock()
@@ -336,22 +307,18 @@ class TestActionReminderEmail:
336
307
  job_id="job-123",
337
308
  user_email="user@example.com",
338
309
  action_type="lyrics",
339
- audio_hash="hash123",
340
- review_token="token456",
341
310
  )
342
311
 
343
312
  # Verify the review URL was passed to template
344
313
  call_kwargs = service.template_service.render_action_needed_lyrics.call_args.kwargs
345
314
  review_url = call_kwargs.get('review_url')
346
- assert "audioHash=hash123" in review_url
347
- assert "reviewToken=token456" in review_url
315
+ assert review_url == "https://gen.nomadkaraoke.com/app/jobs/job-123/review"
348
316
 
349
317
  @pytest.mark.asyncio
350
318
  async def test_send_instrumental_reminder_includes_url(self):
351
319
  """Test that instrumental reminder includes correct URL."""
352
320
  service = JobNotificationService()
353
321
  service.frontend_url = "https://gen.nomadkaraoke.com"
354
- service.backend_url = "https://api.nomadkaraoke.com"
355
322
  service.email_service = Mock()
356
323
  service.email_service.send_action_reminder.return_value = True
357
324
  service.template_service = Mock()
@@ -362,13 +329,12 @@ class TestActionReminderEmail:
362
329
  job_id="job-123",
363
330
  user_email="user@example.com",
364
331
  action_type="instrumental",
365
- instrumental_token="inst-token",
366
332
  )
367
333
 
368
334
  # Verify the instrumental URL was passed to template
369
335
  call_kwargs = service.template_service.render_action_needed_instrumental.call_args.kwargs
370
336
  instrumental_url = call_kwargs.get('instrumental_url')
371
- assert "instrumentalToken=inst-token" in instrumental_url
337
+ assert instrumental_url == "https://gen.nomadkaraoke.com/app/jobs/job-123/instrumental"
372
338
 
373
339
 
374
340
  class TestGetCompletionMessage:
@@ -0,0 +1,460 @@
1
+ """
2
+ Tests for PushNotificationService.
3
+
4
+ Tests the Web Push notification sending and subscription management.
5
+ """
6
+ import pytest
7
+ from unittest.mock import Mock, patch, AsyncMock, MagicMock
8
+ from datetime import datetime, timezone
9
+
10
+ from backend.services.push_notification_service import (
11
+ PushNotificationService,
12
+ get_push_notification_service,
13
+ SubscriptionGoneError,
14
+ )
15
+
16
+
17
+ @pytest.fixture
18
+ def mock_settings():
19
+ """Mock settings with push notifications enabled."""
20
+ settings = Mock()
21
+ settings.enable_push_notifications = True
22
+ settings.max_push_subscriptions_per_user = 5
23
+ settings.vapid_subject = "mailto:test@example.com"
24
+ settings.get_secret = Mock(side_effect=lambda x: {
25
+ "vapid-public-key": "test-public-key",
26
+ "vapid-private-key": "test-private-key"
27
+ }.get(x))
28
+ return settings
29
+
30
+
31
+ @pytest.fixture
32
+ def mock_db():
33
+ """Mock Firestore client."""
34
+ return Mock()
35
+
36
+
37
+ @pytest.fixture
38
+ def push_service(mock_settings, mock_db):
39
+ """Create PushNotificationService with mocked dependencies."""
40
+ with patch('backend.services.push_notification_service.get_settings', return_value=mock_settings):
41
+ service = PushNotificationService(db=mock_db)
42
+ return service
43
+
44
+
45
+ class TestPushNotificationServiceInit:
46
+ """Tests for service initialization and configuration."""
47
+
48
+ def test_is_enabled_when_configured(self, push_service):
49
+ """Service reports enabled when all config present."""
50
+ assert push_service.is_enabled() is True
51
+
52
+ def test_is_disabled_when_feature_flag_off(self, mock_db):
53
+ """Service reports disabled when feature flag off."""
54
+ settings = Mock()
55
+ settings.enable_push_notifications = False
56
+ settings.get_secret = Mock(return_value="key")
57
+
58
+ with patch('backend.services.push_notification_service.get_settings', return_value=settings):
59
+ service = PushNotificationService(db=mock_db)
60
+ assert service.is_enabled() is False
61
+
62
+ def test_is_disabled_when_vapid_keys_missing(self, mock_db):
63
+ """Service reports disabled when VAPID keys missing."""
64
+ settings = Mock()
65
+ settings.enable_push_notifications = True
66
+ settings.get_secret = Mock(return_value=None)
67
+
68
+ with patch('backend.services.push_notification_service.get_settings', return_value=settings):
69
+ service = PushNotificationService(db=mock_db)
70
+ assert service.is_enabled() is False
71
+
72
+ def test_get_public_key(self, push_service):
73
+ """Service returns public key when enabled."""
74
+ assert push_service.get_public_key() == "test-public-key"
75
+
76
+ def test_get_public_key_returns_none_when_disabled(self, mock_db):
77
+ """Service returns None for public key when disabled."""
78
+ settings = Mock()
79
+ settings.enable_push_notifications = False
80
+ settings.get_secret = Mock(return_value="key")
81
+
82
+ with patch('backend.services.push_notification_service.get_settings', return_value=settings):
83
+ service = PushNotificationService(db=mock_db)
84
+ assert service.get_public_key() is None
85
+
86
+
87
+ class TestSendPush:
88
+ """Tests for sending push notifications."""
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_send_push_skips_when_disabled(self, mock_db):
92
+ """send_push returns 0 when push notifications disabled."""
93
+ settings = Mock()
94
+ settings.enable_push_notifications = False
95
+ settings.get_secret = Mock(return_value=None)
96
+
97
+ with patch('backend.services.push_notification_service.get_settings', return_value=settings):
98
+ service = PushNotificationService(db=mock_db)
99
+ result = await service.send_push("test@example.com", "Title", "Body")
100
+ assert result == 0
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_send_push_no_user(self, push_service):
104
+ """send_push returns 0 when user not found."""
105
+ # Mock user not existing
106
+ push_service.db.collection.return_value.document.return_value.get.return_value.exists = False
107
+
108
+ result = await push_service.send_push("unknown@example.com", "Title", "Body")
109
+ assert result == 0
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_send_push_no_subscriptions(self, push_service):
113
+ """send_push returns 0 when user has no subscriptions."""
114
+ # Mock user with no subscriptions
115
+ mock_doc = Mock()
116
+ mock_doc.exists = True
117
+ mock_doc.to_dict.return_value = {"push_subscriptions": []}
118
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
119
+
120
+ result = await push_service.send_push("test@example.com", "Title", "Body")
121
+ assert result == 0
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_send_push_success(self, push_service):
125
+ """send_push successfully sends to subscription."""
126
+ # Mock user with subscription
127
+ mock_doc = Mock()
128
+ mock_doc.exists = True
129
+ mock_doc.to_dict.return_value = {
130
+ "push_subscriptions": [{
131
+ "endpoint": "https://push.example.com/endpoint",
132
+ "keys": {"p256dh": "key1", "auth": "key2"},
133
+ "device_name": "Test Device"
134
+ }]
135
+ }
136
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
137
+
138
+ with patch('backend.services.push_notification_service.webpush') as mock_webpush:
139
+ result = await push_service.send_push("test@example.com", "Title", "Body")
140
+
141
+ assert result == 1
142
+ mock_webpush.assert_called_once()
143
+ call_args = mock_webpush.call_args
144
+ assert call_args[1]["subscription_info"]["endpoint"] == "https://push.example.com/endpoint"
145
+
146
+ @pytest.mark.asyncio
147
+ async def test_send_push_removes_gone_subscription(self, push_service):
148
+ """send_push removes subscription when 410 Gone returned."""
149
+ from pywebpush import WebPushException
150
+
151
+ # Mock user with subscription
152
+ mock_doc = Mock()
153
+ mock_doc.exists = True
154
+ mock_doc.to_dict.return_value = {
155
+ "push_subscriptions": [{
156
+ "endpoint": "https://push.example.com/endpoint",
157
+ "keys": {"p256dh": "key1", "auth": "key2"},
158
+ "device_name": "Test Device"
159
+ }]
160
+ }
161
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
162
+
163
+ # Mock webpush to raise 410 error
164
+ mock_response = Mock()
165
+ mock_response.status_code = 410
166
+ error = WebPushException("Gone", response=mock_response)
167
+
168
+ with patch('backend.services.push_notification_service.webpush', side_effect=error):
169
+ result = await push_service.send_push("test@example.com", "Title", "Body")
170
+
171
+ assert result == 0
172
+ # Verify invalid subscription was cleaned up
173
+ push_service.db.collection.return_value.document.return_value.update.assert_called()
174
+
175
+
176
+ class TestSubscriptionManagement:
177
+ """Tests for adding, removing, and listing subscriptions."""
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_add_subscription_new_user(self, push_service):
181
+ """add_subscription returns False for non-existent user."""
182
+ push_service.db.collection.return_value.document.return_value.get.return_value.exists = False
183
+
184
+ result = await push_service.add_subscription(
185
+ "unknown@example.com",
186
+ "https://push.example.com/endpoint",
187
+ {"p256dh": "key1", "auth": "key2"},
188
+ "Test Device"
189
+ )
190
+
191
+ assert result is False
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_add_subscription_success(self, push_service):
195
+ """add_subscription adds new subscription."""
196
+ mock_doc = Mock()
197
+ mock_doc.exists = True
198
+ mock_doc.to_dict.return_value = {"push_subscriptions": []}
199
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
200
+
201
+ result = await push_service.add_subscription(
202
+ "test@example.com",
203
+ "https://push.example.com/endpoint",
204
+ {"p256dh": "key1", "auth": "key2"},
205
+ "Test Device"
206
+ )
207
+
208
+ assert result is True
209
+ push_service.db.collection.return_value.document.return_value.update.assert_called_once()
210
+
211
+ @pytest.mark.asyncio
212
+ async def test_add_subscription_updates_existing(self, push_service):
213
+ """add_subscription updates existing subscription with same endpoint."""
214
+ mock_doc = Mock()
215
+ mock_doc.exists = True
216
+ mock_doc.to_dict.return_value = {
217
+ "push_subscriptions": [{
218
+ "endpoint": "https://push.example.com/endpoint",
219
+ "keys": {"p256dh": "old-key", "auth": "old-auth"},
220
+ "device_name": "Old Device"
221
+ }]
222
+ }
223
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
224
+
225
+ result = await push_service.add_subscription(
226
+ "test@example.com",
227
+ "https://push.example.com/endpoint",
228
+ {"p256dh": "new-key", "auth": "new-auth"},
229
+ "New Device"
230
+ )
231
+
232
+ assert result is True
233
+ # Verify update was called (subscription replaced, not added)
234
+ update_call = push_service.db.collection.return_value.document.return_value.update.call_args
235
+ subs = update_call[0][0]["push_subscriptions"]
236
+ assert len(subs) == 1
237
+ assert subs[0]["device_name"] == "New Device"
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_add_subscription_enforces_max_limit(self, push_service):
241
+ """add_subscription removes oldest when max exceeded."""
242
+ # Create 5 existing subscriptions
243
+ existing_subs = [
244
+ {
245
+ "endpoint": f"https://push.example.com/endpoint{i}",
246
+ "keys": {"p256dh": "key", "auth": "auth"},
247
+ "device_name": f"Device {i}",
248
+ "created_at": f"2024-01-0{i+1}T00:00:00Z"
249
+ }
250
+ for i in range(5)
251
+ ]
252
+
253
+ mock_doc = Mock()
254
+ mock_doc.exists = True
255
+ mock_doc.to_dict.return_value = {"push_subscriptions": existing_subs}
256
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
257
+
258
+ result = await push_service.add_subscription(
259
+ "test@example.com",
260
+ "https://push.example.com/new-endpoint",
261
+ {"p256dh": "new-key", "auth": "new-auth"},
262
+ "New Device"
263
+ )
264
+
265
+ assert result is True
266
+ # Verify oldest was removed (max 5 subscriptions)
267
+ update_call = push_service.db.collection.return_value.document.return_value.update.call_args
268
+ subs = update_call[0][0]["push_subscriptions"]
269
+ assert len(subs) == 5
270
+
271
+ @pytest.mark.asyncio
272
+ async def test_remove_subscription_success(self, push_service):
273
+ """remove_subscription removes existing subscription."""
274
+ mock_doc = Mock()
275
+ mock_doc.exists = True
276
+ mock_doc.to_dict.return_value = {
277
+ "push_subscriptions": [{
278
+ "endpoint": "https://push.example.com/endpoint",
279
+ "keys": {"p256dh": "key", "auth": "auth"},
280
+ "device_name": "Test Device"
281
+ }]
282
+ }
283
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
284
+
285
+ result = await push_service.remove_subscription(
286
+ "test@example.com",
287
+ "https://push.example.com/endpoint"
288
+ )
289
+
290
+ assert result is True
291
+ update_call = push_service.db.collection.return_value.document.return_value.update.call_args
292
+ subs = update_call[0][0]["push_subscriptions"]
293
+ assert len(subs) == 0
294
+
295
+ @pytest.mark.asyncio
296
+ async def test_remove_subscription_not_found(self, push_service):
297
+ """remove_subscription returns False when subscription not found."""
298
+ mock_doc = Mock()
299
+ mock_doc.exists = True
300
+ mock_doc.to_dict.return_value = {"push_subscriptions": []}
301
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
302
+
303
+ result = await push_service.remove_subscription(
304
+ "test@example.com",
305
+ "https://push.example.com/unknown-endpoint"
306
+ )
307
+
308
+ assert result is False
309
+
310
+ @pytest.mark.asyncio
311
+ async def test_list_subscriptions_success(self, push_service):
312
+ """list_subscriptions returns user's subscriptions."""
313
+ mock_doc = Mock()
314
+ mock_doc.exists = True
315
+ mock_doc.to_dict.return_value = {
316
+ "push_subscriptions": [
317
+ {
318
+ "endpoint": "https://push.example.com/endpoint1",
319
+ "keys": {"p256dh": "key", "auth": "auth"},
320
+ "device_name": "Device 1",
321
+ "created_at": "2024-01-01T00:00:00Z",
322
+ "last_used_at": None
323
+ },
324
+ {
325
+ "endpoint": "https://push.example.com/endpoint2",
326
+ "keys": {"p256dh": "key", "auth": "auth"},
327
+ "device_name": "Device 2",
328
+ "created_at": "2024-01-02T00:00:00Z",
329
+ "last_used_at": "2024-01-03T00:00:00Z"
330
+ }
331
+ ]
332
+ }
333
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
334
+
335
+ result = await push_service.list_subscriptions("test@example.com")
336
+
337
+ assert len(result) == 2
338
+ assert result[0]["device_name"] == "Device 1"
339
+ assert result[1]["device_name"] == "Device 2"
340
+ # Verify keys are NOT included in response (security)
341
+ assert "keys" not in result[0]
342
+
343
+
344
+ class TestNotificationFormatting:
345
+ """Tests for blocking and completion notification formatting."""
346
+
347
+ @pytest.mark.asyncio
348
+ async def test_send_blocking_notification_lyrics(self, push_service):
349
+ """send_blocking_notification formats lyrics review notification."""
350
+ mock_doc = Mock()
351
+ mock_doc.exists = True
352
+ mock_doc.to_dict.return_value = {
353
+ "push_subscriptions": [{
354
+ "endpoint": "https://push.example.com/endpoint",
355
+ "keys": {"p256dh": "key", "auth": "auth"}
356
+ }]
357
+ }
358
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
359
+
360
+ job = {
361
+ "job_id": "test-job-123",
362
+ "user_email": "test@example.com",
363
+ "artist": "Test Artist",
364
+ "title": "Test Song"
365
+ }
366
+
367
+ with patch('backend.services.push_notification_service.webpush') as mock_webpush:
368
+ await push_service.send_blocking_notification(job, "lyrics")
369
+
370
+ call_args = mock_webpush.call_args
371
+ import json
372
+ payload = json.loads(call_args[1]["data"])
373
+ assert payload["title"] == "Review Lyrics"
374
+ assert "Test Song" in payload["body"]
375
+ assert "Test Artist" in payload["body"]
376
+ assert "/review/test-job-123" in payload["url"]
377
+
378
+ @pytest.mark.asyncio
379
+ async def test_send_blocking_notification_instrumental(self, push_service):
380
+ """send_blocking_notification formats instrumental selection notification."""
381
+ mock_doc = Mock()
382
+ mock_doc.exists = True
383
+ mock_doc.to_dict.return_value = {
384
+ "push_subscriptions": [{
385
+ "endpoint": "https://push.example.com/endpoint",
386
+ "keys": {"p256dh": "key", "auth": "auth"}
387
+ }]
388
+ }
389
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
390
+
391
+ job = {
392
+ "job_id": "test-job-123",
393
+ "user_email": "test@example.com",
394
+ "artist": "Test Artist",
395
+ "title": "Test Song"
396
+ }
397
+
398
+ with patch('backend.services.push_notification_service.webpush') as mock_webpush:
399
+ await push_service.send_blocking_notification(job, "instrumental")
400
+
401
+ call_args = mock_webpush.call_args
402
+ import json
403
+ payload = json.loads(call_args[1]["data"])
404
+ assert payload["title"] == "Select Instrumental"
405
+ assert "/instrumental/test-job-123" in payload["url"]
406
+
407
+ @pytest.mark.asyncio
408
+ async def test_send_completion_notification(self, push_service):
409
+ """send_completion_notification formats completion notification."""
410
+ mock_doc = Mock()
411
+ mock_doc.exists = True
412
+ mock_doc.to_dict.return_value = {
413
+ "push_subscriptions": [{
414
+ "endpoint": "https://push.example.com/endpoint",
415
+ "keys": {"p256dh": "key", "auth": "auth"}
416
+ }]
417
+ }
418
+ push_service.db.collection.return_value.document.return_value.get.return_value = mock_doc
419
+
420
+ job = {
421
+ "job_id": "test-job-123",
422
+ "user_email": "test@example.com",
423
+ "artist": "Test Artist",
424
+ "title": "Test Song"
425
+ }
426
+
427
+ with patch('backend.services.push_notification_service.webpush') as mock_webpush:
428
+ await push_service.send_completion_notification(job)
429
+
430
+ call_args = mock_webpush.call_args
431
+ import json
432
+ payload = json.loads(call_args[1]["data"])
433
+ assert payload["title"] == "Video Ready!"
434
+ assert "Test Song" in payload["body"]
435
+ assert "Test Artist" in payload["body"]
436
+ assert "download" in payload["body"].lower()
437
+
438
+
439
+ class TestSingleton:
440
+ """Tests for singleton pattern."""
441
+
442
+ def test_get_push_notification_service_returns_singleton(self):
443
+ """get_push_notification_service returns same instance."""
444
+ # Reset singleton for test
445
+ import backend.services.push_notification_service as module
446
+ module._push_service = None
447
+
448
+ with patch('backend.services.push_notification_service.get_settings') as mock_get_settings:
449
+ mock_settings = Mock()
450
+ mock_settings.enable_push_notifications = False
451
+ mock_settings.get_secret = Mock(return_value=None)
452
+ mock_get_settings.return_value = mock_settings
453
+
454
+ service1 = get_push_notification_service()
455
+ service2 = get_push_notification_service()
456
+
457
+ assert service1 is service2
458
+
459
+ # Clean up
460
+ module._push_service = None