docs(05): create phase 5 plans — tester drivers, backend endpoints, cable test UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
468f8930d1
commit
984f67834f
4 changed files with 842 additions and 3 deletions
|
|
@ -106,8 +106,12 @@ Plans:
|
||||||
2. USB, DisplayPort, and HDMI cable test results (continuity, version, eMarker, PD, resistance) are displayed in the Cable Test Station UI and written to the NetBox cable record as structured JSON
|
2. USB, DisplayPort, and HDMI cable test results (continuity, version, eMarker, PD, resistance) are displayed in the Cable Test Station UI and written to the NetBox cable record as structured JSON
|
||||||
3. FNIRSI FNB58 live voltage/current/PD protocol data streams to the UI and is persisted to the cable record
|
3. FNIRSI FNB58 live voltage/current/PD protocol data streams to the UI and is persisted to the cable record
|
||||||
4. A cable can go from test to label printed in under 30 seconds using the rapid workflow
|
4. A cable can go from test to label printed in under 30 seconds using the rapid workflow
|
||||||
**Plans**: TBD
|
**Plans**: 3 plans
|
||||||
**UI hint**: yes
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 05-01-PLAN.md — Tester driver package: TesterDriver interface, TestResult/LiveReading types, mock USB/DP/HDMI/FNB58 drivers, VID:PID registry entries
|
||||||
|
- [ ] 05-02-PLAN.md — Backend: CreateCable on NetBox client, TestHandler (POST /api/test/cable, GET /api/test/events SSE, GET /api/test/recent), router + main.go wiring
|
||||||
|
- [ ] 05-03-PLAN.md — Frontend: Cable Test Station page at /test (three-panel layout, Print & Next workflow, SSE live readout, mobile-responsive)
|
||||||
|
|
||||||
### Phase 6: Lab Advisor
|
### Phase 6: Lab Advisor
|
||||||
**Goal**: Users can ask strategic homelab questions and receive streaming answers from Claude Opus with full inventory context, with conversation history persisted across sessions
|
**Goal**: Users can ask strategic homelab questions and receive streaming answers from Claude Opus with full inventory context, with conversation history persisted across sessions
|
||||||
|
|
@ -144,6 +148,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
|
||||||
| 2. AI Pipeline | 4/4 | Complete | 2026-04-10 |
|
| 2. AI Pipeline | 4/4 | Complete | 2026-04-10 |
|
||||||
| 3. Dashboard & Intake UI | 5/5 | Complete | 2026-04-10 |
|
| 3. Dashboard & Intake UI | 5/5 | Complete | 2026-04-10 |
|
||||||
| 4. USB Manager & Label Printing | 5/5 | Complete | 2026-04-10 |
|
| 4. USB Manager & Label Printing | 5/5 | Complete | 2026-04-10 |
|
||||||
| 5. Cable Test Integration | 0/TBD | Not started | - |
|
| 5. Cable Test Integration | 0/3 | Not started | - |
|
||||||
| 6. Lab Advisor | 0/TBD | Not started | - |
|
| 6. Lab Advisor | 0/TBD | Not started | - |
|
||||||
| 7. Research Agent & Search | 0/TBD | Not started | - |
|
| 7. Research Agent & Search | 0/TBD | Not started | - |
|
||||||
|
|
|
||||||
246
.planning/phases/05-cable-test-integration/05-01-PLAN.md
Normal file
246
.planning/phases/05-cable-test-integration/05-01-PLAN.md
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
---
|
||||||
|
phase: 05-cable-test-integration
|
||||||
|
plan: "01"
|
||||||
|
type: tdd
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- internal/tester/driver.go
|
||||||
|
- internal/tester/mock_drivers.go
|
||||||
|
- internal/tester/driver_test.go
|
||||||
|
- internal/usb/device.go
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CBL-01, CBL-02, CBL-03, CBL-04, CBL-07]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "TesterDriver interface exists with Connect/Read/Disconnect/StreamLive methods"
|
||||||
|
- "TestResult struct captures all cable fields (type, version, speed, power, continuity, eMarker, resistance)"
|
||||||
|
- "MockUSBDriver, MockDPDriver, MockHDMIDriver return deterministic test data without hardware"
|
||||||
|
- "MockFNB58Driver emits a stream of LiveReading samples via a channel"
|
||||||
|
- "KnownDevices in usb/device.go has placeholder entries for all 4 testers (RoleCableTester)"
|
||||||
|
- "All mock drivers satisfy the TesterDriver interface at compile time"
|
||||||
|
artifacts:
|
||||||
|
- path: "internal/tester/driver.go"
|
||||||
|
provides: "TesterDriver interface, TestResult, LiveReading, CableType constants"
|
||||||
|
exports: [TesterDriver, TestResult, LiveReading, CableType]
|
||||||
|
- path: "internal/tester/mock_drivers.go"
|
||||||
|
provides: "MockUSBDriver, MockDPDriver, MockHDMIDriver, MockFNB58Driver"
|
||||||
|
- path: "internal/tester/driver_test.go"
|
||||||
|
provides: "Tests for all four mock drivers"
|
||||||
|
- path: "internal/usb/device.go"
|
||||||
|
provides: "KnownDevices entries for all four tester VID:PIDs"
|
||||||
|
key_links:
|
||||||
|
- from: "internal/usb/device.go KnownDevices"
|
||||||
|
to: "internal/tester/driver.go CableType"
|
||||||
|
via: "VID:PID keys map to CableType constants for auto-detection"
|
||||||
|
- from: "MockFNB58Driver.Stream()"
|
||||||
|
to: "chan LiveReading"
|
||||||
|
via: "channel closed on Disconnect()"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the `internal/tester` package: TesterDriver interface, TestResult/LiveReading types, four mock implementations (TreedixUSB/DP/HDMI + FNB58), and VID:PID placeholder entries in the USB device registry.
|
||||||
|
|
||||||
|
Purpose: All cable test code builds and tests pass without hardware. Real driver implementations are drop-in replacements when hardware arrives 2026-04-13.
|
||||||
|
Output: internal/tester package (driver.go + mock_drivers.go + driver_test.go), updated internal/usb/device.go.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/04-usb-manager-label-printing/04-01-SUMMARY.md
|
||||||
|
@.planning/phases/05-cable-test-integration/05-CONTEXT.md
|
||||||
|
|
||||||
|
@internal/usb/device.go
|
||||||
|
@internal/printer/driver.go
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types from existing packages that tester package must integrate with. -->
|
||||||
|
|
||||||
|
From internal/usb/device.go:
|
||||||
|
```go
|
||||||
|
type DeviceRole int
|
||||||
|
const (
|
||||||
|
RolePrinter DeviceRole = iota
|
||||||
|
RoleCableTester // reserved for Phase 5
|
||||||
|
RoleUnknown
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeviceSpec struct {
|
||||||
|
VID string
|
||||||
|
PID string
|
||||||
|
Name string
|
||||||
|
Role DeviceRole
|
||||||
|
BaudRate int
|
||||||
|
}
|
||||||
|
|
||||||
|
// KnownDevices — add 4 tester entries here (placeholder VID:PIDs)
|
||||||
|
var KnownDevices = map[string]DeviceSpec{
|
||||||
|
"0525:a4a7": {VID: "0525", PID: "a4a7", Name: "PRT Qutie", Role: RolePrinter, BaudRate: 9600},
|
||||||
|
// TODO Phase 5: Treedix testers placeholder entries go here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From internal/printer/driver.go (pattern to follow):
|
||||||
|
```go
|
||||||
|
type PrinterDriver interface {
|
||||||
|
Connect() error
|
||||||
|
Print(bitmap []byte, width, height int) error
|
||||||
|
Disconnect() error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 1: TesterDriver interface, TestResult types, mock discrete drivers (USB/DP/HDMI)</name>
|
||||||
|
<files>internal/tester/driver.go, internal/tester/mock_drivers.go, internal/tester/driver_test.go, internal/usb/device.go</files>
|
||||||
|
<behavior>
|
||||||
|
- TestResult.CableType must be one of: CableTypeUSB, CableTypeDP, CableTypeHDMI
|
||||||
|
- MockUSBDriver.Read() returns TestResult{CableType: CableTypeUSB, USBVersion: "USB 3.2 Gen 2", SpeedGbps: 10.0, MaxWatts: 100, PinContinuity: true, HasEMarker: true, ResistanceOhm: 0.12}
|
||||||
|
- MockDPDriver.Read() returns TestResult{CableType: CableTypeDP, DPVersion: "1.4", PinContinuity: true}
|
||||||
|
- MockHDMIDriver.Read() returns TestResult{CableType: CableTypeHDMI, HDMIVersion: "2.1", PinContinuity: true}
|
||||||
|
- Read() before Connect() returns ErrNotConnected
|
||||||
|
- Disconnect() twice returns nil (idempotent)
|
||||||
|
- Compile-time interface assertion: `var _ TesterDriver = (*MockUSBDriver)(nil)` etc.
|
||||||
|
- KnownDevices in usb/device.go has entries for "dead0:0001" (TreedixUSB), "dead0:0002" (TreedixDP), "dead0:0003" (TreedixHDMI) — all Role: RoleCableTester
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
RED: Write driver_test.go first — compile-time assertions + behavior tests for all three mocks. Run: `go test ./internal/tester/... ./internal/usb/...` — must fail (package does not exist).
|
||||||
|
|
||||||
|
GREEN: Create internal/tester/driver.go:
|
||||||
|
```go
|
||||||
|
package tester
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var ErrNotConnected = errors.New("tester: not connected — call Connect() first")
|
||||||
|
|
||||||
|
type CableType int
|
||||||
|
const (
|
||||||
|
CableTypeUSB CableType = iota
|
||||||
|
CableTypeDP
|
||||||
|
CableTypeHDMI
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestResult struct {
|
||||||
|
CableType CableType
|
||||||
|
USBVersion string
|
||||||
|
DPVersion string
|
||||||
|
HDMIVersion string
|
||||||
|
SpeedGbps float64
|
||||||
|
MaxWatts int
|
||||||
|
PinContinuity bool
|
||||||
|
HasEMarker bool
|
||||||
|
ResistanceOhm float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type TesterDriver interface {
|
||||||
|
Connect() error
|
||||||
|
Read() (TestResult, error)
|
||||||
|
Disconnect() error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create internal/tester/mock_drivers.go with MockUSBDriver, MockDPDriver, MockHDMIDriver structs. Each has a `connected bool` field. Connect() sets connected=true. Disconnect() sets connected=false (idempotent). Read() returns ErrNotConnected if !connected, else returns the deterministic TestResult shown in the behavior block.
|
||||||
|
|
||||||
|
Update internal/usb/device.go KnownDevices map: add three Treedix entries with placeholder VID:PIDs "dead0:0001", "dead0:0002", "dead0:0003", Role: RoleCableTester, BaudRate: 115200. Add comment: `// TODO(hardware): Replace placeholder VID:PIDs after characterization 2026-04-13`.
|
||||||
|
|
||||||
|
Commit RED then GREEN separately.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go test ./internal/tester/... ./internal/usb/... -v -count=1 -run "TestMock|TestKnown"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>All mock discrete driver tests pass; KnownDevices has 4 entries (1 printer + 3 testers); go build ./... passes.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 2: FNB58 streaming mock driver</name>
|
||||||
|
<files>internal/tester/driver.go, internal/tester/mock_drivers.go, internal/tester/driver_test.go</files>
|
||||||
|
<behavior>
|
||||||
|
- LiveReading struct: Voltage float64, CurrentAmps float64, PowerWatts float64, PDProtocol string, Timestamp time.Time
|
||||||
|
- StreamingTesterDriver interface embeds TesterDriver and adds: Stream() <-chan LiveReading
|
||||||
|
- MockFNB58Driver.Stream() returns a channel that emits 3 deterministic LiveReading samples (V=5.1, A=3.0, W=15.3, PDProtocol="PD3.0") at 100ms intervals then closes
|
||||||
|
- Stream() before Connect() returns a closed channel (not nil, not blocking)
|
||||||
|
- Disconnect() closes the stream channel and sets connected=false
|
||||||
|
- Compile-time: `var _ StreamingTesterDriver = (*MockFNB58Driver)(nil)`
|
||||||
|
- KnownDevices has "dead0:0004" for FNB58 (Role: RoleCableTester, BaudRate: 0 = HID, not serial)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
RED: Extend driver_test.go with FNB58-specific tests. Run tests — fail on missing types.
|
||||||
|
|
||||||
|
GREEN: Add to internal/tester/driver.go:
|
||||||
|
```go
|
||||||
|
type LiveReading struct {
|
||||||
|
Voltage float64
|
||||||
|
CurrentAmps float64
|
||||||
|
PowerWatts float64
|
||||||
|
PDProtocol string
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamingTesterDriver interface {
|
||||||
|
TesterDriver
|
||||||
|
Stream() <-chan LiveReading
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add MockFNB58Driver to mock_drivers.go. Connect() sets connected=true, initialises internal streamCh (buffered 8). Disconnect() closes streamCh if open, sets connected=false. Stream() returns streamCh (or a pre-closed channel if !connected). In Connect(), launch a goroutine that sends 3 readings 100ms apart then closes the channel — use a context derived from a cancelFunc stored on the struct so Disconnect() can also stop it early.
|
||||||
|
|
||||||
|
Add "dead0:0004" to KnownDevices: {VID:"dead0", PID:"0004", Name:"FNIRSI FNB58", Role:RoleCableTester, BaudRate:0} with `// TODO(hardware): BaudRate 0 = HID protocol — replace VID:PID after characterization`.
|
||||||
|
|
||||||
|
Commit RED then GREEN.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go test ./internal/tester/... -v -count=1 -run "TestFNB58|TestStream" -timeout 30s</automated>
|
||||||
|
</verify>
|
||||||
|
<done>FNB58 streaming tests pass; Stream() emits 3 readings and closes; Disconnect() stops stream; go build ./... passes; race detector clean (add -race flag).</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| USB serial → tester package | Raw bytes from hardware (not applicable for mocks; real drivers must validate) |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-01 | Tampering | TestResult deserialization (future real drivers) | accept | Mock phase only — add input validation when real drivers are implemented |
|
||||||
|
| T-05-02 | DoS | Stream() channel never closed | mitigate | MockFNB58Driver closes channel after N readings; Disconnect() closes channel; goroutine cancelled via context |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```
|
||||||
|
go build ./... # must pass
|
||||||
|
go test ./internal/tester/... -race -count=1 # all pass, race-clean
|
||||||
|
go test ./internal/usb/... -count=1 # existing tests still pass
|
||||||
|
grep -c "dead0:" internal/usb/device.go # must print 4
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- internal/tester package exists and compiles
|
||||||
|
- TesterDriver and StreamingTesterDriver interfaces defined
|
||||||
|
- MockUSBDriver, MockDPDriver, MockHDMIDriver: deterministic Read(), correct CableType
|
||||||
|
- MockFNB58Driver: Stream() emits readings, closes on Disconnect(), race-clean
|
||||||
|
- KnownDevices has 4 placeholder entries (3 Treedix + 1 FNB58), all Role: RoleCableTester
|
||||||
|
- All existing tests (phases 1-4) continue to pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/05-cable-test-integration/05-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
339
.planning/phases/05-cable-test-integration/05-02-PLAN.md
Normal file
339
.planning/phases/05-cable-test-integration/05-02-PLAN.md
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
---
|
||||||
|
phase: 05-cable-test-integration
|
||||||
|
plan: "02"
|
||||||
|
type: tdd
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["05-01"]
|
||||||
|
files_modified:
|
||||||
|
- internal/netbox/client.go
|
||||||
|
- internal/netbox/types.go
|
||||||
|
- internal/netbox/client_test.go
|
||||||
|
- internal/api/handlers/test.go
|
||||||
|
- internal/api/handlers/test_test.go
|
||||||
|
- internal/api/router.go
|
||||||
|
- cmd/hwlab/main.go
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CBL-05, CBL-06, CBL-07]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "POST /api/test/cable accepts a TestResult JSON body, creates a NetBox cable record, returns {hw_id, netbox_id}"
|
||||||
|
- "GET /api/test/events SSE streams live readings from any connected StreamingTesterDriver"
|
||||||
|
- "GET /api/test/recent returns the last 20 test results from an in-memory ring buffer"
|
||||||
|
- "Auto-detect: when USB Manager emits a DeviceEvent with Role=RoleCableTester, the correct driver type is activated"
|
||||||
|
- "POST /api/test/cable auto-prints the cable label via the existing printer.PrinterDriver"
|
||||||
|
- "Cable record test_data custom field receives structured JSON from TestResult"
|
||||||
|
artifacts:
|
||||||
|
- path: "internal/netbox/client.go"
|
||||||
|
provides: "CreateCable method"
|
||||||
|
exports: [CreateCable]
|
||||||
|
- path: "internal/netbox/types.go"
|
||||||
|
provides: "CableRecord type"
|
||||||
|
exports: [CableRecord]
|
||||||
|
- path: "internal/api/handlers/test.go"
|
||||||
|
provides: "TestHandler (POST /api/test/cable, GET /api/test/events, GET /api/test/recent)"
|
||||||
|
exports: [TestHandler]
|
||||||
|
- path: "internal/api/router.go"
|
||||||
|
provides: "Three new /api/test/* routes"
|
||||||
|
key_links:
|
||||||
|
- from: "POST /api/test/cable handler"
|
||||||
|
to: "netbox.Client.CreateCable"
|
||||||
|
via: "creates cable record with test_data JSON"
|
||||||
|
- from: "POST /api/test/cable handler"
|
||||||
|
to: "printer.PrinterDriver.Print"
|
||||||
|
via: "auto-prints cable label after record creation (non-fatal)"
|
||||||
|
- from: "GET /api/test/events handler"
|
||||||
|
to: "tester.StreamingTesterDriver.Stream()"
|
||||||
|
via: "SSE wraps LiveReading channel; goroutine exits on r.Context().Done()"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add `CreateCable` to the NetBox client, build `TestHandler` for the three cable-test API endpoints, wire them into the router, and connect auto-detection from the USB Manager to the correct tester driver.
|
||||||
|
|
||||||
|
Purpose: Backend can receive test results, persist them to NetBox, stream live FNB58 data via SSE, and auto-print the cable label — all without hardware, using mock drivers.
|
||||||
|
Output: internal/netbox/client.go (CreateCable), internal/api/handlers/test.go, updated router and main.go.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/05-cable-test-integration/05-CONTEXT.md
|
||||||
|
@.planning/phases/05-cable-test-integration/05-01-SUMMARY.md
|
||||||
|
@.planning/phases/04-usb-manager-label-printing/04-03-SUMMARY.md
|
||||||
|
|
||||||
|
@internal/netbox/client.go
|
||||||
|
@internal/netbox/types.go
|
||||||
|
@internal/api/router.go
|
||||||
|
@internal/api/handlers/usb_events.go
|
||||||
|
@internal/tester/driver.go
|
||||||
|
@internal/usb/device.go
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Contracts this plan depends on, extracted from existing code. -->
|
||||||
|
|
||||||
|
From internal/tester/driver.go (created in 05-01):
|
||||||
|
```go
|
||||||
|
type CableType int
|
||||||
|
const (CableTypeUSB CableType = iota; CableTypeDP; CableTypeHDMI)
|
||||||
|
|
||||||
|
type TestResult struct {
|
||||||
|
CableType CableType
|
||||||
|
USBVersion string
|
||||||
|
DPVersion string
|
||||||
|
HDMIVersion string
|
||||||
|
SpeedGbps float64
|
||||||
|
MaxWatts int
|
||||||
|
PinContinuity bool
|
||||||
|
HasEMarker bool
|
||||||
|
ResistanceOhm float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveReading struct {
|
||||||
|
Voltage float64
|
||||||
|
CurrentAmps float64
|
||||||
|
PowerWatts float64
|
||||||
|
PDProtocol string
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type TesterDriver interface {
|
||||||
|
Connect() error
|
||||||
|
Read() (TestResult, error)
|
||||||
|
Disconnect() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamingTesterDriver interface {
|
||||||
|
TesterDriver
|
||||||
|
Stream() <-chan LiveReading
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From internal/usb/device.go:
|
||||||
|
```go
|
||||||
|
type DeviceRole int
|
||||||
|
const (RolePrinter; RoleCableTester; RoleUnknown)
|
||||||
|
type DeviceEvent struct { VIDPID string; Spec DeviceSpec; State DeviceState }
|
||||||
|
type Manager struct { ... }
|
||||||
|
func (m *Manager) Events() <-chan DeviceEvent
|
||||||
|
```
|
||||||
|
|
||||||
|
From internal/printer/driver.go:
|
||||||
|
```go
|
||||||
|
type PrinterDriver interface {
|
||||||
|
Connect() error
|
||||||
|
Print(bitmap []byte, width, height int) error
|
||||||
|
Disconnect() error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From internal/api/handlers/usb_events.go (SSE pattern to mirror):
|
||||||
|
```go
|
||||||
|
// USBEventsHandler pattern:
|
||||||
|
// - 30s keepalive ticker
|
||||||
|
// - select on r.Context().Done() for leak-safe exit
|
||||||
|
// - w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
// - w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
// - flusher := w.(http.Flusher)
|
||||||
|
```
|
||||||
|
|
||||||
|
From internal/api/router.go:
|
||||||
|
```go
|
||||||
|
func NewRouter(
|
||||||
|
staticFiles fs.FS,
|
||||||
|
intakeHandler http.Handler,
|
||||||
|
inventoryHandler *handlers.InventoryHandler,
|
||||||
|
labelHandler *handlers.LabelHandler,
|
||||||
|
usbEventsHandler *handlers.USBEventsHandler,
|
||||||
|
) http.Handler
|
||||||
|
// Add testHandler *handlers.TestHandler parameter
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 1: NetBox CreateCable method + CableRecord type</name>
|
||||||
|
<files>internal/netbox/client.go, internal/netbox/types.go, internal/netbox/client_test.go</files>
|
||||||
|
<behavior>
|
||||||
|
- CableRecord type: ID int, HWID string, Label string, TestData string (raw JSON), CatalogStatus string
|
||||||
|
- CreateCable(ctx, label, assetTag, testDataJSON string) (int64, error) creates a cable in NetBox dcim cables API
|
||||||
|
- CreateCable with empty label returns error "cable label must not be empty"
|
||||||
|
- CreateCable marshals testDataJSON into the test_data custom field
|
||||||
|
- CreateCable sets catalog_status custom field to "complete"
|
||||||
|
- Integration test is build-tagged `//go:build integration` — unit tests use a mock HTTP server (httptest)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
RED: Write unit test using httptest.NewServer to mock the NetBox API. Test: CreateCable returns (id>0, nil) on 201; returns error on 422; rejects empty label. Run `go test ./internal/netbox/... -run TestCreateCable` — fail.
|
||||||
|
|
||||||
|
GREEN: Add to internal/netbox/types.go:
|
||||||
|
```go
|
||||||
|
type CableRecord struct {
|
||||||
|
ID int
|
||||||
|
HWID string
|
||||||
|
Label string
|
||||||
|
TestData string // raw JSON blob
|
||||||
|
CatalogStatus string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to internal/netbox/client.go:
|
||||||
|
```go
|
||||||
|
func (c *Client) CreateCable(ctx context.Context, label, assetTag, testDataJSON string) (int64, error) {
|
||||||
|
if label == "" {
|
||||||
|
return 0, fmt.Errorf("cable label must not be empty")
|
||||||
|
}
|
||||||
|
req := nb.NewWritableCableRequest()
|
||||||
|
req.SetLabel(label)
|
||||||
|
customFields := map[string]interface{}{
|
||||||
|
"test_data": testDataJSON,
|
||||||
|
"catalog_status": "complete",
|
||||||
|
}
|
||||||
|
if assetTag != "" {
|
||||||
|
customFields["hw_id"] = assetTag
|
||||||
|
}
|
||||||
|
req.SetCustomFields(customFields)
|
||||||
|
result, _, err := c.api.DcimAPI.DcimCablesCreate(ctx).
|
||||||
|
WritableCableRequest(*req).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("CreateCable %q: %w", label, err)
|
||||||
|
}
|
||||||
|
return int64(result.GetId()), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: go-netbox v4 cable API uses `DcimCablesCreate`. If the exact method name differs, inspect `c.api.DcimAPI` methods — the pattern is identical to `DcimDevicesCreate` used in CreateDevice.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go test ./internal/netbox/... -run TestCreateCable -v -count=1</automated>
|
||||||
|
</verify>
|
||||||
|
<done>CreateCable unit tests pass; go build ./... passes; integration test file exists with build tag (not run in CI).</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="tdd" tdd="true">
|
||||||
|
<name>Task 2: TestHandler (3 endpoints) + router wiring + main.go init</name>
|
||||||
|
<files>internal/api/handlers/test.go, internal/api/handlers/test_test.go, internal/api/router.go, cmd/hwlab/main.go</files>
|
||||||
|
<behavior>
|
||||||
|
- POST /api/test/cable: accepts JSON body {cable_type, usb_version, dp_version, hdmi_version, speed_gbps, max_watts, pin_continuity, has_emarker, resistance_ohm, hw_id}; creates NetBox cable record; auto-prints label (non-fatal — print failure does not 500); returns 201 {hw_id, netbox_id, print_skipped}
|
||||||
|
- POST /api/test/cable with missing hw_id still succeeds (hw_id defaults to "")
|
||||||
|
- POST /api/test/cable with malformed JSON returns 400
|
||||||
|
- GET /api/test/events: SSE with Content-Type text/event-stream; emits "data: {...LiveReading JSON}\n\n"; exits cleanly on client disconnect; 30s keepalive ticker; goroutine-leak-safe
|
||||||
|
- GET /api/test/recent: returns JSON array of last ≤20 TestResult entries from in-memory ring buffer; empty returns []
|
||||||
|
- TestHandler has NetBoxClient interface (subset: CreateCable only) for test injection
|
||||||
|
- TestHandler has PrinterDriver interface for injection (mirrors label handler pattern)
|
||||||
|
- Ring buffer capacity: 20, thread-safe with sync.Mutex
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
RED: Write test_test.go covering: POST /api/test/cable success (mock NetBox + mock printer), POST malformed body 400, GET /api/test/recent empty returns [], GET /api/test/events emits SSE + closes on disconnect. Run — fail.
|
||||||
|
|
||||||
|
GREEN: Create internal/api/handlers/test.go:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.georgsen.dk/hwlab/internal/tester"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNetBoxClient is the subset of netbox.Client used by TestHandler.
|
||||||
|
type TestNetBoxClient interface {
|
||||||
|
CreateCable(ctx context.Context, label, assetTag, testDataJSON string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestHandler struct {
|
||||||
|
nb TestNetBoxClient
|
||||||
|
printer printer.PrinterDriver
|
||||||
|
mu sync.Mutex
|
||||||
|
recent []tester.TestResult // ring buffer, cap 20
|
||||||
|
liveCh chan tester.LiveReading // set by AttachStream
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTestHandler(nb TestNetBoxClient, p printer.PrinterDriver) *TestHandler {
|
||||||
|
return &TestHandler{nb: nb, printer: p, liveCh: make(chan tester.LiveReading, 64)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachStream wires a StreamingTesterDriver's channel to the SSE broadcaster.
|
||||||
|
func (h *TestHandler) AttachStream(ch <-chan tester.LiveReading) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
POST /api/test/cable handler:
|
||||||
|
1. Decode JSON body into TestResult struct.
|
||||||
|
2. Marshal TestResult to JSON for test_data field.
|
||||||
|
3. Derive label from CableType + USBVersion/DPVersion/HDMIVersion.
|
||||||
|
4. Call h.nb.CreateCable(ctx, label, req.HWID, testDataJSON).
|
||||||
|
5. Attempt label print via h.printer (RenderCable + ImageToRawBitmap + Print); on error set print_skipped=true, log, continue.
|
||||||
|
6. Prepend to recent ring buffer (cap 20, drop oldest).
|
||||||
|
7. Return 201 JSON.
|
||||||
|
|
||||||
|
GET /api/test/events: mirror usb_events.go pattern exactly — Content-Type text/event-stream, 30s keepalive, select on r.Context().Done() and h.liveCh.
|
||||||
|
|
||||||
|
GET /api/test/recent: lock mutex, copy recent slice, unlock, write JSON.
|
||||||
|
|
||||||
|
Update internal/api/router.go: add `testHandler *handlers.TestHandler` param, register three routes:
|
||||||
|
- `r.Post("/test/cable", testHandler.SubmitCableTest)`
|
||||||
|
- `r.Get("/test/events", testHandler.StreamEvents)`
|
||||||
|
- `r.Get("/test/recent", testHandler.RecentTests)`
|
||||||
|
|
||||||
|
Update cmd/hwlab/main.go: construct TestHandler with netboxClient (real) and printer.NewMockDriver(); pass to NewRouter(). Add goroutine that reads usbManager.Events() and calls testHandler.AttachStream() when a RoleCableTester device connects (no-op for now — wires the plumbing).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -run TestTestHandler -v -count=1 -race</automated>
|
||||||
|
</verify>
|
||||||
|
<done>All TestHandler tests pass; go build ./... passes; race-clean; three /api/test/* routes reachable via `go run ./cmd/hwlab` (smoke-test with curl if available).</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| client → POST /api/test/cable | JSON body from browser — untrusted user input |
|
||||||
|
| SSE stream → GET /api/test/events | Server push; client can disconnect at any time |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-03 | Tampering | POST /api/test/cable JSON body | mitigate | json.Decoder with DisallowUnknownFields; return 400 on malformed input |
|
||||||
|
| T-05-04 | DoS | GET /api/test/events goroutine leak | mitigate | Select on r.Context().Done(); 30s keepalive ticker matches usb_events.go pattern |
|
||||||
|
| T-05-05 | DoS | POST /api/test/cable runaway print calls | mitigate | Inherit 1-print/s rate limit from LabelHandler pattern (PrintCooldown); return 429 if violated |
|
||||||
|
| T-05-06 | Information Disclosure | test_data JSON stored in NetBox | accept | LAN-only deployment; NetBox has its own auth; no PII in cable test data |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```
|
||||||
|
go build ./...
|
||||||
|
go test ./internal/netbox/... -run TestCreateCable -v
|
||||||
|
go test ./internal/api/handlers/... -race -v
|
||||||
|
curl -s -X POST http://localhost:8080/api/test/cable \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"cable_type":0,"usb_version":"USB 3.2 Gen 2","speed_gbps":10,"max_watts":100,"pin_continuity":true}' \
|
||||||
|
| jq .
|
||||||
|
curl -s http://localhost:8080/api/test/recent | jq .
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- CreateCable method exists on netbox.Client, unit tests pass
|
||||||
|
- POST /api/test/cable: creates NetBox record, auto-prints (non-fatal), returns 201 with {hw_id, netbox_id, print_skipped}
|
||||||
|
- GET /api/test/events: SSE, goroutine-leak-safe, 30s keepalive
|
||||||
|
- GET /api/test/recent: returns last 20 results, empty array default
|
||||||
|
- All three routes registered in router
|
||||||
|
- Existing tests (phases 1-4) continue to pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/05-cable-test-integration/05-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
250
.planning/phases/05-cable-test-integration/05-03-PLAN.md
Normal file
250
.planning/phases/05-cable-test-integration/05-03-PLAN.md
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
---
|
||||||
|
phase: 05-cable-test-integration
|
||||||
|
plan: "03"
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: ["05-02"]
|
||||||
|
files_modified:
|
||||||
|
- web/src/pages/CableTestPage.tsx
|
||||||
|
- web/src/pages/CableTestPage.test.tsx
|
||||||
|
- web/src/api/test.ts
|
||||||
|
- web/src/router.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CBL-06]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Navigating to /test renders the Cable Test Station page"
|
||||||
|
- "Left panel shows active tester readout (or 'No tester connected' placeholder)"
|
||||||
|
- "Right panel shows last 20 tests in a scrollable list"
|
||||||
|
- "Center panel shows label preview for the most recent test"
|
||||||
|
- "Print & Next button POSTs the current test result and clears the form for the next cable"
|
||||||
|
- "Live FNB58 data streams into the tester readout panel via GET /api/test/events SSE"
|
||||||
|
- "Page is usable on mobile screen (single column stacked layout below lg breakpoint)"
|
||||||
|
artifacts:
|
||||||
|
- path: "web/src/pages/CableTestPage.tsx"
|
||||||
|
provides: "Cable Test Station page with three panels"
|
||||||
|
- path: "web/src/api/test.ts"
|
||||||
|
provides: "submitCableTest(), streamTestEvents(), getRecentTests() API helpers"
|
||||||
|
- path: "web/src/router.tsx"
|
||||||
|
provides: "/test route registered"
|
||||||
|
key_links:
|
||||||
|
- from: "Print & Next button"
|
||||||
|
to: "POST /api/test/cable"
|
||||||
|
via: "submitCableTest() mutation (TanStack Query useMutation)"
|
||||||
|
- from: "Live readout panel"
|
||||||
|
to: "GET /api/test/events"
|
||||||
|
via: "EventSource in useEffect, closed on unmount"
|
||||||
|
- from: "Recent tests panel"
|
||||||
|
to: "GET /api/test/recent"
|
||||||
|
via: "useQuery with 5s refetch interval"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the Cable Test Station page at /test: three-panel layout (tester readout, label preview, recent tests), Print & Next workflow, live SSE updates, mobile-responsive.
|
||||||
|
|
||||||
|
Purpose: Operator can test cables and print labels from a single page without leaving the workflow.
|
||||||
|
Output: web/src/pages/CableTestPage.tsx, web/src/api/test.ts, router update.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/05-cable-test-integration/05-CONTEXT.md
|
||||||
|
@.planning/phases/05-cable-test-integration/05-02-SUMMARY.md
|
||||||
|
@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md
|
||||||
|
|
||||||
|
@web/src/router.tsx
|
||||||
|
@web/src/pages/DashboardPage.tsx
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Backend API contracts this page consumes. -->
|
||||||
|
|
||||||
|
POST /api/test/cable
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cable_type": 0,
|
||||||
|
"usb_version": "USB 3.2 Gen 2",
|
||||||
|
"dp_version": "",
|
||||||
|
"hdmi_version": "",
|
||||||
|
"speed_gbps": 10.0,
|
||||||
|
"max_watts": 100,
|
||||||
|
"pin_continuity": true,
|
||||||
|
"has_emarker": true,
|
||||||
|
"resistance_ohm": 0.12,
|
||||||
|
"hw_id": "HW-00042"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Response 201:
|
||||||
|
```json
|
||||||
|
{ "hw_id": "HW-00042", "netbox_id": 7, "print_skipped": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
GET /api/test/recent
|
||||||
|
Response 200: array of TestResult objects (same shape as POST body)
|
||||||
|
|
||||||
|
GET /api/test/events (SSE)
|
||||||
|
event stream: `data: {"voltage":5.1,"current_amps":3.0,"power_watts":15.3,"pd_protocol":"PD3.0","timestamp":"..."}\n\n`
|
||||||
|
|
||||||
|
Design system tokens (from CLAUDE.md / ClickHouse design):
|
||||||
|
- Background: #000000
|
||||||
|
- Accent: #faff69 (neon volt)
|
||||||
|
- Card background: #111111
|
||||||
|
- Border: #222222
|
||||||
|
- Text primary: #ffffff
|
||||||
|
- Text muted: #888888
|
||||||
|
- Tailwind classes: bg-black, text-[#faff69], bg-[#111], border-[#222]
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: API helpers + Cable Test Station page</name>
|
||||||
|
<files>web/src/api/test.ts, web/src/pages/CableTestPage.tsx, web/src/router.tsx</files>
|
||||||
|
<action>
|
||||||
|
Create web/src/api/test.ts:
|
||||||
|
```typescript
|
||||||
|
export interface TestResult {
|
||||||
|
cable_type: 0 | 1 | 2; // 0=USB, 1=DP, 2=HDMI
|
||||||
|
usb_version: string;
|
||||||
|
dp_version: string;
|
||||||
|
hdmi_version: string;
|
||||||
|
speed_gbps: number;
|
||||||
|
max_watts: number;
|
||||||
|
pin_continuity: boolean;
|
||||||
|
has_emarker: boolean;
|
||||||
|
resistance_ohm: number;
|
||||||
|
hw_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitResponse {
|
||||||
|
hw_id: string;
|
||||||
|
netbox_id: number;
|
||||||
|
print_skipped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveReading {
|
||||||
|
voltage: number;
|
||||||
|
current_amps: number;
|
||||||
|
power_watts: number;
|
||||||
|
pd_protocol: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitCableTest(result: TestResult): Promise<SubmitResponse> {
|
||||||
|
const res = await fetch('/api/test/cable', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(result),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`submit failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentTests(): Promise<TestResult[]> {
|
||||||
|
const res = await fetch('/api/test/recent');
|
||||||
|
if (!res.ok) throw new Error(`recent tests failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create web/src/pages/CableTestPage.tsx:
|
||||||
|
|
||||||
|
Layout: Three-column grid on lg+, single stacked column on mobile.
|
||||||
|
- Column 1 (tester readout): "Cable Test Station" heading; if liveReading state is set show Voltage/Current/Power/Protocol values with neon volt accent; else show "No tester connected" in muted text. Use EventSource in useEffect — close on component unmount. On each SSE message parse JSON into liveReading state.
|
||||||
|
- Column 2 (center — label preview + print): Show a card with current test fields as text (HW ID, type, version, speed, power, continuity, eMarker, resistance). "Print & Next" button (bg-[#faff69] text-black font-bold): calls submitCableTest mutation; on success show toast "Label printed for {hw_id}" (react-hot-toast) then reset form. If print_skipped=true toast says "Saved — printer not available". Loading state: button disabled + spinner.
|
||||||
|
- Column 3 (recent tests): "Recent Tests" heading; useQuery fetching getRecentTests() every 5000ms; render as list rows showing cable_type icon (USB/DP/HDMI), hw_id, pin_continuity chip (green tick / red X), speed_gbps.
|
||||||
|
|
||||||
|
Use lucide-react icons: Usb, Monitor, Tv2 for cable types. CheckCircle2 and XCircle for continuity.
|
||||||
|
|
||||||
|
For the "current test" form: a simple controlled form with number/text inputs for each TestResult field. Pre-populate with mock data when liveReading arrives (voltage/current into the readout; the test fields remain manual until real driver parsing is implemented).
|
||||||
|
|
||||||
|
Register /test route in web/src/router.tsx. Add "Test" nav link in the site nav (alongside Dashboard / Intake / Scan).
|
||||||
|
|
||||||
|
Design tokens: all backgrounds bg-black, cards bg-[#111] border border-[#222] rounded-lg p-4, headings text-white, muted text-[#888], accent text-[#faff69].
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>npm run build exits 0; /test route exists in router; CableTestPage renders three panels; Print & Next button is present.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Unit tests for CableTestPage</name>
|
||||||
|
<files>web/src/pages/CableTestPage.test.tsx</files>
|
||||||
|
<action>
|
||||||
|
Create web/src/pages/CableTestPage.test.tsx using Vitest + React Testing Library (already in the project from Phase 3).
|
||||||
|
|
||||||
|
Mock fetch globally with vi.stubGlobal.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
1. "renders 'No tester connected' when no SSE data" — render CableTestPage, assert text present.
|
||||||
|
2. "renders recent tests list" — mock getRecentTests() returning 2 USB items; assert two rows visible.
|
||||||
|
3. "Print & Next calls submitCableTest and shows toast" — mock submitCableTest to resolve {hw_id:"HW-00001",netbox_id:1,print_skipped:false}; fill hw_id input; click Print & Next; await toast text "Label printed".
|
||||||
|
4. "Print & Next shows print_skipped toast" — mock returns print_skipped:true; assert toast says "Saved — printer not available".
|
||||||
|
|
||||||
|
Mock EventSource in test setup:
|
||||||
|
```typescript
|
||||||
|
class MockEventSource {
|
||||||
|
addEventListener = vi.fn();
|
||||||
|
removeEventListener = vi.fn();
|
||||||
|
close = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal('EventSource', MockEventSource);
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrap component in necessary providers (QueryClientProvider, MemoryRouter, Toaster).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby/web && npm test -- --run CableTestPage 2>&1 | tail -30</automated>
|
||||||
|
</verify>
|
||||||
|
<done>All 4 CableTestPage tests pass; npm run build still exits 0.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser → POST /api/test/cable | User-submitted form data; validated server-side in 05-02 |
|
||||||
|
| SSE stream → browser | Server push only; EventSource is read-only, no injection risk |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-07 | XSS | TestResult fields rendered in DOM | mitigate | Use React JSX (auto-escaped); no dangerouslySetInnerHTML |
|
||||||
|
| T-05-08 | DoS | EventSource leak on unmount | mitigate | useEffect cleanup closes EventSource; verified in test 1 |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```
|
||||||
|
cd web && npm run build # exits 0, no TypeScript errors
|
||||||
|
cd web && npm test -- --run CableTestPage # all 4 tests pass
|
||||||
|
# Manual smoke test: navigate to http://localhost:5173/test
|
||||||
|
# Verify three panels render, Print & Next button present, /test in nav
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- /test route renders Cable Test Station page with three panels
|
||||||
|
- Print & Next submits POST /api/test/cable, shows success toast, resets form
|
||||||
|
- SSE live readings update the tester readout panel; EventSource closed on unmount
|
||||||
|
- Recent tests list auto-refreshes every 5s
|
||||||
|
- Layout is single-column on mobile, three-column on lg+
|
||||||
|
- All 4 unit tests pass; npm run build exits 0
|
||||||
|
- ClickHouse design tokens applied throughout (#000000 background, #faff69 accent)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/05-cable-test-integration/05-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Loading…
Add table
Reference in a new issue