"""Invented field work, injected server clocks and private draft reservations."""
from datetime import datetime,timezone,timedelta
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import copy,json,uuid
import pytest
from fastapi.testclient import TestClient
from backend.app import create_app
from backend.fieldwork import split_days,duration_ms
from test_boundaries import app,client,write,job,quote,portal,ORIGIN
from test_identity import add_staff,member,update,login
from test_workforce import person,entry
from test_scheduling import booking
from test_materials import material
from test_photos import uploaded

class FakeClock:
    def __init__(self,start='2026-10-25T00:00:00+00:00'):self.utc=datetime.fromisoformat(start);self.mono=1000.
    def __call__(self):return self.utc,self.mono
    def advance(self,seconds):self.utc+=timedelta(seconds=seconds);self.mono+=seconds

def setup(c,app,team_size=1):
    j=job(c);assert write(c,'/api/jobs/'+j['id'],{'version':1,'status':'in_progress','notes':'PRIVATE JOB NOTE','scheduled_date':''},method='put').status_code==200
    people=[person(c,name='Worker '+str(n)) for n in range(team_size)];clock=FakeClock();app.state.field_clock=clock;return j,people,clock

def base(j):return '/api/field/jobs/'+j['id']
def field(c,j):
    r=c.get(base(j));assert r.status_code==200,r.text;return r.json()
def timer(c,j,tid):return next(t for t in field(c,j)['timers'] if t['id']==tid)
def start(c,j,people,key=None):return write(c,base(j)+'/timers',{'resource_ids':[p['id'] for p in people],'note':'PRIVATE TIMER NOTE'},key=key)
def act(c,j,tid,action,key=None,version=None):return write(c,base(j)+'/timers/'+tid+'/events/'+action,{'version':version or timer(c,j,tid)['version']},key=key)
def review(c,j,tid,intervals=None):return write(c,base(j)+'/timers/'+tid+'/reviewed',{'version':timer(c,j,tid)['version'],'reason':'Reviewed actual work','intervals':intervals})
def convert(c,j,tid,key=None,version=None):return write(c,base(j)+'/timers/'+tid+'/convert',{'version':version or timer(c,j,tid)['version'],'reason':'Reviewed rounded minutes and internal rates'},key=key)
def captured(c,app,team_size=1):
    j,people,clock=setup(c,app,team_size);r=start(c,j,people);assert r.status_code==200,r.text;tid=r.json()['id'];clock.advance(90);assert act(c,j,tid,'stop').status_code==200;assert review(c,j,tid).status_code==200;assert convert(c,j,tid).status_code==200;return j,people,clock,tid

def suggested(c,j):return c.get('/api/jobs/'+j['id']+'/suggestions').json()
def draft(c,j,sources=None,kind='invoice',target=None,price=1000,key=None):
    sources=sources if sources is not None else suggested(c,j)['sources'];lines=[{k:s[k] for k in ('source_kind','source_id','source_version','description','quantity')}|{'unit_pence':price} for s in sources]
    payload={'kind':kind,'lines':lines,'confirmed':True,'notes':'Reviewed customer note'}
    if target:payload.update(document_id=target['id'],version=target['version'])
    return write(c,'/api/jobs/'+j['id']+'/suggestions/draft',payload,key=key)
def doc(c,d):return c.get('/api/documents/'+d['kind']+'/'+d['id']).json()
def docact(c,d,action):return write(c,'/api/'+('quotes' if d['kind']=='quote' else 'invoices')+'/'+d['id']+'/'+action,{'version':doc(c,d)['version'],'reason':'Reviewed fictional correction'})


def test_active_jobs_exact_staff_scope_and_no_finances(app):
    c=client(app);j,people,clock=setup(c,app);other=job(c);booking(c,j,people[0]);s,_=add_staff(app,c,resource_id=people[0]['id'])
    assert len(s.get('/api/field/jobs').json())==1
    assert s.get(base(other)).status_code==404 and s.get('/api/jobs/'+j['id']+'/suggestions').status_code==403
    r=start(s,j,people);assert r.status_code==200;r=field(s,j);assert 'hourly_cost_pence' not in json.dumps(r) and 'PRIVATE JOB NOTE' not in json.dumps(r)
    assert TestClient(app,headers=ORIGIN).get(base(j)).status_code==401
    assert client(app,1).get(base(j)).status_code==404


