"""Run real wrapper entry points in a relocated checkout with an isolated HOME.""" import json import os from pathlib import Path import shutil import subprocess import pytest ROOT=Path(__file__).resolve().parents[1] @pytest.fixture def runtime(tmp_path): root=tmp_path/'relocated checkout' (root/'bin').mkdir(parents=True) shutil.copy(ROOT/'redmine_cli_runtime.py',root) for name in ('rcontacts-live','rtickets-live','rtl','rtd'): shutil.copy(ROOT/'bin'/name,root/'bin'/name) fake='import json,os,sys; print(json.dumps({"argv":sys.argv[1:],"env":{k:v for k,v in os.environ.items() if k.startswith("REDMINE_") or k.startswith("NMI_")}}))\n' for name in ('redmine_tickets.py','redmine_contacts.py'): (root/name).write_text(fake) home=tmp_path/'home'; home.mkdir() env={'HOME':str(home),'PATH':'/usr/bin:/bin','TMPDIR':os.environ['TMPDIR']} def run(*args,command='rtl',extra=None): return subprocess.run([str(root/'bin'/command),*args],cwd=tmp_path,env={**env,**(extra or {})},text=True,capture_output=True,timeout=10) return root,home,run def test_config_file_and_export_precedence(runtime): root,home,run=runtime (home/'.redmine.live').write_text('export REDMINE_API_KEY="fixture-secret"\nREDMINE_BASE_URL=https://file.example.test\nREDMINE_PROJECT=abc\nREDMINE_BASE_CACHE="data with spaces"\n') p=run('--show-config',extra={'REDMINE_BASE_URL':'https://env.example.test'}) assert p.returncode==0,p.stderr d=json.loads(p.stdout) assert d['base_url']=='https://env.example.test' assert d['project']=='abc' assert d['base_cache']==str(home/'data with spaces') assert d['api_key_present'] assert 'fixture-secret' not in p.stdout+p.stderr assert 'environment' in d['sources']['base_url'] p=run('--base-url','https://cli.example.test','--show-config',extra={'REDMINE_BASE_URL':'https://env.example.test'}) assert json.loads(p.stdout)['base_url']=='https://cli.example.test' def test_real_dispatch_preserves_stdin_arguments_and_key(runtime): root,home,run=runtime (home/'.redmine.live').write_text('REDMINE_API_KEY=fixture-secret\n') p=run('--base-cache','my cache','search','--query','two words',command='rcontacts-live') assert p.returncode==0,p.stderr d=json.loads(p.stdout) assert d['env']['REDMINE_API_KEY']=='fixture-secret' assert d['argv'][-3:]==['search','--query','two words'] cache=d['argv'][d['argv'].index('--cache')+1] assert cache.endswith('my cache/redmine_contacts/rm.ldrprep.com_customer-service.json') assert not Path(cache).is_dir() def test_doctor_missing_key_nonzero_with_search_paths(runtime): root,home,run=runtime p=run('--doctor') assert p.returncode==2 d=json.loads(p.stdout) assert not d['ready'] assert 'REDMINE_API_KEY' in d['problems'][0] assert str(home/'.redmine.live') in d['searched_env_paths'] assert not (root/'cache').exists() p=run('sync') assert p.returncode==2 and 'REDMINE_API_KEY' in p.stderr def test_explicit_missing_or_malformed_file_blocks(runtime): root,home,run=runtime assert run('--env-file','absent','--doctor').returncode==2 file=home/'bad.env'; file.write_text('not an assignment\n') p=run('--env-file',str(file),'--show-config') assert p.returncode==2 and 'Malformed' in p.stderr def test_help_works_without_config(runtime): _,_,run=runtime p=run('--env-file','missing','--help') assert p.returncode==0 assert 'REDMINE_API_KEY' in p.stdout def test_aliases_match_and_dev_never_loads_live(runtime): root,home,run=runtime (home/'.redmine.live').write_text('REDMINE_API_KEY=fixture-live\n') a=json.loads(run('--show-config',command='rtl').stdout) b=json.loads(run('--show-config',command='rtickets-live').stdout) assert a==b d=run('--show-config',command='rtd') data=json.loads(d.stdout) assert not data['api_key_present'] assert data['nmi_mode']=='dev' assert 'DEVELOPMENT' in d.stderr assert data['ticket_cache']==str(root/'cache-dev/redmine_tickets') def test_cache_overrides_and_scope_isolation(runtime): root,_,run=runtime default=json.loads(run('--show-config').stdout) assert default['ticket_cache']==str(root/'cache/redmine_tickets') assert default['contacts_cache'].endswith('/cache/redmine_contacts/rm.ldrprep.com_customer-service.json') assert default['contacts_cache_layout']=='namespaced-file' other=json.loads(run('--project','meetings','--show-config').stdout) assert other['ticket_cache']!=default['ticket_cache'] host=json.loads(run('--base-url','https://other.example.test','--show-config').stdout) assert host['ticket_cache']!=default['ticket_cache'] exact=json.loads(run('--cache-dir','specific path','--show-config').stdout) assert exact['ticket_cache'].endswith('/specific path') def test_json_config_and_key_file_propagation(runtime): root,home,run=runtime config=home/'live.json'; key=home/'key'; key.write_text('fixture-key-file\n') config.write_text(json.dumps({'project':'meetings','cache-dir':'mirror','api-key-file':'key'})) p=run('--config',str(config),'search',extra={'REDMINE_PROJECT':'overridden'}) assert p.returncode==0,p.stderr d=json.loads(p.stdout) assert d['env']['REDMINE_API_KEY']=='fixture-key-file' assert d['env']['REDMINE_PROJECT']=='overridden' assert '--config' not in d['argv'] assert str(home/'mirror') in d['argv'] def test_subcommand_config_is_not_global(runtime): _,_,run=runtime p=run('materialize-queues','--config','queues.json') assert p.returncode==0,p.stderr assert json.loads(p.stdout)['argv'][-3:]==['materialize-queues','--config','queues.json'] def test_directory_contacts_cache_rejected(runtime): root,_,run=runtime p=run('--cache',str(root),'--show-config',command='rcontacts-live') assert p.returncode==2 and 'JSON file' in p.stderr def test_legacy_contacts_snapshot_is_preserved(runtime): root,_,run=runtime legacy=root/'cache/redmine_contacts' legacy.parent.mkdir(parents=True) legacy.write_text(json.dumps({'base_url':'https://rm.ldrprep.com','project':'customer-service','contacts':[]})) d=json.loads(run('--show-config').stdout) assert d['contacts_cache']==str(legacy) assert d['contacts_cache_layout']=='legacy-single-file' def test_contacts_handoff_preserves_resolved_context(tmp_path,monkeypatch): import argparse import shlex from redmine_cli_runtime import contacts_handoff monkeypatch.setenv('REDMINE_ENV_FILE',str(tmp_path/'profile with spaces.env')) monkeypatch.setenv('REDMINE_CONTACTS_CACHE',str(tmp_path/'cache with spaces/contacts.json')) args=argparse.Namespace(base_url='https://rm.example.test',project='meetings',cache_dir=tmp_path/'tickets',api_key_file=tmp_path/'key file') cmd=shlex.split(contacts_handoff(args,42)) assert cmd[cmd.index('--cache')+1]==str(tmp_path/'cache with spaces/contacts.json') assert cmd[cmd.index('--env-file')+1]==str(tmp_path/'profile with spaces.env') assert cmd[cmd.index('--api-key-file')+1]==str(tmp_path/'key file') assert cmd[-3:]==['show','--contact-id','42'] def test_actual_wrapper_invalid_ticket_query_is_clean_error(tmp_path): env={'HOME':str(tmp_path),'PATH':'/usr/bin:/bin','TMPDIR':os.environ['TMPDIR'], 'REDMINE_API_KEY':'fixture-only','REDMINE_BASE_CACHE':str(tmp_path/'cache')} p=subprocess.run([str(ROOT/'bin/rtl'),'search','--where','id == -1'],env=env,cwd=tmp_path, text=True,capture_output=True,timeout=10) assert p.returncode==2 assert 'Invalid --where expression' in p.stderr assert 'Traceback' not in p.stderr def test_actual_wrapper_to_actual_nmi_doctor(tmp_path): cli=ROOT.parent/'mrscClient-php/bin/nmi' if not cli.exists(): pytest.skip('Sibling NMI checkout unavailable') profile=tmp_path/'nmi.env' profile.write_text('NMI_ENDPOINT=https://fixture.example.test\nNMI_MRSC_ACCESS_CODE=fixture-access\nNMI_SECRETKEY=fixture-secret\n') redmine=tmp_path/'redmine.env' redmine.write_text(f'NMI_CLI={cli}\nNMI_ENV_FILE={profile}\nREDMINE_BASE_CACHE={tmp_path}/cache\n') p=subprocess.run([str(ROOT/'bin/rtickets-live'),'--env-file',str(redmine),'--doctor-nmi'], env={'HOME':str(tmp_path),'PATH':'/usr/bin:/bin'},cwd=tmp_path,text=True,capture_output=True,timeout=10) assert p.returncode==0,p.stdout+p.stderr d=json.loads(p.stdout) assert d['ready'] and d['mode']=='production' assert d['env_file']==str(profile) assert not d['network_checked'] assert 'fixture-secret' not in p.stdout+p.stderr