Make a partly-undone import retryable, and refuse a plan it cannot deliver
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Failing after 4m44s

Five findings from the review of the WINC import, plus the rendering each fix
needed to be usable.

An undo that hit a conflict wrote status 'undone' anyway, and undo_batch returns
immediately for an undone batch. So the one entry that needed a retry was the
only one that could never get it: the human resolves the conflict, clicks Undo,
and gets a no-op. A conflicted undo is now 'partially-undone', undoneat is
stamped only when the batch is genuinely finished, and the batches table offers
"Retry undo" for that status. The column was String(16) and the new value is
exactly 16 characters, so migration 7d38 widens it to 32 rather than rely on an
exact fit.

_UNDO_REFERENCES listed only assets, so a business unit this batch created was
hard-deleted even when something else still pointed at it. Two of the three
missing referrers are real FKs (dashboarddefaults.businessunitid,
businessunits.parentid): MySQL raises and the whole undo rolls back, so the
batch cannot be undone at all. The third, notifications.businessunitid, has no
FK, so the delete succeeded and left a notification pointing at an id that no
longer exists - silently, on both engines. All four are listed and the guard is
tested through the route.

The reference guard also queried tables that need not exist. A lean site drops
the tables of plugins it does not install (ADR-014), and notifications is
plugin-owned, so on such a site the query raised 1146, rolled the undo back and
left the batch permanently un-undoable. Absent tables are skipped; a table that
does not exist cannot hold a reference.

_PACKET_GROUPS reports on location, but apply_plan only ever creates business
units - it sets locationid only when the location already resolved. A plan
needing a new location asked the admin to acknowledge it, got the tick, wrote
nothing, and reported nothing. build_packet now takes the groups the writer can
actually create; anything else needed is 'uncreatable' and blocks at plan time,
where the admin can act on it, rather than dying at commit on a message about
acknowledging something the screen had no control for. The writer refuses the
same case independently. Both the console packet and the import page name the
blocking groups: setting blocking without printing the reason would leave the
banner pointing at nothing.

The web undo route caught ValueError only, so refusing a batch belonging to
another importer surfaced as a 500. That refusal is new too: the journal is a
general primitive and undo_batch does not filter on kind, so any other
importer's batches were reversible through this endpoint, carrying WINC's
references map - which lists none of that importer's tables, making every
reference check pass and every delete unconditional.

Nine tests. The two guards were verified by planting violations: dropping
dashboarddefaults from the references map, and passing creatable=None, each fail
the test written for them.

Scope: product
This commit is contained in:
cproudlock
2026-09-03 09:09:55 -04:00
parent 405a2ef919
commit 377cc69264
12 changed files with 408 additions and 21 deletions

View File

