File size: 9,655 Bytes
af69759
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
"""Regression tests for ``shopstack.cli`` (Pass 17, 2026-06-15).

The CLI is the **mode-portability proof** for ShopStack: it
exercises the public service API (``shopstack.app_context.tools``)
without launching the Gradio UI. These tests guard:

  1. The CLI can be imported without importing ``app.py`` or
     ``gradio`` (mode-portability contract).
  2. All 7 subcommands are wired (inventory / shopping / find /
     use-soon / next-buy / tools / whoami).
  3. Output is valid JSON by default; ``--human`` flips to
     pretty-printed output.
  4. ``whoami`` returns the documented contract.
  5. The CLI exit code is 0 on success.

Per ``motto_v3`` Β§0.10 (Observability Is Delivery), the CLI is
the operator-friendly counterpart of the ``/api/whoami`` HTTP
endpoint. Operators who don't want to launch a Gradio server can
get the same introspection data from the shell.
"""
from __future__ import annotations

import io
import json
import re
import sys
from unittest.mock import patch

import pytest


# ── Mode-portability contract ────────────────────────────────────────


def test_cli_does_not_import_app_or_gradio_directly():
    """The CLI module does NOT import ``app`` or ``gradio`` directly.

    This is the mode-portability contract: the CLI proves the
    public service API works without the UI layer. If a future
    change imports ``app.py`` or ``gradio`` directly, the
    mode-portability claim breaks.

    ``shopstack.app_context`` IS allowed (it's the public
    service layer). The Gradio import is an indirect side effect
    of importing ``app_context`` (via the tools registry
    bootstrap), but it's not a direct import in the CLI module.
    """
    cli_path = "shopstack/cli/__init__.py"
    with open(cli_path) as fp:
        src = fp.read()
    # No direct imports of ``app`` (the app.py module) or ``gradio``
    # (other than via app_context).
    # Allowed: ``from shopstack.app_context import ...``
    # Disallowed: ``from app import ...`` or ``import gradio`` (direct)
    assert "from app import" not in src, (
        "CLI must not import `app` directly. "
        "If you need an app-specific function, add it to "
        "`shopstack.app_context` or `shopstack.cli` itself."
    )
    assert "import gradio" not in src, (
        "CLI must not import `gradio` directly. "
        "The whole point of the CLI is to prove the public service "
        "API works without the UI layer."
    )


# ── Parser + dispatch ──────────────────────────────────────────────


def test_parser_includes_all_seven_subcommands():
    """All 7 subcommands are registered (inventory, shopping, find,
    use-soon, next-buy, tools, whoami).
    """
    from shopstack.cli import build_parser

    parser = build_parser()
    # Parse each subcommand with a minimal valid argv.
    for cmd_args in [
        ["inventory"],
        ["shopping"],
        ["find", "milk"],
        ["use-soon"],
        ["use-soon", "--days", "3"],
        ["next-buy"],
        ["tools"],
        ["whoami"],
    ]:
        args = parser.parse_args(cmd_args)
        assert args.cmd is not None, f"no cmd parsed from {cmd_args!r}"


def test_subcommand_dispatch_table_covers_all_commands():
    """The ``SUBCOMMANDS`` dispatch dict covers every registered subcommand."""
    from shopstack.cli import SUBCOMMANDS, build_parser

    parser = build_parser()
    # Re-parse and confirm each subcommand has a handler.
    for cmd_args in [
        ["inventory"], ["shopping"], ["find", "x"],
        ["use-soon"], ["next-buy"], ["tools"], ["whoami"],
    ]:
        args = parser.parse_args(cmd_args)
        assert args.cmd in SUBCOMMANDS, (
            f"subcommand {args.cmd!r} not in SUBCOMMANDS dispatch"
        )


# ── ``whoami`` output contract ─────────────────────────────────────


def test_whoami_payload_shape():
    """``whoami`` returns the documented JSON contract.

    Per the docstring: ``{household, database, runtime, timestamp}``.
    Each sub-section is itself a dict. The timestamp is
    ISO 8601 with timezone info.
    """
    from shopstack.cli import build_parser
    from datetime import datetime

    parser = build_parser()
    args = parser.parse_args(["whoami"])

    # Dispatch through the same path main() uses.
    from shopstack.cli import SUBCOMMANDS
    payload = SUBCOMMANDS["whoami"](args)

    assert "household" in payload
    assert "database" in payload
    assert "runtime" in payload
    assert "timestamp" in payload
    # Database sub-section keys.
    assert "path" in payload["database"]
    assert "size_bytes" in payload["database"]
    assert "table_count" in payload["database"]
    # Runtime sub-section keys.
    assert "pid" in payload["runtime"]
    assert "python_version" in payload["runtime"]
    # Timestamp is parseable ISO 8601 with timezone.
    parsed = datetime.fromisoformat(payload["timestamp"])
    assert parsed.tzinfo is not None


