fix(rank-tracking): unarchive config when re-adding an archived domain (#313)

This commit is contained in:
Ben Senescu 2026-06-29 18:38:15 -04:00 committed by Ben Senescu
parent 9942c5a590
commit 3a2dad3a96
2 changed files with 138 additions and 8 deletions

View File

@ -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",
}),
);
});
});

View File

@ -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,