Files
shopdb-flask/tests/test_plugins/test_collector_deviceid.py
cproudlock d60ed602a1 Stop three ways the collector and the forms wrote things nobody asked for
A review of last week's device-identity work found these; two were writing bad
data and one was reproduced against a live server before being fixed.

AN UPDATE COULD BLANK AN ASSET NUMBER, on all six asset update paths. Create
validates it and the column is NOT NULL, but the conflict check only runs when
the value DIFFERS, and '' collides with nothing - so an empty assetnumber went
straight through to a required column. This is the likely source of the assets
found with no number: a form that loaded blank and was then saved.

MACHINEFORM COULD LOAD BLANK AND LET YOU SAVE IT. One try/catch wrapped eight
reference loads AND the machine fetch, so a single transient failure among them
- one page of listAll() timing out during a collector cycle is enough - rejected
the whole block and rendered a fully editable EDIT form with every field empty,
the error banner far below next to Save. Typing an asset number and saving then
wrote the blanks over a real machine. The record now loads in its own try, and a
failure shows the reason INSTEAD of the form: an empty edit form is
indistinguishable from a record whose fields are genuinely empty.

NAMING A DEVICE THAT DID NOT RESOLVE STILL MINTED A TWIN. Both device paths
warned "not linked" and then fell through to mint <HOST>-PARTMARKER or
<HOST>-CMM - the hostname-derived twin the resolution order exists to prevent.
The warning was true about the typo'd number and false about the twin. Naming a
device is a commitment: if the name does not resolve, or resolves to the wrong
kind of thing, link nothing and say so. Silence still means "work it out", so a
bay with no file keeps the reuse-then-mint behaviour it always had.

TWO PCS COULD BOTH HOLD ONE DEVICE, ACTIVELY, WITH NO WARNING. Verified against
a live server: report as one host, then as another naming the same marker, and
both controls rows stayed active. Neither device path had ever looked at who
else held the target - only at links whose source was THIS PC - so a replaced PC
kept its link forever and an asset-id.txt copied to a second bay claimed the
device silently. It now reuses the machine link's rule rather than inventing a
second one: an incumbent that has gone quiet past the claim window or been moved
off In Use has yielded and is archived, never deleted; a live incumbent keeps
the device and the challenger is recorded dormant.

The swap test asserted the old behaviour and now asserts the new one, split in
two: a live incumbent keeps it, and handover completes once the incumbent
yields. Two other tests were passing while their names lied - the unknown-device
one checked only that the typo'd asset was not created, not that nothing was
linked, and it passed while a twin was minted beside it.
2026-08-20 16:11:08 -04:00

270 lines
12 KiB
Python