def test_timer_pause_resume_rounding_team_costs_and_no_double_count(app):
    c=client(app);j,people,clock=setup(c,app,2);r=start(c,j,people);assert r.status_code==200,r.text;tid=r.json()['id'];clock.advance(70)
    assert act(c,j,tid,'pause').status_code==200;clock.advance(600)
    assert act(c,j,tid,'resume').status_code==200;clock.advance(50);assert act(c,j,tid,'stop').status_code==200
    assert timer(c,j,tid)['confirmed_ms']==120000
    assert c.get('/api/jobs/'+j['id']+'/costs').json()['labour']['minutes']==0
    assert review(c,j,tid).status_code==200;r=convert(c,j,tid);assert r.status_code==200,r.text
    assert len(r.json()['time_ids'])==2 and c.get('/api/jobs/'+j['id']+'/costs').json()['labour']['minutes']==4
    assert [s['source_kind'] for s in suggested(c,j)['sources']]==['field']
    assert convert(c,j,tid).status_code==409
    t=c.get('/api/jobs/'+j['id']+'/time').json()[0];assert write(c,'/api/time/'+t['id']+'/archive',{'version':t['version']}).status_code==409


def test_timer_retry_concurrency_and_cross_job_resource_lock(app):
    c=client(app);b=client(app);j,people,clock=setup(c,app);key=str(uuid.uuid4())
    with ThreadPoolExecutor(2) as pool:rs=list(pool.map(lambda who:start(who,j,people,key),[c,b]))
    assert all(r.status_code==200 for r in rs) and rs[0].json()==rs[1].json();tid=rs[0].json()['id']
    assert start(c,j,people).status_code==409
    clock.advance(60);version=timer(c,j,tid)['version'];key=str(uuid.uuid4())
    with ThreadPoolExecutor(2) as pool:rs=list(pool.map(lambda who:act(who,j,tid,'stop',key,version),[c,b]))
    assert all(r.status_code==200 for r in rs);assert review(c,j,tid).status_code==200;version=timer(c,j,tid)['version'];key=str(uuid.uuid4())
    with ThreadPoolExecutor(2) as pool:rs=list(pool.map(lambda who:convert(who,j,tid,key,version),[c,b]))
    assert all(r.status_code==200 for r in rs) and len(c.get('/api/jobs/'+j['id']+'/time').json())==1


def test_lost_heartbeat_and_restart_never_add_downtime(app):
    c=client(app);j,people,clock=setup(c,app);tid=start(c,j,people).json()['id'];clock.advance(60);assert act(c,j,tid,'heartbeat').status_code==200
    clock.advance(1000);row=timer(c,j,tid);assert row['state']=='interrupted' and row['confirmed_ms']==60000
    assert act(c,j,tid,'resume').status_code==409
    assert review(c,j,tid).status_code==200;assert convert(c,j,tid).status_code==200
    clock.advance(1);new=start(c,j,people).json()['id'];clock.advance(70);assert act(c,j,new,'heartbeat').status_code==200
    reopened=create_app(app.state.store.root);row=timer(client(reopened),j,new);assert row['state']=='interrupted' and row['confirmed_ms']==70000


@pytest.mark.parametrize('jump',[-60,3600])
def test_server_clock_drift_interrupts_at_last_confirmed_time(app,jump):
    c=client(app);j,people,clock=setup(c,app);tid=start(c,j,people).json()['id'];clock.advance(60);assert act(c,j,tid,'heartbeat').status_code==200
    clock.utc+=timedelta(seconds=jump);row=timer(c,j,tid);assert row['state']=='interrupted' and row['confirmed_ms']==60000


@pytest.mark.parametrize('start_at,end_at,minutes',[('2026-03-29T00:30:00+00:00','2026-03-29T01:30:00+00:00',60),('2026-10-25T01:30:00+01:00','2026-10-25T01:30:00+00:00',60)])
def test_dst_actual_elapsed_not_wall_clock_hours(start_at,end_at,minutes):
    rows=[{'start':start_at,'end':end_at}];assert duration_ms(rows)==minutes*60000 and sum(split_days(rows).values())==minutes*60000


def test_london_midnight_splits_actual_milliseconds_and_dates():
    assert split_days([{'start':'2026-10-24T22:59:30+00:00','end':'2026-10-24T23:00:30+00:00'}])=={'2026-10-24':30000,'2026-10-25':30000}


