From 4ec89d076dc67f21d8deaac37fa6275364288b1a Mon Sep 17 00:00:00 2001
From: sallsup
Date: Mon, 8 Jun 2026 16:43:10 +0000
Subject: [PATCH] feat: allow test cases in multiple suites
---
.../add_test_case_suite_memberships.py | 94 ++++++
backend/app/crud.py | 244 ++++++++++++++--
backend/app/models.py | 24 ++
backend/app/routes/test_management.py | 127 +++++++--
backend/app/schemas.py | 29 +-
frontend/src/lib/api.ts | 11 +
frontend/src/locales/ar.ts | 6 +
frontend/src/locales/en.ts | 6 +
frontend/src/locales/fa.ts | 6 +
frontend/src/pages/TestCaseDetail.tsx | 74 +++++
frontend/src/pages/TestCaseEdit.tsx | 269 ++++++++++++++++--
frontend/src/pages/TestCases.tsx | 16 +-
frontend/src/pages/TestRuns.tsx | 9 +-
frontend/src/types/index.ts | 19 ++
frontend/src/utils/testCaseSuites.ts | 24 ++
15 files changed, 877 insertions(+), 81 deletions(-)
create mode 100644 backend/alembic/versions/add_test_case_suite_memberships.py
create mode 100644 frontend/src/utils/testCaseSuites.ts
diff --git a/backend/alembic/versions/add_test_case_suite_memberships.py b/backend/alembic/versions/add_test_case_suite_memberships.py
new file mode 100644
index 0000000..88411ca
--- /dev/null
+++ b/backend/alembic/versions/add_test_case_suite_memberships.py
@@ -0,0 +1,94 @@
+"""Add test case suite memberships
+
+Revision ID: add_test_case_suite_memberships
+Revises: add_composite_indexes
+Create Date: 2026-06-05 00:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+from app.services.migration_helpers import index_exists, table_exists
+
+
+revision = "add_test_case_suite_memberships"
+down_revision = "add_composite_indexes"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ connection = op.get_bind()
+
+ if not table_exists(connection, "test_case_suite_memberships"):
+ op.create_table(
+ "test_case_suite_memberships",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("test_case_id", sa.Integer(), nullable=False),
+ sa.Column("test_suite_id", sa.Integer(), nullable=False),
+ sa.Column("section_id", sa.Integer(), nullable=True),
+ sa.Column("order_index", sa.Integer(), nullable=True),
+ sa.Column("is_primary", sa.Boolean(), server_default=sa.false(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(["section_id"], ["test_case_sections.id"]),
+ sa.ForeignKeyConstraint(["test_case_id"], ["test_cases.id"]),
+ sa.ForeignKeyConstraint(["test_suite_id"], ["test_suites.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint(
+ "test_case_id",
+ "test_suite_id",
+ name="uq_test_case_suite_memberships_case_suite",
+ ),
+ )
+
+ for index_name, columns, unique in (
+ (op.f("ix_test_case_suite_memberships_id"), ["id"], False),
+ (op.f("ix_test_case_suite_memberships_test_case_id"), ["test_case_id"], False),
+ (op.f("ix_test_case_suite_memberships_test_suite_id"), ["test_suite_id"], False),
+ (op.f("ix_test_case_suite_memberships_section_id"), ["section_id"], False),
+ ):
+ if not index_exists(connection, "test_case_suite_memberships", index_name):
+ op.create_index(index_name, "test_case_suite_memberships", columns, unique=unique)
+
+ op.execute(
+ """
+ INSERT INTO test_case_suite_memberships (
+ test_case_id,
+ test_suite_id,
+ section_id,
+ order_index,
+ is_primary
+ )
+ SELECT
+ tc.id,
+ tc.test_suite_id,
+ tc.section_id,
+ COALESCE(tc.order_index, 0),
+ 1
+ FROM test_cases tc
+ WHERE tc.test_suite_id IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM test_case_suite_memberships existing
+ WHERE existing.test_case_id = tc.id
+ AND existing.test_suite_id = tc.test_suite_id
+ )
+ """
+ )
+
+
+def downgrade() -> None:
+ connection = op.get_bind()
+ if not table_exists(connection, "test_case_suite_memberships"):
+ return
+
+ for index_name in (
+ op.f("ix_test_case_suite_memberships_section_id"),
+ op.f("ix_test_case_suite_memberships_test_suite_id"),
+ op.f("ix_test_case_suite_memberships_test_case_id"),
+ op.f("ix_test_case_suite_memberships_id"),
+ ):
+ if index_exists(connection, "test_case_suite_memberships", index_name):
+ op.drop_index(index_name, table_name="test_case_suite_memberships")
+ op.drop_table("test_case_suite_memberships")
diff --git a/backend/app/crud.py b/backend/app/crud.py
index d2fa2dd..ed9731f 100644
--- a/backend/app/crud.py
+++ b/backend/app/crud.py
@@ -18,7 +18,7 @@
mark_invitation_as_used,
update_onboarding_task,
)
-from .models import Project, TestSuite, TestCase, TestCaseStep, TestRun, TestResult, User, Role, CustomFieldDefinition, CustomFieldValue, CustomFieldType, JiraIntegration, JiraIssue, Requirement, Defect, TestPlan, Milestone, TraceabilityMatrix, CoverageReport, Notification, TestCaseSection, SharedStep, GlobalParameter, TestDataset, TestMindmap, ImpactAnalysis, ExecutionEnvironment, ExecutionLog, TestSchedule, ExecutionEngine, TestRunEnvironment, DefectComment, DefectAttachment, DefectHistory, DefectWorkflow, DefectTemplate, TestResultDefectLink, DefectLinkType, DefectStatus, IssueTrackerIntegration, SyncLog, KPIData, TestStepResult, ShareableReport, RootCauseAnalysis, DashboardWidget, TestCaseRevision, RequirementStatus, Priority, EntityType, TestTypeDefinition, PriorityDefinition, SharedStepTemplate, TestExecutionSettings, NotificationSettings, AutomationSettings, SystemSettings, requirement_test_case_links, requirement_test_plan_links, RequirementVersion, RequirementChatConversation, RequirementChatMessage, RequirementFolder
+from .models import Project, TestSuite, TestCase, TestCaseStep, TestRun, TestResult, User, Role, CustomFieldDefinition, CustomFieldValue, CustomFieldType, JiraIntegration, JiraIssue, Requirement, Defect, TestPlan, Milestone, TraceabilityMatrix, CoverageReport, Notification, TestCaseSection, TestCaseSuiteMembership, SharedStep, GlobalParameter, TestDataset, TestMindmap, ImpactAnalysis, ExecutionEnvironment, ExecutionLog, TestSchedule, ExecutionEngine, TestRunEnvironment, DefectComment, DefectAttachment, DefectHistory, DefectWorkflow, DefectTemplate, TestResultDefectLink, DefectLinkType, DefectStatus, IssueTrackerIntegration, SyncLog, KPIData, TestStepResult, ShareableReport, RootCauseAnalysis, DashboardWidget, TestCaseRevision, RequirementStatus, Priority, EntityType, TestTypeDefinition, PriorityDefinition, SharedStepTemplate, TestExecutionSettings, NotificationSettings, AutomationSettings, SystemSettings, requirement_test_case_links, requirement_test_plan_links, RequirementVersion, RequirementChatConversation, RequirementChatMessage, RequirementFolder
from .schemas import (
ProjectCreate, ProjectUpdate,
TestSuiteCreate, TestSuiteUpdate,
@@ -197,6 +197,10 @@ def delete_project(db: Session, project_id: int):
)
# Delete test cases (through test suites)
+ if test_case_ids:
+ db.query(TestCaseSuiteMembership).filter(
+ TestCaseSuiteMembership.test_case_id.in_(test_case_ids)
+ ).delete(synchronize_session=False)
for test_suite in test_suites:
db.query(TestCase).filter(TestCase.test_suite_id == test_suite.id).delete()
@@ -234,36 +238,67 @@ def get_test_suites(db: Session, project_id: Optional[int] = None, skip: int = 0
return query.order_by(TestSuite.created_at.desc(), TestSuite.id.desc()).offset(skip).limit(limit).all()
+def _dedupe_ids(ids: List[int]) -> List[int]:
+ seen = set()
+ result = []
+ for item in ids:
+ if item in seen:
+ continue
+ seen.add(item)
+ result.append(item)
+ return result
+
+
def create_test_suite(db: Session, test_suite: TestSuiteCreate):
payload = test_suite.model_dump()
- # test_case_ids is handled separately below; remove it before constructing the model
- requested_case_ids = payload.pop("test_case_ids", None) or []
+ # test_case_ids attaches existing reusable test cases to the new suite.
+ requested_case_ids = _dedupe_ids(payload.pop("test_case_ids", None) or [])
db_test_suite = TestSuite(**payload)
- db.add(db_test_suite)
- safe_commit(db)
- db.refresh(db_test_suite)
+ try:
+ db.add(db_test_suite)
+ db.flush()
+
+ if requested_case_ids:
+ if any(case_id <= 0 for case_id in requested_case_ids):
+ raise ValueError("test_case_ids must contain positive integers")
+ if len(requested_case_ids) > 500:
+ raise ValueError("A maximum of 500 test cases can be attached to a suite at once")
+
+ source_case_ids = {
+ case_id
+ for (case_id,) in (
+ db.query(TestCase.id)
+ .join(TestSuite, TestCase.test_suite_id == TestSuite.id)
+ .filter(
+ TestCase.id.in_(requested_case_ids),
+ TestSuite.project_id == db_test_suite.project_id,
+ ((TestCase.is_deleted.is_(None)) | (TestCase.is_deleted.is_(False))),
+ )
+ .all()
+ )
+ }
+ missing_or_invalid = [case_id for case_id in requested_case_ids if case_id not in source_case_ids]
+ if missing_or_invalid:
+ raise ValueError(
+ f"Test cases must exist, be active, and belong to project {db_test_suite.project_id}: {missing_or_invalid}"
+ )
- if requested_case_ids:
- # Only move cases that live in the same project and aren't soft-deleted.
- valid_case_ids = [
- row[0]
- for row in db.query(TestCase.id)
- .join(TestSuite, TestCase.test_suite_id == TestSuite.id)
- .filter(
- TestCase.id.in_(requested_case_ids),
- TestSuite.project_id == db_test_suite.project_id,
- ((TestCase.is_deleted.is_(None)) | (TestCase.is_deleted.is_(False))),
- )
- .all()
- ]
- if valid_case_ids:
- db.query(TestCase).filter(TestCase.id.in_(valid_case_ids)).update(
- {"test_suite_id": db_test_suite.id, "section_id": None},
- synchronize_session=False,
- )
- safe_commit(db)
- db.refresh(db_test_suite)
+ for case_id in requested_case_ids:
+ upsert_test_case_suite_membership(
+ db,
+ test_case_id=case_id,
+ test_suite_id=db_test_suite.id,
+ section_id=None,
+ order_index=0,
+ commit=False,
+ )
+
+ safe_commit(db)
+ db.refresh(db_test_suite)
+ except Exception:
+ db.rollback()
+ raise
return db_test_suite
@@ -272,16 +307,39 @@ def get_test_case_counts_by_suite(db: Session, suite_ids: List[int]) -> dict:
"""Return {suite_id: count_of_non_deleted_test_cases} for a list of suite ids."""
if not suite_ids:
return {}
- rows = (
+ membership_rows = (
+ db.query(
+ TestCaseSuiteMembership.test_suite_id,
+ func.count(func.distinct(TestCase.id)),
+ )
+ .join(TestCase, TestCase.id == TestCaseSuiteMembership.test_case_id)
+ .filter(
+ TestCaseSuiteMembership.test_suite_id.in_(suite_ids),
+ ((TestCase.is_deleted.is_(None)) | (TestCase.is_deleted.is_(False))),
+ )
+ .group_by(TestCaseSuiteMembership.test_suite_id)
+ .all()
+ )
+ counts = {sid: int(cnt or 0) for sid, cnt in membership_rows}
+
+ legacy_rows = (
db.query(TestCase.test_suite_id, func.count(TestCase.id))
+ .outerjoin(
+ TestCaseSuiteMembership,
+ (TestCaseSuiteMembership.test_case_id == TestCase.id)
+ & (TestCaseSuiteMembership.test_suite_id == TestCase.test_suite_id),
+ )
.filter(
TestCase.test_suite_id.in_(suite_ids),
+ TestCaseSuiteMembership.id.is_(None),
((TestCase.is_deleted.is_(None)) | (TestCase.is_deleted.is_(False))),
)
.group_by(TestCase.test_suite_id)
.all()
)
- return {sid: int(cnt or 0) for sid, cnt in rows}
+ for suite_id, count in legacy_rows:
+ counts[suite_id] = counts.get(suite_id, 0) + int(count or 0)
+ return counts
def update_test_suite(db: Session, test_suite_id: int, test_suite: TestSuiteUpdate):
@@ -309,27 +367,93 @@ def delete_test_suite(db: Session, test_suite_id: int):
def get_test_case(db: Session, test_case_id: int):
return db.query(TestCase).options(
joinedload(TestCase.test_suite).joinedload(TestSuite.project),
+ selectinload(TestCase.suite_memberships).joinedload(TestCaseSuiteMembership.test_suite),
+ selectinload(TestCase.suite_memberships).joinedload(TestCaseSuiteMembership.section),
joinedload(TestCase.section),
joinedload(TestCase.creator),
selectinload(TestCase.custom_field_values)
).filter(TestCase.id == test_case_id).first()
+def apply_test_suite_membership_filter(query, test_suite_id: Optional[int]):
+ if test_suite_id is None:
+ return query
+ return (
+ query.outerjoin(
+ TestCaseSuiteMembership,
+ TestCaseSuiteMembership.test_case_id == TestCase.id,
+ )
+ .filter(
+ or_(
+ TestCase.test_suite_id == test_suite_id,
+ TestCaseSuiteMembership.test_suite_id == test_suite_id,
+ )
+ )
+ .distinct()
+ )
+
+
+def ensure_primary_test_case_suite_membership(
+ db: Session,
+ test_case: TestCase,
+ *,
+ commit: bool = True,
+) -> TestCaseSuiteMembership:
+ membership = (
+ db.query(TestCaseSuiteMembership)
+ .filter(
+ TestCaseSuiteMembership.test_case_id == test_case.id,
+ TestCaseSuiteMembership.test_suite_id == test_case.test_suite_id,
+ )
+ .first()
+ )
+ if membership is None:
+ membership = TestCaseSuiteMembership(
+ test_case_id=test_case.id,
+ test_suite_id=test_case.test_suite_id,
+ )
+ db.add(membership)
+
+ membership.section_id = test_case.section_id
+ membership.order_index = test_case.order_index or 0
+ membership.is_primary = True
+
+ db.query(TestCaseSuiteMembership).filter(
+ TestCaseSuiteMembership.test_case_id == test_case.id,
+ TestCaseSuiteMembership.id != membership.id,
+ TestCaseSuiteMembership.is_primary.is_(True),
+ ).update({TestCaseSuiteMembership.is_primary: False}, synchronize_session=False)
+
+ if commit:
+ safe_commit(db)
+ db.refresh(membership)
+ return membership
+
+
def get_test_cases(db: Session, test_suite_id: Optional[int] = None, section_id: Optional[int] = None, skip: int = 0, limit: int = 100):
from sqlalchemy.orm import joinedload
query = db.query(TestCase).options(
joinedload(TestCase.test_suite).joinedload(TestSuite.project),
+ selectinload(TestCase.suite_memberships).joinedload(TestCaseSuiteMembership.test_suite),
+ selectinload(TestCase.suite_memberships).joinedload(TestCaseSuiteMembership.section),
joinedload(TestCase.section),
joinedload(TestCase.creator),
selectinload(TestCase.custom_field_values)
).filter(
((TestCase.is_deleted.is_(None)) | (TestCase.is_deleted.is_(False)))
)
- if test_suite_id is not None:
- query = query.filter(TestCase.test_suite_id == test_suite_id)
+ query = apply_test_suite_membership_filter(query, test_suite_id)
if section_id is not None:
- query = query.filter(TestCase.section_id == section_id)
+ if test_suite_id is not None:
+ query = query.filter(
+ or_(
+ TestCase.section_id == section_id,
+ TestCaseSuiteMembership.section_id == section_id,
+ )
+ )
+ else:
+ query = query.filter(TestCase.section_id == section_id)
return query.offset(skip).limit(limit).all()
@@ -348,6 +472,7 @@ def create_test_case(db: Session, test_case: TestCaseCreate, created_by: int):
db.add(db_test_case)
safe_commit(db)
db.refresh(db_test_case)
+ ensure_primary_test_case_suite_membership(db, db_test_case)
# Create test steps if provided (multi-step support)
if test_steps_data and len(test_steps_data) > 0:
@@ -368,9 +493,66 @@ def update_test_case(db: Session, test_case_id: int, test_case: TestCaseUpdate):
setattr(db_test_case, key, value)
safe_commit(db)
db.refresh(db_test_case)
+ ensure_primary_test_case_suite_membership(db, db_test_case)
return db_test_case
+def upsert_test_case_suite_membership(
+ db: Session,
+ *,
+ test_case_id: int,
+ test_suite_id: int,
+ section_id: Optional[int] = None,
+ order_index: Optional[int] = None,
+ commit: bool = True,
+) -> TestCaseSuiteMembership:
+ membership = (
+ db.query(TestCaseSuiteMembership)
+ .filter(
+ TestCaseSuiteMembership.test_case_id == test_case_id,
+ TestCaseSuiteMembership.test_suite_id == test_suite_id,
+ )
+ .first()
+ )
+ if membership is None:
+ membership = TestCaseSuiteMembership(
+ test_case_id=test_case_id,
+ test_suite_id=test_suite_id,
+ is_primary=False,
+ )
+ db.add(membership)
+
+ membership.section_id = section_id
+ if order_index is not None:
+ membership.order_index = order_index
+
+ if commit:
+ safe_commit(db)
+ db.refresh(membership)
+ return membership
+
+
+def delete_test_case_suite_membership(
+ db: Session,
+ *,
+ test_case_id: int,
+ test_suite_id: int,
+) -> Optional[TestCaseSuiteMembership]:
+ membership = (
+ db.query(TestCaseSuiteMembership)
+ .filter(
+ TestCaseSuiteMembership.test_case_id == test_case_id,
+ TestCaseSuiteMembership.test_suite_id == test_suite_id,
+ )
+ .first()
+ )
+ if membership is None:
+ return None
+ db.delete(membership)
+ safe_commit(db)
+ return membership
+
+
def delete_test_case(db: Session, test_case_id: int):
db_test_case = db.query(TestCase).filter(TestCase.id == test_case_id).first()
if db_test_case:
diff --git a/backend/app/models.py b/backend/app/models.py
index febc1a8..65910bd 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -656,6 +656,7 @@ class TestSuite(Base):
project = relationship("Project", back_populates="test_suites")
test_cases = relationship("TestCase", back_populates="test_suite")
+ test_case_memberships = relationship("TestCaseSuiteMembership", back_populates="test_suite", cascade="all, delete-orphan")
sections = relationship("TestCaseSection", back_populates="test_suite")
@@ -677,6 +678,28 @@ class TestCaseSection(Base):
parent_section = relationship("TestCaseSection", remote_side=[id])
child_sections = relationship("TestCaseSection", back_populates="parent_section")
test_cases = relationship("TestCase", back_populates="section")
+ test_case_memberships = relationship("TestCaseSuiteMembership", back_populates="section")
+
+
+class TestCaseSuiteMembership(Base):
+ __tablename__ = "test_case_suite_memberships"
+
+ id = Column(Integer, primary_key=True, index=True)
+ test_case_id = Column(Integer, ForeignKey("test_cases.id"), nullable=False, index=True)
+ test_suite_id = Column(Integer, ForeignKey("test_suites.id"), nullable=False, index=True)
+ section_id = Column(Integer, ForeignKey("test_case_sections.id"), nullable=True, index=True)
+ order_index = Column(Integer, default=0)
+ is_primary = Column(Boolean, default=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+
+ test_case = relationship("TestCase", back_populates="suite_memberships")
+ test_suite = relationship("TestSuite", back_populates="test_case_memberships")
+ section = relationship("TestCaseSection", back_populates="test_case_memberships")
+
+ __table_args__ = (
+ UniqueConstraint("test_case_id", "test_suite_id", name="uq_test_case_suite_memberships_case_suite"),
+ )
class TestCase(Base):
@@ -713,6 +736,7 @@ class TestCase(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
test_suite = relationship("TestSuite", back_populates="test_cases")
+ suite_memberships = relationship("TestCaseSuiteMembership", back_populates="test_case", cascade="all, delete-orphan")
section = relationship("TestCaseSection", back_populates="test_cases")
test_results = relationship("TestResult", back_populates="test_case")
custom_field_values = relationship("CustomFieldValue", back_populates="test_case")
diff --git a/backend/app/routes/test_management.py b/backend/app/routes/test_management.py
index 4b4241b..2cc40c1 100644
--- a/backend/app/routes/test_management.py
+++ b/backend/app/routes/test_management.py
@@ -490,12 +490,17 @@ def delete_test_suite(
.filter(models.TestCaseSection.test_suite_id == test_suite_id)
.count()
)
- if test_case_count or section_count:
+ membership_count = (
+ db.query(models.TestCaseSuiteMembership)
+ .filter(models.TestCaseSuiteMembership.test_suite_id == test_suite_id)
+ .count()
+ )
+ if test_case_count or section_count or membership_count:
raise HTTPException(
status_code=409,
detail=(
"Test suite is not empty. Move or delete its test cases and sections first "
- f"({test_case_count} test cases, {section_count} sections)."
+ f"({test_case_count} test cases, {section_count} sections, {membership_count} memberships)."
),
)
@@ -729,10 +734,13 @@ def delete_test_case_section(
models.TestCase.section_id == section_id,
((models.TestCase.is_deleted.is_(None)) | (models.TestCase.is_deleted.is_(False))),
).count()
- if child_count or case_count:
+ membership_count = db.query(models.TestCaseSuiteMembership).filter(
+ models.TestCaseSuiteMembership.section_id == section_id,
+ ).count()
+ if child_count or case_count or membership_count:
raise HTTPException(
status_code=409,
- detail="Section is not empty. Move or delete its subsections and test cases first.",
+ detail="Section is not empty. Move or delete its subsections, test cases, and suite memberships first.",
)
section_id_val = db_section.id
@@ -865,6 +873,8 @@ def read_test_cases(
query = db.query(models.TestCase).options(
joinedload(models.TestCase.test_suite).joinedload(models.TestSuite.project),
+ selectinload(models.TestCase.suite_memberships).joinedload(models.TestCaseSuiteMembership.test_suite),
+ selectinload(models.TestCase.suite_memberships).joinedload(models.TestCaseSuiteMembership.section),
joinedload(models.TestCase.section),
joinedload(models.TestCase.creator),
selectinload(models.TestCase.custom_field_values)
@@ -872,10 +882,17 @@ def read_test_cases(
models.TestSuite.project_id == scoped_project_id,
((models.TestCase.is_deleted.is_(None)) | (models.TestCase.is_deleted.is_(False))),
)
- if test_suite_id is not None:
- query = query.filter(models.TestCase.test_suite_id == test_suite_id)
+ query = crud.apply_test_suite_membership_filter(query, test_suite_id)
if section_id is not None:
- query = query.filter(models.TestCase.section_id == section_id)
+ if test_suite_id is not None:
+ query = query.filter(
+ or_(
+ models.TestCase.section_id == section_id,
+ models.TestCaseSuiteMembership.section_id == section_id,
+ )
+ )
+ else:
+ query = query.filter(models.TestCase.section_id == section_id)
sort_columns = {
"id": models.TestCase.id,
@@ -923,21 +940,35 @@ def get_test_cases_count(
models.TestSuite.project_id == project_id,
((models.TestCase.is_deleted.is_(None)) | (models.TestCase.is_deleted.is_(False))),
)
- if test_suite_id:
- query = query.filter(models.TestCase.test_suite_id == test_suite_id)
+ query = crud.apply_test_suite_membership_filter(query, test_suite_id)
if section_id:
- query = query.filter(models.TestCase.section_id == section_id)
- count = query.count()
+ if test_suite_id:
+ query = query.filter(
+ or_(
+ models.TestCase.section_id == section_id,
+ models.TestCaseSuiteMembership.section_id == section_id,
+ )
+ )
+ else:
+ query = query.filter(models.TestCase.section_id == section_id)
+ count = query.with_entities(func.count(func.distinct(models.TestCase.id))).scalar() or 0
else:
# Use existing logic from crud
query = db.query(models.TestCase).filter(
((models.TestCase.is_deleted.is_(None)) | (models.TestCase.is_deleted.is_(False))),
)
- if test_suite_id:
- query = query.filter(models.TestCase.test_suite_id == test_suite_id)
+ query = crud.apply_test_suite_membership_filter(query, test_suite_id)
if section_id:
- query = query.filter(models.TestCase.section_id == section_id)
- count = query.count()
+ if test_suite_id:
+ query = query.filter(
+ or_(
+ models.TestCase.section_id == section_id,
+ models.TestCaseSuiteMembership.section_id == section_id,
+ )
+ )
+ else:
+ query = query.filter(models.TestCase.section_id == section_id)
+ count = query.with_entities(func.count(func.distinct(models.TestCase.id))).scalar() or 0
return {"count": count}
@app.get("/test-cases/{test_case_id}", response_model=schemas.TestCaseWithRelations)
@@ -963,6 +994,65 @@ def read_test_case(
return db_test_case
+ @app.post("/test-cases/{test_case_id}/suite-memberships", response_model=schemas.TestCaseSuiteMembership)
+ def add_test_case_suite_membership(
+ test_case_id: int,
+ membership: schemas.TestCaseSuiteMembershipCreate,
+ db: Session = Depends(get_db),
+ current_user: schemas.User = Depends(get_current_active_user),
+ ):
+ db_test_case = crud.get_test_case(db, test_case_id=test_case_id)
+ if db_test_case is None or getattr(db_test_case, "is_deleted", False):
+ raise HTTPException(status_code=404, detail="Test case not found")
+
+ primary_suite = crud.get_test_suite(db, test_suite_id=db_test_case.test_suite_id)
+ target_suite = crud.get_test_suite(db, test_suite_id=membership.test_suite_id)
+ if not primary_suite or not target_suite:
+ raise HTTPException(status_code=404, detail="Test suite not found")
+ if primary_suite.project_id != target_suite.project_id:
+ raise HTTPException(status_code=400, detail="Test case memberships must stay within the same project")
+ if not rbac.has_permission(current_user, "write", primary_suite.project_id, db):
+ raise HTTPException(status_code=403, detail="Not authorized to update this test case")
+
+ if membership.section_id is not None:
+ section = crud.get_test_case_section(db, section_id=membership.section_id)
+ if not section:
+ raise HTTPException(status_code=404, detail="Section not found")
+ if section.test_suite_id != target_suite.id:
+ raise HTTPException(status_code=400, detail="Section must belong to the membership test suite")
+
+ return crud.upsert_test_case_suite_membership(
+ db,
+ test_case_id=test_case_id,
+ test_suite_id=target_suite.id,
+ section_id=membership.section_id,
+ order_index=membership.order_index,
+ )
+
+ @app.delete("/test-cases/{test_case_id}/suite-memberships/{test_suite_id}")
+ def remove_test_case_suite_membership(
+ test_case_id: int,
+ test_suite_id: int = Path(..., ge=1),
+ db: Session = Depends(get_db),
+ current_user: schemas.User = Depends(get_current_active_user),
+ ):
+ db_test_case = crud.get_test_case(db, test_case_id=test_case_id)
+ if db_test_case is None or getattr(db_test_case, "is_deleted", False):
+ raise HTTPException(status_code=404, detail="Test case not found")
+ if db_test_case.test_suite_id == test_suite_id:
+ raise HTTPException(status_code=400, detail="Cannot remove the primary suite membership")
+
+ primary_suite = crud.get_test_suite(db, test_suite_id=db_test_case.test_suite_id)
+ if not primary_suite:
+ raise HTTPException(status_code=404, detail="Test suite not found for this test case")
+ if not rbac.has_permission(current_user, "write", primary_suite.project_id, db):
+ raise HTTPException(status_code=403, detail="Not authorized to update this test case")
+
+ deleted = crud.delete_test_case_suite_membership(db, test_case_id=test_case_id, test_suite_id=test_suite_id)
+ if deleted is None:
+ raise HTTPException(status_code=404, detail="Suite membership not found")
+ return {"message": "Suite membership removed"}
+
@app.put("/test-cases/{test_case_id}", response_model=schemas.TestCaseWithRelations)
def update_test_case(
test_case_id: int,
@@ -2493,10 +2583,13 @@ def delete_section(
models.TestCase.section_id == section_id,
((models.TestCase.is_deleted.is_(None)) | (models.TestCase.is_deleted.is_(False))),
).count()
- if child_count or case_count:
+ membership_count = db.query(models.TestCaseSuiteMembership).filter(
+ models.TestCaseSuiteMembership.section_id == section_id,
+ ).count()
+ if child_count or case_count or membership_count:
raise HTTPException(
status_code=409,
- detail="Section is not empty. Move or delete its subsections and test cases first.",
+ detail="Section is not empty. Move or delete its subsections, test cases, and suite memberships first.",
)
crud.delete_test_case_section(db, section_id=section_id)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index ba67958..4da42a8 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -128,8 +128,7 @@ def sanitize_html(cls, data):
class TestSuiteCreate(TestSuiteBase):
project_id: int
- # Optional bulk-move of existing test cases into the new suite. Cases that don't
- # exist or live in another project are skipped rather than failing the whole create.
+ # Optional reusable attachments of existing project test cases to the new suite.
test_case_ids: Optional[List[int]] = None
@@ -242,6 +241,17 @@ def sanitize_html(cls, data):
return data
+class TestCaseSuiteMembershipCreate(BaseModel):
+ test_suite_id: int
+ section_id: Optional[int] = None
+ order_index: Optional[int] = 0
+
+
+class TestCaseSuiteMembershipUpdate(BaseModel):
+ section_id: Optional[int] = None
+ order_index: Optional[int] = None
+
+
class TestCase(TestCaseBase):
id: int
project_seq: Optional[int] = None # per-project sequence (URLs/badges)
@@ -306,6 +316,20 @@ class Config:
from_attributes = True
+class TestCaseSuiteMembership(BaseModel):
+ id: int
+ test_case_id: int
+ test_suite_id: int
+ section_id: Optional[int] = None
+ order_index: Optional[int] = 0
+ is_primary: Optional[bool] = False
+ test_suite: Optional[TestSuiteNested] = None
+ section: Optional[TestCaseSectionNested] = None
+
+ class Config:
+ from_attributes = True
+
+
class TestCaseLinkedRequirement(BaseModel):
id: int
requirement_id: str
@@ -327,6 +351,7 @@ class TestCaseWithRelations(TestCaseBase):
created_at: datetime
updated_at: Optional[datetime] = None
test_suite: Optional[TestSuiteNested] = None
+ suite_memberships: List[TestCaseSuiteMembership] = Field(default_factory=list)
section: Optional[TestCaseSectionNested] = None
test_steps: List[TestCaseStep] = []
custom_field_values: List['CustomFieldValue'] = []
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 204ec7c..490eed5 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1177,6 +1177,17 @@ export const testCasesAPI = {
const response = await api.delete(`/test-cases/${id}`);
return response.data;
},
+ addSuiteMembership: async (
+ id: number,
+ membership: { test_suite_id: number; section_id?: number | null; order_index?: number | null }
+ ) => {
+ const response = await api.post(`/test-cases/${id}/suite-memberships`, membership);
+ return response.data;
+ },
+ removeSuiteMembership: async (id: number, testSuiteId: number) => {
+ const response = await api.delete(`/test-cases/${id}/suite-memberships/${testSuiteId}`);
+ return response.data;
+ },
getCount: async (projectId?: number, testSuiteId?: number, sectionId?: number) => {
const params = new URLSearchParams();
if (projectId) params.append('project_id', projectId.toString());
diff --git a/frontend/src/locales/ar.ts b/frontend/src/locales/ar.ts
index d13bfed..88640b4 100644
--- a/frontend/src/locales/ar.ts
+++ b/frontend/src/locales/ar.ts
@@ -4115,4 +4115,10 @@ export const ar = {
docConvertAiReason_rate_limited: 'تم بلوغ الحد الشهري لرموز الذكاء الاصطناعي لهذا المشروع. ارفعه من مدير الذكاء الاصطناعي أو انتظر الدورة التالية.',
docConvertAiReason_nothing_to_enhance: 'لا يوجد شيء لمراجعته بعد.',
aiSource_docs: 'المستندات',
+ additionalSuites: 'مجموعات اختبار إضافية',
+ additionalTestSuite: 'مجموعة اختبار إضافية',
+ additionalTestSuites: 'مجموعات اختبار إضافية',
+ noAdditionalSuites: 'لا توجد مجموعات إضافية',
+ noAdditionalSuitesSelected: 'لم يتم تحديد مجموعات إضافية',
+ primarySuite: 'أساسية',
};
diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts
index 8a74d8d..b152541 100644
--- a/frontend/src/locales/en.ts
+++ b/frontend/src/locales/en.ts
@@ -4297,4 +4297,10 @@ export const en = {
docConvertAiReason_rate_limited: 'Monthly AI token limit reached for this project. Raise it in AI Manager or wait for the next cycle.',
docConvertAiReason_nothing_to_enhance: 'There is nothing to review yet.',
aiSource_docs: 'Docs',
+ additionalSuites: 'Additional suites',
+ additionalTestSuite: 'Additional test suite',
+ additionalTestSuites: 'Additional test suites',
+ noAdditionalSuites: 'No additional suites',
+ noAdditionalSuitesSelected: 'No additional suites selected',
+ primarySuite: 'Primary',
};
diff --git a/frontend/src/locales/fa.ts b/frontend/src/locales/fa.ts
index 8ade2b0..0ee56b5 100644
--- a/frontend/src/locales/fa.ts
+++ b/frontend/src/locales/fa.ts
@@ -4084,4 +4084,10 @@ export const fa = {
docConvertAiReason_rate_limited: 'سقف ماهانهٔ توکن هوش مصنوعی این پروژه پر شده است. آن را در مدیر هوش مصنوعی افزایش دهید یا تا دورهٔ بعد صبر کنید.',
docConvertAiReason_nothing_to_enhance: 'هنوز چیزی برای بازبینی وجود ندارد.',
aiSource_docs: 'اسناد',
+ additionalSuites: 'مجموعههای آزمون اضافی',
+ additionalTestSuite: 'مجموعه آزمون اضافی',
+ additionalTestSuites: 'مجموعههای آزمون اضافی',
+ noAdditionalSuites: 'مجموعه اضافی وجود ندارد',
+ noAdditionalSuitesSelected: 'هیچ مجموعه اضافی انتخاب نشده است',
+ primarySuite: 'اصلی',
};
diff --git a/frontend/src/pages/TestCaseDetail.tsx b/frontend/src/pages/TestCaseDetail.tsx
index 35fca4e..2136b0a 100644
--- a/frontend/src/pages/TestCaseDetail.tsx
+++ b/frontend/src/pages/TestCaseDetail.tsx
@@ -22,6 +22,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useTranslation } from '@/hooks/useTranslation';
import { useToast } from '@/hooks/use-toast';
import { api, customFieldsAPI, datasetsAPI, sectionsAPI, testCasesAPI, testSuitesAPI, type TestDataset, type GlobalParameter } from '@/lib/api';
@@ -466,6 +467,37 @@ export function TestCaseDetail() {
const hasCustomFieldRows = customFieldsLoading || customFieldRows.length > 0;
+ const additionalSuiteRows = useMemo(() => {
+ const memberships = ((testCase as any)?.suite_memberships || []) as Array<{
+ id?: number;
+ test_suite_id?: number | null;
+ section_id?: number | null;
+ is_primary?: boolean;
+ test_suite?: { id?: number; name?: string | null } | null;
+ section?: { id?: number; name?: string | null } | null;
+ }>;
+ const seenSuiteIds = new Set();
+
+ return memberships
+ .filter((membership) => {
+ const suiteId = Number(membership.test_suite_id);
+ if (!Number.isInteger(suiteId) || suiteId <= 0) return false;
+ if (suiteId === Number(testCase?.test_suite_id)) return false;
+ if (membership.is_primary) return false;
+ if (seenSuiteIds.has(suiteId)) return false;
+ seenSuiteIds.add(suiteId);
+ return true;
+ })
+ .map((membership) => {
+ const suiteId = Number(membership.test_suite_id);
+ return {
+ id: membership.id ?? suiteId,
+ suiteName: membership.test_suite?.name || `${t('suite')} ${suiteId}`,
+ sectionName: membership.section?.name || t('noSection'),
+ };
+ });
+ }, [testCase, t]);
+
const handleExecute = () => {
if (!testCase) return;
if (effectiveProjectId) {
@@ -868,6 +900,48 @@ export function TestCaseDetail() {
) : t('noSection')}
/>
+
+
+
+ {t('additionalTestSuites')}
+
+ {additionalSuiteRows.length > 0 && (
+
+ {additionalSuiteRows.length}
+
+ )}
+
+
+
+
+
+ {t('testSuite')}
+ {t('section')}
+
+
+
+ {additionalSuiteRows.length > 0 ? (
+ additionalSuiteRows.map((row) => (
+
+
+ {row.suiteName}
+
+
+ {row.sectionName}
+
+
+ ))
+ ) : (
+
+
+ {t('noAdditionalSuites')}
+
+
+ )}
+
+
+
+
{hasReference && (
([]);
const [testSuitesLoading, setTestSuitesLoading] = useState(false);
+ const [secondarySuiteIds, setSecondarySuiteIds] = useState([]);
+ const [originalSecondarySuiteIds, setOriginalSecondarySuiteIds] = useState([]);
+ const [secondarySuiteSectionIds, setSecondarySuiteSectionIds] = useState>({});
+ const [sectionOptionsBySuite, setSectionOptionsBySuite] = useState>({});
const [currentProjectId, setCurrentProjectId] = useState(null);
const [customFields, setCustomFields] = useState([]);
const [customFieldValues, setCustomFieldValues] = useState>({});
@@ -121,6 +128,25 @@ export function TestCaseEdit() {
];
}, [formData.test_type, testTypeOptions]);
+ const additionalSuiteRows = useMemo(() => {
+ const suitesById = new Map(testSuiteOptions.map((suite) => [suite.id, suite.name]));
+
+ return Array.from(new Set(secondarySuiteIds))
+ .filter((suiteId) => suiteId !== formData.test_suite_id)
+ .map((suiteId) => {
+ const sectionId = secondarySuiteSectionIds[suiteId] ?? null;
+ const sectionName = sectionId
+ ? (sectionOptionsBySuite[suiteId] ?? []).find((section) => section.id === sectionId)?.name
+ : null;
+
+ return {
+ suiteId,
+ suiteName: suitesById.get(suiteId) ?? `${t('suite')} ${suiteId}`,
+ sectionName: sectionName ?? t('noSection'),
+ };
+ });
+ }, [secondarySuiteIds, formData.test_suite_id, secondarySuiteSectionIds, sectionOptionsBySuite, testSuiteOptions, t]);
+
const navigateBack = () => {
const targetProjectId = currentProjectId || (projectId ? parseInt(projectId, 10) : null);
if (targetProjectId) {
@@ -178,6 +204,32 @@ export function TestCaseEdit() {
const suiteId = (testCaseData as any).test_suite_id ?? null;
const sectionId = (testCaseData as any).section_id ?? null;
+ const loadedSecondarySuiteIds: number[] = Array.from(new Set(
+ ((testCaseData as any).suite_memberships || [])
+ .map((membership: { test_suite_id?: number | null }) => Number(membership.test_suite_id))
+ .filter((membershipSuiteId: number) => (
+ Number.isInteger(membershipSuiteId) &&
+ membershipSuiteId > 0 &&
+ membershipSuiteId !== suiteId
+ ))
+ ));
+ const loadedSecondarySectionIds = ((testCaseData as any).suite_memberships || []).reduce(
+ (
+ sections: Record,
+ membership: { test_suite_id?: number | null; section_id?: number | null }
+ ) => {
+ const membershipSuiteId = Number(membership.test_suite_id);
+ if (
+ Number.isInteger(membershipSuiteId) &&
+ membershipSuiteId > 0 &&
+ membershipSuiteId !== suiteId
+ ) {
+ sections[membershipSuiteId] = membership.section_id ?? null;
+ }
+ return sections;
+ },
+ {}
+ );
const existingCustomValues = ((testCaseData as any).custom_field_values || []).reduce(
(
values: {
@@ -214,6 +266,9 @@ export function TestCaseEdit() {
});
setCustomFieldValues(existingCustomValues.fieldValues);
setExistingCustomFieldValueIds(existingCustomValues.valueIds);
+ setSecondarySuiteIds(loadedSecondarySuiteIds);
+ setOriginalSecondarySuiteIds(loadedSecondarySuiteIds);
+ setSecondarySuiteSectionIds(loadedSecondarySectionIds);
setOriginalIsMultistep(isMultistep);
if (isMultistep) {
@@ -366,14 +421,45 @@ export function TestCaseEdit() {
useEffect(() => {
const loadSections = async () => {
- if (!currentProjectId || !formData.test_suite_id) {
+ if (!currentProjectId) {
setSectionOptions([]);
+ setSectionOptionsBySuite({});
return;
}
setSectionsLoading(true);
try {
const data = await sectionsAPI.getProjectSectionHierarchy(currentProjectId);
const hierarchy = data?.hierarchy ?? [];
+ const optionsBySuite: Record = {};
+
+ const flattenSections = (sections: SectionNode[] = []): SectionOption[] => {
+ const seenSections = new Set();
+ const flat: SectionOption[] = [];
+
+ const pushSection = (section: SectionNode, indent: number) => {
+ if (seenSections.has(section.id)) return;
+ seenSections.add(section.id);
+ flat.push({ id: section.id, name: section.name, indent });
+ (section.subsections ?? []).forEach((subsection) => pushSection(subsection, indent + 1));
+ };
+
+ sections.forEach((section) => pushSection(section, 0));
+ return flat;
+ };
+
+ hierarchy.forEach((suiteBlock: { test_suite?: { id?: number }; sections?: SectionNode[] }) => {
+ const suiteId = Number(suiteBlock.test_suite?.id);
+ if (Number.isInteger(suiteId) && suiteId > 0) {
+ optionsBySuite[suiteId] = flattenSections(suiteBlock.sections ?? []);
+ }
+ });
+ setSectionOptionsBySuite(optionsBySuite);
+
+ if (!formData.test_suite_id) {
+ setSectionOptions([]);
+ return;
+ }
+
const suiteBlock = hierarchy.find(
(h: { test_suite: { id: number } }) => h.test_suite?.id === formData.test_suite_id
);
@@ -383,30 +469,11 @@ export function TestCaseEdit() {
return;
}
- // Use a Set to track unique section IDs and prevent duplicates
- const seenSections = new Set();
- const flat: { id: number; name: string; indent: number }[] = [];
-
- const pushSection = (s: { id: number; name: string; subsections?: { id: number; name: string; subsections?: any[] }[] }, indent: number) => {
- // Skip if we've already seen this section (prevents duplicates)
- if (seenSections.has(s.id)) {
- return;
- }
- seenSections.add(s.id);
- flat.push({ id: s.id, name: s.name, indent });
- (s.subsections ?? []).forEach((sub: { id: number; name: string; subsections?: any[] }) =>
- pushSection(sub, indent + 1)
- );
- };
-
- (suiteBlock.sections ?? []).forEach((s: { id: number; name: string; subsections?: { id: number; name: string; subsections?: any[] }[] }) =>
- pushSection(s, 0)
- );
-
- setSectionOptions(flat);
+ setSectionOptions(optionsBySuite[formData.test_suite_id] ?? []);
} catch (error) {
console.error('Failed to load sections:', error);
setSectionOptions([]);
+ setSectionOptionsBySuite({});
} finally {
setSectionsLoading(false);
}
@@ -414,6 +481,16 @@ export function TestCaseEdit() {
loadSections();
}, [currentProjectId, formData.test_suite_id]);
+ useEffect(() => {
+ if (formData.test_suite_id === null) return;
+ setSecondarySuiteIds((current) => current.filter((suiteId) => suiteId !== formData.test_suite_id));
+ setSecondarySuiteSectionIds((current) => {
+ const next = { ...current };
+ delete next[formData.test_suite_id as number];
+ return next;
+ });
+ }, [formData.test_suite_id]);
+
// Reusable datasets this case can iterate over during a run.
useEffect(() => {
if (!currentProjectId) {
@@ -895,6 +972,37 @@ export function TestCaseEdit() {
return { failedFields };
};
+ const syncSuiteMemberships = async (testCaseId: number, primarySuiteId: number): Promise => {
+ const desiredSecondarySuiteIds = Array.from(new Set(secondarySuiteIds))
+ .filter((suiteId) => suiteId !== primarySuiteId);
+ const desiredSecondarySet = new Set(desiredSecondarySuiteIds);
+
+ const suiteIdsToRemove = originalSecondarySuiteIds.filter(
+ (suiteId) => suiteId !== primarySuiteId && !desiredSecondarySet.has(suiteId)
+ );
+
+ await Promise.all([
+ ...desiredSecondarySuiteIds.map((suiteId) =>
+ testCasesAPI.addSuiteMembership(testCaseId, {
+ test_suite_id: suiteId,
+ section_id: secondarySuiteSectionIds[suiteId] ?? null,
+ order_index: 0,
+ })
+ ),
+ ...suiteIdsToRemove.map((suiteId) => testCasesAPI.removeSuiteMembership(testCaseId, suiteId)),
+ ]);
+
+ setSecondarySuiteIds(desiredSecondarySuiteIds);
+ setOriginalSecondarySuiteIds(desiredSecondarySuiteIds);
+ setSecondarySuiteSectionIds((current) => {
+ const next: Record = {};
+ desiredSecondarySuiteIds.forEach((suiteId) => {
+ next[suiteId] = current[suiteId] ?? null;
+ });
+ return next;
+ });
+ };
+
const handleSave = async () => {
const numericId = Number(id);
if (!id || !Number.isFinite(numericId) || numericId <= 0) {
@@ -963,6 +1071,7 @@ export function TestCaseEdit() {
await testCasesAPI.update(numericId, payload);
const { failedFields } = await syncCustomFieldValues(numericId);
+ await syncSuiteMemberships(numericId, formData.test_suite_id);
// Avoid an extra round-trip when the user was never in multistep mode
// and didn't toggle on — there's nothing to clear.
@@ -1177,6 +1286,122 @@ export function TestCaseEdit() {
{t('ensureTestCaseBelongsToProject')}
)}
+ {currentProjectId && testSuiteOptions.length > 1 && (
+
+
+
+ {secondarySuiteIds.length > 0 && (
+
+ {secondarySuiteIds.length}
+
+ )}
+
+
+ {testSuiteOptions.map((suite) => {
+ const isPrimarySuite = suite.id === formData.test_suite_id;
+ const checked = secondarySuiteIds.includes(suite.id);
+ const suiteSectionOptions = sectionOptionsBySuite[suite.id] ?? [];
+ return (
+
+
+ {!isPrimarySuite && checked && (
+
+
+ {!sectionsLoading && suiteSectionOptions.length === 0 && (
+
{t('noSectionsAvailable')}
+ )}
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+ {t('additionalTestSuite')}
+ {t('section')}
+
+
+
+ {additionalSuiteRows.length > 0 ? (
+ additionalSuiteRows.map((row) => (
+
+
+ {row.suiteName}
+
+
+ {row.sectionName}
+
+
+ ))
+ ) : (
+
+
+ {t('noAdditionalSuitesSelected')}
+
+
+ )}
+
+
+
+
+ )}
diff --git a/frontend/src/pages/TestCases.tsx b/frontend/src/pages/TestCases.tsx
index 56aed22..7692de3 100644
--- a/frontend/src/pages/TestCases.tsx
+++ b/frontend/src/pages/TestCases.tsx
@@ -93,6 +93,7 @@ import { ImportPreview } from '@/components/ImportPreview';
import { SortableTestCaseRow } from '@/components/TestCases/SortableTestCaseRow';
import { SavedFilters } from '@/components/SavedFilters';
import { BulkEditTestCasesDialog } from '@/components/BulkEditTestCasesDialog';
+import { caseBelongsToSuite, caseSectionIdForSuite } from '@/utils/testCaseSuites';
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:8000';
const CUSTOM_FIELD_FILTER_ALL = 'all';
@@ -1440,16 +1441,21 @@ export function TestCases() {
const selectedSuiteId = getSuiteIdFromSelectionValue(selectedTestSuite);
if (selectedSuiteId) {
- matchesSuite = testCase.test_suite_id === selectedSuiteId;
+ matchesSuite = caseBelongsToSuite(testCase, selectedSuiteId);
if (isUnsectionedSelectionValue(selectedTestSuite)) {
- matchesSuite = matchesSuite && !testCase.section_id;
+ matchesSuite = matchesSuite && !caseSectionIdForSuite(testCase, selectedSuiteId);
}
} else {
const selectedSection = findSectionById(mockSections, selectedTestSuite);
const sectionIds = selectedSection
? collectSectionAndDescendantIds(selectedSection)
: [Number(selectedTestSuite)];
- matchesSuite = !!testCase.section_id && sectionIds.includes(testCase.section_id);
+ matchesSuite = Boolean(
+ (testCase.section_id && sectionIds.includes(testCase.section_id)) ||
+ testCase.suite_memberships?.some(
+ (membership) => membership.section_id && sectionIds.includes(membership.section_id)
+ )
+ );
}
}
@@ -3868,13 +3874,13 @@ export function TestCases() {
{suiteData.name}
- {t('testCasesCountSimple', { count: apiTestCases.filter((tc) => tc.test_suite_id === suiteId).length })}
+ {t('testCasesCountSimple', { count: apiTestCases.filter((tc) => caseBelongsToSuite(tc, suiteId)).length })}
{(() => {
const unsectionedValue = getUnsectionedSelectionValue(suiteId);
- const unsectionedCount = apiTestCases.filter((tc) => tc.test_suite_id === suiteId && !tc.section_id).length;
+ const unsectionedCount = apiTestCases.filter((tc) => caseBelongsToSuite(tc, suiteId) && !caseSectionIdForSuite(tc, suiteId)).length;
const unsectionedMatches = t('unsectioned').toLowerCase().includes(normalizedQuery);
const showUnsectioned = !normalizedQuery || suiteData.matchesSuite || unsectionedMatches || unsectionedCount > 0;
diff --git a/frontend/src/pages/TestRuns.tsx b/frontend/src/pages/TestRuns.tsx
index 9e0c47c..81864c3 100644
--- a/frontend/src/pages/TestRuns.tsx
+++ b/frontend/src/pages/TestRuns.tsx
@@ -48,6 +48,7 @@ import { TestRun, TestCase } from '@/types';
import { entityKey } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { useAuthStore } from '@/stores/authStore';
+import { caseBelongsToSuite, caseHasAnySuite } from '@/utils/testCaseSuites';
// Define User interface locally since it's not in types
interface User {
@@ -617,7 +618,7 @@ export function TestRuns() {
// Toggle test suite selection
const toggleTestSuiteSelection = (suiteId: number) => {
- const suiteTestCases = testCases.filter(tc => tc.test_suite_id === suiteId);
+ const suiteTestCases = testCases.filter(tc => caseBelongsToSuite(tc, suiteId));
const isAllSelected = suiteTestCases.every(tc => selectedTestCases.includes(tc.id));
if (isAllSelected) {
@@ -1038,7 +1039,7 @@ export function TestRuns() {
{t('testSuitesLabel')}
{testSuites.map((suite) => {
- const suiteTestCases = testCases.filter(tc => tc.test_suite_id === suite.id);
+ const suiteTestCases = testCases.filter(tc => caseBelongsToSuite(tc, suite.id));
const filteredSuiteTestCases = suiteTestCases.filter(tc =>
tc.title?.toLowerCase().includes(searchQuery.toLowerCase())
);
@@ -1126,14 +1127,14 @@ export function TestRuns() {
)}
{/* Uncategorized Test Cases */}
- {testCases.filter(tc => !tc.test_suite_id && !(tc as any).section_id).length > 0 && (
+ {testCases.filter(tc => !caseHasAnySuite(tc) && !(tc as any).section_id).length > 0 && (
{t('uncategorizedTestCases')}
{testCases
- .filter(tc => !tc.test_suite_id && !(tc as any).section_id)
+ .filter(tc => !caseHasAnySuite(tc) && !(tc as any).section_id)
.filter(tc => tc.title?.toLowerCase().includes(searchQuery.toLowerCase()))
.slice(0, searchQuery ? undefined : 50)
.map((testCase) => (
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 45af484..b34c967 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -79,10 +79,29 @@ export interface TestCase {
name: string;
};
};
+ suite_memberships?: TestCaseSuiteMembership[];
// For backward compatibility
project_name?: string;
}
+export interface TestCaseSuiteMembership {
+ id: number;
+ test_case_id: number;
+ test_suite_id: number;
+ section_id?: number | null;
+ order_index?: number | null;
+ is_primary?: boolean;
+ test_suite?: {
+ id: number;
+ name: string;
+ project_id: number;
+ };
+ section?: {
+ id: number;
+ name: string;
+ };
+}
+
export interface TestCaseSection {
id: number;
name: string;
diff --git a/frontend/src/utils/testCaseSuites.ts b/frontend/src/utils/testCaseSuites.ts
new file mode 100644
index 0000000..82d5ecb
--- /dev/null
+++ b/frontend/src/utils/testCaseSuites.ts
@@ -0,0 +1,24 @@
+import { TestCase } from '@/types';
+
+export function caseBelongsToSuite(testCase: TestCase, suiteId: number): boolean {
+ return (
+ testCase.test_suite_id === suiteId ||
+ Boolean(testCase.suite_memberships?.some((membership) => membership.test_suite_id === suiteId))
+ );
+}
+
+export function caseSectionIdForSuite(testCase: TestCase, suiteId: number): number | undefined {
+ const membershipSectionId = testCase.suite_memberships?.find(
+ (membership) => membership.test_suite_id === suiteId
+ )?.section_id;
+
+ if (membershipSectionId !== undefined && membershipSectionId !== null) {
+ return membershipSectionId;
+ }
+
+ return testCase.test_suite_id === suiteId ? testCase.section_id : undefined;
+}
+
+export function caseHasAnySuite(testCase: TestCase): boolean {
+ return Boolean(testCase.test_suite_id || testCase.suite_memberships?.length);
+}