test_device_assets.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. from datetime import datetime, timedelta
  4. import pytest
  5. SOURCE_UID = "00000000-0000-0000-0000-000000000101"
  6. class MemoryDeviceAssetRepository:
  7. def __init__(self, *, active_sources=(SOURCE_UID,)):
  8. self.active_sources = set(active_sources)
  9. self.assets = {}
  10. self.mappings = {}
  11. self.versions = {}
  12. def source_is_active(self, source_uid):
  13. return source_uid in self.active_sources
  14. @staticmethod
  15. def _mapping_key(source_uid, source_entity, asset_type, source_code):
  16. return source_uid, source_entity, asset_type, source_code
  17. def find_mapping(
  18. self,
  19. source_uid,
  20. source_entity,
  21. asset_type,
  22. source_code,
  23. *,
  24. for_update=False,
  25. ):
  26. del for_update
  27. return self.mappings.get(
  28. self._mapping_key(
  29. source_uid,
  30. source_entity,
  31. asset_type,
  32. source_code,
  33. )
  34. )
  35. def get(self, asset_uid):
  36. return self.assets.get(asset_uid)
  37. def create_asset(self, asset, mapping, version):
  38. self.assets[asset.uid] = asset
  39. self.mappings[
  40. self._mapping_key(
  41. mapping.source_uid,
  42. mapping.source_entity,
  43. mapping.asset_type,
  44. mapping.source_code,
  45. )
  46. ] = mapping
  47. self.versions[asset.uid] = [version]
  48. return asset, mapping
  49. def update_asset(self, asset, mapping, version):
  50. self.assets[asset.uid] = asset
  51. self.mappings[
  52. self._mapping_key(
  53. mapping.source_uid,
  54. mapping.source_entity,
  55. mapping.asset_type,
  56. mapping.source_code,
  57. )
  58. ] = mapping
  59. self.versions[asset.uid].append(version)
  60. return asset, mapping
  61. def touch_mapping(self, mapping, *, source_updated_at, last_seen_at):
  62. updated = replace(
  63. mapping,
  64. source_updated_at=source_updated_at,
  65. last_seen_at=last_seen_at,
  66. )
  67. self.mappings[
  68. self._mapping_key(
  69. mapping.source_uid,
  70. mapping.source_entity,
  71. mapping.asset_type,
  72. mapping.source_code,
  73. )
  74. ] = updated
  75. return updated
  76. def search(self, filters, *, page, page_size):
  77. del filters
  78. records = list(self.assets.values())
  79. start = (page - 1) * page_size
  80. return records[start : start + page_size], len(records)
  81. def list_mappings(self, asset_uid):
  82. return [
  83. mapping
  84. for mapping in self.mappings.values()
  85. if mapping.asset_uid == asset_uid
  86. ]
  87. def list_versions(self, asset_uid):
  88. return list(self.versions.get(asset_uid, ()))
  89. def payload(**record_overrides):
  90. record = {
  91. "asset_type": "device",
  92. "source_code": "EQ-001",
  93. "name": "一号循环泵",
  94. "location": "一号车间",
  95. "organization": "动力部",
  96. "responsible_person": "设备管理员",
  97. "status": "active",
  98. "source_updated_at": "2026-07-29T08:00:00+08:00",
  99. "attributes": {"model": "P-100", "criticality": "A"},
  100. }
  101. record.update(record_overrides)
  102. return {
  103. "source_uid": SOURCE_UID,
  104. "source_entity": "asset.equipment",
  105. "records": [record],
  106. }
  107. def service(repository=None):
  108. from app.core.data_research.device_assets import DeviceAssetService
  109. ids = iter(
  110. (
  111. "00000000-0000-7000-8000-000000000201",
  112. "00000000-0000-7000-8000-000000000202",
  113. "00000000-0000-7000-8000-000000000203",
  114. "00000000-0000-7000-8000-000000000204",
  115. "00000000-0000-7000-8000-000000000205",
  116. "00000000-0000-7000-8000-000000000206",
  117. )
  118. )
  119. clock = iter(
  120. (
  121. datetime(2026, 7, 29, 8, 1),
  122. datetime(2026, 7, 29, 8, 2),
  123. datetime(2026, 7, 29, 8, 3),
  124. )
  125. )
  126. return DeviceAssetService(
  127. repository or MemoryDeviceAssetRepository(),
  128. uid_factory=ids.__next__,
  129. now_factory=clock.__next__,
  130. )
  131. def test_reimport_preserves_stable_uid_and_does_not_version_unchanged_data():
  132. repository = MemoryDeviceAssetRepository()
  133. assets = service(repository)
  134. first = assets.import_records(payload(), actor_uid="editor-1")
  135. second = assets.import_records(
  136. payload(source_updated_at="2026-07-29T09:00:00+08:00"),
  137. actor_uid="editor-1",
  138. )
  139. assert first.created_count == 1
  140. assert first.updated_count == 0
  141. assert second.unchanged_count == 1
  142. assert second.items[0].asset.uid == first.items[0].asset.uid
  143. assert second.items[0].asset.current_version == 1
  144. assert len(repository.versions[first.items[0].asset.uid]) == 1
  145. assert second.items[0].mapping.source_updated_at.isoformat() == (
  146. "2026-07-29T09:00:00+08:00"
  147. )
  148. def test_changed_normalized_record_creates_one_next_immutable_version():
  149. repository = MemoryDeviceAssetRepository()
  150. assets = service(repository)
  151. first = assets.import_records(payload(), actor_uid="editor-1")
  152. changed = assets.import_records(
  153. payload(location="二号车间"),
  154. actor_uid="editor-2",
  155. )
  156. asset_uid = first.items[0].asset.uid
  157. assert changed.updated_count == 1
  158. assert changed.items[0].asset.uid == asset_uid
  159. assert changed.items[0].asset.current_version == 2
  160. assert [item.version for item in repository.versions[asset_uid]] == [1, 2]
  161. assert repository.versions[asset_uid][0].snapshot["location"] == "一号车间"
  162. assert repository.versions[asset_uid][1].snapshot["location"] == "二号车间"
  163. assert repository.versions[asset_uid][1].actor_uid == "editor-2"
  164. @pytest.mark.parametrize(
  165. "asset_type",
  166. (
  167. "device",
  168. "component",
  169. "measurement_point",
  170. "alarm",
  171. "maintenance_record",
  172. ),
  173. )
  174. def test_import_accepts_each_wp04_asset_type(asset_type):
  175. result = service().import_records(
  176. payload(asset_type=asset_type),
  177. actor_uid="editor-1",
  178. )
  179. assert result.items[0].asset.asset_type == asset_type
  180. @pytest.mark.parametrize(
  181. ("bad_payload", "message"),
  182. (
  183. ({"source_uid": "missing", "source_entity": "asset.equipment", "records": []}, "source"),
  184. (payload(asset_type="report"), "asset_type"),
  185. (payload(source_code=""), "source_code"),
  186. (payload(name=""), "name"),
  187. (payload(uid="caller-selected"), "uid"),
  188. (payload(attributes={"password": "secret"}), "secret"),
  189. (payload(attributes={"nested": {"api_key": "secret"}}), "secret"),
  190. ),
  191. )
  192. def test_import_rejects_invalid_or_secret_bearing_records(
  193. bad_payload,
  194. message,
  195. ):
  196. from app.core.data_research.errors import DeviceAssetInvalid
  197. with pytest.raises(DeviceAssetInvalid, match=message):
  198. service().import_records(bad_payload, actor_uid="editor-1")
  199. def test_import_rejects_inactive_source_duplicate_identity_and_oversized_batch():
  200. from app.core.data_research.errors import DeviceAssetInvalid
  201. inactive = service(MemoryDeviceAssetRepository(active_sources=()))
  202. with pytest.raises(DeviceAssetInvalid, match="active"):
  203. inactive.import_records(payload(), actor_uid="editor-1")
  204. duplicate = payload()
  205. duplicate["records"].append(dict(duplicate["records"][0]))
  206. with pytest.raises(DeviceAssetInvalid, match="duplicate"):
  207. service().import_records(duplicate, actor_uid="editor-1")
  208. oversized = payload()
  209. oversized["records"] = [
  210. {
  211. **oversized["records"][0],
  212. "source_code": f"EQ-{index:04d}",
  213. }
  214. for index in range(501)
  215. ]
  216. with pytest.raises(DeviceAssetInvalid, match="500"):
  217. service().import_records(oversized, actor_uid="editor-1")
  218. def test_import_rejects_attributes_larger_than_the_catalog_boundary():
  219. from app.core.data_research.errors import DeviceAssetInvalid
  220. with pytest.raises(DeviceAssetInvalid, match="attributes"):
  221. service().import_records(
  222. payload(attributes={"notes": "x" * 65536}),
  223. actor_uid="editor-1",
  224. )
  225. def test_search_returns_asset_details_with_source_mappings():
  226. repository = MemoryDeviceAssetRepository()
  227. assets = service(repository)
  228. imported = assets.import_records(payload(), actor_uid="editor-1")
  229. records, total = assets.search(
  230. {"keyword": "循环泵"},
  231. page=1,
  232. page_size=20,
  233. )
  234. assert total == 1
  235. assert records[0].asset.uid == imported.items[0].asset.uid
  236. assert records[0].mappings == (imported.items[0].mapping,)
  237. def test_default_catalog_timestamps_keep_the_china_timezone():
  238. from app.core.data_research.device_assets import DeviceAssetService
  239. ids = iter(
  240. (
  241. "00000000-0000-7000-8000-000000000401",
  242. "00000000-0000-7000-8000-000000000402",
  243. "00000000-0000-7000-8000-000000000403",
  244. )
  245. )
  246. result = DeviceAssetService(
  247. MemoryDeviceAssetRepository(),
  248. uid_factory=ids.__next__,
  249. ).import_records(payload(), actor_uid="editor-1")
  250. created_at = result.items[0].asset.created_at
  251. assert created_at.tzinfo is not None
  252. assert created_at.utcoffset() == timedelta(hours=8)
  253. @pytest.mark.parametrize(
  254. "filters",
  255. (
  256. {"source_uid": "not-a-uuid"},
  257. {"asset_type": "report"},
  258. {"status": "deleted"},
  259. {"keyword": "x" * 301},
  260. ),
  261. )
  262. def test_search_rejects_invalid_catalog_filters(filters):
  263. from app.core.data_research.errors import DeviceAssetInvalid
  264. with pytest.raises(DeviceAssetInvalid):
  265. service().search(filters, page=1, page_size=20)