providers-recluster.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. const getSessionMock = vi.fn();
  3. const findAllProvidersFreshMock = vi.fn();
  4. const findProviderVendorByIdMock = vi.fn();
  5. const getOrCreateProviderVendorIdFromUrlsMock = vi.fn();
  6. const computeVendorKeyMock = vi.fn();
  7. const backfillProviderEndpointsFromProvidersMock = vi.fn();
  8. const tryDeleteProviderVendorIfEmptyMock = vi.fn();
  9. const publishProviderCacheInvalidationMock = vi.fn();
  10. const dbMock = {
  11. transaction: vi.fn(),
  12. update: vi.fn(),
  13. };
  14. vi.mock("@/lib/auth", () => ({
  15. getSession: getSessionMock,
  16. }));
  17. vi.mock("@/repository/provider", () => ({
  18. findAllProviders: vi.fn(async () => []),
  19. findAllProvidersFresh: findAllProvidersFreshMock,
  20. findProviderById: vi.fn(async () => null),
  21. }));
  22. vi.mock("@/repository/provider-endpoints", () => ({
  23. computeVendorKey: computeVendorKeyMock,
  24. findProviderVendorById: findProviderVendorByIdMock,
  25. findProviderVendorsByIds: vi.fn(async (vendorIds: number[]) => {
  26. const vendors = await Promise.all(vendorIds.map((id) => findProviderVendorByIdMock(id)));
  27. return vendors.filter((vendor): vendor is NonNullable<typeof vendor> => vendor !== null);
  28. }),
  29. getOrCreateProviderVendorIdFromUrls: getOrCreateProviderVendorIdFromUrlsMock,
  30. backfillProviderEndpointsFromProviders: backfillProviderEndpointsFromProvidersMock,
  31. tryDeleteProviderVendorIfEmpty: tryDeleteProviderVendorIfEmptyMock,
  32. }));
  33. vi.mock("@/lib/cache/provider-cache", () => ({
  34. publishProviderCacheInvalidation: publishProviderCacheInvalidationMock,
  35. }));
  36. vi.mock("@/drizzle/db", () => ({
  37. db: dbMock,
  38. }));
  39. vi.mock("@/lib/logger", () => ({
  40. logger: {
  41. trace: vi.fn(),
  42. debug: vi.fn(),
  43. info: vi.fn(),
  44. warn: vi.fn(),
  45. error: vi.fn(),
  46. },
  47. }));
  48. describe("reclusterProviderVendors", () => {
  49. beforeEach(() => {
  50. vi.clearAllMocks();
  51. });
  52. describe("permission checks", () => {
  53. it("returns error when not logged in", async () => {
  54. getSessionMock.mockResolvedValue(null);
  55. const { reclusterProviderVendors } = await import("@/actions/providers");
  56. const result = await reclusterProviderVendors({ confirm: false });
  57. expect(result.ok).toBe(false);
  58. expect(result.error).toBeDefined();
  59. });
  60. it("returns error when user is not admin", async () => {
  61. getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } });
  62. const { reclusterProviderVendors } = await import("@/actions/providers");
  63. const result = await reclusterProviderVendors({ confirm: false });
  64. expect(result.ok).toBe(false);
  65. expect(result.error).toBeDefined();
  66. });
  67. it("allows admin users", async () => {
  68. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  69. findAllProvidersFreshMock.mockResolvedValue([]);
  70. const { reclusterProviderVendors } = await import("@/actions/providers");
  71. const result = await reclusterProviderVendors({ confirm: false });
  72. expect(result.ok).toBe(true);
  73. });
  74. });
  75. describe("preview mode (confirm=false)", () => {
  76. it("returns empty changes when no providers", async () => {
  77. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  78. findAllProvidersFreshMock.mockResolvedValue([]);
  79. const { reclusterProviderVendors } = await import("@/actions/providers");
  80. const result = await reclusterProviderVendors({ confirm: false });
  81. expect(result.ok).toBe(true);
  82. if (result.ok) {
  83. expect(result.data.applied).toBe(false);
  84. expect(result.data.changes).toEqual([]);
  85. expect(result.data.preview.providersMoved).toBe(0);
  86. }
  87. });
  88. it("detects providers that need vendor change", async () => {
  89. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  90. findAllProvidersFreshMock.mockResolvedValue([
  91. {
  92. id: 1,
  93. name: "Provider 1",
  94. url: "http://192.168.1.1:8080/v1/messages",
  95. websiteUrl: null,
  96. providerVendorId: 1,
  97. },
  98. {
  99. id: 2,
  100. name: "Provider 2",
  101. url: "http://192.168.1.1:9090/v1/messages",
  102. websiteUrl: null,
  103. providerVendorId: 1, // Same vendor but different port - should change
  104. },
  105. ]);
  106. // Current vendor has domain "192.168.1.1" (old behavior)
  107. findProviderVendorByIdMock.mockResolvedValue({
  108. id: 1,
  109. websiteDomain: "192.168.1.1",
  110. });
  111. // New vendor keys include port
  112. computeVendorKeyMock
  113. .mockReturnValueOnce("192.168.1.1:8080")
  114. .mockReturnValueOnce("192.168.1.1:9090");
  115. const { reclusterProviderVendors } = await import("@/actions/providers");
  116. const result = await reclusterProviderVendors({ confirm: false });
  117. expect(result.ok).toBe(true);
  118. if (result.ok) {
  119. expect(result.data.applied).toBe(false);
  120. expect(result.data.preview.providersMoved).toBe(2);
  121. expect(result.data.changes.length).toBe(2);
  122. }
  123. });
  124. it("does not modify database in preview mode", async () => {
  125. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  126. findAllProvidersFreshMock.mockResolvedValue([
  127. {
  128. id: 1,
  129. name: "Provider 1",
  130. url: "http://192.168.1.1:8080/v1/messages",
  131. websiteUrl: null,
  132. providerVendorId: 1,
  133. },
  134. ]);
  135. findProviderVendorByIdMock.mockResolvedValue({
  136. id: 1,
  137. websiteDomain: "192.168.1.1",
  138. });
  139. computeVendorKeyMock.mockReturnValue("192.168.1.1:8080");
  140. const { reclusterProviderVendors } = await import("@/actions/providers");
  141. await reclusterProviderVendors({ confirm: false });
  142. expect(dbMock.transaction).not.toHaveBeenCalled();
  143. expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled();
  144. });
  145. it("skips providers with invalid URLs", async () => {
  146. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  147. findAllProvidersFreshMock.mockResolvedValue([
  148. {
  149. id: 1,
  150. name: "Invalid Provider",
  151. url: "://invalid",
  152. websiteUrl: null,
  153. providerVendorId: null,
  154. },
  155. ]);
  156. computeVendorKeyMock.mockReturnValue(null);
  157. const { reclusterProviderVendors } = await import("@/actions/providers");
  158. const result = await reclusterProviderVendors({ confirm: false });
  159. expect(result.ok).toBe(true);
  160. if (result.ok) {
  161. expect(result.data.preview.skippedInvalidUrl).toBe(1);
  162. expect(result.data.preview.providersMoved).toBe(0);
  163. }
  164. });
  165. });
  166. describe("apply mode (confirm=true)", () => {
  167. it("executes database updates in transaction", async () => {
  168. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  169. findAllProvidersFreshMock.mockResolvedValue([
  170. {
  171. id: 1,
  172. name: "Provider 1",
  173. url: "http://192.168.1.1:8080/v1/messages",
  174. websiteUrl: null,
  175. providerVendorId: 1,
  176. },
  177. ]);
  178. findProviderVendorByIdMock.mockResolvedValue({
  179. id: 1,
  180. websiteDomain: "192.168.1.1",
  181. });
  182. computeVendorKeyMock.mockReturnValue("192.168.1.1:8080");
  183. getOrCreateProviderVendorIdFromUrlsMock.mockResolvedValue(2);
  184. backfillProviderEndpointsFromProvidersMock.mockResolvedValue({});
  185. tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true);
  186. const tx = {
  187. update: vi.fn().mockReturnValue({
  188. set: vi.fn().mockReturnValue({
  189. where: vi.fn().mockResolvedValue({}),
  190. }),
  191. }),
  192. };
  193. dbMock.transaction.mockImplementation(async (fn) => {
  194. return fn(tx);
  195. });
  196. const { reclusterProviderVendors } = await import("@/actions/providers");
  197. const result = await reclusterProviderVendors({ confirm: true });
  198. expect(result.ok).toBe(true);
  199. if (result.ok) {
  200. expect(result.data.applied).toBe(true);
  201. }
  202. expect(dbMock.transaction).toHaveBeenCalled();
  203. expect(getOrCreateProviderVendorIdFromUrlsMock).toHaveBeenCalledWith(
  204. expect.objectContaining({
  205. providerUrl: "http://192.168.1.1:8080/v1/messages",
  206. websiteUrl: null,
  207. }),
  208. { tx }
  209. );
  210. });
  211. it("publishes cache invalidation after apply", async () => {
  212. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  213. findAllProvidersFreshMock.mockResolvedValue([
  214. {
  215. id: 1,
  216. name: "Provider 1",
  217. url: "http://192.168.1.1:8080/v1/messages",
  218. websiteUrl: null,
  219. providerVendorId: 1,
  220. },
  221. ]);
  222. findProviderVendorByIdMock.mockResolvedValue({
  223. id: 1,
  224. websiteDomain: "192.168.1.1",
  225. });
  226. computeVendorKeyMock.mockReturnValue("192.168.1.1:8080");
  227. getOrCreateProviderVendorIdFromUrlsMock.mockResolvedValue(2);
  228. backfillProviderEndpointsFromProvidersMock.mockResolvedValue({});
  229. tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true);
  230. dbMock.transaction.mockImplementation(async (fn) => {
  231. return fn({
  232. update: vi.fn().mockReturnValue({
  233. set: vi.fn().mockReturnValue({
  234. where: vi.fn().mockResolvedValue({}),
  235. }),
  236. }),
  237. });
  238. });
  239. const { reclusterProviderVendors } = await import("@/actions/providers");
  240. await reclusterProviderVendors({ confirm: true });
  241. expect(publishProviderCacheInvalidationMock).toHaveBeenCalled();
  242. });
  243. it("does not apply when no changes needed", async () => {
  244. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  245. findAllProvidersFreshMock.mockResolvedValue([
  246. {
  247. id: 1,
  248. name: "Provider 1",
  249. url: "http://192.168.1.1:8080/v1/messages",
  250. websiteUrl: null,
  251. providerVendorId: 1,
  252. },
  253. ]);
  254. // Vendor already has correct domain
  255. findProviderVendorByIdMock.mockResolvedValue({
  256. id: 1,
  257. websiteDomain: "192.168.1.1:8080",
  258. });
  259. computeVendorKeyMock.mockReturnValue("192.168.1.1:8080");
  260. const { reclusterProviderVendors } = await import("@/actions/providers");
  261. const result = await reclusterProviderVendors({ confirm: true });
  262. expect(result.ok).toBe(true);
  263. if (result.ok) {
  264. expect(result.data.applied).toBe(true);
  265. expect(result.data.preview.providersMoved).toBe(0);
  266. }
  267. expect(dbMock.transaction).not.toHaveBeenCalled();
  268. });
  269. });
  270. });