def test_revoked_assignment_blocks_cached_timer_and_log_writes(app):
    c=client(app);j,people,clock=setup(c,app);book=booking(c,j,people[0]);s,_=add_staff(app,c,resource_id=people[0]['id']);key=str(uuid.uuid4());r=start(s,j,people,key);tid=r.json()['id']
    assert write(c,'/api/calendar/'+book['id']+'/archive',{'version':1}).status_code==200
    assert start(s,j,people,key).status_code==404
    assert write(s,base(j)+'/timers/'+tid+'/events/stop',{'version':1}).status_code==404
    clock.advance(60);assert act(c,j,tid,'stop').status_code==200;assert review(c,j,tid).status_code==200


def test_staff_cannot_attribute_unassigned_person_or_owner_review(app):
    c=client(app);j,people,clock=setup(c,app,2);booking(c,j,people[0]);s,_=add_staff(app,c,resource_id=people[0]['id'])
    assert start(s,j,people).status_code==403
    tid=start(s,j,people[:1]).json()['id'];clock.advance(90);assert act(s,j,tid,'stop').status_code==200
    assert write(s,base(j)+'/timers/'+tid+'/reviewed',{'version':2,'reason':'No access'}).status_code==403


def test_review_correction_retains_rates_archives_prior_conversion_and_prevents_overlap(app):
    c=client(app);j,people,clock,tid=captured(c,app)
    old=c.get('/api/jobs/'+j['id']+'/time').json()[0];assert write(c,'/api/team/'+people[0]['id'],{'version':1,'name':'New name','role':'Worker','hourly_cost':'99.00','colour':'#245648'},method='put').status_code==200
    interval=timer(c,j,tid)['raw_intervals'][0];interval['end']=(datetime.fromisoformat(interval['start'])+timedelta(seconds=60)).isoformat()
    assert review(c,j,tid,[interval]).status_code==200;assert c.get('/api/jobs/'+j['id']+'/time').json()==[];assert convert(c,j,tid).status_code==200
    fresh=c.get('/api/jobs/'+j['id']+'/time').json()[0];assert fresh['hourly_cost_pence']==old['hourly_cost_pence'] and fresh['duration_minutes']==1
    assert len(c.get('/api/jobs/'+j['id']+'/time?archived=true').json())==1
    other=start(c,j,people).json()['id'];clock.advance(60);assert act(c,j,other,'stop').status_code==200
    assert review(c,j,other,[interval]).status_code==409


def test_field_logs_exact_photos_review_material_conversion_and_relink_guard(app):
    c=client(app);j,people,clock=setup(c,app);p=uploaded(c,j);data={'kind':'materials','content':'Fictional fixings','quantity':'1.125','unit':'box','work_date':'2026-10-25','photo_ids':[p['id']]};r=write(c,base(j)+'/logs',data);assert r.status_code==200,r.text;lid=r.json()['id']
    url=base(j)+'/logs/'+lid;assert write(c,url+'/state/review',{'version':1,'reason':'Owner checked observation'}).status_code==200
    key=str(uuid.uuid4());payload={'version':2,'reason':'Reviewed cost and received quantity','cost_price':'1.23','status':'received'};r=write(c,url+'/material',payload,key=key);assert r.status_code==200,r.text
    assert write(c,url+'/material',payload,key=key).json()==r.json();assert len(c.get('/api/materials').json()['items'])==1
    source=suggested(c,j)['sources'][0];assert source['internal_cost_pence']==138 and source['unit_pence'] is None
    other=job(c);p2=uploaded(c,other);assert write(c,base(j)+'/logs',{**data,'photo_ids':[p2['id']]}).status_code==404
    assert len(c.get(url+'/history').json())==3


def test_source_draft_never_auto_shares_and_preserves_costs(app):
    c=client(app);j,people,clock,tid=captured(c,app,2);source=suggested(c,j)['sources'][0];before=c.get('/api/jobs/'+j['id']+'/costs').json();r=draft(c,j,price=1999);assert r.status_code==200,r.text;d=r.json();stored=doc(c,d)
    assert stored['status']=='draft' and stored['snapshot']['items'][0]['unit_pence']==1999 and stored['total_pence']==134
    assert c.get('/api/jobs/'+j['id']+'/costs').json()==before
    p,_=portal(app,c,j);assert p.get('/api/portal/oak-and-vale/documents/invoice/'+d['id']).status_code==404
    assert draft(c,j).status_code==409
    assert review(c,j,tid).status_code==409
    assert docact(c,d,'issue').status_code==200
    exposed=p.get('/api/portal/oak-and-vale/documents/invoice/'+d['id']);assert exposed.status_code==200
    assert 'source_review' not in exposed.text and 'hourly_cost' not in exposed.text and 'PRIVATE TIMER NOTE' not in exposed.text


