Skip to content

Commit 4b6a92a

Browse files
authored
fix(forecast): ignore soft-deleted test cases in forecast calculations (#478)
Forecast calculations never filtered isDeleted, so soft-deleted repository cases (and cases removed from a run) still: - had their historical durations averaged into their link-group forecast, - had their own forecastManual/forecastAutomated columns rewritten, and - contributed their forecast to any test run still referencing them. Scope every forecast read to live cases: - forecastService.updateRepositoryCaseForecast: group fetch excludes isDeleted; forecast writes scoped to live members only (uniqueCaseIds is still used for affected-run discovery so a run that contained a just-deleted case is refreshed to drop it). - forecastService.updateTestRunForecast: run-membership query excludes soft-deleted memberships; run-sum excludes globally soft-deleted cases. - testRunService.updateTestRunForecast (UI path): same exclusions. Adds forecastService unit tests and a soft-delete regression test for testRunService.
1 parent 7a6b378 commit 4b6a92a

4 files changed

Lines changed: 206 additions & 12 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
// Mock the prisma client used by forecastService.
4+
const repoFindUnique = vi.fn();
5+
const repoFindMany = vi.fn();
6+
const repoUpdate = vi.fn();
7+
const trcFindMany = vi.fn();
8+
const trrFindMany = vi.fn();
9+
const junitFindMany = vi.fn();
10+
const runsFindUnique = vi.fn();
11+
const runsUpdate = vi.fn();
12+
13+
vi.mock("../lib/prismaBase", () => ({
14+
prisma: {
15+
repositoryCases: {
16+
findUnique: (...a: any[]) => repoFindUnique(...a),
17+
findMany: (...a: any[]) => repoFindMany(...a),
18+
update: (...a: any[]) => repoUpdate(...a),
19+
},
20+
testRunCases: { findMany: (...a: any[]) => trcFindMany(...a) },
21+
testRunResults: { findMany: (...a: any[]) => trrFindMany(...a) },
22+
jUnitTestResult: { findMany: (...a: any[]) => junitFindMany(...a) },
23+
testRuns: {
24+
findUnique: (...a: any[]) => runsFindUnique(...a),
25+
update: (...a: any[]) => runsUpdate(...a),
26+
},
27+
},
28+
}));
29+
30+
import {
31+
updateRepositoryCaseForecast,
32+
updateTestRunForecast,
33+
} from "./forecastService";
34+
35+
describe("forecastService — soft-deleted cases are ignored", () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks();
38+
});
39+
40+
describe("updateRepositoryCaseForecast", () => {
41+
it("filters soft-deleted cases out of the group and never writes their forecasts", async () => {
42+
// Seed case 1 is linked to case 2 (which is soft-deleted in the DB).
43+
repoFindUnique.mockResolvedValue({
44+
id: 1,
45+
source: "MANUAL",
46+
linksFrom: [{ caseBId: 2 }],
47+
linksTo: [],
48+
});
49+
50+
// repositoryCases.findMany is used twice: (a) the group fetch with a
51+
// `source` selection, (b) the current-forecasts fetch keyed by id.
52+
repoFindMany.mockImplementation((arg: any) => {
53+
if (arg?.select?.source) {
54+
// allCases: the DB returns only the live case (2 is soft-deleted).
55+
return Promise.resolve([{ id: 1, source: "MANUAL" }]);
56+
}
57+
// currentForecasts (keyed off liveCaseIds).
58+
return Promise.resolve([
59+
{ id: 1, forecastManual: null, forecastAutomated: null },
60+
]);
61+
});
62+
63+
trcFindMany.mockResolvedValue([]); // no manual results, no affected runs
64+
repoUpdate.mockResolvedValue({});
65+
66+
await updateRepositoryCaseForecast(1, { skipTestRunUpdate: true });
67+
68+
// The group query must exclude soft-deleted cases.
69+
const allCasesCall = repoFindMany.mock.calls.find(
70+
(c) => c[0]?.select?.source
71+
);
72+
expect(allCasesCall?.[0].where).toEqual({
73+
id: { in: [1, 2] },
74+
isDeleted: false,
75+
});
76+
77+
// Forecast writes are scoped to the live case ids only — case 2 is gone.
78+
const currentForecastsCall = repoFindMany.mock.calls.find(
79+
(c) => c[0]?.select?.forecastManual && c[0]?.select?.id
80+
);
81+
expect(currentForecastsCall?.[0].where).toEqual({ id: { in: [1] } });
82+
83+
// The soft-deleted case 2 must never be updated.
84+
const updatedIds = repoUpdate.mock.calls.map((c) => c[0]?.where?.id);
85+
expect(updatedIds).not.toContain(2);
86+
});
87+
});
88+
89+
describe("updateTestRunForecast", () => {
90+
it("excludes soft-deleted memberships and soft-deleted repository cases from the run total", async () => {
91+
// Two untested cases in the run; the DB will only return the live one
92+
// from the forecast-sum query (case 2's repository case is deleted).
93+
trcFindMany.mockResolvedValue([
94+
{ repositoryCaseId: 1, status: null },
95+
{ repositoryCaseId: 2, status: null },
96+
]);
97+
repoFindMany.mockResolvedValue([
98+
{ forecastManual: 100, forecastAutomated: 50 },
99+
]);
100+
runsFindUnique.mockResolvedValue({
101+
forecastManual: null,
102+
forecastAutomated: null,
103+
});
104+
runsUpdate.mockResolvedValue({});
105+
106+
// Pre-seed alreadyRefreshedCaseIds so the function skips the per-case
107+
// refresh recursion and goes straight to the run-sum logic.
108+
await updateTestRunForecast(7, {
109+
alreadyRefreshedCaseIds: new Set([1, 2]),
110+
});
111+
112+
// The run-membership query must exclude soft-deleted memberships.
113+
expect(trcFindMany.mock.calls[0][0].where).toEqual({
114+
testRunId: 7,
115+
isDeleted: false,
116+
});
117+
118+
// The forecast-sum query must exclude soft-deleted repository cases.
119+
const sumCall = repoFindMany.mock.calls.find(
120+
(c) => c[0]?.select?.forecastManual && !c[0]?.select?.id
121+
);
122+
expect(sumCall?.[0].where).toEqual({
123+
id: { in: [1, 2] },
124+
isDeleted: false,
125+
});
126+
127+
// Only the live case's forecast (100/50) lands on the run.
128+
expect(runsUpdate).toHaveBeenCalledWith({
129+
where: { id: 7 },
130+
data: { forecastManual: 100, forecastAutomated: 50 },
131+
});
132+
});
133+
});
134+
});

