diff --git a/src/server/features/rank-tracking/services/RankTrackingService.test.ts b/src/server/features/rank-tracking/services/RankTrackingService.test.ts new file mode 100644 index 0000000..c7699f5 --- /dev/null +++ b/src/server/features/rank-tracking/services/RankTrackingService.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getConfigByProjectDomainLocation: vi.fn(), + getConfigsForProject: vi.fn(), + createConfig: vi.fn(), + updateConfig: vi.fn(), +})); + +vi.mock("cloudflare:workers", () => ({ env: {} })); +vi.mock("@/server/lib/dataforseo", () => ({ createDataforseoClient: vi.fn() })); +vi.mock( + "@/server/features/rank-tracking/repositories/RankTrackingRepository", + () => ({ RankTrackingRepository: mocks }), +); + +const archivedConfig = { + id: "config_archived", + projectId: "project_1", + domain: "acme.com", + locationCode: 2840, + languageCode: "en", + devices: "both" as const, + serpDepth: 20, + scheduleInterval: "weekly" as const, + isActive: false, + lastSkipReason: "insufficient_credits", +}; + +const baseInput = { + projectId: "project_1", + domain: "acme.com", + locationCode: 2840, + languageCode: "es", + devices: "desktop" as const, + serpDepth: 40, + scheduleInterval: "daily" as const, +}; + +describe("RankTrackingService.createConfig", () => { + beforeEach(() => { + vi.resetModules(); + for (const mock of Object.values(mocks)) mock.mockReset(); + }); + + it("reactivates an archived config instead of throwing, applying the new settings", async () => { + mocks.getConfigByProjectDomainLocation.mockResolvedValue(archivedConfig); + mocks.updateConfig.mockResolvedValue(undefined); + const { RankTrackingService } = await import("./RankTrackingService"); + + await expect(RankTrackingService.createConfig(baseInput)).resolves.toEqual({ + configId: "config_archived", + }); + + expect(mocks.updateConfig).toHaveBeenCalledTimes(1); + expect(mocks.updateConfig).toHaveBeenCalledWith( + "config_archived", + "project_1", + expect.objectContaining({ + isActive: true, + languageCode: "es", + devices: "desktop", + serpDepth: 40, + scheduleInterval: "daily", + lastSkipReason: null, + }), + ); + // Reactivation must not insert a duplicate row. + expect(mocks.createConfig).not.toHaveBeenCalled(); + }); + + it("throws when an active config already tracks the same domain + location", async () => { + mocks.getConfigByProjectDomainLocation.mockResolvedValue({ + ...archivedConfig, + isActive: true, + }); + const { RankTrackingService } = await import("./RankTrackingService"); + + await expect( + RankTrackingService.createConfig(baseInput), + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + expect(mocks.updateConfig).not.toHaveBeenCalled(); + expect(mocks.createConfig).not.toHaveBeenCalled(); + }); + + it("creates a new config when none exists for the domain + location", async () => { + mocks.getConfigByProjectDomainLocation.mockResolvedValue(null); + mocks.getConfigsForProject.mockResolvedValue([]); + mocks.createConfig.mockResolvedValue(undefined); + const { RankTrackingService } = await import("./RankTrackingService"); + + const result = await RankTrackingService.createConfig(baseInput); + + expect(result.configId).toBeTruthy(); + expect(mocks.createConfig).toHaveBeenCalledTimes(1); + expect(mocks.updateConfig).not.toHaveBeenCalled(); + expect(mocks.createConfig).toHaveBeenCalledWith( + expect.objectContaining({ + id: result.configId, + projectId: "project_1", + domain: "acme.com", + devices: "desktop", + serpDepth: 40, + scheduleInterval: "daily", + }), + ); + }); +}); diff --git a/src/server/features/rank-tracking/services/RankTrackingService.ts b/src/server/features/rank-tracking/services/RankTrackingService.ts index 8917c23..b165d3d 100644 --- a/src/server/features/rank-tracking/services/RankTrackingService.ts +++ b/src/server/features/rank-tracking/services/RankTrackingService.ts @@ -37,6 +37,11 @@ async function createConfig(input: { const normalizedDomain = normalizeDomain(input.domain); const locationCode = input.locationCode ?? 2840; + const scheduleInterval = input.scheduleInterval ?? "weekly"; + const nextCheckAt = isScheduledRankTrackingInterval(scheduleInterval) + ? computeNextCheckAt(scheduleInterval) + : null; + const existing = await RankTrackingRepository.getConfigByProjectDomainLocation( input.projectId, @@ -44,10 +49,31 @@ async function createConfig(input: { locationCode, ); if (existing) { - throw new AppError( - "VALIDATION_ERROR", - "This domain + country combination is already being tracked", - ); + // The (project, domain, location) row still exists when a domain is + // archived — archiving only flips isActive to false. So re-adding an + // archived domain reactivates that row (keeping its keyword/ranking + // history) with the freshly chosen settings, rather than colliding with + // the unique index. An already-active row is a genuine duplicate. + if (existing.isActive) { + throw new AppError( + "VALIDATION_ERROR", + "This domain + country combination is already being tracked", + ); + } + + await RankTrackingRepository.updateConfig(existing.id, input.projectId, { + isActive: true, + languageCode: input.languageCode ?? "en", + devices: input.devices ?? "both", + serpDepth: input.serpDepth, + scheduleInterval, + nextCheckAt, + // Drop any stale skip reason from before it was archived so the + // re-added domain doesn't surface an outdated warning. + lastSkipReason: null, + }); + + return { configId: existing.id }; } const allConfigs = await RankTrackingRepository.getConfigsForProject( @@ -61,10 +87,6 @@ async function createConfig(input: { } const configId = crypto.randomUUID(); - const scheduleInterval = input.scheduleInterval ?? "weekly"; - const nextCheckAt = isScheduledRankTrackingInterval(scheduleInterval) - ? computeNextCheckAt(scheduleInterval) - : null; await RankTrackingRepository.createConfig({ id: configId,