def test_duplicate_source_claim_concurrency_and_rollback(app):
    c=client(app);b=client(app);j,people,clock,tid=captured(c,app)
    with ThreadPoolExecutor(2) as pool:rs=list(pool.map(lambda who:draft(who,j),[c,b]))
    assert sorted(r.status_code for r in rs)==[200,409]
    assert len(c.get('/api/workspace').json()['invoices'])==1
    with app.state.store.tenant('oak') as db:assert db.execute("SELECT COUNT(*) FROM document_sources WHERE state='reserved'").fetchone()[0]==1


def test_quote_revision_conversion_cancel_void_and_replacement_claims(app):
    c=client(app);j,people,clock,tid=captured(c,app);r=draft(c,j,kind='quote');assert r.status_code==200,r.text;q=r.json()
    assert docact(c,q,'share').status_code==200;r=docact(c,q,'revise');assert r.status_code==200;q={'kind':'quote',**r.json()}
    assert draft(c,j).status_code==409
    assert docact(c,q,'accept').status_code==200;r=docact(c,q,'invoice');assert r.status_code==200,r.text;i={'kind':'invoice',**r.json()}
    assert len(doc(c,i)['source_review'])==1 and doc(c,q)['source_review']==[]
    assert docact(c,i,'cancel').status_code==200;r=docact(c,q,'invoice');assert r.status_code==200,r.text;i={'kind':'invoice',**r.json()}
    assert docact(c,i,'issue').status_code==200;r=docact(c,i,'correct');assert r.status_code==200,r.text;replacement={'kind':'invoice',**r.json()}
    assert len(doc(c,replacement)['source_review'])==1 and draft(c,j).status_code==409
    assert docact(c,replacement,'cancel').status_code==200;assert draft(c,j).status_code==200


def test_draft_source_changes_block_issue_until_explicit_refresh(app):
    c=client(app);j,people,clock=setup(c,app);t=entry(c,j,people[0]);d=draft(c,j).json()
    row=c.get('/api/jobs/'+j['id']+'/time').json()[0];payload={k:row[k] for k in ('version','work_date','duration_minutes','note')};payload.update(duration_minutes=45,hourly_cost='21.00')
    assert write(c,'/api/time/'+row['id'],payload,method='put').status_code==200
    assert doc(c,d)['source_review'][0]['source_changed'];assert docact(c,d,'issue').status_code==409
    r=draft(c,j,target=doc(c,d),price=2500);assert r.status_code==200,r.text;assert docact(c,d,'issue').status_code==200


def test_removed_source_is_released_and_manual_lines_remain_possible(app):
    c=client(app);j,people,clock,tid=captured(c,app);q=draft(c,j,kind='quote').json()
    r=write(c,'/api/jobs/'+j['id']+'/suggestions/draft',{'kind':'quote','document_id':q['id'],'version':doc(c,q)['version'],'confirmed':True,'lines':[{'description':'Manual agreed work','quantity':'1.125','unit_pence':123}]});assert r.status_code==200,r.text
    assert docact(c,q,'accept').status_code==200;invoice=docact(c,q,'invoice');assert invoice.status_code==200,invoice.text
    assert doc(c,{'kind':'invoice',**invoice.json()})['source_review']==[]
    assert draft(c,j).status_code==200


@pytest.mark.parametrize('bad',[False,1,'true'])
def test_source_review_requires_explicit_strict_acknowledgement(app,bad):
    c=client(app);j=job(c);assert write(c,'/api/jobs/'+j['id']+'/suggestions/draft',{'confirmed':bad,'lines':[{'description':'Test','quantity':'1','unit_pence':123}]}).status_code==422