testplanit/services/forecastService.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,19 @@ export async function updateRepositoryCaseForecast(
6969
if (process.env.DEBUG_FORECAST)
7070
console.log("[Forecast] Group case IDs:", uniqueCaseIds);
7171

72-
// 2. Fetch all cases in the group with their source
72+
// 2. Fetch all NON-DELETED cases in the group with their source.
73+
// Soft-deleted cases must not pool their historical results into the
74+
// group average, nor have their own forecast columns recalculated. We
75+
// still keep `uniqueCaseIds` (which may include a just-deleted member)
76+
// for affected-test-run discovery below, so a run that contained the
77+
// deleted case still gets refreshed to drop its contribution.
7378
const allCases = await prisma.repositoryCases.findMany({
74-
where: { id: { in: uniqueCaseIds } },
79+
where: { id: { in: uniqueCaseIds }, isDeleted: false },
7580
select: { id: true, source: true },
7681
});
82+
// The live (non-deleted) group members — the only cases whose forecast
83+
// columns we write back below.
84+
const liveCaseIds = allCases.map((c) => c.id);
7785
if (process.env.DEBUG_FORECAST)
7886
console.log("[Forecast] allCases:", allCases);
7987

@@ -154,9 +162,9 @@ export async function updateRepositoryCaseForecast(
154162
if (process.env.DEBUG_FORECAST)
155163
console.log("[Forecast] avgManual:", avgManual, "avgJunit:", avgJunit);
156164

157-
// 5. Update only cases whose forecast values have actually changed
165+
// 5. Update only live cases whose forecast values have actually changed
158166
const currentForecasts = await prisma.repositoryCases.findMany({
159-
where: { id: { in: uniqueCaseIds } },
167+
where: { id: { in: liveCaseIds } },
160168
select: { id: true, forecastManual: true, forecastAutomated: true },
161169
});
162170
for (const current of currentForecasts) {
@@ -234,7 +242,8 @@ export async function updateTestRunForecast(
234242
try {
235243
// 1. Fetch all TestRunCases for this TestRun, including their status system name
236244
let testRunCasesWithDetails = await prisma.testRunCases.findMany({
237-
where: { testRunId: testRunId },
245+
// Exclude soft-deleted run memberships (cases removed from the run).
246+
where: { testRunId: testRunId, isDeleted: false },
238247
select: {
239248
repositoryCaseId: true,
240249
status: {
@@ -327,9 +336,11 @@ export async function updateTestRunForecast(
327336
return;
328337
}
329338

330-
// 3. Fetch the RepositoryCases for these filtered IDs
339+
// 3. Fetch the live RepositoryCases for these filtered IDs. A case that
340+
// is soft-deleted from the repository must not contribute its forecast
341+
// to the run total, even if a stale run membership still references it.
331342
const repositoryCases = await prisma.repositoryCases.findMany({
332-
where: { id: { in: repositoryCaseIdsToForecast } },
343+
where: { id: { in: repositoryCaseIdsToForecast }, isDeleted: false },
333344
select: { forecastManual: true, forecastAutomated: true },
334345
});
335346

testplanit/services/testRunService.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,11 @@ describe("testRunService", () => {
5252
await updateTestRunForecast(1);
5353

5454
expect(mockFindMany).toHaveBeenCalledWith({
55-
where: { testRunId: 1 },
55+
where: { testRunId: 1, isDeleted: false },
5656
include: {
5757
repositoryCase: {
5858
select: {
59+
isDeleted: true,
5960
forecastManual: true,
6061
forecastAutomated: true,
6162
},
@@ -140,6 +141,44 @@ describe("testRunService", () => {
140141
consoleSpy.mockRestore();
141142
});
142143

144+
it("should exclude soft-deleted repository cases from the forecast", async () => {
145+
mockFindMany.mockResolvedValue([
146+
{
147+
id: 1,
148+
testRunId: 1,
149+
repositoryCase: {
150+
isDeleted: false,
151+
forecastManual: 100,
152+
forecastAutomated: 50,
153+
},
154+
},
155+
{
156+
id: 2,
157+
testRunId: 1,
158+
// Soft-deleted repository case — must not contribute to the total.
159+
repositoryCase: {
160+
isDeleted: true,
161+
forecastManual: 999,
162+
forecastAutomated: 999,
163+
},
164+
},
165+
]);
166+
mockUpdate.mockResolvedValue({});
167+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
168+
169+
await updateTestRunForecast(1);
170+
171+
expect(mockUpdate).toHaveBeenCalledWith({
172+
where: { id: 1 },
173+
data: {
174+
forecastManual: 100, // only the live case counts
175+
forecastAutomated: 50,
176+
},
177+
});
178+
179+
consoleSpy.mockRestore();
180+
});
181+
143182
it("should handle empty test run cases", async () => {
144183
mockFindMany.mockResolvedValue([]);
145184
mockUpdate.mockResolvedValue({});

testplanit/services/testRunService.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { prisma } from "~/lib/prismaBase";
55
// Define a type for the structure returned by the findMany query
66
type TestRunCaseWithForecast = TestRunCases & {
77
repositoryCase: {
8+
isDeleted: boolean;
89
forecastManual: number | null;
910
forecastAutomated: number | null;
1011
} | null;
@@ -18,29 +19,38 @@ type TestRunCaseWithForecast = TestRunCases & {
1819
*/
1920
export async function updateTestRunForecast(testRunId: number): Promise<void> {
2021
try {
21-
// Fetch the TestRunCases and their associated RepositoryCase forecasts
22+
// Fetch the TestRunCases and their associated RepositoryCase forecasts.
23+
// Exclude soft-deleted run memberships (cases removed from the run) so
24+
// their forecast never counts toward the run total.
2225
const testRunCases: TestRunCaseWithForecast[] =
2326
await prisma.testRunCases.findMany({
24-
where: { testRunId: testRunId },
27+
where: { testRunId: testRunId, isDeleted: false },
2528
include: {
2629
repositoryCase: {
2730
select: {
31+
isDeleted: true,
2832
forecastManual: true,
2933
forecastAutomated: true,
3034
},
3135
},
3236
},
3337
});
3438

39+
// Drop cases whose repository case has been soft-deleted from the
40+
// repository (a stale membership may still reference it).
41+
const liveTestRunCases = testRunCases.filter(
42+
(trc) => trc.repositoryCase && !trc.repositoryCase.isDeleted
43+
);
44+
3545
// Calculate the total forecasts, treating null as 0
36-
const totalForecastManual = testRunCases.reduce(
46+
const totalForecastManual = liveTestRunCases.reduce(
3747
(sum: number, testRunCase: any) => {
3848
const forecast = testRunCase.repositoryCase?.forecastManual ?? 0;
3949
return sum + forecast;
4050
},
4151
0
4252
);
43-
const totalForecastAutomated = testRunCases.reduce(
53+
const totalForecastAutomated = liveTestRunCases.reduce(
4454
(sum: number, testRunCase: any) => {
4555
const forecast = testRunCase.repositoryCase?.forecastAutomated ?? 0;
4656
return sum + forecast;

0 commit comments

Comments
 (0)