diff --git a/docs/blog/2026-05-18-introducing-parameterized-test-cases.md b/docs/blog/2026-05-18-introducing-parameterized-test-cases.md new file mode 100644 index 000000000..f16efe850 --- /dev/null +++ b/docs/blog/2026-05-18-introducing-parameterized-test-cases.md @@ -0,0 +1,105 @@ +--- +slug: introducing-parameterized-test-cases +title: "Introducing Parameterized Test Cases: One Case, Many Runs, Real Coverage" +description: "TestPlanIt v0.29.0 ships parameterized test cases. Drive a single case from a table of input rows, see per-row results, and tell your bug tracker exactly which combination failed — without duplicating cases." +authors: [bdermanouelian] +tags: [release, announcement] +image: /img/blog/iteration-matrix-blog.jpg +--- + +
+ The Parameterized Test Iteration Matrix report focused on a Login case with seven parameter rows (Valid user, Locked account, Wrong password, Empty password, SSO user, Expired credentials, Maintenance mode) and configuration columns including CRM Oracle, CRM Salesforce, Edge Windows, and Galaxy S20. A hover popover on the Empty password / no-config cell shows the four runs that contributed: UAT param run, async dup-key repro, async dup-key verify, and Sprint 24 regression. +
The Parameterized Test Iteration Matrix — one Login case, seven input scenarios, every configuration, every run that has ever touched it.
+
+ +Every test team has the same shelf of near-duplicate test cases. "Login — valid user." "Login — empty password." "Login — wrong password." "Login — locked account." "Login — SSO user." Same steps, same expected outcomes, different inputs. Multiply that pattern across a typical product and the repository fills up with copies of the same test case wearing slightly different hats. + +It's not a quality problem — those scenarios all matter. It's a maintenance problem. When the flow changes, you edit one case and forget the other six. When someone joins the team they can't tell which "Login" case is canonical. When a run completes, the report doesn't surface "which input combinations did we actually cover this sprint?" + +TestPlanIt v0.29.0 ships **[parameterized test cases](/docs/user-guide/projects/parameterized-test-cases)**. Write the case once. Attach a table of input rows. Each row becomes an iteration with its own status — and the results roll up to a single case in your report. + +If you're already using [shared steps](/docs/user-guide/shared-steps), this is the missing other half: shared steps reuse the *same steps* across many cases when the flow is identical; [parameterized cases](/docs/user-guide/projects/parameterized-test-cases) reuse the *same case* across many input rows when the steps stay the same but the data changes. + + + +## One Case, Many Rows + +Open any test case and click **Configure Parameters**. Declare the named inputs your case needs — `username`, `attempts`, `country_code`, whatever the case uses. Drop the parameter names into your step text wherever you used to hard-code a value. Add a table of rows: one column per parameter, one row per scenario you want to cover. + +
+ The Configure Parameters sheet on a Login test case, with seven dataset rows for scenarios like Valid user, Locked account, and SSO sign-in. Sensitive password column masked as bullets. Last result per row colored green / red / gray. +
One Login case driven by a 7-row dataset. Sensitive password values are masked, last result is rolled up per row.
+
+ +When the case runs in a test run, every row becomes an **iteration** — a separate result line with its own status from your project's workflow, its own notes, its own evidence. A row labeled "Locked account" shows up as a discrete result. So does "SSO user." So does "Wrong password attempt #3." Open the case in the run and you see them stacked: which inputs passed, which failed, which still need testing. + +
+ The test run drill-down on a parameterized case. Iterations 1-7 listed in the left rail with colored status dots; the right panel shows iteration 1's steps with @username and @password chips substituted with this row's values. +
Iterations on the left, the case's steps on the right — with parameter chips rendering this row's values.
+
+ +Maintenance collapses. Change the steps once, the iterations all pick up the change. Add a new input combination, it's one new row. Retire a scenario, delete a row. + +## Shared Datasets: The Network Effect + +A lot of those input tables aren't unique to one case. Your list of supported browser/OS combinations gets used in five different flows. Your roster of test users gets reused everywhere. Your set of edge-case email addresses shows up in every signup-related case. + +Shared datasets let you author that table **once** at the project level and assign it to every case that needs it. Each case maps the shared dataset's columns to whatever names it uses internally — same table, different parameter names, no duplication. + +When the shared dataset is updated, every case picks it up on the next run. Need to add a new browser to the matrix? Edit one row, everything tested against it next sprint. + +Datasets are also **versioned**. Every save creates an immutable snapshot, so a test run completed last quarter stays readable even after the dataset has been edited and republished. You can pin a case to a known dataset version when you're in the middle of a migration and don't want surprises. + +## The Iteration Matrix Report + +Per-row results unlock a kind of report you couldn't really build before: a **matrix** of cases × configurations × input rows, color-coded by status, all in one view. + +
+ The Parameterized Test Iteration Matrix report focused on a Login case with seven parameter rows (Valid user, Locked account, Wrong password, Empty password, SSO user, Expired credentials, Maintenance mode) and configuration columns including CRM Oracle, CRM Salesforce, Edge Windows, and Galaxy S20. A hover popover on the Empty password / no-config cell shows the four runs that contributed: UAT param run, async dup-key repro, async dup-key verify, and Sprint 24 regression. +
One view, every row across every configuration. Green is a passed iteration; red is a failure; gray is untested. Hover any cell to see which runs contributed.
+
+ +Did Chrome on Windows pass every login scenario this regression? One look. Did the "Locked account" row fail across every browser? One look. Are there configurations you've never actually tested for a critical case? They show up empty. + +The matrix is part of TestPlanIt's Report Builder. Drag it onto any report dashboard, point at the test runs you want to summarize, and export to CSV when you want to bring it into a meeting or attach it to a release sign-off. + +## CI Already Knows Which Row Failed + +If you're running parameterized tests in JUnit, TestNG, xUnit, NUnit, or MSTest, your test framework already emits a property or attribute that says which row of the data provider this result belongs to. TestPlanIt reads that signal directly. Upload your JUnit XML from CI and each iteration lands in the right row of the right test case automatically — no scripts, no hand-mapping. + +Different teams call this property different things — `iteration`, `dataRow`, `iterationIndex`, whatever your CI emitter uses. The project settings let you configure the names TestPlanIt should recognize. Out of the box it looks for the most common name; add yours if you've standardized on something else. + +## Linked Issues That Carry the Context + +The single most annoying part of triaging a parameterized test failure used to be reproducing it. "Login failed" is not a bug report. "Login failed for user `[REDACTED]` on iteration 3 of 7, with these parameter values, in this test run" — that's a bug report. + +When you link an issue from a failed iteration, TestPlanIt pre-fills the description with exactly that: the iteration's title, a table of parameter values for that row, and a deep link back to the iteration in TestPlanIt. Jira gets a real Jira table. GitHub gets a real markdown table. Azure DevOps gets a real HTML structured description. You edit before submit if you want; otherwise click Create and the ticket lands with everything the developer needs. + +The description is translated to the issue-filer's preferred language, so a French QA engineer files a French description for the French developer who's about to read it. + +## Sensitive Values, Handled + +Test data sometimes includes passwords, API tokens, payment card numbers, customer PII. We give you a **Sensitive** flag per parameter. Mark the column once and TestPlanIt masks the value as `••••••` everywhere in the UI for users who don't have explicit permission to read it, and replaces it with `[REDACTED]` in CSV exports and pre-filled issue bodies. + +There's no new permission to teach your team. The existing **Test Case Restricted Fields** and **Test Run Result Restricted Fields** role permissions you already use for masking other fields gate this too. Grant once at the role level and the right people see the right things across every surface. + +For everyday "share-my-screen-in-a-meeting" privacy, this is exactly what you need. For real production secrets, fetch them at execution time from your secrets manager rather than embedding them in test data — same rule as always. + +## Where to Find It + +The whole feature surface lives under one menu entry: **Project Settings → Test Case Parameters**. Shared datasets are managed there. CI iteration property names are configured there. Per-case parameters and inline owner-bound datasets are authored from inside each test case via **Configure Parameters**. Run results, the matrix report, and issue linking all light up automatically as soon as a case has parameters. + +The full user guide is at [Parameterized Test Cases](/docs/user-guide/projects/parameterized-test-cases). + +## Upgrade to v0.29.0 + +Pull the latest, install, generate, and build. Docker users can pull the latest image. Full upgrade notes are in the [release notes](/docs/). + +## Get Involved + +- Star the repo on [GitHub](https://github.com/testplanit/testplanit) +- Follow [@TestPlanItHQ](https://x.com/TestPlanItHQ) for updates +- Join our [Community Discord](https://discord.gg/kpfha4W2JH) +- Report issues and suggest features on GitHub + +Thank you for using TestPlanIt! diff --git a/docs/docs/user-guide/permissions-guide.md b/docs/docs/user-guide/permissions-guide.md index a6a49da68..8c3580e0c 100644 --- a/docs/docs/user-guide/permissions-guide.md +++ b/docs/docs/user-guide/permissions-guide.md @@ -236,11 +236,11 @@ Permissions are granted per application area. The complete list of areas is: - **Documentation** - Creating and editing project documentation - **Milestones** - Creating, editing, and deleting project milestones - **TestCaseRepository** - Creating, editing, deleting, and organizing test case folders and test cases (including test steps) -- **TestCaseRestrictedFields** - Editing restricted field values on test cases +- **TestCaseRestrictedFields** - Editing restricted field values on test cases, and viewing sensitive parameter values in shared/owner-bound datasets attached to test cases (see [Parameterized Test Cases](./projects/parameterized-test-cases.md)) - **TestRuns** - Creating, editing, and deleting active test runs - **ClosedTestRuns** - Deleting completed or archived test runs - **TestRunResults** - Recording and managing results for test cases within a run -- **TestRunResultRestrictedFields** - Recording restricted field values on test run results +- **TestRunResultRestrictedFields** - Recording restricted field values on test run results, and viewing sensitive parameter values on iteration results, matrix cells, matrix exports, and the issue-prefill body when linking an external issue from a failed iteration (see [Parameterized Test Cases](./projects/parameterized-test-cases.md)) - **Sessions** - Creating and managing active test sessions - **SessionsRestrictedFields** - Recording restricted field values on test sessions - **ClosedSessions** - Deleting completed or archived test sessions @@ -260,6 +260,7 @@ For each application area, roles can have: - **canAddEdit** - Create and modify items - **canDelete** - Delete items - **canClose** - Mark items as complete/closed +- **canReadSensitive** - View values otherwise masked as `••••••` or `[REDACTED]`. Honored by the **TestCaseRestrictedFields** and **TestRunResultRestrictedFields** areas; the other areas ignore it. Without this grant on the right area, a user sees `••••••` in dataset rows / iteration cells and `[REDACTED]` in the issue prefill body and matrix exports. ### Default Roles diff --git a/docs/docs/user-guide/projects/parameterized-test-cases.md b/docs/docs/user-guide/projects/parameterized-test-cases.md new file mode 100644 index 000000000..e934a5ea4 --- /dev/null +++ b/docs/docs/user-guide/projects/parameterized-test-cases.md @@ -0,0 +1,228 @@ +--- +title: Parameterized Test Cases +sidebar_position: 7 # Position after Sessions, before Tags +--- + +# Parameterized Test Cases + +Parameterized test cases let a single test case run multiple times in one test run, once per row of input data. Each run is an **iteration** and gets its own status. This is the right model for table-driven testing — login flows with different credentials, payment flows with different amounts, validation rules with positive + negative inputs — without duplicating the case for every combination. + +This page covers the whole feature top to bottom: authoring, datasets, execution, reporting, CI imports, issue linking, settings, and permissions. + +## Concepts + +A **parameter** is a named typed input declared on a test case (e.g. `username`, `attempts`, `isAdmin`). Parameters are referenced inside step text using `@param` chips and resolve to values at run time. + +An **iteration** is one execution of a parameterized case for one row of values. A case with three rows produces three iterations per test run. Iterations have their own per-row status (Passed / Failed / Blocked / etc.); the case-level status is the **worst-of** rollup across iterations. + +A **dataset** is the table of input rows that drives iterations. Datasets come in two shapes: + +- **Owner-bound** — created and edited inline on a single case. Lives with the case and is only used by that case. +- **Shared** — project-scoped, can be assigned to many cases. Each case maps the shared dataset's columns to its own parameter names. + +Datasets are **versioned**: every save creates a new immutable version. A run captures a **snapshot** of the dataset version at the moment the run was created, so historical results stay readable even if the dataset is later edited or deleted. + +## Authoring a Parameterized Case + +Open the test case, then click **Configure Parameters** at the top of the case editor. The sheet that opens has two tabs: + +### Parameters tab + +Declare the parameters your case needs. Each parameter has: + +| Field | What it means | +|---|---| +| **Name** | The chip name you'll use in steps (e.g. `username`). Lowercase + numbers + underscore is conventional. | +| **Type** | `STRING`, `INTEGER`, `BOOLEAN`, or `SELECT` (see [SELECT availability](#select-parameter-availability)). Controls cell editing and per-row validation. | +| **Default** | Value to use when no row supplies one. | +| **Required** | When set, rows with no value for this parameter fail validation on save. | +| **Sensitive** | When set, values are masked as `••••••` in the UI for users without the right role permission (see [Permissions & Sensitive Values](#permissions--sensitive-values)). | + +#### SELECT parameter availability + +`SELECT` parameters have two sources for their allowed values: an **inline list** typed into the dialog one-per-line, or a **lookup dataset** — another shared dataset whose first column is treated as the option list (useful when the same dropdown values are reused across many cases). + +`SELECT` is offered when adding or editing a parameter through the **Configure Parameters → Parameters tab** on a test case (the owner-bound editing surface). It is intentionally **not** offered in the standalone Shared Dataset Editor's **Add column** dialog because a `SELECT` column needs a secondary list (allowed values or a lookup dataset) that doesn't fit a quick-add inline flow. If you need a SELECT-typed column on a shared dataset today, declare it on a case via Configure Parameters and assign the shared dataset to that case; the cell editor in the shared dataset grid recognizes the SELECT type and renders it as a dropdown driven by the configured options. + +### Dataset tab + +Each row in this tab becomes one iteration when the case runs. The grid is a spreadsheet-style editor: + +- Click a cell to edit it. Tab moves right; Enter moves down; Esc cancels. +- `BOOLEAN` cells are direct-toggle checkboxes — no edit mode. +- `INTEGER` cells reject non-numeric input on commit. +- Required cells with no value show a red border and refuse the save. +- Sensitive cells render as `••••••`; click to reveal (for permitted viewers) before editing. +- The **Label** column is free-text — surfaces on the iteration row in execution so testers can scan the table at a glance ("Good username", "Empty password", etc.). + +### @param chips in step text + +Inside any step or expected-result field, type `@` to open the parameter picker, or click the **Insert Parameter** toolbar button. The inserted chip displays as `@username` and renders the iteration's value when the step is shown during execution. Sensitive parameter chips render as `••••••` unless the viewer has the right permission. + +A chip that references a parameter name not declared on the case shows a warning underline — fix it by either adding the parameter or removing the chip before saving the case. + +## Datasets + +### Owner-bound vs Shared + +The toggle at the top of the Dataset tab switches between **Local** (owner-bound, edited inline on this case) and **Shared** (a project-scoped dataset assigned to this case). At any time a case has at most one of each: zero or one owner-bound dataset, zero or one shared-dataset assignment. + +In **Shared** mode you don't edit the dataset rows directly here — you pick a shared dataset and map its columns to this case's parameter names. Click **Manage shared dataset** to open the assignment dialog. + +### Mapping columns to parameters + +A shared dataset is just a named table; the column names in the dataset don't have to match the parameter names on the case. The **mapping** is a per-case translation: for each column you either point it at a parameter on the case, or mark it **Skip** to ignore it. The mapping is stored on the assignment and used at iteration-generation time. + +### Pinning a version + +By default a case follows the **current** version of its shared dataset — every new save of the dataset becomes the source of truth for subsequent runs. Pin to a specific version when you want the case to stay on a known shape (e.g. while a wider migration is in flight). Test runs that have already snapshotted the dataset are unaffected either way. + +## The Standalone Shared Dataset Editor + +Shared datasets get their own full-page editor under **Project Settings → Test Case Parameters → Shared Datasets → Open editor**. This is the surface you use to author the dataset itself, independent of any one case. + +### Column actions + +**Add column** opens a dialog with Name + Type (STRING / INTEGER / BOOLEAN) + Required + Sensitive. The new column is appended to the right with empty values across every row. + +**Delete column** is in the three-dot menu on each column header. The delete is **blocked** while any case maps a parameter to that column — the dialog lists the referencing cases as links so you can clear those mappings first. Newly-added unsaved columns skip the check (nothing can reference them yet). + +### Importing data + +The **Paste CSV** button accepts a clipboard paste of comma- or tab-separated text and maps it to the existing columns by header. **Import CSV** opens the full wizard (file upload, column-mapping preview, validation report) and handles larger imports. + +### Versioning & save semantics + +The editor edits a **draft** of the next version. Save is gated on dirty state (no changes → button is disabled). When you click **Save**: + +1. Per-cell validation runs (types, required, allowed values). +2. A new immutable `DataSetVersion` is written. +3. The dataset's `version` counter is incremented. +4. Earlier versions remain available in the **Version history** picker for restore. + +### Sensitive cells + +Sensitive parameter values render as `••••••` for viewers without the **Test Case Restricted Fields → Read Sensitive** role permission. Permitted viewers see plaintext; clicking the masked cell reveals it before editing. See [Permissions & Sensitive Values](#permissions--sensitive-values) for the full model. + +### Iteration cap + +A test case may not have more than **5000** iterations in a single run. The cap protects against runaway imports and oversized datasets. The editor will not save a dataset with more than 5000 rows, and the [CI import path](#ci-imports) refuses the entire upload if any case requests an iteration index above the cap. + +## Iteration Execution + +When a test run is created from a parameterized case, TestPlanIt generates one `TestRunCaseIteration` row per dataset row. Each iteration gets its own per-row status; the case row in the run summarises the worst-of status across iterations. + +### The iteration drill-down + +Click a parameterized case row in the run to open the iteration drill-down. The drill-down shows: + +- One row per iteration with the dataset row label and the value of each parameter (masked where sensitive). +- The iteration's current status, last result timestamp, and tester. +- Step preview — the case's steps rendered with this iteration's values substituted into the `@param` chips. + +### Recording a result + +The **Add Result** form on the iteration row is the same form used for non-parameterized cases — same status picker, same notes editor, same step results, same screenshot attachment. The only difference is that the chips in the steps render with this iteration's values. + +The **Pass & Next iteration** button is a convenience that records Pass on the current iteration and advances to the next one in the same run — useful for happy-path tables where most rows pass. + +### Worst-of rollup + +The case-level status in the run rolls up from its iterations using these rules, in order: + +1. **No iteration has a recorded result yet** → the case shows the first untested status configured in the project (lowest `order` whose flags are not success / not failure / not completed). +2. **At least one iteration is in a failure status** → the case shows the **most-frequent failure status** across iterations. Ties break to the failure status with the lowest `order`. +3. **No failures, at least one success** → the case shows the most-frequent success status. Same tie-break. +4. **Some iterations recorded but none are success or failure** → the case shows the most-frequent status among all recorded iterations. Same tie-break. + +The algorithm only looks at the `isSuccess` / `isFailure` / `isCompleted` flags and the `order` field on each Status — status _names_ are admin-defined and never consulted. So if your project defines "Blocked" as neither success nor failure (the default), it's treated like any other non-success / non-failure status in rule 4; it doesn't have a built-in tier of its own. Editing one iteration's status recomputes the rollup immediately. + +## Test Result History + +Open the **Test Result History** panel on a case to see every result it has ever produced across all runs. For parameterized cases the table adds: + +- An **Iterations** column with a stacked-squares icon indicating the result came from a specific iteration of a parameterized run. +- An expanded panel (click the row) that shows the **Run details** (configuration name + tester) and **Parameter values** for that iteration. Sensitive values are masked the same way they are in the run UI. + +## Iteration Matrix Report + +The **Parameterized Test Iteration Matrix** is a built-in [Report Builder](../reporting.md) preset that lays iteration results out in a 3-axis grid: **case × configuration × parameter row**. Each cell is colored by the iteration's worst-of status across the filtered runs. + +Add the preset by clicking **+ Add Report → Parameterized Test Iteration Matrix** inside any Report Builder. The matrix loads aggregated cells server-side and refuses requests over **10,000 cells** (e.g. 200 cases × 50 configurations × 1 parameter row) — narrow the filters when the cap is hit. + +The matrix supports CSV export. Sensitive cells in the export are written as `[REDACTED]` for users without the **Test Run Result Restricted Fields → Read Sensitive** role permission. + +## CI Imports + +Most CI emitters can mark a test case as one row of a parameterized run by writing a property, attribute, or trait on the test result. TestPlanIt reads that signal and routes the result to the matching iteration row. + +| Format | Where the iteration property lives | +|---|---| +| JUnit XML | `` child of `` | +| TestNG XML | `N` | +| xUnit XML | `` | +| NUnit XML | `` | +| MSTest TRX | Any `metadata` map exposing the configured name | + +The lookup name defaults to `iteration` (case-insensitive). To configure additional names — `iterationIndex`, `dataRow`, whatever your CI emits — open **Project Settings → Test Case Parameters → Iteration Property Mapping** and add the names there. Lookup is case-insensitive across the whole configured list. + +### Cap-exceeded refusal + +When the imported file requests an iteration index above the 5000 cap, the entire import is refused with `422` and a list of every offending case. This is intentional — refusing the whole import means you fix every offender in one pass rather than fix-one, re-import, fail-on-the-next-one. + +### Behavior when no iteration property is present + +A test case in a CI emitter without an iteration property routes to the case-level status exactly the way it did before this feature shipped — no behavior change for non-parameterized cases. + +## Linking Issues from a Failed Iteration + +When a parameterized iteration fails, you can link an external issue (Jira, GitHub, Azure DevOps) from inside the **Add Result** form: click **Link `provider` Issue → Create New Issue**. The dialog opens with the description pre-filled: + +- **Title** — `Iteration N of M on .` +- **Body** — a rich-text section with: + - A prose lead identifying the iteration and dataset row. + - A `Parameter | Value` table listing every parameter on the case. Sensitive parameter values render as `[REDACTED]` for users without the **Test Run Result Restricted Fields → Read Sensitive** role permission; everyone else sees plaintext. + - A **View iteration in TestPlanIt** deep link (absolute URL using your `NEXTAUTH_URL`) that opens straight to this iteration's drill-down. + +### Edit before submit + +The prefill is a starting point — edit the description freely in the rich-text editor before clicking Create. Whatever's in the editor at submit time is what gets sent to the tracker. The three adapters render the TipTap doc natively (real Jira table, real GitHub markdown, real ADO HTML — not literal markdown pipes). + +### Localization + +Title, prose lead, table headers, and the deep-link label are translated to the issuing user's locale (set in **Account → Preferences**). Parameter names and the row label are user-authored content and stay untranslated. + +### Deep link round-trip + +Clicking the **View iteration in TestPlanIt** link in the tracker opens the iteration drill-down with the correct case and iteration preselected (the URL carries `?iteration=N&selectedCase=...`). + +## Project Settings → Test Case Parameters + +This is the project-scoped settings hub for the feature. It lives at **Project Settings → Test Case Parameters** and groups two surfaces: + +### Iteration Property Mapping + +A small tag-input list of property/attribute/trait names the CI import path will look up on each test case to find the iteration index. Default behavior (empty list) recognizes `iteration` (case-insensitive). Add custom names if your CI uses a different vocabulary. + +### Shared Datasets + +The CRUD list of shared datasets in this project. Columns: Name, Columns, Rows, Version, Last edited, Owner, In use by (the count of cases assigned to the dataset, clickable to drill into the list), Actions (Open editor / Delete). Delete is blocked by an active confirm if the dataset has assignments; the prompt surfaces the count. + +Access to both surfaces: +- **System Admin** — unconditional. +- **Project Admin** — must be assigned to this specific project. + +## Permissions & Sensitive Values + +Sensitive parameter values are gated by the existing **Read Sensitive** flag on two role permission areas — no new permission was introduced for this feature: + +| Surface | Gate | +|---|---| +| Dataset rows on a case (Configure Parameters → Dataset tab, standalone editor) | `Test Case Restricted Fields` → `canReadSensitive` | +| Iteration cells in execution, matrix cells, matrix CSV export, issue-prefill body | `Test Run Result Restricted Fields` → `canReadSensitive` | + +Without the relevant grant, sensitive values render as `••••••` in the UI and `[REDACTED]` in the issue body and CSV exports. System admins bypass both gates. For the broader role permission model see the [Roles guide](../roles.md) and [Permissions overview](../permissions-guide.md). + +### What "sensitive" actually means + +Marking a parameter sensitive masks its value in the UI and redacts it in exports + issue bodies. It is **not** an encryption-at-rest guarantee, and a determined user with access to the browser's DOM inspector or the underlying database can still read the value. The tradeoff is intentional: cleartext in memory keeps the testing UX simple (editing, copy-paste, comparison) while masking covers the everyday over-the-shoulder and "share my screen in a meeting" cases. Treat the flag as a UX-level signal, not a secrets manager. For real secrets — production credentials, API keys, payment card data — fetch from a secrets store at test execution time rather than embedding them in dataset rows. diff --git a/docs/docs/user-guide/projects/tags.md b/docs/docs/user-guide/projects/tags.md index b540bc1c7..145b8f955 100644 --- a/docs/docs/user-guide/projects/tags.md +++ b/docs/docs/user-guide/projects/tags.md @@ -1,6 +1,6 @@ --- title: Tags -sidebar_position: 7 # Position after Sessions +sidebar_position: 8 # Position after Parameterized Test Cases --- # Project Tags List diff --git a/docs/docs/user-guide/roles.md b/docs/docs/user-guide/roles.md index b346f9a57..5bf754ecf 100644 --- a/docs/docs/user-guide/roles.md +++ b/docs/docs/user-guide/roles.md @@ -79,11 +79,11 @@ When a user is assigned to a project, they are also assigned a project-specific - **Documentation**: Creating and editing project documentation. - **Milestones**: Creating, editing, and deleting project milestones. - **Test Case Repository**: Creating, editing, deleting, and organizing test case folders and test cases. -- **Test Case Restricted Fields**: Editing restricted field values on test cases. +- **Test Case Restricted Fields**: Editing restricted field values on test cases, and (via the role's `Read Sensitive` grant on this area) viewing sensitive parameter values in datasets attached to test cases. - **Test Runs**: Creating, Editing and Deleting active test runs. - **Closed Test Runs**: Deleting completed/archived test runs. - **Test Run Results**: Recording and managing results for test cases within a run. -- **Test Run Result Restricted Fields**: Recording restricted field values on test run results. +- **Test Run Result Restricted Fields**: Recording restricted field values on test run results, and (via the role's `Read Sensitive` grant on this area) viewing sensitive parameter values on iteration results, matrix cells, matrix CSV exports, and the auto-prefilled body when linking an external issue from a failed iteration. - **Sessions**: Creating and managing active test sessions. - **Sessions Restricted Fields**: Recording restricted field values on test sessions. - **Closed Sessions**: Deleting completed/archived test sessions. @@ -91,7 +91,9 @@ When a user is assigned to a project, they are also assigned a project-specific - **Tags**: Creating new tags. ::: -Each role defines specific permissions (e.g., Add/Edit, Delete, Complete) for these areas. +Each role defines specific permissions (e.g., Add/Edit, Delete, Complete, Read Sensitive) for these areas. + +The `Read Sensitive` permission is only honored on the **Test Case Restricted Fields** and **Test Run Result Restricted Fields** areas, where it controls whether the role can view sensitive parameter values; the other areas ignore it. Without this grant, sensitive values render as `••••••` in dataset rows and iteration cells, and as `[REDACTED]` in the issue-prefill body and CSV exports. **Example:** A "Tester" role might have `Add/Edit` permissions for `TestRunResults` and `SessionResults` but not for `TestCaseRepository` and `Milestones`. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index b4a157f7e..7c479efce 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -198,6 +198,7 @@ const sidebars: SidebarsConfig = { 'user-guide/projects/sessions-execution', // Corresponds to sessions-execution.md ], }, + 'user-guide/projects/parameterized-test-cases', // Parameterized Test Cases hub 'user-guide/projects/tags', // Corresponds to tags.md 'user-guide/projects/issues', // Add Project Issues page here // Add other project-specific pages here later diff --git a/docs/static/img/blog/dataset-tab-blog.png b/docs/static/img/blog/dataset-tab-blog.png new file mode 100644 index 000000000..13091599c Binary files /dev/null and b/docs/static/img/blog/dataset-tab-blog.png differ diff --git a/docs/static/img/blog/iteration-matrix-blog.jpg b/docs/static/img/blog/iteration-matrix-blog.jpg new file mode 100644 index 000000000..4633fd5fc Binary files /dev/null and b/docs/static/img/blog/iteration-matrix-blog.jpg differ diff --git a/docs/static/img/blog/run-iteration-blog.png b/docs/static/img/blog/run-iteration-blog.png new file mode 100644 index 000000000..6fe7d3c09 Binary files /dev/null and b/docs/static/img/blog/run-iteration-blog.png differ diff --git a/testplanit/.env.example b/testplanit/.env.example index 08c2b03f5..71d14dcad 100644 --- a/testplanit/.env.example +++ b/testplanit/.env.example @@ -148,4 +148,12 @@ ADMIN_PASSWORD=admin # DOCKER_ELASTICSEARCH_TRANSPORT_PORT=9300 # DOCKER_MINIO_API_PORT=9000 # DOCKER_MINIO_CONSOLE_PORT=9001 -# DOCKER_NGINX_HTTP_PORT=80 \ No newline at end of file +# DOCKER_NGINX_HTTP_PORT=80 + +# Parameterized Test Cases — cardinality guards (Phase 3 enforces; Phase 1 reserves) +# Hard refusal: run creation refuses > N total iterations across the run +# PARAMETERIZED_RUN_HARD_CAP=5000 +# Soft confirmation: surfaces an AlertDialog above N total iterations +# PARAMETERIZED_RUN_SOFT_CAP=1000 +# Async fan-out: runs above N iterations route through BullMQ worker +# PARAMETERIZED_RUN_ASYNC_CAP=500 \ No newline at end of file diff --git a/testplanit/__tests__/integration/cross-project-dataset-filter.test.ts b/testplanit/__tests__/integration/cross-project-dataset-filter.test.ts new file mode 100644 index 000000000..51d95f90e --- /dev/null +++ b/testplanit/__tests__/integration/cross-project-dataset-filter.test.ts @@ -0,0 +1,169 @@ +/** + * Phase 1 carry-forward gap (01-03-SUMMARY): the DataSet `@@deny` only + * blocks cross-project create/update — NOT cross-project READ. Phase 2 + * authoring routes MUST filter `WHERE projectId = currentProjectId` + * explicitly. This test asserts that the dataset GET endpoint returns + * 404 (or null dataset) when a user attempts to fetch a dataset whose + * `projectId` differs from the case's `projectId`. + * + * Wiring contract test — uses mocked enhanced DB to prove the route + * applies the explicit projectId filter on the dataset query. + */ + +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockDb, sessionRef, dataSetFindFirst } = vi.hoisted(() => { + const dataSetFindFirstFn = vi.fn(); + const db: any = { + repositoryCases: { findFirst: vi.fn() }, + dataSet: { findFirst: dataSetFindFirstFn }, + // The route counts rows for the pagination response shape after the + // GET handler picked up `?page=` support — stub a default so tests + // that don't care about totalRows don't crash. + dataSetRow: { count: vi.fn(async () => 0) }, + testCaseParameter: { findMany: vi.fn(async () => []) }, + user: { findUnique: vi.fn() }, + }; + return { + mockDb: db, + sessionRef: { + current: { user: { id: "u-A", name: "U", email: "u@e.com" } }, + }, + dataSetFindFirst: dataSetFindFirstFn, + }; +}); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(async () => sessionRef.current), +})); +vi.mock("~/server/auth", () => ({ authOptions: {} })); +vi.mock("~/lib/auth/utils", () => ({ + getEnhancedDb: vi.fn(async () => mockDb), +})); + +import { GET as datasetGet } from "~/app/api/repository/cases/[caseId]/dataset/route"; + +function jsonRequest(body: unknown = {}): NextRequest { + // The GET handler reads `new URL(request.url)` for pagination params, + // so the stub needs a usable `url` even when callers only care about + // `json()`. + return { + url: "http://test.local/api/repository/cases/5/dataset", + json: async () => body, + } as unknown as NextRequest; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("cross-project dataset read filter (Phase 1 carry-forward)", () => { + it("dataset query passes projectId from the case as an explicit WHERE filter", async () => { + mockDb.repositoryCases.findFirst = vi.fn(async () => ({ + id: 5, + projectId: 100, + })); + dataSetFindFirst.mockResolvedValueOnce({ + id: 7, + projectId: 100, + ownerCaseId: 5, + rows: [], + }); + + await datasetGet(jsonRequest(), { + params: Promise.resolve({ caseId: "5" }), + }); + + // The first dataSet.findFirst call must scope by projectId:100 + const args = dataSetFindFirst.mock.calls[0][0]; + expect(args.where).toMatchObject({ + ownerCaseId: 5, + projectId: 100, + isDeleted: false, + }); + }); + + it("returns dataset:null when the case is not found (cross-tenant blocked at policy)", async () => { + mockDb.repositoryCases.findFirst = vi.fn(async () => null); + + const res = await datasetGet(jsonRequest(), { + params: Promise.resolve({ caseId: "999" }), + }); + expect(res.status).toBe(404); + }); + + it("returns dataset:null when ownerCase exists but no dataset is attached yet", async () => { + mockDb.repositoryCases.findFirst = vi.fn(async () => ({ + id: 5, + projectId: 100, + })); + dataSetFindFirst.mockResolvedValueOnce(null); + + const res = await datasetGet(jsonRequest(), { + params: Promise.resolve({ caseId: "5" }), + }); + const body = await res.json(); + expect(body.dataset).toBeNull(); + }); + + it("redacts sensitive values for users without canReadSensitive", async () => { + mockDb.repositoryCases.findFirst = vi.fn(async () => ({ + id: 5, + projectId: 100, + })); + dataSetFindFirst.mockResolvedValueOnce({ + id: 7, + projectId: 100, + ownerCaseId: 5, + rows: [{ id: 1, valuesJson: { username: "alice", password: "secret" } }], + }); + mockDb.testCaseParameter.findMany = vi.fn(async () => [ + { name: "username", sensitive: false }, + { name: "password", sensitive: true }, + ]); + // No role -> no canReadSensitive + mockDb.user.findUnique = vi.fn(async () => ({ + id: "u-A", + access: "USER", + role: { rolePermissions: [] }, + })); + + const res = await datasetGet(jsonRequest(), { + params: Promise.resolve({ caseId: "5" }), + }); + const body = await res.json(); + expect(body.dataset.rows[0].valuesJson).toMatchObject({ + username: "alice", + password: "[REDACTED]", + }); + }); + + it("does NOT redact for ADMIN users (canReadSensitive bypassed via access=ADMIN)", async () => { + mockDb.repositoryCases.findFirst = vi.fn(async () => ({ + id: 5, + projectId: 100, + })); + dataSetFindFirst.mockResolvedValueOnce({ + id: 7, + projectId: 100, + ownerCaseId: 5, + rows: [{ id: 1, valuesJson: { username: "alice", password: "secret" } }], + }); + mockDb.testCaseParameter.findMany = vi.fn(async () => [ + { name: "username", sensitive: false }, + { name: "password", sensitive: true }, + ]); + mockDb.user.findUnique = vi.fn(async () => ({ + id: "u-A", + access: "ADMIN", + role: { rolePermissions: [] }, + })); + + const res = await datasetGet(jsonRequest(), { + params: Promise.resolve({ caseId: "5" }), + }); + const body = await res.json(); + expect(body.dataset.rows[0].valuesJson.password).toBe("secret"); + }); +}); diff --git a/testplanit/__tests__/integration/data-model-foundation.test.ts b/testplanit/__tests__/integration/data-model-foundation.test.ts new file mode 100644 index 000000000..d598c9d31 --- /dev/null +++ b/testplanit/__tests__/integration/data-model-foundation.test.ts @@ -0,0 +1,856 @@ +// Integration tests — Phase 1 Data Model Foundation regression / policy gates. +// +// Requires the dev DB seeded by `pnpm generate` (Plan 01-01 already pushed the +// schema). The tests build a self-contained fixture (two projects, two cases, +// one user assigned to project A) using raw `prisma`, then exercise the +// ZenStack enhanced client to prove policy enforcement. +// +// Run via: +// cd testplanit && pnpm test data-model-foundation --run +// +// Coverage: +// Group 1 — @@validate runtime smoke-test (PARAM-02, RESEARCH.md A1) +// Group 2 — Cross-tenant unauthenticated-read denial (PITFALLS.md §9) +// Group 3 — DSET-06 cross-project denial (DSET-06, RESEARCH.md Pitfall G) +// Group 4 — hasParameters legacy-query regression (PARAM-07) +// +// Adaptive smoke-test contract: +// The plan (Plan 01-03 PARAM-02 / RESEARCH.md A1) anticipates that ZenStack +// v2.22.2's @@validate clause may or may not fire under enhance(). Likewise, +// ZenStack policy semantics for @@deny on creator-owned rows is novel in +// this codebase. Rather than fail the suite when policies don't fire, each +// smoke-test: +// 1. attempts the operation that SHOULD be denied +// 2. records the actual outcome (FIRES or DOES NOT FIRE) +// 3. passes the test in either case so the suite exits 0 +// The recorded findings are emitted at suite teardown so the SUMMARY.md can +// capture the post-Phase-1 ground truth (RESEARCH.md A1 / Pitfall G). +// +// "@@validate did NOT fire" / "fall back to Zod" appear verbatim below as +// the fall-back guidance signal per RESEARCH.md A1. +// +// Cleanup: every fixture row is soft-deleted in afterAll (per memory +// `feedback_soft_delete`); never use deleteMany / hard delete. + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { PrismaClient, WorkflowScope } from "@prisma/client"; +import { enhance } from "@zenstackhq/runtime"; + +// Live-DB gate (matches lib/services/iterationFanOut.integration.test.ts). +// CI runs the default test suite without a DATABASE_URL, so these tests +// must opt out unless both RUN_DB_INTEGRATION=1 and DATABASE_URL are set. +const RUN_INTEGRATION = process.env.RUN_DB_INTEGRATION === "1"; +const HAS_DB_URL = Boolean(process.env.DATABASE_URL); +const describeIntegration = + RUN_INTEGRATION && HAS_DB_URL ? describe : describe.skip; + +const prisma = new PrismaClient(); + +// Unique suffix isolates this run from any concurrent / prior test fixture. +const RUN_TAG = `pf1-03-${Date.now()}`; + +// The enhance() user signature is the full Prisma User row (with non-null +// relations the policy expressions reference, e.g. role.rolePermissions). +// We type it as the inferred return of the fetch call to avoid pinning to a +// generated Prisma type that may shift between schema regenerations. +type AuthUser = Awaited>; + +async function fetchAuthUser(userId: string) { + return prisma.user.findUniqueOrThrow({ + where: { id: userId }, + include: { role: { include: { rolePermissions: true } } }, + }); +} + +// Module-level findings recorder for the @@validate / @@deny smoke-tests. +// Emitted in afterAll so the user (and SUMMARY.md author) sees a clear +// summary of the runtime behavior of policies under enhance(). +type Finding = { + check: string; + outcome: "FIRES" | "DOES_NOT_FIRE"; + note?: string; +}; +const findings: Finding[] = []; +function record(check: string, outcome: Finding["outcome"], note?: string) { + findings.push({ check, outcome, note }); +} + +interface Fixture { + projectA: { id: number; name: string }; + projectB: { id: number; name: string }; + caseInA: { id: number }; + caseInB: { id: number }; + userInA: AuthUser; + userInB: AuthUser; // creator of projectB; userInA is OUTSIDE projectB +} + +let fixture: Fixture | null = null; + +async function setupTwoProjectFixture(): Promise { + // --- Existing seeded dependencies (NEVER created here; surface clear error if missing) --- + const userRole = await prisma.roles.findFirst({ where: { name: "user" } }); + if (!userRole) { + throw new Error( + "Dev DB missing seeded `user` role. Run `pnpm tsx prisma/seed.ts` first." + ); + } + const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } }); + if (!adminRole) { + throw new Error("Dev DB missing seeded `admin` role."); + } + const template = await prisma.templates.findFirst({ + where: { isDefault: true, isEnabled: true, isDeleted: false }, + }); + if (!template) { + throw new Error( + "Dev DB missing default Templates row. Run `pnpm tsx prisma/seed.ts` first." + ); + } + const caseWorkflow = await prisma.workflows.findFirst({ + where: { scope: WorkflowScope.CASES, isDeleted: false, isEnabled: true }, + }); + if (!caseWorkflow) { + throw new Error("Dev DB missing CASES-scoped Workflows row."); + } + + // --- Two distinct user fixtures --- + // userInA: creator of projectA, member of projectA only. + // userInB: creator of projectB. We use userInB as the projectB creator so + // userInA is genuinely OUTSIDE projectB (not a creator, not a + // member). This is load-bearing for cross-tenant denial tests — + // if both projects had the same creator, @@allow('all', creator + // == auth()) would short-circuit the @@deny clauses we're trying + // to verify. + const userInACreated = await prisma.user.create({ + data: { + email: `${RUN_TAG}-userA@example.test`, + name: `${RUN_TAG} User A`, + access: "USER", + roleId: userRole.id, + }, + }); + const userInBCreated = await prisma.user.create({ + data: { + email: `${RUN_TAG}-userB@example.test`, + name: `${RUN_TAG} User B`, + access: "USER", + roleId: userRole.id, + }, + }); + const userInA = await fetchAuthUser(userInACreated.id); + const userInB = await fetchAuthUser(userInBCreated.id); + + // --- Two projects with DIFFERENT creators --- + const projectA = await prisma.projects.create({ + data: { + name: `${RUN_TAG}-A`, + createdBy: userInA.id, + defaultAccessType: "GLOBAL_ROLE", + }, + }); + const projectB = await prisma.projects.create({ + data: { + name: `${RUN_TAG}-B`, + createdBy: userInB.id, // <- DIFFERENT creator + defaultAccessType: "GLOBAL_ROLE", + }, + }); + + // Assign each user to their own project only. + await prisma.userProjectPermission.create({ + data: { + userId: userInA.id, + projectId: projectA.id, + accessType: "GLOBAL_ROLE", + roleId: userRole.id, + }, + }); + await prisma.projectAssignment.create({ + data: { userId: userInA.id, projectId: projectA.id }, + }); + await prisma.userProjectPermission.create({ + data: { + userId: userInB.id, + projectId: projectB.id, + accessType: "GLOBAL_ROLE", + roleId: userRole.id, + }, + }); + await prisma.projectAssignment.create({ + data: { userId: userInB.id, projectId: projectB.id }, + }); + + // --- Repositories + Folders (one per project, owned by each project's creator) --- + const repoA = await prisma.repositories.create({ + data: { projectId: projectA.id }, + }); + const repoB = await prisma.repositories.create({ + data: { projectId: projectB.id }, + }); + const folderA = await prisma.repositoryFolders.create({ + data: { + projectId: projectA.id, + repositoryId: repoA.id, + name: `${RUN_TAG}-folderA`, + creatorId: userInA.id, + }, + }); + const folderB = await prisma.repositoryFolders.create({ + data: { + projectId: projectB.id, + repositoryId: repoB.id, + name: `${RUN_TAG}-folderB`, + creatorId: userInB.id, + }, + }); + + // --- Cases --- + const caseInA = await prisma.repositoryCases.create({ + data: { + projectId: projectA.id, + repositoryId: repoA.id, + folderId: folderA.id, + templateId: template.id, + name: `${RUN_TAG}-caseA`, + stateId: caseWorkflow.id, + creatorId: userInA.id, + }, + }); + const caseInB = await prisma.repositoryCases.create({ + data: { + projectId: projectB.id, + repositoryId: repoB.id, + folderId: folderB.id, + templateId: template.id, + name: `${RUN_TAG}-caseB`, + stateId: caseWorkflow.id, + creatorId: userInB.id, + }, + }); + + return { + projectA: { id: projectA.id, name: projectA.name }, + projectB: { id: projectB.id, name: projectB.name }, + caseInA: { id: caseInA.id }, + caseInB: { id: caseInB.id }, + userInA, + userInB, + }; +} + +async function cleanupFixture(f: Fixture | null): Promise { + if (!f) return; + // Soft-delete all created rows. Use raw prisma so policy denials don't + // block teardown. Wrap each in try/catch so a single failure doesn't leak + // partial cleanup. Per memory `feedback_soft_delete`: never use deleteMany. + const safe = async (op: () => Promise): Promise => { + try { + await op(); + } catch { + /* swallow — best-effort soft-delete */ + } + }; + + // Soft-delete leaf rows first. + await safe(() => + prisma.testCaseParameter.updateMany({ + where: { testCase: { name: { startsWith: RUN_TAG } } }, + data: { isDeleted: true }, + }) + ); + await safe(() => + prisma.dataSetRow.updateMany({ + where: { dataSet: { project: { name: { startsWith: RUN_TAG } } } }, + data: { isDeleted: true }, + }) + ); + await safe(() => + prisma.dataSet.updateMany({ + where: { project: { name: { startsWith: RUN_TAG } } }, + data: { isDeleted: true }, + }) + ); + await safe(() => + prisma.repositoryCases.updateMany({ + where: { name: { startsWith: RUN_TAG } }, + data: { isDeleted: true }, + }) + ); + await safe(() => + prisma.projects.updateMany({ + where: { name: { startsWith: RUN_TAG } }, + data: { isDeleted: true }, + }) + ); + await safe(() => + prisma.user.updateMany({ + where: { email: { startsWith: RUN_TAG } }, + data: { isDeleted: true, isActive: false }, + }) + ); +} + +beforeAll(async () => { + // Skip fixture build when the live-DB gate is closed — the describes + // below are describe.skip in that case and won't read fixture. + if (!RUN_INTEGRATION || !HAS_DB_URL) return; + fixture = await setupTwoProjectFixture(); +}, 30_000); + +afterAll(async () => { + if (!RUN_INTEGRATION || !HAS_DB_URL) return; + await cleanupFixture(fixture); + await prisma.$disconnect(); + // Emit findings table so the test summary captures policy runtime behavior. + if (findings.length > 0) { + console.log("\n[Plan 01-03] Policy runtime findings:"); + for (const f of findings) { + console.log( + ` - ${f.check}: ${f.outcome}${f.note ? ` (${f.note})` : ""}` + ); + } + } +}, 30_000); + +// Helper that runs an operation that SHOULD be policy-denied and records +// whether the policy actually fired. Returns true if denied (rejected or +// returned []), false if it succeeded (policy did NOT fire). +async function expectPolicyDenial( + fn: () => Promise +): Promise<{ denied: boolean; result?: unknown }> { + try { + const result = await fn(); + if (Array.isArray(result) && result.length === 0) { + return { denied: true }; + } + return { denied: false, result }; + } catch { + return { denied: true }; + } +} + +// --------------------------------------------------------------------------- +// Group 1 — @@validate runtime smoke-test (PARAM-02, RESEARCH.md A1) +// --------------------------------------------------------------------------- +// +// If "@@validate did NOT fire" outcomes appear in the findings table, the +// fall back to Zod-only enforcement plan from RESEARCH.md A1 is what Phase 2 +// must adopt. The boundary check at lib/schemas/parameterSchema.ts (Plan +// 01-02) already implements that fall-back, so the contingency is in place. +describeIntegration("Phase 1 @@validate SELECT XOR runtime smoke-test", () => { + it("smoke 1.1: SELECT with both allowedValuesJson AND lookupDataSetId set should be rejected", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + const enhancedDb = enhance(prisma, { user: fixture.userInA }); + const lookupDs = await prisma.dataSet.create({ + data: { + projectId: fixture.projectA.id, + ownerCaseId: fixture.caseInA.id, + name: `${RUN_TAG}-lookup1`, + createdById: fixture.userInA.id, + }, + }); + const { denied, result } = await expectPolicyDenial(() => + enhancedDb.testCaseParameter.create({ + data: { + testCaseId: fixture!.caseInA.id, + name: `bad-select-both-${RUN_TAG}`, + type: "SELECT", + allowedValuesJson: ["a", "b"], + lookupDataSetId: lookupDs.id, + }, + }) + ); + record( + "@@validate SELECT with both allowedValuesJson + lookupDataSetId", + denied ? "FIRES" : "DOES_NOT_FIRE" + ); + if (!denied) { + // soft-delete the leaked row so the fixture stays clean + const r = result as { id?: number } | undefined; + if (r?.id) { + await prisma.testCaseParameter + .update({ where: { id: r.id }, data: { isDeleted: true } }) + .catch(() => undefined); + } + } + // Adaptive: pass either way; the SUMMARY captures the actual behavior. + // If denied===false, the enforcement is Zod-only (Plan 01-02 boundary check). + expect(typeof denied).toBe("boolean"); + }); + + it("smoke 1.2: SELECT with both allowedValuesJson AND lookupDataSetId NULL should be rejected", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + const enhancedDb = enhance(prisma, { user: fixture.userInA }); + const { denied, result } = await expectPolicyDenial(() => + enhancedDb.testCaseParameter.create({ + data: { + testCaseId: fixture!.caseInA.id, + name: `bad-select-neither-${RUN_TAG}`, + type: "SELECT", + }, + }) + ); + record( + "@@validate SELECT with neither allowedValuesJson nor lookupDataSetId", + denied ? "FIRES" : "DOES_NOT_FIRE" + ); + if (!denied) { + const r = result as { id?: number } | undefined; + if (r?.id) { + await prisma.testCaseParameter + .update({ where: { id: r.id }, data: { isDeleted: true } }) + .catch(() => undefined); + } + } + expect(typeof denied).toBe("boolean"); + }); + + it("smoke 1.3: SELECT with only allowedValuesJson set succeeds (positive control)", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + const enhancedDb = enhance(prisma, { user: fixture.userInA }); + const created = await enhancedDb.testCaseParameter.create({ + data: { + testCaseId: fixture.caseInA.id, + name: `good-select-inline-${RUN_TAG}`, + type: "SELECT", + allowedValuesJson: ["yes", "no"], + }, + }); + expect(created.id).toBeGreaterThan(0); + expect(created.type).toBe("SELECT"); + }); + + it("smoke 1.4: STRING with allowedValuesJson set should be rejected", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + const enhancedDb = enhance(prisma, { user: fixture.userInA }); + const { denied, result } = await expectPolicyDenial(() => + enhancedDb.testCaseParameter.create({ + data: { + testCaseId: fixture!.caseInA.id, + name: `bad-string-with-list-${RUN_TAG}`, + type: "STRING", + allowedValuesJson: ["a"], + }, + }) + ); + record( + "@@validate STRING with allowedValuesJson set", + denied ? "FIRES" : "DOES_NOT_FIRE", + denied ? undefined : "fall back to Zod-only enforcement (RESEARCH.md A1)" + ); + if (!denied) { + const r = result as { id?: number } | undefined; + if (r?.id) { + await prisma.testCaseParameter + .update({ where: { id: r.id }, data: { isDeleted: true } }) + .catch(() => undefined); + } + } + expect(typeof denied).toBe("boolean"); + }); + + it("smoke 1.5: INTEGER with both NULL succeeds (positive control)", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + const enhancedDb = enhance(prisma, { user: fixture.userInA }); + const created = await enhancedDb.testCaseParameter.create({ + data: { + testCaseId: fixture.caseInA.id, + name: `good-integer-${RUN_TAG}`, + type: "INTEGER", + }, + }); + expect(created.type).toBe("INTEGER"); + expect(created.allowedValuesJson).toBeNull(); + expect(created.lookupDataSetId).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Group 2 — Cross-tenant unauthenticated-read denial (PITFALLS.md §9) +// --------------------------------------------------------------------------- +// +// Loops over each new model. For each, raw-prisma seeds a row in projectA +// (or attached to caseInA / runs in A), then we attempt to read via an +// enhanced client built with a NO_ACCESS-style user (a fresh user with +// access=NONE). The policy `@@deny('all', auth().access == 'NONE')` MUST +// reject every read. +// +// If any model returns rows for the outsider, that is a regression of the +// Pitfall §9 mitigation and the SUMMARY must flag the offending model so +// Phase 2 hardens the policy. +describeIntegration("Phase 1 cross-tenant unauthenticated-read denial", () => { + // Plant fresh "outsider" rows scoped to the fixture's projectA. + let plantedParameterId = 0; + let plantedDataSetId = 0; + let plantedDataSetRowId = 0; + let plantedIterationId = 0; + let plantedSnapshotId = 0; + let outsiderUser: AuthUser | null = null; + + beforeAll(async () => { + if (!fixture) throw new Error("Fixture not initialised"); + + // The outsider has access=NONE. ZenStack policies on every new model + // include `@@deny('all', auth().access == 'NONE')`, so this user is + // the canonical "no access" probe. (More reliable than passing + // user: undefined, which can crash some policy evaluators.) + const userRole = await prisma.roles.findFirst({ where: { name: "user" } }); + if (!userRole) throw new Error("user role missing"); + const created = await prisma.user.create({ + data: { + email: `${RUN_TAG}-outsider@example.test`, + name: `${RUN_TAG} Outsider`, + access: "NONE", + roleId: userRole.id, + }, + }); + outsiderUser = await fetchAuthUser(created.id); + + // Plant rows via raw prisma so we can prove the enhanced reader denies them. + const param = await prisma.testCaseParameter.create({ + data: { + testCaseId: fixture.caseInA.id, + name: `tenant-probe-param-${RUN_TAG}`, + type: "STRING", + }, + }); + plantedParameterId = param.id; + + const ds = await prisma.dataSet.create({ + data: { + projectId: fixture.projectA.id, + name: `${RUN_TAG}-probe-ds`, + createdById: fixture.userInA.id, + }, + }); + plantedDataSetId = ds.id; + + const dsr = await prisma.dataSetRow.create({ + data: { + dataSetId: ds.id, + rowIndex: 0, + valuesJson: { x: "1" }, + }, + }); + plantedDataSetRowId = dsr.id; + + // For TestRunCaseIteration / TestRunCaseDataSetSnapshot we need a TestRun + + // TestRunCase. Use a CASES-scoped workflow we already have, plus a RUNS one. + const runWorkflow = await prisma.workflows.findFirst({ + where: { scope: WorkflowScope.RUNS, isDeleted: false, isEnabled: true }, + }); + if (!runWorkflow) { + throw new Error("Dev DB missing RUNS-scoped Workflows row."); + } + const testRun = await prisma.testRuns.create({ + data: { + projectId: fixture.projectA.id, + name: `${RUN_TAG}-run`, + stateId: runWorkflow.id, + createdById: fixture.userInA.id, + }, + }); + const testRunCase = await prisma.testRunCases.create({ + data: { + testRunId: testRun.id, + repositoryCaseId: fixture.caseInA.id, + }, + }); + const iter = await prisma.testRunCaseIteration.create({ + data: { + testRunCaseId: testRunCase.id, + rowIndex: 0, + valuesJson: { x: "1" }, + }, + }); + plantedIterationId = iter.id; + + const snap = await prisma.testRunCaseDataSetSnapshot.create({ + data: { + testRunCaseId: testRunCase.id, + sourceDataSetId: ds.id, + sourceDataSetName: ds.name, + parametersJson: [], + rowsJson: [], + }, + }); + plantedSnapshotId = snap.id; + }, 30_000); + + // The enhance() return type is a structural alias; cast to a thin shape + // that exposes the five model accessors we need. + type EnhancedReader = { + testCaseParameter: { findMany: (q: unknown) => Promise }; + dataSet: { findMany: (q: unknown) => Promise }; + dataSetRow: { findMany: (q: unknown) => Promise }; + testRunCaseIteration: { findMany: (q: unknown) => Promise }; + testRunCaseDataSetSnapshot: { + findMany: (q: unknown) => Promise; + }; + }; + + it("(adaptive) outsider user (access=NONE) read across the 5 new models — record per-model denial outcomes", async () => { + if (!outsiderUser) throw new Error("outsider user not seeded"); + const denyDb = enhance(prisma, { + user: outsiderUser, + }) as unknown as EnhancedReader; + + const probes = [ + { + model: "testCaseParameter", + query: () => + denyDb.testCaseParameter.findMany({ + where: { id: plantedParameterId }, + }), + }, + { + model: "dataSet", + query: () => + denyDb.dataSet.findMany({ where: { id: plantedDataSetId } }), + }, + { + model: "dataSetRow", + query: () => + denyDb.dataSetRow.findMany({ where: { id: plantedDataSetRowId } }), + }, + { + model: "testRunCaseIteration", + query: () => + denyDb.testRunCaseIteration.findMany({ + where: { id: plantedIterationId }, + }), + }, + { + model: "testRunCaseDataSetSnapshot", + query: () => + denyDb.testRunCaseDataSetSnapshot.findMany({ + where: { id: plantedSnapshotId }, + }), + }, + ]; + + for (const { model, query } of probes) { + const { denied } = await expectPolicyDenial(query); + record( + `cross-tenant outsider read denial (${model})`, + denied ? "FIRES" : "DOES_NOT_FIRE", + denied + ? undefined + : "Pitfall §9 regression: enhanced client returned rows for access=NONE user" + ); + } + // Adaptive: the suite passes regardless; SUMMARY surfaces any DOES_NOT_FIRE. + expect(probes.length).toBe(5); + }); + + it("project-A user CAN read TestCaseParameter planted in project A (positive control)", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + const okDb = enhance(prisma, { user: fixture.userInA }); + const rows = await okDb.testCaseParameter.findMany({ + where: { id: plantedParameterId }, + }); + expect(rows.length).toBeGreaterThanOrEqual(1); + }); + + it("(adaptive) project-A user reading a DataSet in project B — record cross-tenant outcome", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + // Plant a DataSet in projectB, owned (createdBy) by userInB. + // userInA has zero permissions on projectB so the read should be denied. + const dsB = await prisma.dataSet.create({ + data: { + projectId: fixture.projectB.id, + name: `${RUN_TAG}-cross-tenant-ds`, + createdById: fixture.userInB.id, + }, + }); + try { + const okDb = enhance(prisma, { user: fixture.userInA }); + const rows = await okDb.dataSet.findMany({ where: { id: dsB.id } }); + const denied = Array.isArray(rows) && rows.length === 0; + record( + "cross-tenant DataSet read (project A user reads projectB row)", + denied ? "FIRES" : "DOES_NOT_FIRE", + denied + ? undefined + : "DataSet @@deny('read', ...) did not filter project-B row for project-A user" + ); + expect(typeof denied).toBe("boolean"); + } finally { + await prisma.dataSet.update({ + where: { id: dsB.id }, + data: { isDeleted: true }, + }); + } + }); + + it("(adaptive) a fully unauthenticated probe (no session at all) — record outcome", async () => { + // Adaptive smoke test: in a v2 ZenStack environment without the route- + // level wiring, enhance(prisma, { user: undefined }) may not fire @@deny + // policies as expected. The application's production paths use + // getEnhancedDb(session) which always passes a fully-resolved user, so + // the user: undefined case is academic — the boundary that matters is + // route-level auth (Plan 01-02 boundary helpers + getEnhancedDb). + // + // We record the outcome for SUMMARY.md without failing the suite. + const emptyDb = enhance(prisma, { user: undefined }); + let crashed = false; + let leaked = false; + try { + const rows = await emptyDb.testCaseParameter.findMany(); + if (Array.isArray(rows) && rows.length > 0) leaked = true; + } catch { + crashed = true; + } + record( + "unauthenticated read (user: undefined) of testCaseParameter", + leaked ? "DOES_NOT_FIRE" : "FIRES", + leaked + ? "ZenStack returned rows for user: undefined — Phase 2 must rely on route-level auth gate (getEnhancedDb)" + : crashed + ? "denied via thrown error" + : "denied via empty array" + ); + expect(typeof leaked).toBe("boolean"); + }); +}); + +// --------------------------------------------------------------------------- +// Group 3 — DSET-06 cross-project denial +// --------------------------------------------------------------------------- +// +// DSET-06 says: a DataSet's projectId must match its ownerCase.projectId. The +// schema encodes this as `@@deny('create,update', ownerCaseId != null && +// ownerCase.projectId != projectId)` (schema.zmodel:3466). +// +// Test 3.1 attempts the cross-project assignment via the enhanced client. +// Test 3.2 demonstrates the documented gap: raw prisma BYPASSES the @@deny +// (Phase 2's UI must use the enhanced client). Test 3.3 cleans up the leak. +describeIntegration( + "Phase 1 DSET-06 cross-project DataSet ownership denial", + () => { + it("(adaptive) Test 3.1: enhanced-client cross-project DataSet create — record outcome", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + // userInB is the creator of projectB. Use userInB so they have create + // rights on a projectB-owned DataSet, making the cross-project field + // (ownerCaseId pointing at caseInA in projectA) the load-bearing mistake. + const enhancedDb = enhance(prisma, { user: fixture.userInB }); + const { denied, result } = await expectPolicyDenial(() => + enhancedDb.dataSet.create({ + data: { + projectId: fixture!.projectB.id, + ownerCaseId: fixture!.caseInA.id, // <-- case is in projectA + name: `${RUN_TAG}-cross-project-attempt`, + createdById: fixture!.userInB.id, + }, + }) + ); + record( + "DSET-06 enhanced-client cross-project DataSet create", + denied ? "FIRES" : "DOES_NOT_FIRE", + denied + ? undefined + : "DataSet @@deny('create,update', ownerCase.projectId != projectId) did not fire — Phase 2 UI must rely on Zod-layer + getEnhancedDb" + ); + if (!denied) { + const r = result as { id?: number } | undefined; + if (r?.id) { + await prisma.dataSet + .update({ where: { id: r.id }, data: { isDeleted: true } }) + .catch(() => undefined); + } + } + expect(typeof denied).toBe("boolean"); + }); + + it("DOCUMENTED GAP: raw prisma BYPASSES the @@deny — DSET-06 enforcement requires the enhanced client (Test 3.2)", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + // EXPECTED BEHAVIOR: raw `prisma` does NOT run @@deny; this write + // SUCCEEDS. This is intentional — Phase 2's UI layer MUST go through + // `getEnhancedDb(session)` per memory `feedback_default_to_enhanced_db`, + // which closes this gap. Phase 1 documents the gap and Phase 2 closes it. + // + // If you change this test to assert rejection, you've changed the + // architecture. Re-read RESEARCH.md "Don't Hand-Roll" + Pitfall G. + const leaked = await prisma.dataSet.create({ + data: { + projectId: fixture.projectA.id, + ownerCaseId: fixture.caseInB.id, // cross-project, raw prisma allows it + name: `${RUN_TAG}-raw-bypass`, + createdById: fixture.userInA.id, + }, + }); + expect(leaked.id).toBeGreaterThan(0); + expect(leaked.projectId).toBe(fixture.projectA.id); + expect(leaked.ownerCaseId).toBe(fixture.caseInB.id); + + // Test 3.3 — cleanup the leaked test row immediately (soft-delete per + // memory `feedback_soft_delete`) so subsequent runs / queries don't see it. + await prisma.dataSet.update({ + where: { id: leaked.id }, + data: { isDeleted: true }, + }); + const after = await prisma.dataSet.findUnique({ + where: { id: leaked.id }, + }); + expect(after?.isDeleted).toBe(true); + }); + + it("accepts DataSet create with same-project ownerCaseId (positive control)", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + const enhancedDb = enhance(prisma, { user: fixture.userInA }); + const ds = await enhancedDb.dataSet.create({ + data: { + projectId: fixture.projectA.id, + ownerCaseId: fixture.caseInA.id, // same project — DSET-06 satisfied + name: `${RUN_TAG}-same-project-ok`, + createdById: fixture.userInA.id, + }, + }); + expect(ds.id).toBeGreaterThan(0); + expect(ds.ownerCaseId).toBe(fixture.caseInA.id); + }); + } +); + +// --------------------------------------------------------------------------- +// Group 4 — hasParameters legacy-query regression (PARAM-07) +// --------------------------------------------------------------------------- +describeIntegration("Phase 1 hasParameters regression gate", () => { + it("RepositoryCases.findMany returns hasParameters=false for fresh fixture cases (Test 4.1)", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + const rows = await prisma.repositoryCases.findMany({ + where: { + projectId: fixture.projectA.id, + // Avoid cases that may have parameters created in Group 1 — filter + // by name prefix to constrain to the fixture set. + name: { startsWith: RUN_TAG }, + }, + select: { id: true, hasParameters: true, name: true }, + }); + expect(rows.length).toBeGreaterThanOrEqual(1); + // The hasParameters helper hasn't been wired yet (Phase 2). All fixture + // cases must surface as `false` so legacy queries see them as + // non-parameterized. + for (const row of rows) { + expect(row.hasParameters).toBe(false); + } + }); + + it("Filtering by hasParameters=false returns the same row count (Test 4.2)", async () => { + if (!fixture) throw new Error("Fixture not initialised"); + const allRows = await prisma.repositoryCases.findMany({ + where: { + projectId: fixture.projectA.id, + name: { startsWith: RUN_TAG }, + }, + }); + const filtered = await prisma.repositoryCases.findMany({ + where: { + projectId: fixture.projectA.id, + name: { startsWith: RUN_TAG }, + hasParameters: false, + }, + }); + expect(filtered.length).toBe(allRows.length); + }); +}); diff --git a/testplanit/__tests__/integration/dataset-csv-import-atomicity.test.ts b/testplanit/__tests__/integration/dataset-csv-import-atomicity.test.ts new file mode 100644 index 000000000..5e5a01d4b --- /dev/null +++ b/testplanit/__tests__/integration/dataset-csv-import-atomicity.test.ts @@ -0,0 +1,236 @@ +/** + * CSV import atomicity contract: + * + * - Body validates against `buildRowSchemaFromParameters` BEFORE any DB + * write. If ANY row fails, the whole commit aborts (no inserts). + * - Replace mode soft-deletes existing rows (NEVER hard-deletes) inside + * the same `$transaction` as the new inserts. + * - Append mode preserves existing rows; new rows get `rowIndex = max + 1`. + * - 5 MB body cap is enforced server-side. + * + * Wiring contract test against a mocked enhanced DB. + */ + +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockDb, mockTx, sessionRef, txCalls } = vi.hoisted(() => { + const calls: { op: string; args: unknown[] }[] = []; + const tx: any = { + dataSetRow: { + updateMany: vi.fn(async (args: unknown) => { + calls.push({ op: "dataSetRow.updateMany", args: [args] }); + return { count: 0 }; + }), + create: vi.fn(async (args: { data: Record }) => { + calls.push({ op: "dataSetRow.create", args: [args] }); + return { id: Math.floor(Math.random() * 10000), ...args.data }; + }), + aggregate: vi.fn(async () => ({ _max: { rowIndex: null } })), + }, + dataSet: { + findFirst: vi.fn(async () => ({ + id: 50, + ownerCaseId: 5, + projectId: 100, + })), + create: vi.fn(), + }, + testCaseParameter: { + count: vi.fn(async () => 2), + }, + repositoryCases: { + update: vi.fn(async () => ({})), + }, + }; + const db: any = { + repositoryCases: { findFirst: vi.fn() }, + testCaseParameter: { findMany: vi.fn() }, + dataSet: { findFirst: vi.fn() }, + dataSetRow: { aggregate: vi.fn() }, + $transaction: vi.fn(async (fn: (t: any) => Promise) => { + calls.length = 0; + return fn(tx); + }), + }; + return { + mockDb: db, + mockTx: tx, + sessionRef: { + current: { user: { id: "u-1", name: "U", email: "u@e.com" } }, + }, + txCalls: calls, + }; +}); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(async () => sessionRef.current), +})); +vi.mock("~/server/auth", () => ({ authOptions: {} })); +vi.mock("~/lib/auth/utils", () => ({ + getEnhancedDb: vi.fn(async () => mockDb), +})); + +import { POST as importCsvPost } from "~/app/api/repository/cases/[caseId]/dataset/import-csv/route"; + +function jsonRequest(body: unknown): NextRequest { + return { json: async () => body } as unknown as NextRequest; +} + +beforeEach(() => { + vi.clearAllMocks(); + txCalls.length = 0; + mockDb.repositoryCases.findFirst = vi.fn(async () => ({ + id: 5, + projectId: 100, + })); + mockDb.testCaseParameter.findMany = vi.fn(async () => [ + { + name: "username", + type: "STRING", + required: true, + allowedValuesJson: null, + lookupDataSetId: null, + }, + { + name: "count", + type: "INTEGER", + required: true, + allowedValuesJson: null, + lookupDataSetId: null, + }, + ]); + mockDb.dataSet.findFirst = vi.fn(async () => ({ + id: 50, + ownerCaseId: 5, + projectId: 100, + })); + mockDb.dataSetRow.aggregate = vi.fn(async () => ({ + _max: { rowIndex: null }, + })); + // Reset tx mocks + mockTx.dataSetRow.updateMany.mockClear(); + mockTx.dataSetRow.create.mockClear(); + mockTx.dataSetRow.aggregate.mockClear(); + mockTx.dataSet.findFirst.mockClear(); +}); + +describe("CSV import atomicity", () => { + it("rejects with 400 if any row fails Zod validation; NO DB writes happen", async () => { + const res = await importCsvPost( + jsonRequest({ + mode: "replace", + mapping: { Username: "username", Count: "count" }, + rows: [ + { Username: "alice", Count: "10" }, + { Username: "bob", Count: "not-a-number" }, // invalid INTEGER + ], + }), + { params: Promise.resolve({ caseId: "5" }) } + ); + expect(res.status).toBe(400); + expect(mockDb.$transaction).not.toHaveBeenCalled(); + }); + + it("replace mode soft-deletes existing rows then inserts new rows in one tx", async () => { + const res = await importCsvPost( + jsonRequest({ + mode: "replace", + mapping: { Username: "username", Count: "count" }, + rows: [ + { Username: "alice", Count: "10" }, + { Username: "bob", Count: "20" }, + ], + }), + { params: Promise.resolve({ caseId: "5" }) } + ); + expect(res.status).toBe(200); + expect(mockDb.$transaction).toHaveBeenCalledTimes(1); + expect(mockTx.dataSetRow.updateMany).toHaveBeenCalledTimes(1); + const updateArgs = mockTx.dataSetRow.updateMany.mock.calls[0][0]; + expect(updateArgs.data).toMatchObject({ isDeleted: true }); + expect(mockTx.dataSetRow.create).toHaveBeenCalledTimes(2); + }); + + it("append mode preserves existing rows; new rows start at max+1", async () => { + mockTx.dataSetRow.aggregate = vi.fn(async () => ({ + _max: { rowIndex: 4 }, + })); + + const res = await importCsvPost( + jsonRequest({ + mode: "append", + mapping: { Username: "username", Count: "count" }, + rows: [ + { Username: "carol", Count: "30" }, + { Username: "dave", Count: "40" }, + ], + }), + { params: Promise.resolve({ caseId: "5" }) } + ); + expect(res.status).toBe(200); + expect(mockTx.dataSetRow.updateMany).not.toHaveBeenCalled(); + expect(mockTx.dataSetRow.create).toHaveBeenCalledTimes(2); + const firstRow = mockTx.dataSetRow.create.mock.calls[0][0]; + const secondRow = mockTx.dataSetRow.create.mock.calls[1][0]; + expect(firstRow.data.rowIndex).toBe(5); + expect(secondRow.data.rowIndex).toBe(6); + }); + + it("rejects when body is larger than 5 MB", async () => { + // Build a body whose JSON length exceeds 5 MB + const bigString = "x".repeat(5 * 1024 * 1024 + 100); + const res = await importCsvPost( + jsonRequest({ + mode: "append", + mapping: { Username: "username", Count: "count" }, + rows: [{ Username: bigString, Count: "1" }], + }), + { params: Promise.resolve({ caseId: "5" }) } + ); + expect([400, 413]).toContain(res.status); + }); + + it("skips unmapped CSV columns when mapping value is __skip__", async () => { + const res = await importCsvPost( + jsonRequest({ + mode: "append", + mapping: { Username: "username", Count: "count", Comment: "__skip__" }, + rows: [{ Username: "alice", Count: "10", Comment: "ignored" }], + }), + { params: Promise.resolve({ caseId: "5" }) } + ); + expect(res.status).toBe(200); + const created = mockTx.dataSetRow.create.mock.calls[0][0]; + expect(created.data.valuesJson).toMatchObject({ + username: "alice", + count: 10, + }); + // Comment should NOT be in the persisted values + expect(created.data.valuesJson.Comment).toBeUndefined(); + }); + + it("rejects with 400 if a required parameter is missing from mapping", async () => { + const res = await importCsvPost( + jsonRequest({ + mode: "append", + // Missing 'count' mapping — required parameter is unfilled + mapping: { Username: "username" }, + rows: [{ Username: "alice", Count: "10" }], + }), + { params: Promise.resolve({ caseId: "5" }) } + ); + expect(res.status).toBe(400); + expect(mockDb.$transaction).not.toHaveBeenCalled(); + }); + + it("returns 401 when unauthenticated", async () => { + sessionRef.current = null as never; + const res = await importCsvPost( + jsonRequest({ mode: "append", mapping: {}, rows: [] }), + { params: Promise.resolve({ caseId: "5" }) } + ); + expect(res.status).toBe(401); + sessionRef.current = { user: { id: "u-1", name: "U", email: "u@e.com" } }; + }); +}); diff --git a/testplanit/__tests__/integration/dataset-single-attachment.test.ts b/testplanit/__tests__/integration/dataset-single-attachment.test.ts new file mode 100644 index 000000000..33ef55bc1 --- /dev/null +++ b/testplanit/__tests__/integration/dataset-single-attachment.test.ts @@ -0,0 +1,134 @@ +/** + * DSET-01 invariant: at most one attached DataSet per case. + * + * The POST /api/repository/cases/[caseId]/dataset endpoint is idempotent: + * - Calling POST when no dataset exists creates one and returns it. + * - Calling POST when a dataset already exists returns the EXISTING dataset + * and creates no new row. + * + * Wiring contract test — the route never calls dataSet.create when a + * matching row already exists. This test exercises the route handler with + * a mocked enhanced DB so we don't depend on a live database. + */ + +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockDb, sessionRef } = vi.hoisted(() => { + const db: any = { + repositoryCases: { findFirst: vi.fn() }, + dataSet: { + findFirst: vi.fn(), + create: vi.fn(), + }, + }; + return { + mockDb: db, + sessionRef: { + current: { user: { id: "u-1", name: "U", email: "u@e.com" } }, + }, + }; +}); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(async () => sessionRef.current), +})); +vi.mock("~/server/auth", () => ({ authOptions: {} })); +vi.mock("~/lib/auth/utils", () => ({ + getEnhancedDb: vi.fn(async () => mockDb), +})); + +import { POST as datasetPost } from "~/app/api/repository/cases/[caseId]/dataset/route"; + +function jsonRequest(): NextRequest { + return { json: async () => ({}) } as unknown as NextRequest; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("DSET-01 — single-attachment-per-case", () => { + it("creates a new dataset when none exists; returns it", async () => { + mockDb.repositoryCases.findFirst = vi.fn(async () => ({ + id: 5, + projectId: 100, + name: "Login flow", + })); + mockDb.dataSet.findFirst = vi.fn(async () => null); + mockDb.dataSet.create = vi.fn( + async (args: { data: Record }) => ({ + id: 999, + ...args.data, + }) + ); + + const res = await datasetPost(jsonRequest(), { + params: Promise.resolve({ caseId: "5" }), + }); + expect(res.status).toBe(200); + expect(mockDb.dataSet.create).toHaveBeenCalledTimes(1); + const body = await res.json(); + expect(body.dataset.id).toBe(999); + }); + + it("is idempotent — returns existing dataset without creating a new row", async () => { + mockDb.repositoryCases.findFirst = vi.fn(async () => ({ + id: 5, + projectId: 100, + name: "Login flow", + })); + mockDb.dataSet.findFirst = vi.fn(async () => ({ + id: 42, + projectId: 100, + ownerCaseId: 5, + name: "Existing", + })); + + const res = await datasetPost(jsonRequest(), { + params: Promise.resolve({ caseId: "5" }), + }); + expect(res.status).toBe(200); + expect(mockDb.dataSet.create).not.toHaveBeenCalled(); + const body = await res.json(); + expect(body.dataset.id).toBe(42); + }); + + it("returns 404 when the case is missing", async () => { + mockDb.repositoryCases.findFirst = vi.fn(async () => null); + const res = await datasetPost(jsonRequest(), { + params: Promise.resolve({ caseId: "999" }), + }); + expect(res.status).toBe(404); + }); + + it("returns 401 when unauthenticated", async () => { + sessionRef.current = null as never; + const res = await datasetPost(jsonRequest(), { + params: Promise.resolve({ caseId: "5" }), + }); + expect(res.status).toBe(401); + sessionRef.current = { user: { id: "u-1", name: "U", email: "u@e.com" } }; + }); + + it("scopes the existence check by projectId (cross-project filter)", async () => { + mockDb.repositoryCases.findFirst = vi.fn(async () => ({ + id: 5, + projectId: 100, + name: "Case", + })); + mockDb.dataSet.findFirst = vi.fn(async () => null); + mockDb.dataSet.create = vi.fn(async () => ({ id: 1 })); + + await datasetPost(jsonRequest(), { + params: Promise.resolve({ caseId: "5" }), + }); + + const args = mockDb.dataSet.findFirst.mock.calls[0][0]; + expect(args.where).toMatchObject({ + ownerCaseId: 5, + projectId: 100, + isDeleted: false, + }); + }); +}); diff --git a/testplanit/__tests__/integration/parameter-mutation-coverage.test.ts b/testplanit/__tests__/integration/parameter-mutation-coverage.test.ts new file mode 100644 index 000000000..ff1db29aa --- /dev/null +++ b/testplanit/__tests__/integration/parameter-mutation-coverage.test.ts @@ -0,0 +1,134 @@ +/** + * Phase 1 carry-forward (Plan 01-02 must_haves T-02-04): + * + * Every TestCaseParameter mutation site MUST invoke `updateHasParameters` + * within the same `$transaction` so the denormalized + * `RepositoryCases.hasParameters` flag stays in sync. + * + * This is a static-analysis test: it greps every `tx.testCaseParameter.{create|update}` + * call site under `app/` and `lib/` and asserts that the next 30 lines + * contain `updateHasParameters(`. Sites in the helper module + * (`parameterMutations.ts`) are exempt because the helper itself is + * the single canonical invocation site (the helpers MUST themselves + * call `updateHasParameters`, which is verified separately). + * + * Phase 2 introduces all current sites; future phases MUST extend this + * test or refactor through the helpers when adding new mutation paths. + */ + +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { resolve } from "node:path"; + +const ROOT = resolve(__dirname, "..", ".."); +const SCAN_TARGETS = ["app", "lib"].map((d) => resolve(ROOT, d)); + +interface Hit { + file: string; + line: number; +} + +function findMutationSites(): Hit[] { + // Use grep -n to enumerate every tx.testCaseParameter.{create,update} site. + // Returns lines like: + // app/api/.../route.ts:42: await tx.testCaseParameter.update({ + const cmd = + `grep -rn -E "tx\\.testCaseParameter\\.(create|update)\\(" ` + + SCAN_TARGETS.map((s) => `'${s}'`).join(" ") + + ` --include='*.ts' --include='*.tsx' || true`; + const out = execSync(cmd, { encoding: "utf-8" }); + if (!out.trim()) return []; + return out + .trim() + .split("\n") + .map((line) => { + const m = line.match(/^(.*?):(\d+):/); + if (!m) return null; + return { file: m[1], line: Number(m[2]) }; + }) + .filter((x): x is Hit => x !== null); +} + +const HELPER_FILE = resolve(ROOT, "lib", "services", "parameterMutations.ts"); +const TEST_DIR_MARKER = `${ROOT}/__tests__`; +const SERVICE_TEST_DIR_MARKER = "/__tests__/"; + +function isExemptFile(file: string): boolean { + if (file === HELPER_FILE) return true; + if (file.startsWith(TEST_DIR_MARKER)) return true; + if (file.includes(SERVICE_TEST_DIR_MARKER)) return true; + // Co-located test files (e.g. `route.integration.test.ts` next to a + // route) are also exempt — they exercise mutation paths through fixtures + // that don't represent production code. + if (/\.(test|spec|integration\.test)\.(t|j)sx?$/.test(file)) return true; + return false; +} + +function hasUpdateHasParametersWithin( + file: string, + lineNumber: number, + window = 30 +): boolean { + const content = readFileSync(file, "utf-8").split("\n"); + const start = Math.max(0, lineNumber - 1); + const end = Math.min(content.length, lineNumber - 1 + window); + for (let i = start; i < end; i++) { + if (content[i].includes("updateHasParameters(")) return true; + } + return false; +} + +describe("PARAM coverage — updateHasParameters must follow every TestCaseParameter mutation site", () => { + it("every non-helper, non-test mutation site is followed by updateHasParameters within 30 lines", () => { + const hits = findMutationSites(); + const offenders: Hit[] = []; + for (const h of hits) { + if (isExemptFile(h.file)) continue; + if (!hasUpdateHasParametersWithin(h.file, h.line)) { + offenders.push(h); + } + } + expect(offenders).toEqual([]); + }); + + it("the helper module itself invokes updateHasParameters at every mutation site", () => { + // The helper file is exempt from the global scan above. This test + // confirms the helper still complies with the invariant: every + // tx.testCaseParameter.{create,update} call inside the helper has + // updateHasParameters within 30 lines. + const content = readFileSync(HELPER_FILE, "utf-8").split("\n"); + const offenders: number[] = []; + for (let i = 0; i < content.length; i++) { + if (/tx\.testCaseParameter\.(create|update)\(/.test(content[i])) { + const window = content + .slice(i, Math.min(content.length, i + 30)) + .join("\n"); + if (!window.includes("updateHasParameters(")) { + offenders.push(i + 1); + } + } + } + expect(offenders).toEqual([]); + }); + + it("no hard delete on TestCaseParameter anywhere in app/ or lib/", () => { + const cmd = + `grep -rn -E "tx\\.testCaseParameter\\.delete\\(" ` + + SCAN_TARGETS.map((s) => `'${s}'`).join(" ") + + ` --include='*.ts' --include='*.tsx' || true`; + const out = execSync(cmd, { encoding: "utf-8" }); + const lines = out + .trim() + .split("\n") + .filter((l) => l.length > 0) + .filter((l) => { + const file = l.split(":")[0]; + // exclude tests + return ( + !file.includes("/__tests__/") && !file.startsWith(TEST_DIR_MARKER) + ); + }); + expect(lines).toEqual([]); + }); +}); diff --git a/testplanit/__tests__/integration/parameter-mutations.test.ts b/testplanit/__tests__/integration/parameter-mutations.test.ts new file mode 100644 index 000000000..d35312fad --- /dev/null +++ b/testplanit/__tests__/integration/parameter-mutations.test.ts @@ -0,0 +1,183 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockTx, mockDb, sessionRef } = vi.hoisted(() => { + const tx: any = {}; + const db: any = { + $transaction: vi.fn(async (fn: (t: any) => Promise) => fn(tx)), + testCaseParameter: { findFirst: vi.fn() }, + }; + return { + mockTx: tx, + mockDb: db, + sessionRef: { + current: { user: { id: "u-1", name: "U", email: "u@e.com" } }, + }, + }; +}); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(async () => sessionRef.current), +})); + +vi.mock("~/server/auth", () => ({ authOptions: {} })); + +vi.mock("~/lib/auth/utils", () => ({ + getEnhancedDb: vi.fn(async () => mockDb), +})); + +const helperSpies = vi.hoisted(() => ({ + createParameterInTransaction: vi.fn(), + updateParameterInTransaction: vi.fn(), + softDeleteParameterInTransaction: vi.fn(), +})); + +vi.mock("~/lib/services/parameterMutations", () => ({ + createParameterInTransaction: helperSpies.createParameterInTransaction, + updateParameterInTransaction: helperSpies.updateParameterInTransaction, + softDeleteParameterInTransaction: + helperSpies.softDeleteParameterInTransaction, +})); + +import { POST as parametersPost } from "~/app/api/repository/cases/[caseId]/parameters/route"; +import { + PATCH as paramPatch, + DELETE as paramDelete, +} from "~/app/api/repository/cases/[caseId]/parameters/[paramId]/route"; + +function jsonRequest(body: unknown): NextRequest { + return { + json: async () => body, + } as unknown as NextRequest; +} + +beforeEach(() => { + vi.clearAllMocks(); + sessionRef.current = { user: { id: "u-1", name: "U", email: "u@e.com" } }; + helperSpies.createParameterInTransaction.mockResolvedValue({ + id: 1, + name: "username", + }); + helperSpies.updateParameterInTransaction.mockResolvedValue({ + id: 1, + name: "username", + }); + helperSpies.softDeleteParameterInTransaction.mockResolvedValue(undefined); + mockDb.testCaseParameter.findFirst = vi.fn(async () => ({ + id: 1, + name: "username", + })); +}); + +describe("POST /api/repository/cases/[caseId]/parameters", () => { + it("returns 401 when unauthenticated", async () => { + sessionRef.current = null as never; + const res = await parametersPost(jsonRequest({}), { + params: Promise.resolve({ caseId: "5" }), + }); + expect(res.status).toBe(401); + }); + + it("returns 400 when caseId is not numeric", async () => { + const res = await parametersPost(jsonRequest({}), { + params: Promise.resolve({ caseId: "abc" }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when SELECT body has both allowedValuesJson and lookupDataSetId (XOR fail)", async () => { + const res = await parametersPost( + jsonRequest({ + name: "env", + type: "SELECT", + allowedValuesJson: ["dev"], + lookupDataSetId: 99, + }), + { params: Promise.resolve({ caseId: "5" }) } + ); + expect(res.status).toBe(400); + expect(helperSpies.createParameterInTransaction).not.toHaveBeenCalled(); + }); + + it("invokes createParameterInTransaction inside $transaction with valid body", async () => { + const res = await parametersPost( + jsonRequest({ + name: "username", + type: "STRING", + }), + { params: Promise.resolve({ caseId: "5" }) } + ); + expect(res.status).toBe(200); + expect(mockDb.$transaction).toHaveBeenCalledTimes(1); + expect(helperSpies.createParameterInTransaction).toHaveBeenCalledTimes(1); + const args = helperSpies.createParameterInTransaction.mock.calls[0]; + expect(args[0]).toBe(mockTx); + expect(args[1]).toBe(5); + expect(args[2].name).toBe("username"); + expect(args[3]).toMatchObject({ id: "u-1" }); + }); + + it("rolls up to 500 when helper throws", async () => { + helperSpies.createParameterInTransaction.mockRejectedValueOnce( + new Error("boom") + ); + const res = await parametersPost( + jsonRequest({ name: "username", type: "STRING" }), + { params: Promise.resolve({ caseId: "5" }) } + ); + expect(res.status).toBe(500); + }); +}); + +describe("PATCH /api/repository/cases/[caseId]/parameters/[paramId]", () => { + it("returns 401 when unauthenticated", async () => { + sessionRef.current = null as never; + const res = await paramPatch(jsonRequest({ name: "x" }), { + params: Promise.resolve({ caseId: "5", paramId: "1" }), + }); + expect(res.status).toBe(401); + }); + + it("returns 400 when body is empty", async () => { + const res = await paramPatch(jsonRequest({}), { + params: Promise.resolve({ caseId: "5", paramId: "1" }), + }); + expect(res.status).toBe(400); + }); + + it("invokes updateParameterInTransaction with valid name change", async () => { + const res = await paramPatch(jsonRequest({ name: "newName" }), { + params: Promise.resolve({ caseId: "5", paramId: "1" }), + }); + expect(res.status).toBe(200); + expect(helperSpies.updateParameterInTransaction).toHaveBeenCalledTimes(1); + const args = helperSpies.updateParameterInTransaction.mock.calls[0]; + expect(args[1]).toBe(5); + expect(args[2]).toBe(1); + expect(args[3]).toMatchObject({ name: "newName" }); + }); +}); + +describe("DELETE /api/repository/cases/[caseId]/parameters/[paramId]", () => { + it("returns 401 when unauthenticated", async () => { + sessionRef.current = null as never; + const res = await paramDelete(jsonRequest({}), { + params: Promise.resolve({ caseId: "5", paramId: "1" }), + }); + expect(res.status).toBe(401); + }); + + it("invokes softDeleteParameterInTransaction inside $transaction", async () => { + const res = await paramDelete(jsonRequest({}), { + params: Promise.resolve({ caseId: "5", paramId: "1" }), + }); + expect(res.status).toBe(200); + expect(mockDb.$transaction).toHaveBeenCalledTimes(1); + expect(helperSpies.softDeleteParameterInTransaction).toHaveBeenCalledTimes( + 1 + ); + const args = helperSpies.softDeleteParameterInTransaction.mock.calls[0]; + expect(args[1]).toBe(5); + expect(args[2]).toBe(1); + }); +}); diff --git a/testplanit/__tests__/integration/parameter-redaction-contract.test.ts b/testplanit/__tests__/integration/parameter-redaction-contract.test.ts new file mode 100644 index 000000000..24e0a2d79 --- /dev/null +++ b/testplanit/__tests__/integration/parameter-redaction-contract.test.ts @@ -0,0 +1,166 @@ +// Integration test — parameter-redaction ↔ AuditEvent.metadata type contract. +// +// Plan 01-03 Task 2. The redactValues helper (Plan 01-02) produces a +// Record map. The AuditEvent interface (lib/services/auditLog.ts) +// types its metadata field as `Record | undefined`. This file +// LOCKS the type-level + value-level contract between the two so Phase 3's +// audit-log wiring fails at compile-time (not runtime) if either side drifts. +// +// Why a separate file: Plan 01-02 already unit-tests `redactValues` against +// its own contract. This file proves the helper's OUTPUT is assignable to +// AuditEvent.metadata — that's a different test target (the wire-up between +// two modules), and it lives at the integration boundary per VALIDATION.md. +// +// Run via: +// cd testplanit && pnpm test parameter-redaction-contract --run +// +// Pure-function test — no Prisma, no BullMQ, no DB. + +import { describe, expect, it } from "vitest"; +import { + redactValues, + type ParameterSchemaEntry, +} from "@/lib/services/parameterRedaction"; +import type { AuditEvent } from "@/lib/services/auditLog"; +// AuditAction lives on the generated Prisma client. We verified the enum +// values present in schema.zmodel (lines 4511-4554) — RESULT_RECORDED is not +// among the current enum values; Phase 3 will introduce a new value (likely +// ITERATION_RESULT_RECORDED). For Phase 1 we use CREATE, which IS guaranteed +// to exist (it's the canonical first AuditAction value), and document the +// Phase 3 follow-up via a comment. +import { AuditAction } from "@prisma/client"; + +// AuditAction enum verification: schema.zmodel:4511-4554 includes CREATE, +// UPDATE, DELETE, etc. RESULT_RECORDED is NOT in the enum at Phase 1. +// Phase 3 (iteration writes) will add a new action value such as +// ITERATION_RESULT_RECORDED — at which point the references below should be +// updated to match the new enum. The redaction sentinel is the same byte- +// sequence used by the existing audit-log SENSITIVE_FIELDS redaction +// (lib/services/auditLog.ts: "[REDACTED]"); if this changes, both helpers +// must change atomically — see PARAM-03 / RESEARCH.md Q5. + +describe("parameter-redaction ↔ AuditEvent.metadata contract", () => { + describe("Group 1: Type contract", () => { + it("Test 1.1: redactValues output is structurally assignable to AuditEvent.metadata", () => { + const paramSchema: ParameterSchemaEntry[] = [ + { name: "apiKey", sensitive: true }, + ]; + // The line below is the load-bearing type contract: if Phase 3's + // wiring makes AuditEvent.metadata stricter than Record, + // this assignment will fail to type-check and Vitest will surface the + // failure as a compile-time error. + const event: AuditEvent = { + action: AuditAction.CREATE, + entityType: "TestRunCaseIteration", + entityId: "42", + metadata: redactValues( + { apiKey: "secret" }, + paramSchema, + /* viewerCanReadSensitive */ false + ), + }; + expect(event.metadata).toEqual({ apiKey: "[REDACTED]" }); + }); + }); + + describe("Group 2: Value contract", () => { + it("Test 2.1: redactValues output preserves the redaction sentinel exactly when assigned to AuditEvent.metadata", () => { + const paramSchema: ParameterSchemaEntry[] = [ + { name: "apiKey", sensitive: true }, + { name: "username", sensitive: false }, + ]; + const event: AuditEvent = { + action: AuditAction.CREATE, + entityType: "TestRunCaseIteration", + entityId: "1", + metadata: redactValues( + { apiKey: "k1", username: "alice" }, + paramSchema, + false + ), + }; + expect(event.metadata?.apiKey).toBe("[REDACTED]"); + expect(event.metadata?.username).toBe("alice"); + }); + + it("Test 2.2: viewerCanReadSensitive=true preserves original values structurally", () => { + const paramSchema: ParameterSchemaEntry[] = [ + { name: "apiKey", sensitive: true }, + { name: "region", sensitive: false }, + ]; + const original = { apiKey: "k1", region: "us-east-1" }; + const event: AuditEvent = { + action: AuditAction.CREATE, + entityType: "TestRunCaseIteration", + entityId: "2", + metadata: redactValues( + original, + paramSchema, + /* viewerCanReadSensitive */ true + ), + }; + // Deep-equal by value; redactValues is permitted to return a copy. + expect(event.metadata).toEqual(original); + }); + + it("Test 2.3: empty values map produces empty metadata (not undefined)", () => { + const event: AuditEvent = { + action: AuditAction.CREATE, + entityType: "TestRunCaseIteration", + entityId: "3", + metadata: redactValues({}, [], false), + }; + expect(event.metadata).toEqual({}); + expect(event.metadata).not.toBeUndefined(); + }); + + it("Test 2.4: AuditAction enum value used exists at runtime (Phase 3 will add ITERATION_RESULT_RECORDED)", () => { + // We use CREATE for Phase 1 because RESULT_RECORDED is not part of the + // current AuditAction enum (verified against schema.zmodel:4511-4554). + // Phase 3 (iteration result writes) is the planned introduction point + // for a parametized-iteration-specific action. + expect(AuditAction.CREATE).toBe("CREATE"); + // Document the existing sentinel here too — both helpers MUST emit the + // same string, see lib/services/auditLog.ts. + const out = redactValues( + { apiKey: "x" }, + [{ name: "apiKey", sensitive: true }], + false + ); + expect(out.apiKey).toBe("[REDACTED]"); + }); + }); + + describe("Group 3: Sentinel uniqueness + cross-helper compatibility", () => { + it("Test 3.1: the redaction sentinel matches the auditLog SENSITIVE_FIELDS sentinel byte-for-byte", () => { + // Both helpers use "[REDACTED]"; if this changes, both must change + // atomically — see PARAM-03 / RESEARCH.md Q5. + const out = redactValues( + { token: "value" }, + [{ name: "token", sensitive: true }], + false + ); + expect(out.token).toBe("[REDACTED]"); + // Confirm the literal includes the brackets (sentinels often drift to + // or **REDACTED** under refactor; this assertion is the + // canary). + expect(String(out.token).startsWith("[")).toBe(true); + expect(String(out.token).endsWith("]")).toBe(true); + }); + + it("Test 3.2: redactValues output type IS assignable to AuditEvent['metadata'] (TS-only check, encoded as runtime no-op)", () => { + // This test exists primarily to compile. If the assignment below ever + // stops type-checking, Vitest surfaces it via tsc — which is the whole + // point of the contract test (Phase 3 wiring drift catches at compile- + // time, not runtime). + const meta: AuditEvent["metadata"] = redactValues( + { secret: "x", normal: 42 }, + [{ name: "secret", sensitive: true }], + false + ); + expect(meta).toBeDefined(); + expect(meta?.secret).toBe("[REDACTED]"); + expect(meta?.normal).toBe(42); + }); + }); +}); diff --git a/testplanit/__tests__/integration/parameter-rename-atomicity.test.ts b/testplanit/__tests__/integration/parameter-rename-atomicity.test.ts new file mode 100644 index 000000000..7e14d86ed --- /dev/null +++ b/testplanit/__tests__/integration/parameter-rename-atomicity.test.ts @@ -0,0 +1,84 @@ +/** + * Atomicity contract for parameter rename: the rewrite of step JSON + + * dataset row keys + version snapshot all happen inside the SAME + * `$transaction`. If any step throws, the route handler must propagate + * the failure (which the caller's `$transaction` rolls back). + * + * This test asserts the wiring contract — the route opens a single + * transaction and the helper does ALL its work inside it. Real database + * rollback is exercised by the underlying transaction client when a + * non-mocked DB is used; here we prove the route never escapes the tx. + */ + +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockDb, txMock, sessionRef } = vi.hoisted(() => { + const tx = { __isTransaction__: true } as any; + const db = { + $transaction: vi.fn(async (fn: (t: any) => Promise) => fn(tx)), + testCaseParameter: { findFirst: vi.fn() }, + }; + return { + mockDb: db, + txMock: tx, + sessionRef: { + current: { user: { id: "u-1", name: "U", email: "u@e.com" } }, + }, + }; +}); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(async () => sessionRef.current), +})); +vi.mock("~/server/auth", () => ({ authOptions: {} })); +vi.mock("~/lib/auth/utils", () => ({ + getEnhancedDb: vi.fn(async () => mockDb), +})); + +const helperSpies = vi.hoisted(() => ({ + updateParameterInTransaction: vi.fn(), +})); + +vi.mock("~/lib/services/parameterMutations", () => ({ + updateParameterInTransaction: helperSpies.updateParameterInTransaction, + createParameterInTransaction: vi.fn(), + softDeleteParameterInTransaction: vi.fn(), +})); + +import { PATCH as paramPatch } from "~/app/api/repository/cases/[caseId]/parameters/[paramId]/route"; + +function jsonRequest(body: unknown): NextRequest { + return { json: async () => body } as unknown as NextRequest; +} + +beforeEach(() => { + vi.clearAllMocks(); + helperSpies.updateParameterInTransaction.mockResolvedValue({ + id: 1, + name: "newName", + }); +}); + +describe("rename atomicity wiring", () => { + it("invokes updateParameterInTransaction with the SAME tx the $transaction created", async () => { + await paramPatch(jsonRequest({ name: "newName" }), { + params: Promise.resolve({ caseId: "5", paramId: "1" }), + }); + expect(helperSpies.updateParameterInTransaction).toHaveBeenCalledTimes(1); + const passedTx = helperSpies.updateParameterInTransaction.mock.calls[0][0]; + expect(passedTx).toBe(txMock); + }); + + it("propagates helper errors so caller transaction rolls back", async () => { + helperSpies.updateParameterInTransaction.mockRejectedValueOnce( + new Error("simulated rewrite failure") + ); + const res = await paramPatch(jsonRequest({ name: "newName" }), { + params: Promise.resolve({ caseId: "5", paramId: "1" }), + }); + // Route returns 500; the underlying $transaction would roll back DB writes + // because the rejection bubbled out of the transaction callback. + expect(res.status).toBe(500); + }); +}); diff --git a/testplanit/__tests__/integration/parameter-version-bump.test.ts b/testplanit/__tests__/integration/parameter-version-bump.test.ts new file mode 100644 index 000000000..bce9c7d4d --- /dev/null +++ b/testplanit/__tests__/integration/parameter-version-bump.test.ts @@ -0,0 +1,105 @@ +/** + * PARAM-06 wiring contract: every parameter add/remove/rename/retype + * passes through the `parameterMutations` helpers — which are the + * canonical place that bumps `RepositoryCases.currentVersion` and + * snapshots the case via `createTestCaseVersionInTransaction`. + * + * This test asserts that ALL three route surfaces (POST/PATCH/DELETE) + * delegate to the version-bumping helpers. The helpers themselves are + * unit-tested in `parameterMutations.test.ts` to verify they call + * `currentVersion: { increment: 1 }` and `createTestCaseVersionInTransaction`. + */ + +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockDb, txMock, sessionRef } = vi.hoisted(() => { + const tx: any = {}; + const db: any = { + $transaction: vi.fn(async (fn: (t: any) => Promise) => fn(tx)), + testCaseParameter: { findFirst: vi.fn() }, + }; + return { + mockDb: db, + txMock: tx, + sessionRef: { + current: { user: { id: "u-1", name: "U", email: "u@e.com" } }, + }, + }; +}); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(async () => sessionRef.current), +})); +vi.mock("~/server/auth", () => ({ authOptions: {} })); +vi.mock("~/lib/auth/utils", () => ({ + getEnhancedDb: vi.fn(async () => mockDb), +})); + +const helperSpies = vi.hoisted(() => ({ + createParameterInTransaction: vi.fn(), + updateParameterInTransaction: vi.fn(), + softDeleteParameterInTransaction: vi.fn(), +})); + +vi.mock("~/lib/services/parameterMutations", () => ({ + createParameterInTransaction: helperSpies.createParameterInTransaction, + updateParameterInTransaction: helperSpies.updateParameterInTransaction, + softDeleteParameterInTransaction: + helperSpies.softDeleteParameterInTransaction, +})); + +import { POST as parametersPost } from "~/app/api/repository/cases/[caseId]/parameters/route"; +import { + PATCH as paramPatch, + DELETE as paramDelete, +} from "~/app/api/repository/cases/[caseId]/parameters/[paramId]/route"; + +function jsonRequest(body: unknown): NextRequest { + return { json: async () => body } as unknown as NextRequest; +} + +beforeEach(() => { + vi.clearAllMocks(); + helperSpies.createParameterInTransaction.mockResolvedValue({ id: 1 }); + helperSpies.updateParameterInTransaction.mockResolvedValue({ id: 1 }); + helperSpies.softDeleteParameterInTransaction.mockResolvedValue(undefined); +}); + +describe("PARAM-06 — every parameter mutation routes through helpers that bump the version", () => { + it("POST routes through createParameterInTransaction", async () => { + const res = await parametersPost( + jsonRequest({ name: "x", type: "STRING" }), + { params: Promise.resolve({ caseId: "5" }) } + ); + expect(res.status).toBe(200); + expect(helperSpies.createParameterInTransaction).toHaveBeenCalledTimes(1); + expect(helperSpies.createParameterInTransaction.mock.calls[0][0]).toBe( + txMock + ); + }); + + it("PATCH routes through updateParameterInTransaction (rename = retype = field change)", async () => { + const res = await paramPatch(jsonRequest({ sensitive: true }), { + params: Promise.resolve({ caseId: "5", paramId: "1" }), + }); + expect(res.status).toBe(200); + expect(helperSpies.updateParameterInTransaction).toHaveBeenCalledTimes(1); + expect(helperSpies.updateParameterInTransaction.mock.calls[0][0]).toBe( + txMock + ); + }); + + it("DELETE routes through softDeleteParameterInTransaction", async () => { + const res = await paramDelete(jsonRequest({}), { + params: Promise.resolve({ caseId: "5", paramId: "1" }), + }); + expect(res.status).toBe(200); + expect(helperSpies.softDeleteParameterInTransaction).toHaveBeenCalledTimes( + 1 + ); + expect(helperSpies.softDeleteParameterInTransaction.mock.calls[0][0]).toBe( + txMock + ); + }); +}); diff --git a/testplanit/app/[locale]/admin/configurations/Categories.tsx b/testplanit/app/[locale]/admin/configurations/Categories.tsx index 8247e7150..df897d396 100644 --- a/testplanit/app/[locale]/admin/configurations/Categories.tsx +++ b/testplanit/app/[locale]/admin/configurations/Categories.tsx @@ -441,7 +441,7 @@ function ConfigCategoriesList() { }} className="flex items-center p-0 h-auto text-sm" > - + {`${tCommon("add")} Variant`} )} diff --git a/testplanit/app/[locale]/admin/projects/columns.spec.tsx b/testplanit/app/[locale]/admin/projects/columns.spec.tsx index e89b4eebf..fb8038cdb 100644 --- a/testplanit/app/[locale]/admin/projects/columns.spec.tsx +++ b/testplanit/app/[locale]/admin/projects/columns.spec.tsx @@ -143,6 +143,7 @@ const testProject: ExtendedProjects = { promptConfigId: null, defaultCaseExportTemplateId: null, quickScriptEnabled: false, + junitIterationPropertyNames: [], creator: { id: "user-1", name: "Test User", diff --git a/testplanit/app/[locale]/admin/sso/page.tsx b/testplanit/app/[locale]/admin/sso/page.tsx index 5cf4ed910..5f76ebb24 100644 --- a/testplanit/app/[locale]/admin/sso/page.tsx +++ b/testplanit/app/[locale]/admin/sso/page.tsx @@ -1430,7 +1430,7 @@ export default function SSOAdminPage() { disabled={isAddingDomain || !newDomain} size="sm" > - + {t("admin.sso.registration.allowedDomains.add")} diff --git a/testplanit/app/[locale]/layout.tsx b/testplanit/app/[locale]/layout.tsx index 6978da5dc..3b158fb1e 100644 --- a/testplanit/app/[locale]/layout.tsx +++ b/testplanit/app/[locale]/layout.tsx @@ -1,4 +1,5 @@ import { Header } from "@/components/Header"; +import { RunGenerationProgressMount } from "@/components/runs/RunGenerationProgressToast"; import { UpgradeNotificationChecker } from "@/components/UpgradeNotificationChecker"; import type { Metadata } from "next"; import { NextIntlClientProvider } from "next-intl"; @@ -55,6 +56,7 @@ export default async function RootLayout(props: any) { {props.children} + diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/AddResultModal.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/AddResultModal.tsx index c7a668528..b6e5b0075 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/AddResultModal.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/AddResultModal.tsx @@ -32,6 +32,7 @@ import { toast } from "sonner"; import * as z from "zod/v4"; import { emptyEditorContent } from "~/app/constants"; import { useProjectPermissions } from "~/hooks/useProjectPermissions"; +import type { ParameterChipMeta } from "~/lib/tiptap/parameterMentionExtension"; import { useCreateAttachments, useCreateResultFieldValues, @@ -227,6 +228,24 @@ interface AddResultModalProps { selectedCases?: ExtendedCases[]; steps?: EnrichedStep[]; // Updated to EnrichedStep configuration?: { id: number; name: string } | null; + /** + * Phase 3 — when set, every result submitted from this modal is recorded + * against the given iteration of a parameterized test case (server runs + * worst-of rollup + counter updates). Omit on non-parameterized cases. + */ + iterationId?: number; + /** + * Phase 3 — when set, the dialog title shows iteration context (e.g. + * "Add result for Iteration 3 of 10"). Caller pre-formats the label. + */ + iterationLabel?: string; + /** + * Phase 3 — parameter chip metadata with the active iteration's effective + * values. Threaded into all step-text TipTapEditor instances inside the + * modal so chips render substituted (e.g. `@username: alice@example.com`) + * instead of just `@username`. Matches the case-detail surface. + */ + parameters?: ParameterChipMeta[]; } export function AddResultModal({ @@ -242,6 +261,9 @@ export function AddResultModal({ selectedCases = [], steps = [], // Default to empty array configuration, + iterationId, + iterationLabel, + parameters, }: AddResultModalProps) { const t = useTranslations(); const tCommon = useTranslations("common"); @@ -986,6 +1008,7 @@ export function AddResultModal({ testRunCaseVersion: repositoryCase.currentVersion, issueIds: issueIdsToConnect, inProgressStateId: inProgressWorkflow?.id ?? null, + iterationId, }); // Save template field values if any exist @@ -1324,7 +1347,10 @@ export function AddResultModal({ - {tCommon("actions.addResult")} + + {tCommon("actions.addResult")} + {iterationLabel ? ` — ${iterationLabel}` : ""} + @@ -1644,7 +1670,10 @@ export function AddResultModal({ }} > - {tCommon("actions.addResult")} + + {tCommon("actions.addResult")} + {iterationLabel ? ` — ${iterationLabel}` : ""} +
{isBulkResult ? ( @@ -1764,6 +1793,15 @@ export function AddResultModal({ linkedIssueIds={selectedMainIssues} setLinkedIssueIds={setSelectedMainIssues} entityType="testRunResult" + iterationContext={ + iterationId && testRunCaseId + ? { + iterationId, + testRunId, + testRunCaseId, + } + : undefined + } /> @@ -1843,6 +1881,7 @@ export function AddResultModal({ setSelectedIssues={setSelectedSharedItemIssues} issueMap={issueMap} onMainStatusChange={() => setAnimateBorder(true)} + parameters={parameters} /> ); @@ -1897,6 +1936,7 @@ export function AddResultModal({ readOnly={true} projectId={`step_${step.id}`} className="prose-sm" + parameters={parameters} />
@@ -1908,6 +1948,7 @@ export function AddResultModal({ readOnly={true} projectId={`step_${step.id}_expected`} className="prose-sm" + parameters={parameters} /> @@ -2110,6 +2151,7 @@ interface SharedStepGroupInputsProps { >; issueMap: Map; onMainStatusChange?: () => void; + parameters?: ParameterChipMeta[]; } const SharedStepGroupInputs: React.FC = ({ @@ -2124,6 +2166,7 @@ const SharedStepGroupInputs: React.FC = ({ setSelectedIssues, issueMap: _issueMap, onMainStatusChange, + parameters, }): React.ReactNode => { // Explicitly set return type to React.ReactNode const t = useTranslations(); @@ -2214,6 +2257,7 @@ const SharedStepGroupInputs: React.FC = ({ readOnly projectId={`shared_item_step_${item.id}`} className="prose-sm" + parameters={parameters} /> @@ -2225,6 +2269,7 @@ const SharedStepGroupInputs: React.FC = ({ readOnly projectId={`shared_item_expected_${item.id}`} className="prose-sm" + parameters={parameters} /> diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/Cases.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/Cases.tsx index 8f7c18563..a403b03cc 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/Cases.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/Cases.tsx @@ -1739,6 +1739,9 @@ export default function Cases({ startedAt: true, completedAt: true, elapsed: true, + // Phase 3 — surface iteration count so the status cell can detect + // parameterized cases and render its read-only sheet-opener. + totalIterations: true, testRun: { select: { id: true, @@ -2549,6 +2552,9 @@ export default function Cases({ order: trc.order, testRunId: trc.testRun?.id, testRunConfiguration: trc.testRun?.configuration, + // Phase 3 — surface the iteration count so the status cell can + // detect parameterized cases and render read-only. + totalIterations: (trc as { totalIterations?: number }).totalIterations, })); } // Not in isRunMode. Use 'data' directly (already server-side paginated and filtered). diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/EditResultModal.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/EditResultModal.tsx index 421fa8c8f..e582fa711 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/EditResultModal.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/EditResultModal.tsx @@ -13,7 +13,7 @@ import { Bug, ListChecks, LockIcon, SearchCheck, Trash2 } from "lucide-react"; import { useSession } from "next-auth/react"; import { useLocale, useTranslations } from "next-intl"; import parseDuration from "parse-duration"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useForm, useWatch } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod/v4"; @@ -25,6 +25,7 @@ import { useCreateTestRunStepResults, useFindFirstProjects, useFindFirstRepositoryCases, + useFindFirstTestRunResults, useFindManyStatus, useFindManyTemplateResultAssignment, useFindManyTestRunResults, @@ -32,6 +33,7 @@ import { useUpdateTestRunResults, useUpdateTestRunStepResults, } from "~/lib/hooks"; +import type { ParameterChipMeta } from "~/lib/tiptap/parameterMentionExtension"; import { toHumanReadable } from "~/utils/duration"; import { fetchSignedUrl } from "~/utils/fetchSignedUrl"; @@ -79,11 +81,13 @@ interface StepsWithExpectedResult { step: any; testCaseId: number; order: number; - expectedResult?: { - id: number; - expectedResult: any; - stepId: number; - } | null; + // `expectedResult` is the Json column directly on the Steps model (see + // schema.zmodel) — it carries the TipTap doc (or a stringified one), + // not a wrapper relation. The previous typing here was wrong and the + // parsing below double-dereferenced into a nonexistent inner field, + // which is why the expected-result editor in this modal always + // rendered empty. + expectedResult?: any; } interface TestRunResult { @@ -362,6 +366,108 @@ export function EditResultModal({ }, }); + // Load the iteration this result was recorded against (if any) so step + // renders can substitute @parameter chips with the iteration's effective + // values. Non-parameterized results have `iteration` = null and the + // memoized `parameters` below stays `undefined` — chips fall back to + // `@name` (unsubstituted) which matches the pre-iterations behavior. + const { data: resultRow } = useFindFirstTestRunResults( + { + where: { id: resultId, isDeleted: false }, + select: { + id: true, + iteration: { + select: { + id: true, + valuesJson: true, + testRunCase: { + select: { + dataSetSnapshot: { + select: { parametersJson: true }, + }, + }, + }, + }, + }, + }, + }, + { enabled: !!resultId } + ); + + /** + * Build the iteration-aware `ParameterChipMeta[]` consumed by the + * read-only step + expected-result TipTapEditor mounts so `@username` + * chips render as `@username: alice@example.com` (or the redacted + * fallback for sensitive params the viewer can't see). Mirrors the + * shape `IterationAwareTestRunCaseDetails` produces — the chip + * extension is the consumer either way. + * + * Sensitive-param gate: the audit boundary is the source of truth; + * the client gate here is defense in depth and matches the + * IterationAwareTestRunCaseDetails convention (`access === "ADMIN"` + * sees plaintext; everyone else falls back to `@name`). + */ + const stepParameters: ParameterChipMeta[] | undefined = useMemo(() => { + const iteration = resultRow?.iteration; + if (!iteration) return undefined; + const valuesJson = + (iteration.valuesJson as Record | null | undefined) ?? + {}; + const parametersJson = iteration.testRunCase?.dataSetSnapshot + ?.parametersJson as + | Array<{ + id?: number; + name: string; + type: string; + sensitive?: boolean; + }> + | null + | undefined; + if (!parametersJson || !Array.isArray(parametersJson)) return undefined; + const viewerCanReadSensitive = isSuperAdmin; + const VALID_PARAM_TYPES: ParameterChipMeta["type"][] = [ + "STRING", + "INTEGER", + "BOOLEAN", + "SELECT", + ]; + return parametersJson.map((p): ParameterChipMeta => { + const raw = valuesJson[p.name]; + let val: string | null; + if (raw === null || raw === undefined) { + val = null; + } else if (typeof raw === "string") { + val = raw; + } else { + try { + val = JSON.stringify(raw); + } catch { + val = String(raw); + } + } + if (p.sensitive && !viewerCanReadSensitive) { + val = null; + } + // The snapshot's parametersJson is a Prisma `Json` column, so the + // `type` field arrives as a raw string. Narrow to the four valid + // chip types; anything unexpected falls back to STRING so the + // chip still renders (matches the editor extension's permissive + // string handling). + const narrowedType = (VALID_PARAM_TYPES as readonly string[]).includes( + p.type + ) + ? (p.type as ParameterChipMeta["type"]) + : "STRING"; + return { + id: p.id ?? 0, + name: p.name, + type: narrowedType, + defaultValue: val, + sensitive: !!p.sensitive, + }; + }); + }, [resultRow, isSuperAdmin]); + // Find the repository case to get its template ID const { data: repositoryCase, isLoading: isLoadingCase } = useFindFirstRepositoryCases({ @@ -1535,11 +1641,18 @@ export function EditResultModal({ let expectedResultContent; try { - expectedResultContent = - typeof step.expectedResult?.expectedResult === "string" - ? JSON.parse(step.expectedResult.expectedResult) - : step.expectedResult?.expectedResult || - emptyEditorContent; + if (typeof step.expectedResult === "string") { + expectedResultContent = JSON.parse(step.expectedResult); + } else if ( + typeof step.expectedResult === "object" && + step.expectedResult !== null + ) { + // Already a TipTap doc — pass through + expectedResultContent = step.expectedResult; + } else { + // null / undefined / scalar — render empty editor + expectedResultContent = emptyEditorContent; + } } catch (error) { console.warn( "Error parsing expected result content:", @@ -1560,6 +1673,7 @@ export function EditResultModal({ readOnly={true} projectId={`step_${step.id}`} className="prose-sm" + parameters={stepParameters} /> @@ -1571,6 +1685,7 @@ export function EditResultModal({ readOnly={true} projectId={`step_${step.id}_expected`} className="prose-sm" + parameters={stepParameters} /> diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/GenerateTestCasesWizard.test.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/GenerateTestCasesWizard.test.tsx new file mode 100644 index 000000000..1582e148c --- /dev/null +++ b/testplanit/app/[locale]/projects/repository/[projectId]/GenerateTestCasesWizard.test.tsx @@ -0,0 +1,152 @@ +/** + * INT-06 wizard plumbing tests. + * + * The wizard is 5,400+ lines of React with deeply-nested useState + form + + * streaming logic and many ZenStack/integration dependencies. Spinning up a + * full Testing-Library render harness would require mocking ~30 modules and + * still wouldn't reach the preview UI deterministically (the cards only + * render after a multi-step interaction). + * + * Instead, this test file performs structural / contract checks: + * 1. The wizard source contains the admin-gated toggle (defense in depth on + * top of the API admin gate). + * 2. All four LLM fetch sites thread `includeParameters` into the POST body. + * 3. The parser warnings setter is wired and reset between generations. + * + * The behavioral coverage (toggle visible to admins, body threading, dataset + * truncation rendering) is exercised end-to-end by the Playwright spec at + * e2e/tests/llm/generate-cases-with-parameters.spec.ts. The route layer's + * threading is already covered by route.test.ts. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const WIZARD_PATH = path.join(__dirname, "GenerateTestCasesWizard.tsx"); + +function readWizard(): string { + return readFileSync(WIZARD_PATH, "utf8"); +} + +describe("GenerateTestCasesWizard — INT-06 plumbing", () => { + it("declares the includeParameters state with default false (D-10 opt-in)", () => { + const src = readWizard(); + expect(src).toMatch( + /const \[includeParameters, setIncludeParameters\] = useState\(false\)/ + ); + }); + + it("derives isAdmin from the session.user.access pattern (canonical admin check)", () => { + const src = readWizard(); + expect(src).toMatch( + /const isAdmin = session\?\.user\?\.access === "ADMIN"/ + ); + }); + + it("renders the include-parameters toggle ONLY when isAdmin is truthy", () => { + const src = readWizard(); + // The conditional render must reference both isAdmin and the testid. + expect(src).toMatch( + /\{isAdmin &&[\s\S]*?data-testid="include-parameters-toggle"/ + ); + }); + + it("threads includeParameters into the stream POST body (single-shot path)", () => { + const src = readWizard(); + // The stream fetch body must include `includeParameters` alongside + // `autoGenerateTags`. + const streamFetchIdx = src.indexOf( + 'fetch("/api/llm/generate-test-cases/stream"' + ); + expect(streamFetchIdx).toBeGreaterThan(-1); + const streamSnippet = src.slice(streamFetchIdx, streamFetchIdx + 1500); + expect(streamSnippet).toMatch(/autoGenerateTags,\s*\n\s*includeParameters/); + }); + + it("threads includeParameters into the expand POST body", () => { + const src = readWizard(); + const expandFetchIdx = src.indexOf('"/api/llm/generate-test-cases/expand"'); + expect(expandFetchIdx).toBeGreaterThan(-1); + const expandSnippet = src.slice(expandFetchIdx, expandFetchIdx + 1500); + expect(expandSnippet).toMatch( + /autoGenerateTags,\s*\n\s*includeParameters,/ + ); + }); + + it("threads includeParameters into the outline POST body (accepted-but-ignored)", () => { + const src = readWizard(); + const outlineFetchIdx = src.indexOf( + '"/api/llm/generate-test-cases/outline"' + ); + expect(outlineFetchIdx).toBeGreaterThan(-1); + const outlineSnippet = src.slice(outlineFetchIdx, outlineFetchIdx + 1000); + expect(outlineSnippet).toMatch(/includeParameters,?\s*\n/); + }); + + it("threads includeParameters into the URL-job submit POST body", () => { + const src = readWizard(); + const urlFetchIdx = src.indexOf('"/api/llm/generate-from-url/submit"'); + expect(urlFetchIdx).toBeGreaterThan(-1); + const urlSnippet = src.slice(urlFetchIdx, urlFetchIdx + 1500); + expect(urlSnippet).toMatch( + /includeParameters: includeParameters \|\| undefined/ + ); + }); + + it("captures parser warnings from parseAndValidateTestCases and resets between generations", () => { + const src = readWizard(); + // The parser callsite destructures `warnings` alongside `testCases`. + expect(src).toMatch( + /warnings:\s*pageWarnings\s*,?\s*\}\s*=\s*parseAndValidateTestCases/ + ); + // The reset happens at the top of a fresh `generateTestCases` invocation. + expect(src).toMatch(/setLlmWarnings\(\[\]\)/); + }); + + it("renders the parameters and starter-dataset preview sections with data-testids", () => { + const src = readWizard(); + expect(src).toContain('data-testid="wizard-preview-parameters-section"'); + expect(src).toContain('data-testid="wizard-preview-dataset-section"'); + expect(src).toContain('data-testid="wizard-preview-parameter-chip"'); + expect(src).toContain('data-testid="wizard-preview-warning"'); + }); + + it("references the new i18n keys for the toggle label, help, and warning copy", () => { + const src = readWizard(); + expect(src).toContain("generateTestCases.includeParametersLabel"); + expect(src).toContain("generateTestCases.includeParametersHelp"); + expect(src).toContain("generateTestCases.parametersSection"); + expect(src).toContain("generateTestCases.starterDatasetSection"); + expect(src).toContain("generateTestCases.datasetTruncatedWarning"); + }); +}); + +describe("en-US.json — INT-06 i18n keys", () => { + it("contains all new generateTestCases keys", () => { + const en = JSON.parse( + readFileSync( + path.join( + __dirname, + "..", + "..", + "..", + "..", + "..", + "messages", + "en-US.json" + ), + "utf8" + ) + ); + const ns = en.repository.generateTestCases; + expect(typeof ns.includeParametersLabel).toBe("string"); + expect(typeof ns.includeParametersHelp).toBe("string"); + expect(typeof ns.parametersSection).toBe("string"); + expect(typeof ns.starterDatasetSection).toBe("string"); + expect(typeof ns.datasetTruncatedWarning).toBe("string"); + expect(typeof ns.datasetCappedWarning).toBe("string"); + expect(typeof ns.invalidParameterWarning).toBe("string"); + expect(typeof ns.datasetMoreRows).toBe("string"); + expect(typeof ns.parameterChipSensitive).toBe("string"); + }); +}); diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/GenerateTestCasesWizard.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/GenerateTestCasesWizard.tsx index cccf704dd..129c7cd72 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/GenerateTestCasesWizard.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/GenerateTestCasesWizard.tsx @@ -159,6 +159,18 @@ interface GeneratedTestCase { sourceUrl?: string; /** True while the test case is still streaming from the LLM */ _streaming?: boolean; + /** INT-06: LLM-proposed parameter schema (present when includeParameters=true). */ + parameters?: Array<{ + name: string; + type: "STRING" | "INTEGER" | "BOOLEAN" | "SELECT"; + sensitive: boolean; + allowedValuesJson?: string[]; + }>; + /** INT-06: LLM-proposed starter dataset rows (present when includeParameters=true). */ + starterDataset?: Array<{ + label?: string; + values: Record; + }>; } /** Derive folder name from a URL — mirrors the logic in importGeneratedTestCases.ts */ @@ -460,6 +472,8 @@ interface GeneratedTestCaseCardProps { index: number; formSubmitHandlersRef: MutableRefObject void>>; folderLabel?: string; + /** INT-06: parser warnings scoped to this case index. */ + caseWarnings?: Array<{ caseIndex: number; message: string }>; } const GeneratedTestCaseCard = memo(function GeneratedTestCaseCard({ @@ -481,6 +495,7 @@ const GeneratedTestCaseCard = memo(function GeneratedTestCaseCard({ index, formSubmitHandlersRef, folderLabel, + caseWarnings, }: GeneratedTestCaseCardProps) { const cardRef = useRef(null); @@ -1178,6 +1193,111 @@ const GeneratedTestCaseCard = memo(function GeneratedTestCaseCard({ ))} + + {/* INT-06: LLM-proposed parameter chips */} + {testCase.parameters && testCase.parameters.length > 0 && ( +
+ +
+ {testCase.parameters.map((p, idx) => ( + + {p.name} + + {`(${p.type.toLowerCase()})`} + + {p.sensitive && ( + + {`· ${_t("generateTestCases.parameterChipSensitive")}`} + + )} + + ))} +
+
+ )} + + {/* INT-06: dataset_truncated / dataset_capped / invalid_parameter + warnings for this case. */} + {caseWarnings && caseWarnings.length > 0 && ( + + + {caseWarnings.some( + (w) => w.message === "dataset_truncated" + ) && _t("generateTestCases.datasetTruncatedWarning")} + {caseWarnings.some((w) => w.message === "dataset_capped") && + " " + _t("generateTestCases.datasetCappedWarning")} + {caseWarnings.some((w) => + w.message.startsWith("invalid_parameter") + ) && " " + _t("generateTestCases.invalidParameterWarning")} + + + )} + + {/* INT-06: starter dataset preview (first 5 rows + "...and N more"). */} + {testCase.starterDataset && testCase.starterDataset.length > 0 && ( +
+ +
+ + + + {testCase.parameters?.map((p) => ( + + ))} + + + + {testCase.starterDataset.slice(0, 5).map((row, rIdx) => ( + + {testCase.parameters?.map((p) => ( + + ))} + + ))} + +
+ {p.name} +
+ {String(row.values?.[p.name] ?? "")} +
+
+ {testCase.starterDataset.length > 5 && ( +

+ {_t("generateTestCases.datasetMoreRows", { + count: testCase.starterDataset.length - 5, + })} +

+ )} +
+ )} @@ -1249,6 +1369,20 @@ export function GenerateTestCasesWizard({ const [userNotes, setUserNotes] = useState(""); const [quantity, setQuantity] = useState("several"); const [autoGenerateTags, setAutoGenerateTags] = useState(true); + // INT-06: opt-in toggle (default false) — when on, the LLM emits a parameter + // schema + starter dataset per case so the result is immediately runnable + // as iterations. Hiding the toggle for non-admins is the user-experience + // layer; the authoritative gate is the API-tier admin check enforced in + // the 3 LLM routes (route.ts / stream/route.ts / expand/route.ts) — a + // crafted request body with `includeParameters: true` from a non-admin + // is rejected there with 403 / FORBIDDEN_PARAMETER_GENERATION (CR-03). + const [includeParameters, setIncludeParameters] = useState(false); + // INT-06: parser warnings keyed by caseIndex (e.g., dataset_truncated). + // Surfaced as an Alert on the preview card. + const [llmWarnings, setLlmWarnings] = useState< + Array<{ caseIndex: number; message: string }> + >([]); + const isAdmin = session?.user?.access === "ADMIN"; const [linkedIssueRefs, setLinkedIssueRefs] = useState([]); const [droppedLinkedIssues, setDroppedLinkedIssues] = useState([]); const [generatedTestCases, setGeneratedTestCases] = useState< @@ -2197,6 +2331,7 @@ export function GenerateTestCasesWizard({ }, quantity, autoGenerateTags, + includeParameters, feature: llmFeature, }), signal: abortController.signal, @@ -2321,13 +2456,19 @@ export function GenerateTestCasesWizard({ status: "Web Content", }; - const { testCases: finalPageCases } = parseAndValidateTestCases( - accumulated, - templateForParsing, - issueForParsing, - autoGenerateTags, - quantity - ); + const { testCases: finalPageCases, warnings: pageWarnings } = + parseAndValidateTestCases( + accumulated, + templateForParsing, + issueForParsing, + autoGenerateTags, + quantity + ); + // INT-06: surface parser warnings (dataset_truncated, dataset_capped, + // invalid_parameter:) on the wizard so the preview can flag them. + if (pageWarnings && pageWarnings.length > 0) { + setLlmWarnings((prev) => [...prev, ...pageWarnings]); + } if (finalPageCases.length > pageYieldedCount) { // There were cases the stream parser missed (e.g., truncated last case) @@ -2487,6 +2628,7 @@ export function GenerateTestCasesWizard({ userNotes: userNotes || undefined, quantity: quantity || undefined, autoGenerateTags: autoGenerateTags || undefined, + includeParameters: includeParameters || undefined, options: { followLinks, maxDepth, @@ -2514,6 +2656,7 @@ export function GenerateTestCasesWizard({ setGeneratedTestCases([]); setSelectedTestCases(new Set()); setDroppedLinkedIssues([]); + setLlmWarnings([]); setCaseOutlines([]); setExpandedCases([]); for (const ac of expandAbortControllersRef.current.values()) ac.abort(); @@ -2606,6 +2749,9 @@ export function GenerateTestCasesWizard({ issue: issueData, context: contextPayload, quantity, + // outline endpoint accepts-but-ignores includeParameters — kept for + // uniform wizard plumbing. + includeParameters, }), signal: abortController.signal, }); @@ -2668,6 +2814,7 @@ export function GenerateTestCasesWizard({ context: contextPayload, outline, autoGenerateTags, + includeParameters, }), signal: ac.signal, } @@ -4534,6 +4681,36 @@ export function GenerateTestCasesWizard({ + {/* INT-06: Generate parameters + starter dataset + (admin-only — defense in depth on top of the API + admin gate). */} + {isAdmin && ( +
+ + setIncludeParameters(checked === true) + } + /> +
+ +

+ {t("generateTestCases.includeParametersHelp")} +

+
+
+ )} + {/* Quick suggestions */}
); diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/FieldValueRenderer.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/FieldValueRenderer.tsx index d90d9243a..ca0015224 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/FieldValueRenderer.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/FieldValueRenderer.tsx @@ -27,6 +27,7 @@ import { StepsResults } from "./StepsResults"; import { Steps as PrismaSteps } from "@prisma/client"; import { Minus, Plus } from "lucide-react"; import { Link } from "~/lib/navigation"; +import type { ParameterChipMeta } from "~/lib/tiptap/parameterMentionExtension"; import { ensureTipTapJSON } from "~/utils/tiptapConversion"; // Re-defining DisplayStep here for clarity, assuming it's similar to StepsDisplay's internal type @@ -56,6 +57,8 @@ interface FieldValueRendererProps { onSharedStepCreated?: () => void; stepsForDisplay?: DisplayStep[]; explicitFieldNameForSteps?: string; + parameters?: ParameterChipMeta[]; + onOpenParametersSheet?: () => void; } const FieldValueRenderer: React.FC = ({ @@ -77,6 +80,8 @@ const FieldValueRenderer: React.FC = ({ onSharedStepCreated, stepsForDisplay, explicitFieldNameForSteps, + parameters, + onOpenParametersSheet, }) => { const { theme } = useTheme(); const customStyles = getCustomStyles({ theme }); @@ -582,6 +587,8 @@ const FieldValueRenderer: React.FC = ({ readOnly={isEffectivelyReadOnly} projectId={projectId!} onSharedStepCreated={onSharedStepCreated} + parameters={parameters} + onOpenParametersSheet={onOpenParametersSheet} /> ); } else if (isRunMode) { @@ -589,6 +596,7 @@ const FieldValueRenderer: React.FC = ({ ); } else { @@ -596,6 +604,7 @@ const FieldValueRenderer: React.FC = ({ ); } diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/StepsDisplay.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/StepsDisplay.tsx index af8dccf0e..ff87c7abf 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/StepsDisplay.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/StepsDisplay.tsx @@ -1,4 +1,5 @@ import TextFromJson from "@/components/TextFromJson"; +import type { ParameterChipMeta } from "~/lib/tiptap/parameterMentionExtension"; import { Layers, Minus, Plus, SearchCheck } from "lucide-react"; import { useTranslations } from "next-intl"; import React from "react"; @@ -21,16 +22,19 @@ interface DisplayStep { interface StepsProps { steps: DisplayStep[]; previousSteps?: DisplayStep[]; + parameters?: ParameterChipMeta[]; } interface RenderSharedGroupItemsProps { sharedStepGroupId: number; sharedStepGroupName: string; + parameters?: ParameterChipMeta[]; } const RenderSharedGroupItems: React.FC = ({ sharedStepGroupId, sharedStepGroupName: _sharedStepGroupName, + parameters, }) => { const t_steps = useTranslations("repository.steps"); @@ -103,7 +107,8 @@ const RenderSharedGroupItems: React.FC = ({ item.step, undefined, `shared-${sharedStepGroupId}-item-${item.id || itemIndex}-step`, - false + false, + parameters )} @@ -114,7 +119,8 @@ const RenderSharedGroupItems: React.FC = ({ item.expectedResult, undefined, `shared-${sharedStepGroupId}-item-${item.id || itemIndex}-expected`, - false + false, + parameters )} @@ -129,7 +135,8 @@ const renderFieldValue = ( fieldValue: any, previousFieldValue: any | undefined, key: string, - showDiff: boolean + showDiff: boolean, + parameters?: ParameterChipMeta[] ) => { // Ensure we have a valid JSON string for the TipTapEditor const ensureValidJsonString = (value: any): string => { @@ -199,6 +206,7 @@ const renderFieldValue = ( jsonString={fieldValueString} room={key} format="html" + parameters={parameters} /> ); @@ -217,6 +225,7 @@ const renderFieldValue = ( jsonString={fieldValueString} room={key} format="html" + parameters={parameters} /> @@ -234,7 +243,7 @@ const renderFieldValue = (
- +
@@ -243,6 +252,7 @@ const renderFieldValue = ( jsonString={previousFieldValueString} room={"prev" + key} format="html" + parameters={parameters} />
@@ -257,6 +267,7 @@ const renderFieldValue = ( jsonString={fieldValueString} room={key} format="html" + parameters={parameters} />
@@ -270,6 +281,7 @@ const renderFieldValue = ( jsonString={fieldValueString} room={key} format="html" + parameters={parameters} />
); @@ -279,6 +291,7 @@ const renderFieldValue = ( export const StepsDisplay: React.FC = ({ steps, previousSteps, + parameters, }) => { const t_repo_steps = useTranslations("repository.steps"); const tGlobal = useTranslations(); @@ -347,6 +360,7 @@ export const StepsDisplay: React.FC = ({ step.sharedStepGroupName || "Shared Steps" } + parameters={parameters} /> @@ -377,7 +391,8 @@ export const StepsDisplay: React.FC = ({ step.step || "", previousStep ? previousStep.step || "" : undefined, step.id.toString(), - showDiff + showDiff, + parameters )} @@ -393,7 +408,8 @@ export const StepsDisplay: React.FC = ({ ? previousStep.expectedResult || "" : undefined, step.id.toString() + "-expected", - showDiff + showDiff, + parameters )} @@ -465,6 +481,7 @@ export const StepsDisplay: React.FC = ({ jsonString={ensureValidJsonString(step.step)} room={"prev" + step.id.toString()} format="html" + parameters={parameters} /> @@ -484,6 +501,7 @@ export const StepsDisplay: React.FC = ({ )} room={"prev" + step.id.toString() + "-expected"} format="html" + parameters={parameters} /> diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/StepsResults.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/StepsResults.tsx index c893364e7..3f9b64a1e 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/StepsResults.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/StepsResults.tsx @@ -6,6 +6,7 @@ import React from "react"; import { emptyEditorContent } from "~/app/constants"; import { Separator } from "~/components/ui/separator"; import { useFindManySharedStepItem } from "~/lib/hooks"; +import type { ParameterChipMeta } from "~/lib/tiptap/parameterMentionExtension"; interface DisplayStep extends PrismaSteps { isShared?: boolean; @@ -16,16 +17,18 @@ interface DisplayStep extends PrismaSteps { interface StepsResultsProps { steps: DisplayStep[]; projectId?: number; + parameters?: ParameterChipMeta[]; } interface RenderSharedGroupItemsForResultsProps { sharedStepGroupId: number; projectId?: number; + parameters?: ParameterChipMeta[]; } const RenderSharedGroupItemsForResults: React.FC< RenderSharedGroupItemsForResultsProps -> = ({ sharedStepGroupId, projectId }) => { +> = ({ sharedStepGroupId, projectId, parameters }) => { const t = useTranslations("repository.steps"); const { data: items, isLoading } = useFindManySharedStepItem( { @@ -92,6 +95,7 @@ const RenderSharedGroupItemsForResults: React.FC< readOnly={true} projectId={projectId?.toString()} className="bg-muted/30 p-1 rounded" + parameters={parameters} /> @@ -108,6 +112,7 @@ const RenderSharedGroupItemsForResults: React.FC< readOnly={true} projectId={projectId?.toString()} className="bg-muted/30 p-1 rounded" + parameters={parameters} /> @@ -121,6 +126,7 @@ const RenderSharedGroupItemsForResults: React.FC< export const StepsResults: React.FC = ({ steps, projectId, + parameters, }) => { const t_repo_steps = useTranslations("repository.steps"); @@ -162,6 +168,7 @@ export const StepsResults: React.FC = ({ @@ -205,6 +212,7 @@ export const StepsResults: React.FC = ({ readOnly={true} projectId={`step_result_${step.id}`} className="prose-sm" + parameters={parameters} /> @@ -216,6 +224,7 @@ export const StepsResults: React.FC = ({ readOnly={true} projectId={`step_result_${step.id}_expected`} className="prose-sm" + parameters={parameters} /> diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/[version]/page.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/[version]/page.tsx index bae228c1b..8fb4fc14d 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/[version]/page.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/[caseId]/[version]/page.tsx @@ -49,7 +49,7 @@ import { ChevronLeft, LinkIcon, Minus, Plus } from "lucide-react"; import { useSession } from "next-auth/react"; import { useLocale, useTranslations } from "next-intl"; import { useParams } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { emptyEditorContent } from "~/app/constants"; import { useFindFirstRepositoryCaseVersions, @@ -57,7 +57,9 @@ import { useFindManyIssue, useFindManyRepositoryCaseVersions, useFindManyTemplates, + useFindManyTestCaseParameter, } from "~/lib/hooks"; +import type { ParameterChipMeta } from "~/lib/tiptap/parameterMentionExtension"; import { Link, useRouter } from "~/lib/navigation"; import { IconName } from "~/types/globals"; import { determineIssueDifferences } from "~/utils/determineIssueDifferences"; @@ -157,6 +159,41 @@ export default function TestCaseVersions() { }, }); + // Parameter chip metadata for the version's snapshotted Tiptap step content. + // Without this, `createParameterMentionExtension` is omitted from the TipTap + // editor's extensions list (`TipTapEditor.tsx:320-322`), so any `{{paramName}}` + // mention nodes in the snapshot's `step.step` JSON render incorrectly. We + // fetch the case's live parameters because `RepositoryCaseVersions.parameters` + // is `null` for every existing version row — the create-version route doesn't + // yet snapshot params alongside steps. This is slightly lossy if parameters + // were renamed/deleted after the version, but it matches the case-detail + // page's rendering and keeps mentions from showing as raw text. + const numericCaseId = Number(caseId); + const { data: liveCaseParameters } = useFindManyTestCaseParameter( + { + where: { testCaseId: numericCaseId, isDeleted: false }, + orderBy: { order: "asc" }, + }, + { enabled: !Number.isNaN(numericCaseId) && numericCaseId > 0 } + ); + + const parameterChipMeta = useMemo( + () => + (liveCaseParameters ?? []).map((p: any) => ({ + id: p.id, + name: p.name, + type: p.type as "STRING" | "INTEGER" | "BOOLEAN" | "SELECT", + defaultValue: + p.defaultValue === null || p.defaultValue === undefined + ? null + : typeof p.defaultValue === "string" + ? p.defaultValue + : JSON.stringify(p.defaultValue), + sensitive: Boolean(p.sensitive), + })), + [liveCaseParameters] + ); + const testcase = data ? { ...(data as CaseVersionExtended), @@ -851,9 +888,13 @@ export default function TestCaseVersions() { ) : ( - + )} (false); const [isEditMode, setIsEditMode] = useState(false); const [isDeleteCaseOpen, setIsDeleteCaseOpen] = useState(false); + const [isParamSheetOpen, setIsParamSheetOpen] = useState(false); + + const numericCaseId = Number(caseId); + const isValidCaseId = !isNaN(numericCaseId); + const { data: caseParameters = [] } = useFindManyTestCaseParameter( + { + where: { testCaseId: numericCaseId, isDeleted: false }, + orderBy: { order: "asc" }, + }, + { enabled: isValidCaseId } + ); + const parameterCount = caseParameters.length; + const parameterChipMeta = useMemo( + () => + caseParameters.map((p: any) => ({ + id: p.id, + name: p.name, + type: p.type as "STRING" | "INTEGER" | "BOOLEAN" | "SELECT", + defaultValue: + p.defaultValue === null || p.defaultValue === undefined + ? null + : typeof p.defaultValue === "string" + ? p.defaultValue + : JSON.stringify(p.defaultValue), + })), + [caseParameters] + ); const [, setFolderHierarchy] = useState([]); const [breadcrumbItems, setBreadcrumbItems] = useState([]); @@ -935,6 +972,21 @@ export default function TestCaseDetails() { setIsEditMode(!isEditMode); }; + const editParamProcessed = useRef(false); + useEffect(() => { + if ( + !editParamProcessed.current && + searchParams.get("edit") === "true" && + canAddEdit && + testcase?.template?.id && + !isEditMode + ) { + editParamProcessed.current = true; + setSelectedTemplateId(testcase.template.id); + setIsEditMode(true); + } + }, [searchParams, canAddEdit, testcase, isEditMode]); + const handleCancel = () => { setIsEditMode(false); setSelectedTemplateId(testcase.template.id ?? null); @@ -2030,6 +2082,22 @@ export default function TestCaseDetails() { onExpand={() => setIsCollapsedLeft(false)} >
+ {/* Configure Parameters entry point at the top of the + left panel. The placement is unconditional (in both + read and edit modes) so the button stays reachable + regardless of whether the Steps caseField is + filtered out by the read-mode empty-value check + below — a fresh case with no steps yet still needs + a way to declare parameters before adding them. + `ConfigureParametersButton` itself returns null if + the viewer lacks `canAddEdit`. */} +
+ setIsParamSheetOpen(true)} + /> +
    {(testcase?.template?.caseFields || []).map( (field, fieldIndex) => { @@ -2098,6 +2166,10 @@ export default function TestCaseDetails() { ? "steps" : undefined } + parameters={parameterChipMeta} + onOpenParametersSheet={() => + setIsParamSheetOpen(true) + } {...(field.caseField.type.type === "Steps" && { onSharedStepCreated: refetch, })} @@ -2132,6 +2204,7 @@ export default function TestCaseDetails() { ...s, sharedStepGroupName: s.sharedStepGroup?.name, }))} + parameters={parameterChipMeta} /> + setIsParamSheetOpen(true) + } /> )} + {isValidProjectId && isValidCaseId && ( + setIsParamSheetOpen(false)} + caseId={numericCaseId} + projectId={numericProjectId} + /> + )} ); } diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/columns.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/columns.tsx index 5eb454508..cb482a665 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/columns.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/columns.tsx @@ -86,6 +86,8 @@ import { Plus, PlusSquare, ScrollText, + SquarePen, + SquareStack, Trash2, UserCog, } from "lucide-react"; @@ -170,6 +172,12 @@ export interface ExtendedCases extends RepositoryCases { }; } | null; testRunStatusId?: number | null; + /** + * Phase 3 — when > 0, the case is parameterized in this run. The status + * cell becomes read-only (sheet-opener) since case-level status is + * derived from iteration rollup, not user input. + */ + totalIterations?: number; assignedToId?: string | null; assignedTo?: { id: string; @@ -468,6 +476,7 @@ const TestRunStatusCell = React.memo(function TestRunStatusCell({ steps, isSoftDeletedInRun, onOpenAddResultModal, + totalIterations, }: { status: ExtendedCases["testRunStatus"]; caseId: number; @@ -495,7 +504,15 @@ const TestRunStatusCell = React.memo(function TestRunStatusCell({ steps?: any[]; configuration?: { id: number; name: string } | null; }) => void; + totalIterations?: number; }) { + // For parameterized cases, the status is derived from the iteration + // rollup (no per-case-level result writes). Render a click-to-open-sheet + // button instead of the status-picker dropdown. + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const isParameterized = (totalIterations ?? 0) > 0; const [showAssignModal, setShowAssignModal] = useState(false); const [isBulkAssign, setIsBulkAssign] = useState(false); const [isInitialRender, setIsInitialRender] = useState(true); @@ -550,6 +567,13 @@ const TestRunStatusCell = React.memo(function TestRunStatusCell({ const displayStatus = status || defaultStatus; if (!displayStatus) return null; + const handleOpenParameterizedSheet = () => { + if (isSoftDeletedInRun) return; + const params = new URLSearchParams(searchParams.toString()); + params.set("selectedCase", caseId.toString()); + router.replace(`${pathname}?${params.toString()}`); + }; + // Combine isCompleted with isSoftDeletedInRun for disabling logic const isDisabled = isCompleted || isSoftDeletedInRun; @@ -664,47 +688,68 @@ const TestRunStatusCell = React.memo(function TestRunStatusCell({ return ( <>
    - - - - - - {statuses?.map((statusOption) => ( - handleStatusChange(statusOption.id.toString())} - className={`flex items-center cursor-pointer ${ - statusOption.id === displayStatus.id ? "bg-muted" : "" - }`} + {isParameterized ? ( + + ) : ( + + + + + + {statuses?.map((statusOption) => ( + handleStatusChange(statusOption.id.toString())} + className={`flex items-center cursor-pointer ${ + statusOption.id === displayStatus.id ? "bg-muted" : "" + }`} + > + + {statusOption.id === displayStatus.id && ( + + )} + + ))} + + + )} @@ -736,19 +781,21 @@ const TestRunStatusCell = React.memo(function TestRunStatusCell({ })} - - - - {t("common.actions.addResultSelected", { - count: selectedCount, - })} - - + {!isParameterized && ( + + + + {t("common.actions.addResultSelected", { + count: selectedCount, + })} + + + )} ) : ( <> @@ -761,15 +808,17 @@ const TestRunStatusCell = React.memo(function TestRunStatusCell({ {t("common.actions.assign")} - - - {t("common.actions.addResult")} - + {!isParameterized && ( + + + {t("common.actions.addResult")} + + )} )} + {!isRunMode && !isSelectionMode && canAddEdit && ( + + + + {t("common.actions.edit")} + + + )} {!isRunMode && !isSelectionMode && quickScriptEnabled && @@ -2209,6 +2268,7 @@ export const getColumns = ( }) : undefined } + totalIterations={row.original.totalIterations} /> ); }, diff --git a/testplanit/app/[locale]/projects/runs/[projectId]/AddTestRunModal.tsx b/testplanit/app/[locale]/projects/runs/[projectId]/AddTestRunModal.tsx index 976c43f2e..eef3fabb0 100644 --- a/testplanit/app/[locale]/projects/runs/[projectId]/AddTestRunModal.tsx +++ b/testplanit/app/[locale]/projects/runs/[projectId]/AddTestRunModal.tsx @@ -46,17 +46,23 @@ import { useTranslations } from "next-intl"; import { useParams } from "next/navigation"; import * as React from "react"; import { useEffect, useMemo, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { toast } from "sonner"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod/v4"; +import { useQueryClient } from "@tanstack/react-query"; import { getAssignmentsForRunCases, type GetAssignmentsResponse, } from "~/app/actions/getAssignmentsForRunCases"; import { emptyEditorContent } from "~/app/constants"; +import { iterationProgressBus } from "~/lib/services/iterationProgressBus"; import LoadingSpinner from "~/components/LoadingSpinner"; import LoadingSpinnerAlert from "~/components/LoadingSpinnerAlert"; +import { RunPreflightChip } from "@/components/runs/RunPreflightChip"; +import { RunCardinalityHardRefuseDialog } from "@/components/runs/RunCardinalityHardRefuseDialog"; +import { RunCardinalitySoftConfirmDialog } from "@/components/runs/RunCardinalitySoftConfirmDialog"; +import type { PreflightResult } from "~/lib/types/iterationCardinality"; import { useProjectPermissions } from "~/hooks/useProjectPermissions"; import { useCreateAttachments, @@ -213,7 +219,7 @@ const BasicInfoDialog = React.memo( }; return ( - + <> {t("title")} @@ -547,7 +553,7 @@ const BasicInfoDialog = React.memo( canEdit={false} /> )} - + ); } ); @@ -575,8 +581,18 @@ const TestCasesDialog = React.memo( form, projectId, linkedIssueIds, + numericProjectId, + onPreflightResult, + onPreflightChipClick, + preflightClassification, }: any) => { const tRepository = useTranslations("repository"); + // Live config selection from the parent form — drives the preflight chip + // alongside the modal's selectedTestCases. Using `useWatch` keeps the + // chip in sync without re-rendering the entire TestCasesDialog tree. + const watchedConfigIds: number[] = + (useWatch({ control: form.control, name: "configIds" }) as number[]) ?? + []; // Local pagination state for the modal (independent from parent page) const [modalCurrentPage, setModalCurrentPage] = useState(1); const [modalPageSize, setModalPageSize] = useState(10); @@ -701,7 +717,7 @@ const TestCasesDialog = React.memo( }, [selectedTestCases, form, open]); return ( - + <> {tRepository("cases.selectCases")} @@ -771,7 +787,7 @@ const TestCasesDialog = React.memo(
    -
    +
    -
    +
    + {selectedTestCases.length > 0 && numericProjectId ? ( +
    + +
    + ) : null}
    - + ); } ); @@ -846,13 +877,25 @@ export default function AddTestRunModal({ initialSelectedCaseIds || [] ); const [selectedFiles, setSelectedFiles] = useState([]); + // Latest cardinality preflight result reported by the chip in Step 1. + // Drives the soft-confirm gate + hard-refuse breakdown dialog. + const [preflightResult, setPreflightResult] = useState< + PreflightResult | undefined + >(undefined); + const [hardRefuseDialogOpen, setHardRefuseDialogOpen] = useState(false); + const [softConfirmDialogOpen, setSoftConfirmDialogOpen] = useState(false); + // Set to true once the user accepts the soft-confirm dialog, so the next + // call to onSubmit bypasses the gate and proceeds to create the run. + const softConfirmAcceptedRef = useRef(false); const { data: session } = useSession(); const { projectId } = useParams(); const numericProjectId = Number(projectId); const t = useTranslations("runs.add"); const tCommon = useTranslations("common"); + const tParameters = useTranslations("parameters"); const tGlobal = useTranslations(); + const queryClient = useQueryClient(); const [isSubmitting, setIsSubmitting] = useState(false); const [creationProgress, setCreationProgress] = useState({ current: 0, @@ -1155,6 +1198,24 @@ export default function AddTestRunModal({ const handleNext = () => { if (step === 1) { setValue("testCases", selectedCaseIds); // Ensure selectedCaseIds from state is used + + // Cardinality gate (Surface E.1/E.3): block hardRefuse, soft-confirm in + // the warning band. Server-side enforcement still applies — this just + // saves a round-trip and surfaces the friction sooner. + if (preflightResult && !softConfirmAcceptedRef.current) { + if (preflightResult.classification === "hardRefuse") { + setHardRefuseDialogOpen(true); + return; + } + if (preflightResult.classification === "softConfirm") { + setSoftConfirmDialogOpen(true); + return; + } + } + // Reset the soft-confirm latch after one use so subsequent submissions + // re-evaluate the (possibly-changed) cardinality band. + softConfirmAcceptedRef.current = false; + void handleSubmit(onSubmit, (errors) => { console.error("Form validation errors:", errors); })(); @@ -1288,6 +1349,70 @@ export default function AddTestRunModal({ } await updateTestRunForecast(newTestRun.id); + + // Fan out iteration rows for any parameterized cases. Three + // possible response shapes from /generate-iterations: + // - 422 hardRefuse → toast.error with cap details + // - 200 async:true → register on iterationProgressBus (Wave 3 + // toast/sidebar consumes the bus) + // - 200 async:false → invalidate ZenStack caches so iteration + // counts surface immediately in the UI + // Failures are non-fatal for the run itself — the run still + // exists; only iteration generation failed. Surface the error + // but do NOT throw (other configs in the loop should still get + // their fan-out attempt). + try { + const fanOutRes = await fetch( + `/api/test-runs/${newTestRun.id}/generate-iterations`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + } + ); + const fanOutBody = await fanOutRes.json().catch(() => ({})); + + if (fanOutRes.status === 422 && fanOutBody?.refused) { + toast.error(tParameters("runHardRefuseTitle"), { + description: tParameters("runHardRefuseDescription", { + count: fanOutBody.iterationCount ?? 0, + cap: fanOutBody.cap ?? 0, + }), + }); + } else if (!fanOutRes.ok) { + toast.error(tParameters("runProgressFailed"), { + description: fanOutBody?.error ?? `HTTP ${fanOutRes.status}`, + }); + } else if (fanOutBody?.async === true) { + iterationProgressBus.start({ + jobId: String(fanOutBody.jobId), + runId: newTestRun.id, + runName: createData.name, + total: fanOutBody.iterationCount ?? 0, + }); + } else { + // Sync path — invalidate ZenStack caches so iteration counts + // appear in the UI without a manual refresh. Use the locked + // ["zenstack", "ModelName"] prefix per Phase 2 carry-forward. + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["zenstack", "TestRunCases"], + }), + queryClient.invalidateQueries({ + queryKey: ["zenstack", "TestRunCaseIteration"], + }), + ]); + } + } catch (fanOutErr) { + // Network or JSON-parse failure — surface, don't throw. + console.error("[generate-iterations]", fanOutErr); + toast.error(tParameters("runProgressFailed"), { + description: + fanOutErr instanceof Error + ? fanOutErr.message + : String(fanOutErr), + }); + } } } @@ -1358,6 +1483,14 @@ export default function AddTestRunModal({ form: form, projectId: projectId?.toString() || "", linkedIssueIds: linkedIssueIds, + numericProjectId: numericProjectId, + onPreflightResult: setPreflightResult, + onPreflightChipClick: (r: PreflightResult) => { + if (r.classification === "hardRefuse") { + setHardRefuseDialogOpen(true); + } + }, + preflightClassification: preflightResult?.classification, } : {}; @@ -1372,11 +1505,39 @@ export default function AddTestRunModal({ return ; } + const dialogContentClassName = + step === 1 + ? "max-w-[1200px] h-[90vh] flex flex-col p-0" + : "sm:max-w-[600px] lg:max-w-[1000px]"; + return ( - - {DialogContentComponent && open && ( - - )} - + <> + + {open && ( + + {DialogContentComponent && ( + + )} + + )} + + + { + softConfirmAcceptedRef.current = true; + // Re-trigger handleNext now that the gate has been accepted. + handleNext(); + }} + /> + ); } diff --git a/testplanit/app/[locale]/projects/runs/[projectId]/TestRunDisplay.tsx b/testplanit/app/[locale]/projects/runs/[projectId]/TestRunDisplay.tsx index 8ab6070a5..9fc758179 100644 --- a/testplanit/app/[locale]/projects/runs/[projectId]/TestRunDisplay.tsx +++ b/testplanit/app/[locale]/projects/runs/[projectId]/TestRunDisplay.tsx @@ -541,6 +541,7 @@ const TestRunDisplay: React.FC = ({ createdBy: testRun.createdBy, forecastManual: testRun.forecastManual, forecastAutomated: testRun.forecastAutomated, + createdAt: testRun.createdAt, }} milestonePath={testRun.milestone?.name} onDuplicate={onDuplicateTestRun} @@ -733,6 +734,7 @@ const TestRunDisplay: React.FC = ({ createdBy: testRun.createdBy, forecastManual: testRun.forecastManual, forecastAutomated: testRun.forecastAutomated, + createdAt: testRun.createdAt, }} onComplete={handleOpenDialogParam} isAdmin={isAdminParam} @@ -845,6 +847,7 @@ const TestRunDisplay: React.FC = ({ createdBy: testRun.createdBy, forecastManual: testRun.forecastManual, forecastAutomated: testRun.forecastAutomated, + createdAt: testRun.createdAt, }} onComplete={handleOpenDialogParam} isAdmin={isAdminParam} diff --git a/testplanit/app/[locale]/projects/runs/[projectId]/TestRunItem.tsx b/testplanit/app/[locale]/projects/runs/[projectId]/TestRunItem.tsx index bb001b8a2..210e4dd44 100644 --- a/testplanit/app/[locale]/projects/runs/[projectId]/TestRunItem.tsx +++ b/testplanit/app/[locale]/projects/runs/[projectId]/TestRunItem.tsx @@ -25,6 +25,7 @@ import { CheckCircle, Combine, Copy, + Flame, LinkIcon, MoreVertical, Pencil, @@ -49,6 +50,7 @@ export interface TestRunItemProps { testRunType: string; configuration: Configurations | null; configurationGroupId: string | null; + createdAt?: Date | string; state: { id: number; name: string; @@ -127,6 +129,10 @@ const TestRunItem: React.FC = ({ const showMoreMenu = showEditItem || showCompleteItem || showDuplicateItem; + const isRecentlyCreated = + !!testRun.createdAt && + Date.now() - new Date(testRun.createdAt).getTime() < 5 * 60 * 1000; + // Fetch test run cases with their results and assigned users const { data: testRunCases } = useFindManyTestRunCases({ where: { @@ -246,6 +252,14 @@ const TestRunItem: React.FC = ({ className="group inline-flex items-center gap-1 max-w-full" >

    + {isRecentlyCreated && ( + + + + + {tCommon("labels.new")} + + )} {isAutomatedRun ? ( ) : ( diff --git a/testplanit/app/[locale]/projects/runs/[projectId]/[runId]/page.tsx b/testplanit/app/[locale]/projects/runs/[projectId]/[runId]/page.tsx index 44bbfc6c9..717b58e0c 100644 --- a/testplanit/app/[locale]/projects/runs/[projectId]/[runId]/page.tsx +++ b/testplanit/app/[locale]/projects/runs/[projectId]/[runId]/page.tsx @@ -9,6 +9,7 @@ import { transformMilestones } from "@/components/forms/MilestoneSelect"; import { Loading } from "@/components/Loading"; import LoadingSpinnerAlert from "@/components/LoadingSpinnerAlert"; import { TestRunCaseDetails } from "@/components/TestRunCaseDetails"; +import { IterationAwareTestRunCaseDetails } from "~/components/iterations/IterationAwareTestRunCaseDetails"; import TipTapEditor from "@/components/tiptap/TipTapEditor"; import { AlertDialog, @@ -428,6 +429,9 @@ export default function TestRunPage() { select: { id: true, order: true, + totalIterations: true, + passedIterations: true, + failedIterations: true, status: { select: { id: true, @@ -2037,38 +2041,58 @@ export default function TestRunPage() { {/* Using key to force remount on case change */} - {selectedTestCaseId && testRunData && ( - tc.repositoryCase.id === selectedTestCaseId - )?.id - } - currentStatus={ - testRunData.testCases.find( - (tc) => tc.repositoryCase.id === selectedTestCaseId - )?.status + {selectedTestCaseId && + testRunData && + (() => { + const trc = testRunData.testCases.find( + (tc) => tc.repositoryCase.id === selectedTestCaseId + ); + if (!trc) return null; + const innerProps = { + caseId: selectedTestCaseId, + projectId: Number(projectId), + testRunId: Number(runId), + testRunCaseId: trc.id, + currentStatus: trc.status, + onClose: () => handleSheetOpenChange(false), + onNextCase: (nextCaseId: number) => { + setIsTransitioning(true); + const params = new URLSearchParams(searchParams.toString()); + params.set("selectedCase", nextCaseId.toString()); + router.replace(`${pathname}?${params.toString()}`); + }, + isTransitioning, + testRunCasesData: testRunData.testCases.map((tc) => ({ + id: tc.id, + order: tc.order, + repositoryCaseId: tc.repositoryCase.id, + })), + isCompleted: testRunData.isCompleted, + }; + + const totalIterations = + (trc as { totalIterations?: number }).totalIterations ?? 0; + + if (totalIterations === 0) { + return ( + + ); } - onClose={() => handleSheetOpenChange(false)} // Use the handler to close sheet - onNextCase={(nextCaseId) => { - setIsTransitioning(true); - const params = new URLSearchParams(searchParams.toString()); - params.set("selectedCase", nextCaseId.toString()); - router.replace(`${pathname}?${params.toString()}`); - }} - isTransitioning={isTransitioning} - testRunCasesData={testRunData.testCases.map((tc) => ({ - id: tc.id, - order: tc.order, - repositoryCaseId: tc.repositoryCase.id, - }))} - isCompleted={testRunData.isCompleted} - /> - )} + + return ( + + ); + })()} {/* Dialog: Show if canAddEditRun and not JUNIT (regardless of completion status) */} diff --git a/testplanit/app/[locale]/projects/sessions/[projectId]/SessionItem.tsx b/testplanit/app/[locale]/projects/sessions/[projectId]/SessionItem.tsx index 503b6e83e..6b0d05e88 100644 --- a/testplanit/app/[locale]/projects/sessions/[projectId]/SessionItem.tsx +++ b/testplanit/app/[locale]/projects/sessions/[projectId]/SessionItem.tsx @@ -22,6 +22,7 @@ import { CheckCircle, Combine, Copy, + Flame, LinkIcon, MoreVertical, Pencil, @@ -76,6 +77,10 @@ const SessionItem: React.FC = ({ const showDuplicateItem = canDuplicate ?? canEditSession; const showMoreMenu = showEditItem || showCompleteItem || showDuplicateItem; + const isRecentlyCreated = + !!testSession.createdAt && + Date.now() - new Date(testSession.createdAt).getTime() < 5 * 60 * 1000; + // Transform state data to match WorkflowStateDisplay expectations const workflowState = { state: { @@ -136,6 +141,14 @@ const SessionItem: React.FC = ({ className="group inline-flex items-center gap-1 max-w-full" >

    + {isRecentlyCreated && ( + + + + + {t("common.labels.new")} + + )} {testSession.name} diff --git a/testplanit/app/[locale]/projects/settings/[projectId]/datasets/[dataSetId]/page.tsx b/testplanit/app/[locale]/projects/settings/[projectId]/datasets/[dataSetId]/page.tsx new file mode 100644 index 000000000..36780aa6b --- /dev/null +++ b/testplanit/app/[locale]/projects/settings/[projectId]/datasets/[dataSetId]/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { SharedDatasetEditor } from "../shared-dataset-editor"; + +export default function ProjectSharedDatasetEditorPage() { + const params = useParams(); + const projectId = parseInt(params.projectId as string); + const dataSetId = parseInt(params.dataSetId as string); + + if ( + !Number.isFinite(projectId) || + !Number.isFinite(dataSetId) || + isNaN(projectId) || + isNaN(dataSetId) + ) { + return null; + } + + return ; +} diff --git a/testplanit/app/[locale]/projects/settings/[projectId]/datasets/dataset-create-dialog.tsx b/testplanit/app/[locale]/projects/settings/[projectId]/datasets/dataset-create-dialog.tsx new file mode 100644 index 000000000..361efb349 --- /dev/null +++ b/testplanit/app/[locale]/projects/settings/[projectId]/datasets/dataset-create-dialog.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod/v4"; +import { useRouter } from "~/lib/navigation"; + +interface DatasetCreateDialogProps { + projectId: number; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DatasetCreateDialog({ + projectId, + open, + onOpenChange, +}: DatasetCreateDialogProps) { + const t = useTranslations("projects.settings.datasets"); + const tCreate = useTranslations("projects.settings.datasets.create"); + const queryClient = useQueryClient(); + const router = useRouter(); + const [submitting, setSubmitting] = useState(false); + + const formSchema = z.object({ + name: z + .string() + .min(1, tCreate("validationNameRequired")) + .max(120, tCreate("validationNameTooLong")), + description: z + .string() + .max(2000, tCreate("validationDescriptionTooLong")) + .optional(), + }); + + type FormData = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema) as never, + defaultValues: { name: "", description: "" }, + }); + + useEffect(() => { + if (open) { + form.reset({ name: "", description: "" }); + } + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + const onSubmit = async (values: FormData) => { + setSubmitting(true); + try { + const res = await fetch(`/api/projects/${projectId}/datasets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: values.name, + description: values.description || undefined, + }), + }); + if (!res.ok) { + toast.error(tCreate("error")); + setSubmitting(false); + return; + } + const json = (await res.json()) as { + dataSet: { id: number; name: string }; + }; + void queryClient.invalidateQueries({ queryKey: ["zenstack", "DataSet"] }); + toast.success(t("createSuccess", { name: json.dataSet.name })); + onOpenChange(false); + router.push( + `/projects/settings/${projectId}/datasets/${json.dataSet.id}` + ); + } catch { + toast.error(tCreate("error")); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {tCreate("title")} + {tCreate("description")} + +
    + + ( + + {tCreate("nameLabel")} + + + + + + )} + /> + ( + + {tCreate("descriptionLabel")} + +