"""asset-id.txt names the device, and that name survives a PC swap.
Every other identity the collector has for a subordinate device is derived from
the PC: reuse looks for a prior link from THIS PC asset, and adoption looks up
`<PC number>-<SUFFIX>`. Both survive a re-image and neither survives a swap - a
new hostname is a new PC asset with no prior link and a predicted number that
has never existed, so the same physical device gets a second record while the
first keeps its history under a dead PC's name.
That is the failure that left 43 measuring tools shadowed by minted twins. These
tests pin the fix for BOTH device families, because the part-marker path was
modelled on the metrology path as it stood before it was fixed.
"""
import pytest
from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
KEY = 'deviceid-key'
@pytest.fixture
def collector_key(app):
old = app.config.get('COLLECTOR_API_KEY')
app.config['COLLECTOR_API_KEY'] = KEY
yield KEY
app.config['COLLECTOR_API_KEY'] = old
@pytest.fixture
def rig(db):
for name in ('computer', 'machine', 'measuring_tool'):
if not AssetType.query.filter_by(assettype=name).first():
db.session.add(AssetType(assettype=name))
for name in ('controls', 'partof'):
if not RelationshipType.query.filter_by(relationshiptype=name).first():
db.session.add(RelationshipType(relationshiptype=name))
db.session.commit()
def _report(client, key, hostname, pctype, **extra):
payload = {'hostname': hostname, 'pctype': pctype}
payload.update(extra)
return client.post('/api/collector/computers', json=payload,
headers={'X-API-Key': key})
def _asset(db, assetnumber, assettype='machine'):
"""A bare asset: an operation, or something that is NOT a device."""
at = AssetType.query.filter_by(assettype=assettype).first()
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
db.session.add(asset)
db.session.commit()
return asset
def _marker(db, assetnumber):
"""A Part Marker the collector did NOT create - the real unit on the floor.
The extension row and machine type are what make it a marker; a bare asset
of the right number is deliberately refused, which the wrong-type test pins.
"""
from plugins.machines.models import Machine, MachineType
at = AssetType.query.filter_by(assettype='machine').first()
mtype = MachineType.query.filter_by(machinetype='Part Marker').first()
if mtype is None:
mtype = MachineType(machinetype='Part Marker')
db.session.add(mtype)
db.session.flush()
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(Machine(assetid=asset.assetid,
machinetypeid=mtype.machinetypeid))
db.session.commit()
return asset
def _tool(db, assetnumber):
"""A measuring tool the collector did NOT create."""
from plugins.measuringtools.models import MeasuringTool
at = AssetType.query.filter_by(assettype='measuring_tool').first()
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(MeasuringTool(assetid=asset.assetid))
db.session.commit()
return asset
def _controlled(pcname, label):
"""Asset numbers this PC controls under a collector label."""
pc = Asset.query.filter(Asset.assetnumber.ilike(pcname)).first()
if pc is None:
return []
rels = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid, label=label, isactive=True).all()
return sorted(Asset.query.filter_by(assetid=r.targetassetid).first().assetnumber
for r in rels)
# --------------------------------------------------------------- part markers
def _retire(db, hostname):
"""Move a PC off In Use, which is the one-step way to yield its device."""
from shopdb.core.models import AssetStatus
retired = AssetStatus.query.filter_by(status='Retired').first()
if retired is None:
retired = AssetStatus(status='Retired')
db.session.add(retired)
db.session.flush()
pc = Asset.query.filter(Asset.assetnumber.ilike(hostname)).first()
pc.statusid = retired.statusid
db.session.commit()
def test_a_pc_swap_does_not_mint_a_second_marker(client, db, rig, collector_key):
"""THE case this exists for. Same physical marker, two different PCs.
Neither PC mints a twin. Who HOLDS the marker is settled separately, by the
two tests below - this one pins only that the physical unit stays one row.
"""
_asset(db, '0613')
marker = _marker(db, 'PM-0613-A')
first = _report(client, collector_key, 'FMARK100',
pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert first.status_code in (200, 201), first.get_data(as_text=True)[:300]
# The bay's PC is replaced. New hostname, same marker named in asset-id.txt.
second = _report(client, collector_key, 'FMARK200',
pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert second.status_code in (200, 201), second.get_data(as_text=True)[:300]
assert Asset.query.filter_by(assetnumber='FMARK100-PARTMARKER').first() is None
assert Asset.query.filter_by(assetnumber='FMARK200-PARTMARKER').first() is None
assert Asset.query.filter_by(assetnumber='PM-0613-A').count() == 1
assert marker.assetid == Asset.query.filter_by(
assetnumber='PM-0613-A').first().assetid
def test_a_live_incumbent_keeps_the_marker_and_the_challenger_is_dormant(
client, db, rig, collector_key):
"""Two PCs naming one device must not both hold it actively.
Before this, neither device path looked at who else held the target, so a
replaced PC kept its link forever and a copied asset-id.txt claimed the same
marker from every bay, silently.
"""
_asset(db, '0613')
_marker(db, 'PM-0613-A')
_report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
resp = _report(client, collector_key, 'FMARK200',
pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A']
assert _controlled('FMARK200', 'collector:partmarker') == []
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'FMARK100' in warnings and 'PM-0613-A' in warnings
def test_handover_completes_once_the_incumbent_yields(client, db, rig,
collector_key):
"""The swap case as it actually happens: the old PC is retired or goes quiet."""
_asset(db, '0613')
_marker(db, 'PM-0613-A')
_report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
_retire(db, 'FMARK100')
_report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert _controlled('FMARK200', 'collector:partmarker') == ['PM-0613-A']
# Archived, never deleted: "which PC drove this in June" stays answerable.
assert _controlled('FMARK100', 'collector:partmarker') == []
assert AssetRelationship.query.filter_by(label='collector:partmarker').count() >= 2
def test_without_the_file_a_swap_still_mints_the_old_way(client, db, rig,
collector_key):
"""The unfixed behaviour, pinned so the file's value stays visible."""
_asset(db, '0614')
assert _report(client, collector_key, 'FMARK300',
pctype='gea-shopfloor-partmarker',
machinenumber='0614').status_code in (200, 201)
assert _report(client, collector_key, 'FMARK400',
pctype='gea-shopfloor-partmarker',
machinenumber='0614').status_code in (200, 201)
assert Asset.query.filter_by(assetnumber='FMARK300-PARTMARKER').first()
assert Asset.query.filter_by(assetnumber='FMARK400-PARTMARKER').first()
def test_an_unknown_device_warns_and_links_nothing(client, db, rig,
collector_key):
_asset(db, '0616')
resp = _report(client, collector_key, 'FMARK500',
pctype='gea-shopfloor-partmarker',
machinenumber='0616', deviceid='PM-TYPO')
assert resp.status_code in (200, 201)
assert Asset.query.filter_by(assetnumber='PM-TYPO').first() is None
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'PM-TYPO' in warnings
# LINKS NOTHING, which is what the name claims. This used to warn and then
# mint FMARK500-PARTMARKER anyway - the twin the whole path exists to stop -
# and the test passed because it only checked that PM-TYPO was not created.
assert Asset.query.filter_by(assetnumber='FMARK500-PARTMARKER').first() is None
assert _controlled('FMARK500', 'collector:partmarker') == []
def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key):
"""A machine number pasted into asset-id.txt must not become a marker."""
_asset(db, '0617')
_asset(db, 'PLAIN-MACHINE')
resp = _report(client, collector_key, 'FMARK600',
pctype='gea-shopfloor-partmarker',
machinenumber='0617', deviceid='PLAIN-MACHINE')
assert resp.status_code in (200, 201)
assert _controlled('FMARK600', 'collector:partmarker') == []
assert Asset.query.filter_by(assetnumber='FMARK600-PARTMARKER').first() is None
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'PLAIN-MACHINE' in warnings
def test_repeat_cycles_with_the_file_are_stable(client, db, rig, collector_key):
_asset(db, '0618')
_marker(db, 'PM-0618-A')
codes = [_report(client, collector_key, 'FMARK700',
pctype='gea-shopfloor-partmarker',
machinenumber='0618', deviceid='PM-0618-A').status_code
for _ in range(3)]
assert codes == [codes[0]] * 3, codes
assert _controlled('FMARK700', 'collector:partmarker') == ['PM-0618-A']
# ------------------------------------------------------------ measuring tools
def test_the_same_file_serves_a_metrology_bay(client, db, rig, collector_key):
"""One file, no device type in it: the pc-type decides which sync uses it."""
_tool(db, 'MT-9001')
resp = _report(client, collector_key, 'KEYENCE100',
pctype='gea-shopfloor-keyence', deviceid='MT-9001')
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
assert _controlled('KEYENCE100', 'collector:measuringtool') == ['MT-9001']
assert Asset.query.filter_by(assetnumber='KEYENCE100-KEYENCE').first() is None
def test_the_0120_field_name_still_works(client, db, rig, collector_key):
"""measuringtool-id.txt shipped in 0.12.0; a staged bay keeps reporting."""
_tool(db, 'MT-9002')
resp = _report(client, collector_key, 'KEYENCE200',
pctype='gea-shopfloor-keyence', measuringtoolid='MT-9002')
assert resp.status_code in (200, 201)
assert _controlled('KEYENCE200', 'collector:measuringtool') == ['MT-9002']
def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key):
_tool(db, 'MT-9003')
_tool(db, 'MT-9004')
resp = _report(client, collector_key, 'KEYENCE300',
pctype='gea-shopfloor-keyence',
deviceid='MT-9003', measuringtoolid='MT-9004')
assert resp.status_code in (200, 201)
assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003']