@pytest.mark.parametrize('revocation',['coworker_archive','coworker_unassign','staff_disable'])
def test_cached_start_and_heartbeat_recheck_team_and_account(app,revocation):
    c=client(app);j,people,clock=setup(c,app,2);books=[booking(c,j,p) for p in people];s,_=add_staff(app,c,resource_id=people[0]['id']);key=str(uuid.uuid4());tid=start(s,j,people,key).json()['id']
    if revocation=='staff_disable':assert update(c,member(c),enabled=False).status_code==200
    else:
        assert write(c,'/api/calendar/'+books[1]['id']+'/archive',{'version':1}).status_code==200
        if revocation=='coworker_archive':assert write(c,'/api/team/'+people[1]['id']+'/archive',{'version':1}).status_code==200
    assert start(s,j,people,key).status_code in (401,403,404)
    assert write(s,base(j)+'/timers/'+tid+'/events/heartbeat',{'version':1}).status_code in (401,403,404)
    clock.advance(60);assert act(c,j,tid,'stop').status_code==200
    assert review(c,j,tid).status_code==200;assert convert(c,j,tid).status_code==200


def test_owner_can_resolve_closed_job_after_staff_access_ends(app):
    c=client(app);j,people,clock=setup(c,app);booking(c,j,people[0]);s,_=add_staff(app,c,resource_id=people[0]['id']);tid=start(s,j,people).json()['id'];clock.advance(60)
    assert write(c,'/api/jobs/'+j['id'],{'version':2,'status':'completed','notes':'','scheduled_date':''},method='put').status_code==200
    assert s.get(base(j)).status_code==404 and s.get('/api/field/jobs').json()==[]
    assert any(r['id']==j['id'] and not r['active'] for r in c.get('/api/field/jobs').json())
    assert act(c,j,tid,'stop').status_code==200 and review(c,j,tid).status_code==200 and convert(c,j,tid).status_code==200
    assert start(c,j,people).status_code==404


@pytest.mark.parametrize('invalid',[
    [],[{'start':'2026-10-25T00:00:00','end':'2026-10-25T00:01:00'}],
    [{'start':'2026-10-25T00:01:00Z','end':'2026-10-25T00:00:00Z'}],
    [{'start':'2026-10-25T00:00:00Z','end':'2026-10-25T00:02:00Z'}],
    [{'start':'2026-10-25T00:00:00Z','end':'2026-10-25T00:01:00Z'},{'start':'2026-10-25T00:00:30Z','end':'2026-10-25T00:01:00Z'}],
    [{'start':'2026-10-01T00:00:00Z','end':'2026-10-09T00:00:00Z'}]])
def test_invalid_review_never_changes_raw_history(app,invalid):
    c=client(app);j,people,clock=setup(c,app);tid=start(c,j,people).json()['id'];clock.advance(60);assert act(c,j,tid,'stop').status_code==200
    before=timer(c,j,tid);assert review(c,j,tid,invalid).status_code==422;assert timer(c,j,tid)==before


def test_minute_capacity_failure_rolls_back_entire_two_person_conversion(app):
    c=client(app);j,people,clock=setup(c,app,2);tid=start(c,j,people).json()['id'];clock.advance(90);assert act(c,j,tid,'stop').status_code==200 and review(c,j,tid).status_code==200
    entry(c,j,people[1],duration_minutes=1440,work_date='2026-10-25')
    assert convert(c,j,tid).status_code==409
    assert len(c.get('/api/jobs/'+j['id']+'/time').json())==1 and timer(c,j,tid)['state']=='reviewed'
    with app.state.store.tenant('oak') as db:assert db.execute('SELECT COUNT(*) FROM field_time_links').fetchone()[0]==0


def test_tiny_capture_requires_correction_or_discard_and_retains_raw_time(app):
    c=client(app);j,people,clock=setup(c,app);tid=start(c,j,people).json()['id'];clock.advance(29);assert act(c,j,tid,'stop').status_code==200 and review(c,j,tid).status_code==200
    assert convert(c,j,tid).status_code==422
    assert write(c,base(j)+'/timers/'+tid+'/discard',{'version':timer(c,j,tid)['version'],'reason':'Too small to record'}).status_code==200
    assert timer(c,j,tid)['confirmed_ms']==29000 and suggested(c,j)['sources']==[]


