33Passing ``EntityId`` to ``create_attribute`` must create the attribute AND its
44entity association in a single transaction, so a dropped response can never leave
55an orphaned (persisted-but-unassociated) attribute. Without ``EntityId`` the
6- behaviour is unchanged (attribute only, no association).
6+ behaviour is unchanged (attribute only). If either step fails, the whole create
7+ rolls back — never a half-created attribute.
78
89Drives the real service against a live Postgres (``test_db_session`` fixture).
10+ Seed names are derived from the test name (``request.node.name``) since the
11+ fixture DB is shared across tests in this module.
912"""
1013
14+ import pytest
15+ from fastapi import HTTPException
1116from sqlmodel import select
1217
13- from lif .datatypes .mdr_sql_model import DataModel , DataModelType , Entity , EntityAttributeAssociation
18+ from lif .datatypes .mdr_sql_model import Attribute , DataModel , DataModelType , Entity , EntityAttributeAssociation
1419from lif .mdr_dto .attribute_dto import CreateAttributeDTO
20+ from lif .mdr_services import attribute_service
1521from lif .mdr_services .attribute_service import create_attribute
1622
1723
1824async def _seed_model_and_entity (session , tag ):
19- # Unique names per test — the fixture DB is shared across tests in this module.
2025 dm = DataModel (
2126 Name = f"AtomicCreateDM_{ tag } " ,
2227 Type = DataModelType .SourceSchema ,
2328 DataModelVersion = "1.0" ,
24- ContributorOrganization = "UniconQA " ,
29+ ContributorOrganization = "Test Organization " ,
2530 Deleted = False ,
2631 )
2732 session .add (dm )
@@ -46,9 +51,14 @@ async def _associations_for(session, attribute_id):
4651 return result .scalars ().all ()
4752
4853
49- async def test_create_attribute_with_entity_id_creates_association_atomically (test_db_session ):
54+ async def _attributes_named (session , unique_name ):
55+ result = await session .execute (select (Attribute ).where (Attribute .UniqueName == unique_name ))
56+ return result .scalars ().all ()
57+
58+
59+ async def test_create_attribute_with_entity_id_creates_association_atomically (test_db_session , request ):
5060 session = test_db_session
51- dm , entity = await _seed_model_and_entity (session , "assoc" )
61+ dm , entity = await _seed_model_and_entity (session , request . node . name )
5262
5363 created = await create_attribute (
5464 session ,
@@ -68,17 +78,68 @@ async def test_create_attribute_with_entity_id_creates_association_atomically(te
6878 assert assocs [0 ].EntityId == entity .Id
6979
7080
71- async def test_create_attribute_without_entity_id_makes_no_association (test_db_session ):
81+ async def test_create_attribute_without_entity_id_makes_no_association (test_db_session , request ):
7282 """Backward-compatible: no EntityId -> attribute only, no association (unchanged behaviour)."""
7383 session = test_db_session
74- dm , entity = await _seed_model_and_entity (session , "noassoc" )
84+ dm , _entity = await _seed_model_and_entity (session , request . node . name )
7585
7686 created = await create_attribute (
7787 session ,
7888 CreateAttributeDTO (
79- Name = "size" , UniqueName = f"{ entity .UniqueName } .size" , DataType = "xsd:string" , DataModelId = dm .Id
89+ Name = "size" , UniqueName = f"{ _entity .UniqueName } .size" , DataType = "xsd:string" , DataModelId = dm .Id
8090 ),
8191 )
8292
8393 assert created .Id is not None
8494 assert await _associations_for (session , created .Id ) == []
95+
96+
97+ async def test_failure_creating_attribute_creates_no_association (test_db_session , request ):
98+ """If the attribute step fails (duplicate unique name), the failed attempt creates no association."""
99+ session = test_db_session
100+ dm , entity = await _seed_model_and_entity (session , request .node .name )
101+ dto = CreateAttributeDTO (
102+ Name = "dup" , UniqueName = f"{ entity .UniqueName } .dup" , DataType = "xsd:string" , DataModelId = dm .Id , EntityId = entity .Id
103+ )
104+
105+ first = await create_attribute (session , dto ) # succeeds (attribute + association)
106+
107+ # A second create with the same unique name fails the uniqueness check.
108+ with pytest .raises (HTTPException ) as exc :
109+ await create_attribute (session , dto )
110+ assert exc .value .status_code == 400
111+
112+ # Only the first attribute's association exists — the failed retry added nothing.
113+ result = await session .execute (
114+ select (EntityAttributeAssociation ).where (
115+ EntityAttributeAssociation .EntityId == entity .Id ,
116+ EntityAttributeAssociation .Deleted == False , # noqa: E712
117+ )
118+ )
119+ assocs = result .scalars ().all ()
120+ assert len (assocs ) == 1
121+ assert assocs [0 ].AttributeId == first .Id
122+
123+
124+ async def test_failure_creating_association_rolls_back_the_attribute (test_db_session , request , monkeypatch ):
125+ """If the association step fails, the attribute must roll back too — no orphaned attribute."""
126+ session = test_db_session
127+ dm , entity = await _seed_model_and_entity (session , request .node .name )
128+
129+ class _BoomAssociation :
130+ def __init__ (self , * args , ** kwargs ):
131+ raise RuntimeError ("simulated association-creation failure" )
132+
133+ monkeypatch .setattr (attribute_service , "EntityAttributeAssociation" , _BoomAssociation )
134+
135+ unique_name = f"{ entity .UniqueName } .rollback"
136+ with pytest .raises (RuntimeError ):
137+ await create_attribute (
138+ session ,
139+ CreateAttributeDTO (
140+ Name = "rollback" , UniqueName = unique_name , DataType = "xsd:string" , DataModelId = dm .Id , EntityId = entity .Id
141+ ),
142+ )
143+
144+ # The attribute must NOT have persisted — it was rolled back with the failed association.
145+ assert await _attributes_named (session , unique_name ) == []
0 commit comments