@@ -103,3 +103,97 @@ def test_detail_is_the_message(self) -> None:
103103 err = NotFound ("conversation not found" )
104104 assert err .detail == "conversation not found"
105105 assert str (err ) == "conversation not found"
106+
107+
108+ class TestUploadDurability :
109+ """store_upload write-through: a durability failure must not leave a
110+ half-persisted file in the sandbox workspace."""
111+
112+ def _session (self ):
113+ from app .infra .config import get_settings
114+ from app .infra .db import get_session_factory , reset_engine_for_tests
115+
116+ reset_engine_for_tests (get_settings ().db_url )
117+ return get_session_factory ()()
118+
119+ def _user_conv (self , db ):
120+ from app .models import Conversation , User , new_id
121+
122+ user = User (id = new_id ("usr" ), email = "up@example.com" , password_hash = "x" )
123+ db .add (user )
124+ db .flush ()
125+ conv = Conversation (id = new_id ("cnv" ), user_id = user .id , title = "t" )
126+ db .add (conv )
127+ db .flush ()
128+ return user , conv
129+
130+ def test_durability_failure_unlinks_local_file (self ) -> None :
131+ import asyncio
132+
133+ from app .controllers import files
134+ from app .infra .config import conversation_workspace
135+ from app .infra .storage import StorageError
136+
137+ class _BrokenStorage :
138+ def put_stream (self , key , stream ):
139+ raise StorageError ("durable tier down" )
140+
141+ async def _run (db , user , conv ):
142+ chunks = [b"partial-bytes" , b"" ]
143+
144+ async def read_chunk (_n : int ) -> bytes :
145+ return chunks .pop (0 )
146+
147+ await files .store_upload (
148+ db ,
149+ user ,
150+ conv ,
151+ filename = "x.csv" ,
152+ read_chunk = read_chunk ,
153+ storage = _BrokenStorage (),
154+ )
155+
156+ with self ._session () as db :
157+ user , conv = self ._user_conv (db )
158+ with pytest .raises (StorageError ):
159+ asyncio .run (_run (db , user , conv ))
160+ # no file left behind in the workspace, and no DB row committed
161+ workspace = conversation_workspace (conv .id )
162+ assert list (workspace .iterdir ()) == []
163+ from app .models import ConversationFile
164+
165+ assert db .query (ConversationFile ).count () == 0
166+
167+ def test_success_persists_to_both_tiers (self ) -> None :
168+ import asyncio
169+ from pathlib import Path
170+
171+ from app .controllers import files
172+
173+ class _MemStorage :
174+ def __init__ (self ):
175+ self .puts = {}
176+
177+ def put_stream (self , key , stream ):
178+ data = stream .read ()
179+ self .puts [key ] = data
180+ return len (data )
181+
182+ store = _MemStorage ()
183+
184+ async def _run (db , user , conv ):
185+ chunks = [b"hello,world\n " , b"" ]
186+
187+ async def read_chunk (_n : int ) -> bytes :
188+ return chunks .pop (0 )
189+
190+ return await files .store_upload (
191+ db , user , conv , filename = "x.csv" , read_chunk = read_chunk , storage = store
192+ )
193+
194+ with self ._session () as db :
195+ user , conv = self ._user_conv (db )
196+ row = asyncio .run (_run (db , user , conv ))
197+ key = files .durable_key (conv .id , row .stored_name )
198+ assert store .puts [key ] == b"hello,world\n " # durable tier
199+ assert Path (row .path ).read_bytes () == b"hello,world\n " # sandbox tier
0 commit comments