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
+
+
{t('noSectionsAvailable')}
+ )} +