fix(rank-tracking): unarchive config when re-adding an archived domain (#313)
This commit is contained in:
parent
9942c5a590
commit
3a2dad3a96
@ -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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -37,6 +37,11 @@ async function createConfig(input: {
|
|||||||
const normalizedDomain = normalizeDomain(input.domain);
|
const normalizedDomain = normalizeDomain(input.domain);
|
||||||
|
|
||||||
const locationCode = input.locationCode ?? 2840;
|
const locationCode = input.locationCode ?? 2840;
|
||||||
|
const scheduleInterval = input.scheduleInterval ?? "weekly";
|
||||||
|
const nextCheckAt = isScheduledRankTrackingInterval(scheduleInterval)
|
||||||
|
? computeNextCheckAt(scheduleInterval)
|
||||||
|
: null;
|
||||||
|
|
||||||
const existing =
|
const existing =
|
||||||
await RankTrackingRepository.getConfigByProjectDomainLocation(
|
await RankTrackingRepository.getConfigByProjectDomainLocation(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
@ -44,12 +49,33 @@ async function createConfig(input: {
|
|||||||
locationCode,
|
locationCode,
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
// 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(
|
throw new AppError(
|
||||||
"VALIDATION_ERROR",
|
"VALIDATION_ERROR",
|
||||||
"This domain + country combination is already being tracked",
|
"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(
|
const allConfigs = await RankTrackingRepository.getConfigsForProject(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
);
|
);
|
||||||
@ -61,10 +87,6 @@ async function createConfig(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const configId = crypto.randomUUID();
|
const configId = crypto.randomUUID();
|
||||||
const scheduleInterval = input.scheduleInterval ?? "weekly";
|
|
||||||
const nextCheckAt = isScheduledRankTrackingInterval(scheduleInterval)
|
|
||||||
? computeNextCheckAt(scheduleInterval)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await RankTrackingRepository.createConfig({
|
await RankTrackingRepository.createConfig({
|
||||||
id: configId,
|
id: configId,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user