|
| 1 | +# SPDX-License-Identifier: Apache-2.0 |
| 2 | +# scanpipe/tests/test_curation_commands.py |
| 3 | + |
| 4 | +import json |
| 5 | +from io import StringIO |
| 6 | + |
| 7 | +from django.test import TestCase |
| 8 | +from django.core.management import call_command |
| 9 | +from django.core.management.base import CommandError |
| 10 | + |
| 11 | +from scanpipe.models import Project, CodebaseResource, DiscoveredPackage |
| 12 | + |
| 13 | + |
| 14 | +class CurationCommandStubTestCase(TestCase): |
| 15 | + """ |
| 16 | + Tests for management commands related to curation. |
| 17 | + Commands tested: export_curations, import_curations, propagate_origins |
| 18 | + Uses call_command stubs since commands may not be implemented yet. |
| 19 | + """ |
| 20 | + |
| 21 | + def setUp(self): |
| 22 | + self.project = Project.objects.create(name="cmd-test-project") |
| 23 | + self.out = StringIO() |
| 24 | + self.err = StringIO() |
| 25 | + |
| 26 | + def tearDown(self): |
| 27 | + self.project.delete() |
| 28 | + |
| 29 | + # ------------------------------------------------------------------ |
| 30 | + # Command argument validation stubs |
| 31 | + # ------------------------------------------------------------------ |
| 32 | + |
| 33 | + def test_export_curations_requires_project(self): |
| 34 | + """export_curations command needs --project argument.""" |
| 35 | + try: |
| 36 | + call_command("export_curations", stdout=self.out, stderr=self.err) |
| 37 | + output = self.out.getvalue() |
| 38 | + self.assertIsNotNone(output) |
| 39 | + except (CommandError, SystemExit): |
| 40 | + pass # Expected if command requires --project |
| 41 | + except Exception: |
| 42 | + self.skipTest("export_curations command not yet implemented") |
| 43 | + |
| 44 | + def test_import_curations_requires_file(self): |
| 45 | + """import_curations command needs --file argument.""" |
| 46 | + try: |
| 47 | + call_command("import_curations", stdout=self.out, stderr=self.err) |
| 48 | + except (CommandError, SystemExit): |
| 49 | + pass |
| 50 | + except Exception: |
| 51 | + self.skipTest("import_curations command not yet implemented") |
| 52 | + |
| 53 | + def test_propagate_origins_requires_project(self): |
| 54 | + """propagate_origins command needs --project argument.""" |
| 55 | + try: |
| 56 | + call_command("propagate_origins", stdout=self.out, stderr=self.err) |
| 57 | + except (CommandError, SystemExit): |
| 58 | + pass |
| 59 | + except Exception: |
| 60 | + self.skipTest("propagate_origins command not yet implemented") |
| 61 | + |
| 62 | + # ------------------------------------------------------------------ |
| 63 | + # Export logic unit tests |
| 64 | + # ------------------------------------------------------------------ |
| 65 | + |
| 66 | + def test_export_format_json(self): |
| 67 | + curations = [ |
| 68 | + {"resource_path": "src/a.py", "origin": "pkg:pypi/x@1.0", "confidence": 0.9} |
| 69 | + ] |
| 70 | + export = {"schema_version": "1.0", "curations": curations} |
| 71 | + output = json.dumps(export, indent=2) |
| 72 | + parsed = json.loads(output) |
| 73 | + self.assertIn("curations", parsed) |
| 74 | + self.assertEqual(len(parsed["curations"]), 1) |
| 75 | + |
| 76 | + def test_export_empty_project(self): |
| 77 | + curations = [] |
| 78 | + export = {"schema_version": "1.0", "curations": curations} |
| 79 | + output = json.dumps(export) |
| 80 | + parsed = json.loads(output) |
| 81 | + self.assertEqual(parsed["curations"], []) |
| 82 | + |
| 83 | + def test_export_includes_all_fields(self): |
| 84 | + curation = { |
| 85 | + "resource_path": "src/main.py", |
| 86 | + "origin": "pkg:pypi/requests@2.28.0", |
| 87 | + "confidence": 0.95, |
| 88 | + "status": "confirmed", |
| 89 | + "notes": "Verified", |
| 90 | + "author": "user@example.com", |
| 91 | + } |
| 92 | + for field in ["resource_path", "origin", "confidence", "status"]: |
| 93 | + self.assertIn(field, curation) |
| 94 | + |
| 95 | + def test_export_multiple_curations(self): |
| 96 | + curations = [ |
| 97 | + {"resource_path": f"src/f{i}.py", "origin": f"pkg:pypi/pkg{i}@1.0"} |
| 98 | + for i in range(10) |
| 99 | + ] |
| 100 | + export = json.dumps({"curations": curations}) |
| 101 | + parsed = json.loads(export) |
| 102 | + self.assertEqual(len(parsed["curations"]), 10) |
| 103 | + |
| 104 | + # ------------------------------------------------------------------ |
| 105 | + # Import logic unit tests |
| 106 | + # ------------------------------------------------------------------ |
| 107 | + |
| 108 | + def test_import_valid_json(self): |
| 109 | + json_str = json.dumps({ |
| 110 | + "schema_version": "1.0", |
| 111 | + "curations": [ |
| 112 | + {"resource_path": "src/a.py", "origin": "pkg:pypi/x@1.0"} |
| 113 | + ] |
| 114 | + }) |
| 115 | + data = json.loads(json_str) |
| 116 | + self.assertIn("curations", data) |
| 117 | + self.assertEqual(len(data["curations"]), 1) |
| 118 | + |
| 119 | + def test_import_invalid_json_fails(self): |
| 120 | + with self.assertRaises(json.JSONDecodeError): |
| 121 | + json.loads("not valid json {{{") |
| 122 | + |
| 123 | + def test_import_missing_curations_key(self): |
| 124 | + data = json.loads('{"schema_version": "1.0"}') |
| 125 | + curations = data.get("curations", []) |
| 126 | + self.assertEqual(curations, []) |
| 127 | + |
| 128 | + def test_import_empty_curations(self): |
| 129 | + json_str = json.dumps({"schema_version": "1.0", "curations": []}) |
| 130 | + data = json.loads(json_str) |
| 131 | + self.assertEqual(data["curations"], []) |
| 132 | + |
| 133 | + def test_import_preserves_confidence(self): |
| 134 | + json_str = json.dumps({ |
| 135 | + "curations": [ |
| 136 | + {"resource_path": "a.py", "origin": "pkg:pypi/x@1", "confidence": 0.85} |
| 137 | + ] |
| 138 | + }) |
| 139 | + data = json.loads(json_str) |
| 140 | + self.assertAlmostEqual(data["curations"][0]["confidence"], 0.85) |
| 141 | + |
| 142 | + # ------------------------------------------------------------------ |
| 143 | + # Propagation logic unit tests |
| 144 | + # ------------------------------------------------------------------ |
| 145 | + |
| 146 | + def test_propagation_creates_new_curations(self): |
| 147 | + confirmed = {"resource_path": "src/main.py", "origin": "pkg:pypi/app@1.0"} |
| 148 | + sibling_paths = ["src/utils.py", "src/models.py"] |
| 149 | + |
| 150 | + propagated = [] |
| 151 | + for path in sibling_paths: |
| 152 | + if path.startswith("src/"): |
| 153 | + propagated.append({ |
| 154 | + "resource_path": path, |
| 155 | + "origin": confirmed["origin"], |
| 156 | + "confidence": 0.7, |
| 157 | + "propagated_from": confirmed["resource_path"], |
| 158 | + }) |
| 159 | + |
| 160 | + self.assertEqual(len(propagated), 2) |
| 161 | + for c in propagated: |
| 162 | + self.assertEqual(c["origin"], "pkg:pypi/app@1.0") |
| 163 | + self.assertIn("propagated_from", c) |
| 164 | + |
| 165 | + def test_propagation_confidence_is_lower(self): |
| 166 | + manual_confidence = 1.0 |
| 167 | + propagated_confidence = manual_confidence * 0.7 |
| 168 | + self.assertLess(propagated_confidence, manual_confidence) |
| 169 | + |
| 170 | + def test_propagation_does_not_overwrite_confirmed(self): |
| 171 | + confirmed_paths = {"src/main.py"} |
| 172 | + all_paths = ["src/main.py", "src/utils.py"] |
| 173 | + to_propagate = [p for p in all_paths if p not in confirmed_paths] |
| 174 | + self.assertEqual(to_propagate, ["src/utils.py"]) |
| 175 | + |
| 176 | + # ------------------------------------------------------------------ |
| 177 | + # DB-backed command tests |
| 178 | + # ------------------------------------------------------------------ |
| 179 | + |
| 180 | + def test_project_resources_available_for_export(self): |
| 181 | + CodebaseResource.objects.create(project=self.project, path="src/a.py") |
| 182 | + CodebaseResource.objects.create(project=self.project, path="src/b.py") |
| 183 | + count = CodebaseResource.objects.filter(project=self.project).count() |
| 184 | + self.assertEqual(count, 2) |
| 185 | + |
| 186 | + def test_project_packages_available_for_export(self): |
| 187 | + DiscoveredPackage.objects.create( |
| 188 | + project=self.project, type="pypi", name="requests", version="2.28.0" |
| 189 | + ) |
| 190 | + count = DiscoveredPackage.objects.filter(project=self.project).count() |
| 191 | + self.assertEqual(count, 1) |
| 192 | + |
| 193 | + def test_command_output_encoding(self): |
| 194 | + """Ensure output handles unicode correctly (Python 3.13).""" |
| 195 | + data = {"origin": "pkg:pypi/ünïcödé@1.0", "path": "src/main.py"} |
| 196 | + serialized = json.dumps(data, ensure_ascii=False) |
| 197 | + self.assertIn("ünïcödé", serialized) |
| 198 | + |
| 199 | + def test_command_handles_large_export(self): |
| 200 | + curations = [ |
| 201 | + {"resource_path": f"src/f{i}.py", "origin": f"pkg:pypi/p{i}@1.0"} |
| 202 | + for i in range(500) |
| 203 | + ] |
| 204 | + serialized = json.dumps({"curations": curations}) |
| 205 | + parsed = json.loads(serialized) |
| 206 | + self.assertEqual(len(parsed["curations"]), 500) |
0 commit comments