11import asyncio
2+ import re
23from pathlib import Path
34import tomllib
45
@@ -332,16 +333,16 @@ async def scenario() -> tuple[list[dict[str, object]], object, object, object]:
332333
333334 async def fake_request (method : str , url : httpx .URL , ** kwargs : object ) -> httpx .Response :
334335 calls .append ({"method" : method , "url" : str (url ), "kwargs" : kwargs })
335- if url . path . endswith ( "/objects" ) :
336+ if method == "PUT" :
336337 return httpx .Response (
337338 201 ,
338339 json = {
339340 "bucket" : "avatars" ,
340- "key" : "auto.jpg" ,
341+ "key" : url . path . rsplit ( "/objects/" , 1 )[ - 1 ] ,
341342 "size" : 10 ,
342343 "mimeType" : "image/jpeg" ,
343344 "uploadedAt" : "2024-01-01T00:00:00Z" ,
344- "url" : "/api/storage/buckets/avatars/objects/auto.jpg" ,
345+ "url" : str ( url . path ) ,
345346 },
346347 )
347348 if url .path .endswith ("/confirm-upload" ):
@@ -383,8 +384,11 @@ async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.R
383384
384385 calls , auto , confirm , upload_strategy , download_strategy = asyncio .run (scenario ())
385386
386- assert calls [0 ]["method" ] == "POST" and calls [0 ]["url" ].endswith ("/objects" )
387- assert auto .key == "auto.jpg"
387+ # upload_object_auto mints a unique key client-side and uploads via the
388+ # standard PUT route — the backend no longer generates keys.
389+ assert calls [0 ]["method" ] == "PUT"
390+ assert re .search (r"/objects/auto-\d+-[a-z0-9]{6}\.jpg$" , calls [0 ]["url" ])
391+ assert re .fullmatch (r"auto-\d+-[a-z0-9]{6}\.jpg" , auto .key )
388392
389393 assert calls [1 ]["method" ] == "POST" and calls [1 ]["url" ].endswith ("/confirm-upload" )
390394 assert calls [1 ]["kwargs" ]["json" ] == {"size" : 10 , "etag" : "etag123" }
@@ -403,6 +407,96 @@ async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.R
403407 assert download_strategy .method == "direct"
404408
405409
410+ def test_upload_object_auto_mints_key_client_side_and_uses_put () -> None :
411+ async def scenario () -> tuple [object , dict [str , object ]]:
412+ captured : dict [str , object ] = {}
413+
414+ async def fake_request (method : str , url : httpx .URL , ** kwargs : object ) -> httpx .Response :
415+ captured ["method" ] = method
416+ captured ["url" ] = str (url )
417+ captured ["kwargs" ] = kwargs
418+ key = url .path .rsplit ("/objects/" , 1 )[- 1 ]
419+ return httpx .Response (
420+ 201 ,
421+ json = {
422+ "bucket" : "docs" ,
423+ "key" : key ,
424+ "size" : 3 ,
425+ "mimeType" : "application/pdf" ,
426+ "uploadedAt" : "2026-01-01T00:00:00Z" ,
427+ "url" : str (url .path ),
428+ },
429+ )
430+
431+ async with InsforgeClient (
432+ base_url = "https://example.com" ,
433+ api_key = "ins_test" ,
434+ ) as client :
435+ client .http_client .request = fake_request # type: ignore[method-assign]
436+ result = await client .storage .upload_object_auto (
437+ "docs" ,
438+ data = b"pdf" ,
439+ filename = "report.pdf" ,
440+ content_type = "application/pdf" ,
441+ )
442+ return result , captured
443+
444+ result , captured = asyncio .run (scenario ())
445+
446+ # Client-generated key: sanitized base + timestamp + random, preserving ext.
447+ assert captured ["method" ] == "PUT"
448+ assert re .fullmatch (r"report-\d+-[a-z0-9]{6}\.pdf" , result .key )
449+ assert captured ["url" ].endswith (f"/api/storage/buckets/docs/objects/{ result .key } " )
450+ assert captured ["kwargs" ]["files" ]["file" ] == (result .key , b"pdf" , "application/pdf" )
451+
452+
453+ def test_upload_object_auto_generates_distinct_keys_for_same_filename () -> None :
454+ async def scenario () -> list [str ]:
455+ keys : list [str ] = []
456+
457+ async def fake_request (method : str , url : httpx .URL , ** kwargs : object ) -> httpx .Response :
458+ key = url .path .rsplit ("/objects/" , 1 )[- 1 ]
459+ keys .append (key )
460+ return httpx .Response (
461+ 201 ,
462+ json = {
463+ "bucket" : "docs" ,
464+ "key" : key ,
465+ "size" : 3 ,
466+ "mimeType" : "application/octet-stream" ,
467+ "uploadedAt" : "2026-01-01T00:00:00Z" ,
468+ "url" : str (url .path ),
469+ },
470+ )
471+
472+ async with InsforgeClient (base_url = "https://example.com" , api_key = "ins_test" ) as client :
473+ client .http_client .request = fake_request # type: ignore[method-assign]
474+ await client .storage .upload_object_auto ("docs" , data = b"abc" , filename = "photo.png" )
475+ await client .storage .upload_object_auto ("docs" , data = b"abc" , filename = "photo.png" )
476+ return keys
477+
478+ keys = asyncio .run (scenario ())
479+
480+ assert len (keys ) == 2
481+ assert keys [0 ] != keys [1 ]
482+
483+
484+ def test_generate_object_key_sanitizes_base_and_falls_back_to_file () -> None :
485+ from insforge .storage .client import _generate_object_key
486+
487+ # Non-alphanumeric characters in the base are replaced, extension kept.
488+ assert re .fullmatch (r"my-r-sum--v2-\d+-[a-z0-9]{6}\.pdf" , _generate_object_key ("my résumé v2.pdf" ))
489+ # Base longer than 32 chars is truncated.
490+ key = _generate_object_key ("a" * 50 + ".txt" )
491+ assert re .fullmatch (r"a{32}-\d+-[a-z0-9]{6}\.txt" , key )
492+ # Disallowed characters are each replaced with a dash.
493+ assert re .fullmatch (r"----\d+-[a-z0-9]{6}" , _generate_object_key ("日本語" ))
494+ # An empty base falls back to "file".
495+ assert re .fullmatch (r"file-\d+-[a-z0-9]{6}" , _generate_object_key ("" ))
496+ # A leading dot is not treated as an extension separator.
497+ assert re .fullmatch (r"-gitignore-\d+-[a-z0-9]{6}" , _generate_object_key (".gitignore" ))
498+
499+
406500def test_storage_encoding_for_new_admin_paths () -> None :
407501 async def scenario () -> list [dict [str , object ]]:
408502 calls : list [dict [str , object ]] = []
0 commit comments