def test_whoami_json_output_is_valid():
    """Running ``cli whoami`` produces parseable JSON on stdout."""
    from shopstack.cli import main

    captured = io.StringIO()
    with patch("sys.stdout", captured):
        rc = main(["whoami"])
    assert rc == 0, f"cli whoami returned non-zero: {rc}"
    # Must be valid JSON.
    payload = json.loads(captured.getvalue())
    assert "household" in payload
    assert "database" in payload
    assert "runtime" in payload


def test_whoami_human_output_is_not_json():
    """``--human`` produces a non-JSON, line-based output."""
    from shopstack.cli import main

    captured = io.StringIO()
    with patch("sys.stdout", captured):
        rc = main(["--human", "whoami"])
    assert rc == 0
    out = captured.getvalue()
    # The --human output is plain text, not JSON.
    with pytest.raises(json.JSONDecodeError):
        json.loads(out)
    # But it should contain recognizable keys.
    assert "household:" in out
    assert "pid:" in out


# ── ``tools`` subcommand ────────────────────────────────────────────


def test_tools_subcommand_lists_public_service_surface():
    """The ``tools`` subcommand lists the public service API."""
    from shopstack.cli import main

    captured = io.StringIO()
    with patch("sys.stdout", captured):
        rc = main(["tools"])
    assert rc == 0
    payload = json.loads(captured.getvalue())
    assert "count" in payload
    assert "tools" in payload
    assert payload["count"] > 0
    assert isinstance(payload["tools"], list)
    # Each entry has a name and a description.
    for tool in payload["tools"][:3]:
        assert "name" in tool
        assert "description" in tool


# ── ``find`` subcommand ────────────────────────────────────────────


def test_find_subcommand_returns_results():
    """``find <name>`` returns the tool's response.

    We use a name that's likely to be in the seeded test data
    (``milk`` is a canonical name added by ``seed_walkthrough.py``).
    If the search finds nothing, the test still passes β€” the
    contract is "returns the tool's response", not "finds a match".
    """
    from shopstack.cli import main

    captured = io.StringIO()
    with patch("sys.stdout", captured):
        rc = main(["find", "milk"])
    assert rc == 0
    # The payload may be empty (no match) or non-empty. Either is valid;
    # the contract is that the CLI returns the tool's response cleanly.
    payload = json.loads(captured.getvalue())


# ── Error handling ─────────────────────────────────────────────────


def test_unknown_subcommand_exits_nonzero():
    """An unknown subcommand exits with a non-zero code."""
    from shopstack.cli import main

    # argparse exits with code 2 on unknown subcommands.
    with pytest.raises(SystemExit) as exc_info:
        main(["nonsense-command"])
    assert exc_info.value.code == 2


def test_find_subcommand_requires_name():
    """``find`` requires a NAME argument (argparse enforces this)."""
    from shopstack.cli import main

    with pytest.raises(SystemExit) as exc_info:
        main(["find"])
    assert exc_info.value.code == 2


# ── Mode-portability proof (Tier 3) ─────────────────────────────────


def test_cli_runs_without_launching_gradio():
    """The CLI runs end-to-end without Gradio's launch path.

    This is the Tier-3 (integration) mode-portability proof:
    invoking the CLI does NOT call ``gradio.launch()`` or
    ``app.build_app()``. If a future change accidentally pulls
    the Gradio UI into the CLI's import chain, this test catches
    it.

    We use the ``whoami`` subcommand (no DB writes, no service
    calls beyond introspection) as the canary.
    """
    from shopstack.cli import main

    with patch("gradio.Blocks.launch") as mock_launch:
        captured = io.StringIO()
        with patch("sys.stdout", captured):
            rc = main(["whoami"])
    assert rc == 0
    assert not mock_launch.called, (
        "CLI must not call `gradio.Blocks.launch()`. "
        "If this fires, the CLI's import chain now pulls in the "
        "Gradio UI, which breaks the mode-portability contract."
    )