def test_partial_reservation_failure_keeps_existing_draft_and_claims(app):
    c=client(app);j,people,clock=setup(c,app);entry(c,j,people[0],duration_minutes=1);entry(c,j,people[0],duration_minutes=2)
    sources=suggested(c,j)['sources'];a=draft(c,j,[sources[0]]).json();b=draft(c,j,[sources[1]]).json();before=doc(c,a)
    assert draft(c,j,sources,target=before).status_code==409
    assert doc(c,a)==before and doc(c,b)['source_review'][0]['source_id']==sources[1]['source_id']


def test_sources_reject_other_job_business_and_generated_time(app):
    c=client(app);j,people,clock,tid=captured(c,app);other=job(c);source=suggested(c,j)['sources'][0]
    assert draft(c,other,[source]).status_code==404
    foreign=client(app,1);assert foreign.get('/api/jobs/'+j['id']+'/suggestions').status_code==404
    assert draft(foreign,j,[source]).status_code==404
    t=c.get('/api/jobs/'+j['id']+'/time').json()[0];fake={**source,'source_kind':'time','source_id':t['id'],'source_version':t['version']}
    assert draft(c,j,[fake]).status_code==409
    assert suggested(c,j)['sources'][0]['quantity']=='0.033'


def test_changed_quote_source_cannot_be_shared_accepted_privately_or_converted(app):
    c=client(app);j,people,clock=setup(c,app);entry(c,j,people[0]);q=draft(c,j,kind='quote').json();row=c.get('/api/jobs/'+j['id']+'/time').json()[0]
    assert write(c,'/api/time/'+row['id'],{'version':row['version'],'work_date':row['work_date'],'duration_minutes':45,'hourly_cost':'20','note':''},method='put').status_code==200
    assert docact(c,q,'share').status_code==409 and docact(c,q,'accept').status_code==409
    assert draft(c,j,kind='quote',target=doc(c,q)).status_code==200
    assert docact(c,q,'share').status_code==200 and docact(c,q,'accept').status_code==200
    row=c.get('/api/jobs/'+j['id']+'/time').json()[0];assert write(c,'/api/time/'+row['id']+'/archive',{'version':row['version']}).status_code==200
    assert docact(c,q,'invoice').status_code==409 and len(c.get('/api/workspace').json()['invoices'])==0


def test_paid_invoice_keeps_source_reserved_and_void_releases_unpaid_only(app):
    c=client(app);j,people,clock,tid=captured(c,app);d=draft(c,j).json();assert docact(c,d,'issue').status_code==200
    assert write(c,'/api/invoices/'+d['id']+'/payments',{'amount_pence':1,'note':'Invented receipt'}).status_code==200
    assert docact(c,d,'void').status_code==409 and docact(c,d,'correct').status_code==409 and draft(c,j).status_code==409
    payment=doc(c,d)['payments'][0]
    assert write(c,'/api/invoices/'+d['id']+'/payments/'+payment['id']+'/correct',{'version':doc(c,d)['version'],'reason':'Mistaken invented recording','recording_error':True,'replacement':None}).status_code==200
    assert docact(c,d,'void').status_code==200 and draft(c,j).status_code==200


def test_field_history_cannot_use_retired_unreviewed_customer_relink(app):
    c=client(app);j,people,clock=setup(c,app);other=job(c);log=write(c,base(j)+'/logs',{'kind':'note','content':'Retained work history','work_date':'2026-10-25'}).json()
    assert write(c,base(j)+'/logs/'+log['id']+'/state/archive',{'version':1,'reason':'Retain archived history'}).status_code==200
    assert write(c,'/api/jobs/'+j['id'],{'version':2,'status':'new','notes':'','scheduled_date':''},method='put').status_code==200
    result=write(c,'/api/jobs/'+j['id']+'/customer-link-preview',{'job_version':3,'customer_id':other['customer_id'],'customer_version':1})
    assert result.status_code==410,result.text


def test_closed_job_with_only_field_logs_stays_in_owner_review_queue(app):
    c=client(app);j,people,clock=setup(c,app)
    assert write(c,base(j)+'/logs',{'content':'Retained private site note','work_date':'2026-10-25'}).status_code==200
    assert write(c,'/api/jobs/'+j['id'],{'version':2,'status':'completed','notes':'','scheduled_date':''},method='put').status_code==200
    rows=c.get('/api/field/jobs').json();assert len(rows)==1 and rows[0]['id']==j['id'] and not rows[0]['active']
    assert len(field(c,j)['logs'])==1 and field(c,j)['timers']==[]
