77c76b16c4
- Render remote files as an aligned table with JSON and TSV alternatives. - Add Bash and Zsh completion for commands, options, paths, and remote files. - Allow downloads by exact filename while rejecting ambiguous duplicate names. - Cover table formatting, completion scripts, and selector resolution with tests.
156 lines
4.7 KiB
Python
156 lines
4.7 KiB
Python
import json
|
|
import sys
|
|
import threading
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'cli'))
|
|
|
|
from nanoshare_client import cli
|
|
from nanoshare_client.auth import _CallbackServer
|
|
from nanoshare_client.completion import script as completion_script
|
|
from nanoshare_client.table import format_table
|
|
|
|
class FakeClient:
|
|
def __init__(self):
|
|
self.registry = {'picoshare': 'https://example.com'}
|
|
self.uploads = []
|
|
self.deleted = []
|
|
self.remote_files = []
|
|
|
|
def call(self, node, method, params):
|
|
if method == 'files.list':
|
|
return {'files': list(self.remote_files)}
|
|
if method == 'files.upload':
|
|
file_id = f'file_{len(self.uploads) + 1}'
|
|
self.uploads.append((node, params, file_id))
|
|
self.remote_files.append({
|
|
'file_id': file_id,
|
|
'file_name': params['file_name'],
|
|
'file_size': '1 B',
|
|
})
|
|
return {'file_id': file_id, 'file_name': params['file_name']}
|
|
if method == 'files.delete':
|
|
self.deleted.append(params['file_id'])
|
|
self.remote_files = [item for item in self.remote_files if item['file_id'] != params['file_id']]
|
|
return {'deleted': True}
|
|
raise AssertionError(method)
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
def test_sync_uploads_new_local_file_and_writes_state(tmp_path, monkeypatch):
|
|
fake = FakeClient()
|
|
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
|
|
|
(tmp_path / 'hello.txt').write_text('hello')
|
|
|
|
code = cli.main([
|
|
'sync',
|
|
'--url', 'picoshare=https://example.com',
|
|
'--token', 'token',
|
|
str(tmp_path),
|
|
])
|
|
|
|
assert code == 0
|
|
assert fake.uploads[0][1]['file_name'] == 'hello.txt'
|
|
state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text())
|
|
assert state['files']['hello.txt']['file_id'] == 'file_1'
|
|
|
|
def test_completion_scripts_include_commands():
|
|
zsh = completion_script('zsh')
|
|
bash = completion_script('bash')
|
|
|
|
assert 'compdef _nanoshare nanoshare' in zsh
|
|
assert 'complete -F _nanoshare nanoshare' in bash
|
|
assert 'login upload list download sync watch completion' in zsh
|
|
assert 'login upload list download sync watch completion' in bash
|
|
|
|
def test_resolve_remote_file_accepts_id_or_exact_name():
|
|
files = [
|
|
{'file_id': 'file_1', 'file_name': 'report.pdf'},
|
|
{'file_id': 'file_2', 'file_name': 'photo.png'},
|
|
]
|
|
|
|
assert cli._resolve_remote_file(files, 'file_1')['file_name'] == 'report.pdf'
|
|
assert cli._resolve_remote_file(files, 'photo.png')['file_id'] == 'file_2'
|
|
|
|
def test_resolve_remote_file_rejects_duplicate_names():
|
|
files = [
|
|
{'file_id': 'file_1', 'file_name': 'report.pdf'},
|
|
{'file_id': 'file_2', 'file_name': 'report.pdf'},
|
|
]
|
|
|
|
try:
|
|
cli._resolve_remote_file(files, 'report.pdf')
|
|
except RuntimeError as exc:
|
|
assert 'multiple remote files' in str(exc)
|
|
else:
|
|
raise AssertionError('expected duplicate names to raise')
|
|
|
|
def test_format_table_aligns_columns():
|
|
output = format_table(
|
|
['ID', 'Name', 'Size'],
|
|
[
|
|
['abc', 'short.txt', '1 KB'],
|
|
['longer-id', 'a much longer file name.png', '22 MB'],
|
|
],
|
|
)
|
|
|
|
assert output.splitlines() == [
|
|
'ID Name Size',
|
|
'--------- --------------------------- -----',
|
|
'abc short.txt 1 KB',
|
|
'longer-id a much longer file name.png 22 MB',
|
|
]
|
|
|
|
def test_login_callback_returns_without_waiting_for_timeout():
|
|
server = _CallbackServer('127.0.0.1', 0, 'expected-state')
|
|
result = {}
|
|
|
|
def wait_for_code():
|
|
result['code'] = server.wait_for_code(30)
|
|
|
|
thread = threading.Thread(target=wait_for_code)
|
|
thread.start()
|
|
with urllib.request.urlopen(f'{server.url}?code=login-code&state=expected-state', timeout=5) as response:
|
|
assert response.status == 200
|
|
assert b'login complete' in response.read()
|
|
thread.join(5)
|
|
|
|
assert not thread.is_alive()
|
|
assert result['code'] == 'login-code'
|
|
|
|
def test_sync_deletes_old_remote_when_local_file_changes(tmp_path, monkeypatch):
|
|
fake = FakeClient()
|
|
fake.remote_files = [{'file_id': 'old_file', 'file_name': 'hello.txt', 'file_size': '5 B'}]
|
|
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
|
|
|
path = tmp_path / 'hello.txt'
|
|
path.write_text('changed')
|
|
state_dir = tmp_path / '.nanoshare-sync'
|
|
state_dir.mkdir()
|
|
(state_dir / 'state.json').write_text(json.dumps({
|
|
'version': 1,
|
|
'files': {
|
|
'hello.txt': {
|
|
'file_id': 'old_file',
|
|
'sha256': 'old_hash',
|
|
'local_mtime': 1,
|
|
'file_name': 'hello.txt',
|
|
}
|
|
}
|
|
}))
|
|
|
|
code = cli.main([
|
|
'sync',
|
|
'--url', 'picoshare=https://example.com',
|
|
'--token', 'token',
|
|
str(tmp_path),
|
|
])
|
|
|
|
assert code == 0
|
|
assert fake.deleted == ['old_file']
|
|
state = json.loads((state_dir / 'state.json').read_text())
|
|
assert state['files']['hello.txt']['file_id'] == 'file_1'
|