@@ -404,3 +404,140 @@ def test_the_url_survives_the_round_trip(self):
404404 entries = entries if isinstance (entries , list ) else [entries ]
405405 locations .extend (item .get ("location" ) for item in entries )
406406 self .assertIn (self .URL , locations )
407+
408+
409+ class TestFileValidationDiagnosis (_DatabaseTestCase ):
410+ """WHICH FIELD a file-validation failure blames.
411+
412+ check_files' step 3 used to loop over the actual file_info entries: a
413+ required name declared in the file_list with nothing bound to it in
414+ file_info matched nothing and fell through to is_valid = True. That is
415+ the same fail-open MATLAB PR #182 closed for step 1, one step further
416+ down; DID-matlab#199 closed it for step 3. Python's _validate_files
417+ already read file_info and file_list independently -- MATLAB's own bug
418+ from that PR was a try/catch shape Python does not share -- but the
419+ fail-open in check_files itself was shared, so the port is that fix.
420+ """
421+
422+ def _doc_with_file_info (self , file_info , file_list = None ):
423+ """A demoFile document whose files section is set by the caller.
424+
425+ demoFile declares filename1.ext and filename2.ext, both
426+ mustbenotempty.
427+ """
428+ doc = Document ("demoFile" , ** {"demoFile.value" : 1 })
429+ doc .document_properties ["files" ]["file_info" ] = file_info
430+ if file_list is not None :
431+ doc .document_properties ["files" ]["file_list" ] = file_list
432+ return doc
433+
434+ def _assert_refused (self , doc ):
435+ with self .assertRaises (ValidationError ) as caught :
436+ self .db .add_docs ([doc ])
437+ self .assertEqual (
438+ caught .exception .identifier ,
439+ "DID:Database:ValidationFiles" ,
440+ f"refused for the wrong reason: { caught .exception } " ,
441+ )
442+ return str (caught .exception )
443+
444+ def test_empty_file_info_blames_the_unbound_file_not_the_file_list (self ):
445+ # The reported case. file_list declares both names and is correct;
446+ # nothing is bound to them. file_info = [] used to fall out of the
447+ # empty match loop and be accepted (Python) or throw and get the
448+ # file_list blamed (MATLAB).
449+ doc = self ._doc_with_file_info ([])
450+
451+ msg = self ._assert_refused (doc )
452+
453+ self .assertIn (
454+ "filename1.ext" ,
455+ msg ,
456+ "the message must name the file that is not bound" ,
457+ )
458+ self .assertIn (
459+ "file_info" ,
460+ msg ,
461+ "the message must name the field that is actually empty" ,
462+ )
463+ self .assertNotIn (
464+ "from the file_list" ,
465+ msg ,
466+ (
467+ "the file_list declares the file and is correct, so the "
468+ f"message must not send the reader there: { msg } "
469+ ),
470+ )
471+
472+ def test_name_missing_from_file_list_names_the_absent_name (self ):
473+ # A document whose file_list is genuinely incomplete: it holds
474+ # filename1.ext and not filename2.ext. The absent name is
475+ # filename2.ext, and that is what the message must say.
476+ doc = self ._doc_with_file_info ([], file_list = ["filename1.ext" ])
477+
478+ msg = self ._assert_refused (doc )
479+
480+ self .assertIn (
481+ "file_list" ,
482+ msg ,
483+ "a name absent from the file_list IS a file_list problem" ,
484+ )
485+ self .assertIn (
486+ "filename2.ext" ,
487+ msg ,
488+ "the message must name the file that is absent from the list" ,
489+ )
490+ self .assertNotIn (
491+ "filename1.ext" ,
492+ msg ,
493+ (
494+ "filename1.ext is in the file_list; only filename2.ext is "
495+ f"absent from it: { msg } "
496+ ),
497+ )
498+
499+ def test_document_with_no_files_field_still_validates (self ):
500+ # Most classes declare no files at all, and their documents have no
501+ # files section. That must keep working.
502+ doc = Document ("demoA" , ** {"demoA.value" : 1 })
503+ self .assertNotIn (
504+ "files" ,
505+ doc .document_properties ,
506+ "precondition: a demoA document has no files section" ,
507+ )
508+ self .db .add_docs ([doc ])
509+ self .assertIsNotNone (self .db .get_docs (doc .id ()))
510+
511+ def test_declared_optional_files_need_not_be_bound (self ):
512+ # demoSeries declares both of its files mustbenotempty = 0, so a
513+ # document that binds neither is valid: the file_list is correct
514+ # and nothing it declares is required.
515+ doc = Document ("demoSeries" , ** {"demoSeries.value" : 1 })
516+ doc .document_properties ["files" ]["file_info" ] = []
517+ self .db .add_docs ([doc ])
518+ self .assertIsNotNone (self .db .get_docs (doc .id ()))
519+
520+ def test_bound_required_files_validate (self ):
521+ # The other side of the tightening: a document that binds what its
522+ # schema requires still validates. Validation does no file I/O, so
523+ # the locations need not exist yet.
524+ doc = Document ("demoFile" , ** {"demoFile.value" : 1 })
525+ doc .add_file ("filename1.ext" , "placeholder" , ingest = 0 , delete_original = 0 )
526+ doc .add_file ("filename2.ext" , "placeholder" , ingest = 0 , delete_original = 0 )
527+ self .db .add_docs ([doc ])
528+ self .assertIsNotNone (self .db .get_docs (doc .id ()))
529+
530+ def test_check_files_directly_reports_unbound_required_file (self ):
531+ # The unit-level counterpart, calling check_files without a database.
532+ is_valid , message = check_files (
533+ expected_names = ["filename1.ext" , "filename2.ext" ],
534+ must_have_value = [1 , 1 ],
535+ actual_file_names = [],
536+ doc_name = "demoFile doc X" ,
537+ files = [],
538+ actual_file_list = ["filename1.ext" , "filename2.ext" ],
539+ )
540+ self .assertFalse (is_valid )
541+ self .assertIn ("filename1.ext" , message )
542+ self .assertIn ("file_info" , message )
543+ self .assertNotIn ("from the file_list" , message )
0 commit comments