cli/internal/cmd/php/php_test.go
Snider f2bc912ebe feat: infrastructure packages and lint cleanup (#281)
* ci: consolidate duplicate workflows and merge CodeQL configs

Remove 17 duplicate workflow files that were split copies of the
combined originals. Each family (CI, CodeQL, Coverage, PR Build,
Alpha Release) had the same job duplicated across separate
push/pull_request/schedule/manual trigger files.

Merge codeql.yml and codescan.yml into a single codeql.yml with
a language matrix covering go, javascript-typescript, python,
and actions — matching the previous default setup coverage.

Remaining workflows (one per family):
- ci.yml (push + PR + manual)
- codeql.yml (push + PR + schedule, all languages)
- coverage.yml (push + PR + manual)
- alpha-release.yml (push + manual)
- pr-build.yml (PR + manual)
- release.yml (tag push)
- agent-verify.yml, auto-label.yml, auto-project.yml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add collect, config, crypt, plugin packages and fix all lint issues

Add four new infrastructure packages with CLI commands:
- pkg/config: layered configuration (defaults → file → env → flags)
- pkg/crypt: crypto primitives (Argon2id, AES-GCM, ChaCha20, HMAC, checksums)
- pkg/plugin: plugin system with GitHub-based install/update/remove
- pkg/collect: collection subsystem (GitHub, BitcoinTalk, market, papers, excavate)

Fix all golangci-lint issues across the entire codebase (~100 errcheck,
staticcheck SA1012/SA1019/ST1005, unused, ineffassign fixes) so that
`core go qa` passes with 0 issues.

Closes #167, #168, #170, #250, #251, #252, #253, #254, #255, #256

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:34:43 +00:00

644 lines
18 KiB
Go

package php
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewDevServer_Good(t *testing.T) {
t.Run("creates dev server with default options", func(t *testing.T) {
opts := Options{}
server := NewDevServer(opts)
assert.NotNil(t, server)
assert.Empty(t, server.services)
assert.False(t, server.running)
})
t.Run("creates dev server with custom options", func(t *testing.T) {
opts := Options{
Dir: "/tmp/test",
NoVite: true,
NoHorizon: true,
FrankenPHPPort: 9000,
}
server := NewDevServer(opts)
assert.NotNil(t, server)
assert.Equal(t, "/tmp/test", server.opts.Dir)
assert.True(t, server.opts.NoVite)
})
}
func TestDevServer_IsRunning_Good(t *testing.T) {
t.Run("returns false when not running", func(t *testing.T) {
server := NewDevServer(Options{})
assert.False(t, server.IsRunning())
})
}
func TestDevServer_Status_Good(t *testing.T) {
t.Run("returns empty status when no services", func(t *testing.T) {
server := NewDevServer(Options{})
statuses := server.Status()
assert.Empty(t, statuses)
})
}
func TestDevServer_Services_Good(t *testing.T) {
t.Run("returns empty services list initially", func(t *testing.T) {
server := NewDevServer(Options{})
services := server.Services()
assert.Empty(t, services)
})
}
func TestDevServer_Stop_Good(t *testing.T) {
t.Run("returns nil when not running", func(t *testing.T) {
server := NewDevServer(Options{})
err := server.Stop()
assert.NoError(t, err)
})
}
func TestDevServer_Start_Bad(t *testing.T) {
t.Run("fails when already running", func(t *testing.T) {
server := NewDevServer(Options{})
server.running = true
err := server.Start(context.Background(), Options{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "already running")
})
t.Run("fails for non-Laravel project", func(t *testing.T) {
dir := t.TempDir()
server := NewDevServer(Options{Dir: dir})
err := server.Start(context.Background(), Options{Dir: dir})
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a Laravel project")
})
}
func TestDevServer_Logs_Bad(t *testing.T) {
t.Run("fails for non-existent service", func(t *testing.T) {
server := NewDevServer(Options{})
_, err := server.Logs("nonexistent", false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "service not found")
})
}
func TestDevServer_filterServices_Good(t *testing.T) {
tests := []struct {
name string
services []DetectedService
opts Options
expected []DetectedService
}{
{
name: "no filtering with default options",
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
opts: Options{},
expected: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
},
{
name: "filters Vite when NoVite is true",
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
opts: Options{NoVite: true},
expected: []DetectedService{ServiceFrankenPHP, ServiceHorizon},
},
{
name: "filters Horizon when NoHorizon is true",
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon},
opts: Options{NoHorizon: true},
expected: []DetectedService{ServiceFrankenPHP, ServiceVite},
},
{
name: "filters Reverb when NoReverb is true",
services: []DetectedService{ServiceFrankenPHP, ServiceReverb},
opts: Options{NoReverb: true},
expected: []DetectedService{ServiceFrankenPHP},
},
{
name: "filters Redis when NoRedis is true",
services: []DetectedService{ServiceFrankenPHP, ServiceRedis},
opts: Options{NoRedis: true},
expected: []DetectedService{ServiceFrankenPHP},
},
{
name: "filters multiple services",
services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon, ServiceReverb, ServiceRedis},
opts: Options{NoVite: true, NoHorizon: true, NoReverb: true, NoRedis: true},
expected: []DetectedService{ServiceFrankenPHP},
},
{
name: "keeps unknown services",
services: []DetectedService{ServiceFrankenPHP},
opts: Options{NoVite: true},
expected: []DetectedService{ServiceFrankenPHP},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := NewDevServer(Options{})
result := server.filterServices(tt.services, tt.opts)
assert.Equal(t, tt.expected, result)
})
}
}
func TestMultiServiceReader_Good(t *testing.T) {
t.Run("closes all readers on Close", func(t *testing.T) {
// Create mock readers using files
dir := t.TempDir()
file1, err := os.CreateTemp(dir, "log1-*.log")
require.NoError(t, err)
_, _ = file1.WriteString("test1")
_, _ = file1.Seek(0, 0)
file2, err := os.CreateTemp(dir, "log2-*.log")
require.NoError(t, err)
_, _ = file2.WriteString("test2")
_, _ = file2.Seek(0, 0)
// Create mock services
services := []Service{
&FrankenPHPService{baseService: baseService{name: "svc1"}},
&ViteService{baseService: baseService{name: "svc2"}},
}
readers := []io.ReadCloser{file1, file2}
reader := newMultiServiceReader(services, readers, false)
assert.NotNil(t, reader)
err = reader.Close()
assert.NoError(t, err)
assert.True(t, reader.closed)
})
t.Run("returns EOF when closed", func(t *testing.T) {
reader := &multiServiceReader{closed: true}
buf := make([]byte, 10)
n, err := reader.Read(buf)
assert.Equal(t, 0, n)
assert.Equal(t, io.EOF, err)
})
}
func TestMultiServiceReader_Read_Good(t *testing.T) {
t.Run("reads from readers with service prefix", func(t *testing.T) {
dir := t.TempDir()
file1, err := os.CreateTemp(dir, "log-*.log")
require.NoError(t, err)
_, _ = file1.WriteString("log content")
_, _ = file1.Seek(0, 0)
services := []Service{
&FrankenPHPService{baseService: baseService{name: "TestService"}},
}
readers := []io.ReadCloser{file1}
reader := newMultiServiceReader(services, readers, false)
buf := make([]byte, 100)
n, err := reader.Read(buf)
assert.NoError(t, err)
assert.Greater(t, n, 0)
result := string(buf[:n])
assert.Contains(t, result, "[TestService]")
})
t.Run("returns EOF when all readers are exhausted in non-follow mode", func(t *testing.T) {
dir := t.TempDir()
file1, err := os.CreateTemp(dir, "log-*.log")
require.NoError(t, err)
_ = file1.Close() // Empty file
file1, err = os.Open(file1.Name())
require.NoError(t, err)
services := []Service{
&FrankenPHPService{baseService: baseService{name: "TestService"}},
}
readers := []io.ReadCloser{file1}
reader := newMultiServiceReader(services, readers, false)
buf := make([]byte, 100)
n, err := reader.Read(buf)
assert.Equal(t, 0, n)
assert.Equal(t, io.EOF, err)
})
}
func TestOptions_Good(t *testing.T) {
t.Run("all fields are accessible", func(t *testing.T) {
opts := Options{
Dir: "/test",
Services: []DetectedService{ServiceFrankenPHP},
NoVite: true,
NoHorizon: true,
NoReverb: true,
NoRedis: true,
HTTPS: true,
Domain: "test.local",
FrankenPHPPort: 8000,
HTTPSPort: 443,
VitePort: 5173,
ReverbPort: 8080,
RedisPort: 6379,
}
assert.Equal(t, "/test", opts.Dir)
assert.Equal(t, []DetectedService{ServiceFrankenPHP}, opts.Services)
assert.True(t, opts.NoVite)
assert.True(t, opts.NoHorizon)
assert.True(t, opts.NoReverb)
assert.True(t, opts.NoRedis)
assert.True(t, opts.HTTPS)
assert.Equal(t, "test.local", opts.Domain)
assert.Equal(t, 8000, opts.FrankenPHPPort)
assert.Equal(t, 443, opts.HTTPSPort)
assert.Equal(t, 5173, opts.VitePort)
assert.Equal(t, 8080, opts.ReverbPort)
assert.Equal(t, 6379, opts.RedisPort)
})
}
func TestDevServer_StartStop_Integration(t *testing.T) {
t.Skip("requires PHP/FrankenPHP installed")
dir := t.TempDir()
setupLaravelProject(t, dir)
server := NewDevServer(Options{Dir: dir})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := server.Start(ctx, Options{Dir: dir})
require.NoError(t, err)
assert.True(t, server.IsRunning())
err = server.Stop()
require.NoError(t, err)
assert.False(t, server.IsRunning())
}
// setupLaravelProject creates a minimal Laravel project structure for testing.
func setupLaravelProject(t *testing.T, dir string) {
t.Helper()
// Create artisan file
err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755)
require.NoError(t, err)
// Create composer.json with Laravel
composerJSON := `{
"name": "test/laravel-project",
"require": {
"php": "^8.2",
"laravel/framework": "^11.0",
"laravel/octane": "^2.0"
}
}`
err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
}
func TestDevServer_UnifiedLogs_Bad(t *testing.T) {
t.Run("returns error when service logs fail", func(t *testing.T) {
server := NewDevServer(Options{})
// Create a mock service that will fail to provide logs
mockService := &FrankenPHPService{
baseService: baseService{
name: "FailingService",
logPath: "", // No log path set will cause error
},
}
server.services = []Service{mockService}
_, err := server.Logs("", false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get logs")
})
}
func TestDevServer_Logs_Good(t *testing.T) {
t.Run("finds specific service logs", func(t *testing.T) {
dir := t.TempDir()
logFile := filepath.Join(dir, "test.log")
err := os.WriteFile(logFile, []byte("test log content"), 0644)
require.NoError(t, err)
server := NewDevServer(Options{})
mockService := &FrankenPHPService{
baseService: baseService{
name: "TestService",
logPath: logFile,
},
}
server.services = []Service{mockService}
reader, err := server.Logs("TestService", false)
assert.NoError(t, err)
assert.NotNil(t, reader)
_ = reader.Close()
})
}
func TestDevServer_MergeOptions_Good(t *testing.T) {
t.Run("start merges options correctly", func(t *testing.T) {
dir := t.TempDir()
server := NewDevServer(Options{Dir: "/original"})
// Setup a minimal non-Laravel project to trigger an error
// but still test the options merge happens first
err := server.Start(context.Background(), Options{Dir: dir})
assert.Error(t, err) // Will fail because not Laravel project
// But the directory should have been merged
assert.Equal(t, dir, server.opts.Dir)
})
}
func TestDetectedService_Constants(t *testing.T) {
t.Run("all service constants are defined", func(t *testing.T) {
assert.Equal(t, DetectedService("frankenphp"), ServiceFrankenPHP)
assert.Equal(t, DetectedService("vite"), ServiceVite)
assert.Equal(t, DetectedService("horizon"), ServiceHorizon)
assert.Equal(t, DetectedService("reverb"), ServiceReverb)
assert.Equal(t, DetectedService("redis"), ServiceRedis)
})
}
func TestDevServer_HTTPSSetup(t *testing.T) {
t.Run("extracts domain from APP_URL when HTTPS enabled", func(t *testing.T) {
dir := t.TempDir()
// Create Laravel project
err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755)
require.NoError(t, err)
composerJSON := `{
"require": {
"laravel/framework": "^11.0",
"laravel/octane": "^2.0"
}
}`
err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
// Create .env with APP_URL
envContent := "APP_URL=https://myapp.test"
err = os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
// Verify we can extract the domain
url := GetLaravelAppURL(dir)
domain := ExtractDomainFromURL(url)
assert.Equal(t, "myapp.test", domain)
})
}
func TestDevServer_PortDefaults(t *testing.T) {
t.Run("uses default ports when not specified", func(t *testing.T) {
// This tests the logic in Start() for default port assignment
// We verify the constants/defaults by checking what would be created
// FrankenPHP default port is 8000
svc := NewFrankenPHPService("/tmp", FrankenPHPOptions{})
assert.Equal(t, 8000, svc.port)
// Vite default port is 5173
vite := NewViteService("/tmp", ViteOptions{})
assert.Equal(t, 5173, vite.port)
// Reverb default port is 8080
reverb := NewReverbService("/tmp", ReverbOptions{})
assert.Equal(t, 8080, reverb.port)
// Redis default port is 6379
redis := NewRedisService("/tmp", RedisOptions{})
assert.Equal(t, 6379, redis.port)
})
}
func TestDevServer_ServiceCreation(t *testing.T) {
t.Run("creates correct services based on detected services", func(t *testing.T) {
// Test that the switch statement in Start() creates the right service types
services := []DetectedService{
ServiceFrankenPHP,
ServiceVite,
ServiceHorizon,
ServiceReverb,
ServiceRedis,
}
// Verify each service type string
expected := []string{"frankenphp", "vite", "horizon", "reverb", "redis"}
for i, svc := range services {
assert.Equal(t, expected[i], string(svc))
}
})
}
func TestMultiServiceReader_CloseError(t *testing.T) {
t.Run("returns first close error", func(t *testing.T) {
dir := t.TempDir()
// Create a real file that we can close
file1, err := os.CreateTemp(dir, "log-*.log")
require.NoError(t, err)
file1Name := file1.Name()
_ = file1.Close()
// Reopen for reading
file1, err = os.Open(file1Name)
require.NoError(t, err)
services := []Service{
&FrankenPHPService{baseService: baseService{name: "svc1"}},
}
readers := []io.ReadCloser{file1}
reader := newMultiServiceReader(services, readers, false)
err = reader.Close()
assert.NoError(t, err)
// Second close should still work (files already closed)
// The closed flag prevents double-processing
assert.True(t, reader.closed)
})
}
func TestMultiServiceReader_FollowMode(t *testing.T) {
t.Run("returns 0 bytes without error in follow mode when no data", func(t *testing.T) {
dir := t.TempDir()
file1, err := os.CreateTemp(dir, "log-*.log")
require.NoError(t, err)
file1Name := file1.Name()
_ = file1.Close()
// Reopen for reading (empty file)
file1, err = os.Open(file1Name)
require.NoError(t, err)
services := []Service{
&FrankenPHPService{baseService: baseService{name: "svc1"}},
}
readers := []io.ReadCloser{file1}
reader := newMultiServiceReader(services, readers, true) // follow=true
// Use a channel to timeout the read since follow mode waits
done := make(chan bool)
go func() {
buf := make([]byte, 100)
n, err := reader.Read(buf)
// In follow mode, should return 0 bytes and nil error (waiting for more data)
assert.Equal(t, 0, n)
assert.NoError(t, err)
done <- true
}()
select {
case <-done:
// Good, read completed
case <-time.After(500 * time.Millisecond):
// Also acceptable - follow mode is waiting
}
_ = reader.Close()
})
}
func TestGetLaravelAppURL_Bad(t *testing.T) {
t.Run("no .env file", func(t *testing.T) {
dir := t.TempDir()
assert.Equal(t, "", GetLaravelAppURL(dir))
})
t.Run("no APP_URL in .env", func(t *testing.T) {
dir := t.TempDir()
envContent := "APP_NAME=Test\nAPP_ENV=local"
err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644)
require.NoError(t, err)
assert.Equal(t, "", GetLaravelAppURL(dir))
})
}
func TestExtractDomainFromURL_Edge(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{"empty string", "", ""},
{"just domain", "example.com", "example.com"},
{"http only", "http://", ""},
{"https only", "https://", ""},
{"domain with trailing slash", "https://example.com/", "example.com"},
{"complex path", "https://example.com:8080/path/to/page?query=1", "example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Strip protocol
result := ExtractDomainFromURL(tt.url)
if tt.url != "" && !strings.HasPrefix(tt.url, "http://") && !strings.HasPrefix(tt.url, "https://") && !strings.Contains(tt.url, ":") && !strings.Contains(tt.url, "/") {
assert.Equal(t, tt.expected, result)
}
})
}
}
func TestDevServer_StatusWithServices(t *testing.T) {
t.Run("returns statuses for all services", func(t *testing.T) {
server := NewDevServer(Options{})
// Add mock services
server.services = []Service{
&FrankenPHPService{baseService: baseService{name: "svc1", running: true, port: 8000}},
&ViteService{baseService: baseService{name: "svc2", running: false, port: 5173}},
}
statuses := server.Status()
assert.Len(t, statuses, 2)
assert.Equal(t, "svc1", statuses[0].Name)
assert.True(t, statuses[0].Running)
assert.Equal(t, "svc2", statuses[1].Name)
assert.False(t, statuses[1].Running)
})
}
func TestDevServer_ServicesReturnsAll(t *testing.T) {
t.Run("returns all services", func(t *testing.T) {
server := NewDevServer(Options{})
// Add mock services
server.services = []Service{
&FrankenPHPService{baseService: baseService{name: "svc1"}},
&ViteService{baseService: baseService{name: "svc2"}},
&HorizonService{baseService: baseService{name: "svc3"}},
}
services := server.Services()
assert.Len(t, services, 3)
})
}
func TestDevServer_StopWithCancel(t *testing.T) {
t.Run("calls cancel when running", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
server := NewDevServer(Options{})
server.running = true
server.cancel = cancel
server.ctx = ctx
// Add a mock service that won't error
server.services = []Service{
&FrankenPHPService{baseService: baseService{name: "svc1", running: false}},
}
err := server.Stop()
assert.NoError(t, err)
assert.False(t, server.running)
})
}
func TestMultiServiceReader_CloseWithErrors(t *testing.T) {
t.Run("handles multiple close errors", func(t *testing.T) {
dir := t.TempDir()
// Create files
file1, err := os.CreateTemp(dir, "log1-*.log")
require.NoError(t, err)
file2, err := os.CreateTemp(dir, "log2-*.log")
require.NoError(t, err)
services := []Service{
&FrankenPHPService{baseService: baseService{name: "svc1"}},
&ViteService{baseService: baseService{name: "svc2"}},
}
readers := []io.ReadCloser{file1, file2}
reader := newMultiServiceReader(services, readers, false)
// Close successfully
err = reader.Close()
assert.NoError(t, err)
})
}