@@ -8,6 +8,7 @@ in-memory schema: seed a machine asset, upload an export that matches it, and
assert what preview reports, what commit writes, and that undo puts it back.
"""
import io
from datetime import datetime
import pytest
@@ -268,3 +269,153 @@ def test_a_preview_of_one_file_does_not_approve_another(client, db,
'planhash': approved})
assert response.status_code == 400
assert Asset.query.filter_by(name='Something Else Entirely').first() is None
# --- an undo that hit a conflict must stay retryable -------------------------
#
# undo_batch used to write 'undone' whatever happened, and its early return
# refuses a second undo on an undone batch - so the one entry that needed a
# retry was the only one that could never get it.
def test_a_conflicted_undo_is_marked_partial_and_can_be_retried(
client, db, auth_headers, app):
from shopdb.core.models import Asset, ImportBatch, BATCH_PARTIAL, BATCH_UNDONE
assetid, _ = _seed_machine(db)
committed = _commit(client, auth_headers, extra={'acknowledge': 'businessunit'})
assert committed.status_code == 200
batchid = committed.get_json()['data']['batchid']
# A human edits a cell the import wrote. Undo must refuse to clobber it.
with app.app_context():
asset = db.session.get(Asset, assetid)
asset.name = 'Edited By A Human'
db.session.commit()
first = client.post(UNDO, json={'batchid': batchid}, headers=auth_headers)
assert first.status_code == 200
body = first.get_json()['data']
assert body['conflicts'], 'expected the human edit to conflict'
with app.app_context():
assert db.session.get(ImportBatch, batchid).status == BATCH_PARTIAL
# The human resolves it. The retry must actually do something - this is
# what the old 'undone' status made impossible.
with app.app_context():
db.session.get(Asset, assetid).name = 'Renamed Machine'
db.session.commit()
second = client.post(UNDO, json={'batchid': batchid}, headers=auth_headers)
assert second.status_code == 200
assert second.get_json()['data']['reverted'] >= 1, 'retry did nothing'
with app.app_context():
assert db.session.get(ImportBatch, batchid).status == BATCH_UNDONE
def test_a_clean_undo_is_still_marked_undone(client, db, auth_headers, app):
from shopdb.core.models import ImportBatch, BATCH_UNDONE
_seed_machine(db)
committed = _commit(client, auth_headers, extra={'acknowledge': 'businessunit'})
batchid = committed.get_json()['data']['batchid']
response = client.post(UNDO, json={'batchid': batchid}, headers=auth_headers)
assert response.status_code == 200
assert not response.get_json()['data']['conflicts']
with app.app_context():
batch = db.session.get(ImportBatch, batchid)
assert batch.status == BATCH_UNDONE
assert batch.undoneat is not None
# --- the guards themselves, not just the paths through them ------------------
def test_undo_refuses_a_business_unit_something_else_points_at(
client, db, auth_headers, app):
"""_UNDO_REFERENCES named only assets, so a BU referenced from anywhere
else was hard-deleted anyway. The guard is a SELECT COUNT(*), so SQLite
exercises it exactly as MySQL does."""
from shopdb.core.models import BusinessUnit, DashboardDefault
_seed_machine(db)
committed = _commit(client, auth_headers, extra={'acknowledge': 'businessunit'})
assert committed.status_code == 200
batchid = committed.get_json()['data']['batchid']
with app.app_context():
created = BusinessUnit.query.filter_by(businessunit='Large Parts').first()
assert created is not None, 'the import should have created the BU'
db.session.add(DashboardDefault(businessunitid=created.businessunitid))
db.session.commit()
buid = created.businessunitid
response = client.post(UNDO, json={'batchid': batchid}, headers=auth_headers)
assert response.status_code == 200
reasons = [c['reason'] for c in response.get_json()['data']['conflicts']]
assert any('dashboarddefaults' in r for r in reasons), reasons
with app.app_context():
assert db.session.get(BusinessUnit, buid) is not None, \
'the BU was deleted despite still being referenced'
def test_undo_refuses_a_batch_from_another_importer(client, db, auth_headers, app):
from shopdb.core.models import ImportBatch, BATCH_APPLIED
with app.app_context():
db.session.add(ImportBatch(batchid='not-ours', kind='some-other-import',
planhash='0' * 64, rowcount=0,
createdat=datetime.utcnow(),
status=BATCH_APPLIED))
db.session.commit()
response = client.post(UNDO, json={'batchid': 'not-ours'}, headers=auth_headers)
assert response.status_code == 400, response.get_json()
assert 'different importer' in response.get_json()['data']['error']['message']
# --- a group the writer cannot create must block, visibly ---------------------
#
# _PACKET_GROUPS reports on location, but apply_plan only ever creates business
# units: it sets locationid only for a location that already RESOLVED. So a plan
# needing a new location used to preview clean, get acknowledged, commit, and
# silently do none of it.
# 'WM-T6/U6' is an alias override onto the canonical location 'T6'. Seeding the
# machine without a T6 location is what makes the location NEEDS_CREATE rather
# than UNRESOLVED - an unaliased bay is merely unresolved, which already blocked.
NEEDS_LOCATION_CSV = (
'assetnum,description,location,eq1\r\n'
'W0083A,Renamed Machine,WM-T6/U6,lg parts\r\n'
)
def test_preview_blocks_and_names_a_group_it_cannot_create(client, db,
auth_headers):
_seed_machine(db)
response = _upload(client, auth_headers, PREVIEW,
csv_text=NEEDS_LOCATION_CSV)
assert response.status_code == 200, response.get_json()
report = response.get_json()['data']
assert report['needscreate'].get('location') == {'T6': 1}, report
assert report['blocking'] is True, report
# Named, not merely blocked: the page renders this list, and without it the
# banner says "blocked" with nothing on screen to act on.
assert report['uncreatable'].get('location') == {'T6': 1}, report
def test_commit_refuses_a_group_it_cannot_create(client, db, auth_headers):
from shopdb.core.models.asset import Asset
assetid, _ = _seed_machine(db)
response = _commit(client, auth_headers, csv_text=NEEDS_LOCATION_CSV,
extra={'acknowledge': 'businessunit,location'})
assert response.status_code == 400, response.get_json()
with client.application.app_context():
assert db.session.get(Asset, assetid).name == 'Old Name', \
'nothing may be written'