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 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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user