Skip to content

Commit 87e8aad

Browse files
committed
ci: add Postgres/MySQL schema smoke; expand server and plugin lifecycle tests
1 parent e97fcce commit 87e8aad

8 files changed

Lines changed: 580 additions & 56 deletions

File tree

.github/workflows/go.yml

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,52 @@ jobs:
3636
$(go env GOPATH)/bin/golangci-lint run ./...
3737
else
3838
go vet ./...
39-
fi
39+
fi
40+
41+
database-smoke:
42+
runs-on: ubuntu-latest
43+
services:
44+
postgres:
45+
image: postgres:16-alpine
46+
ports:
47+
- 5432:5432
48+
env:
49+
POSTGRES_USER: marchat
50+
POSTGRES_PASSWORD: marchat
51+
POSTGRES_DB: marchat_ci
52+
options: >-
53+
--health-cmd "pg_isready -U marchat -d marchat_ci"
54+
--health-interval 5s
55+
--health-timeout 5s
56+
--health-retries 10
57+
mysql:
58+
image: mysql:8.0
59+
ports:
60+
- 3306:3306
61+
env:
62+
MYSQL_ROOT_PASSWORD: root
63+
MYSQL_DATABASE: marchat_ci
64+
MYSQL_USER: marchat
65+
MYSQL_PASSWORD: marchat
66+
options: >-
67+
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -proot"
68+
--health-interval 5s
69+
--health-timeout 5s
70+
--health-retries 15
71+
steps:
72+
- name: Checkout code
73+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
74+
75+
- name: Set up Go
76+
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
77+
with:
78+
go-version: '1.25.8'
79+
80+
- name: Tidy modules
81+
run: go mod tidy
82+
83+
- name: Postgres and MySQL schema smoke
84+
env:
85+
MARCHAT_CI_POSTGRES_URL: postgres://marchat:marchat@127.0.0.1:5432/marchat_ci?sslmode=disable
86+
MARCHAT_CI_MYSQL_URL: marchat:marchat@tcp(127.0.0.1:3306)/marchat_ci
87+
run: go test -race ./server -run 'Test(Postgres|MySQL)InitDBAndSchemaSmoke' -count=1 -v

README.md

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -733,7 +733,7 @@ Profiles stored in platform-appropriate locations:
733733

734734
## Testing
735735

736-
Foundational test suite covering core functionality, cryptography, and plugins.
736+
Foundational test suite covering core functionality, cryptography, and plugins. CI (`.github/workflows/go.yml`) runs the full suite with the race detector and a separate **database-smoke** job against Postgres and MySQL (see [TESTING.md](TESTING.md)).
737737

738738
### Running Tests
739739
```bash
@@ -752,21 +752,21 @@ Percentages are **statement coverage** from a merged profile (`go test -coverpro
752752

753753
| Package | Coverage | Size | Status |
754754
|---------|----------|------|--------|
755-
| `shared` | 86.8% | 203 LOC | High |
756-
| `plugin/license` | 85.4% | 198 LOC | High |
757-
| `client/crypto` | 79.5% | 282 LOC | High |
758-
| `config` | 73.2% | 277 LOC | High |
759-
| `client/config` | 57.0% | 1679 LOC | Medium |
760-
| `internal/doctor` | 50.2% | 666 LOC | Medium |
761-
| `plugin/store` | 47.0% | 490 LOC | Medium |
762-
| `cmd/license` | 42.2% | 140 LOC | Medium |
763-
| `server` | 35.3% | 6273 LOC | Low |
764-
| `plugin/manager` | 26.8% | 626 LOC | Low |
765-
| `client` | 23.1% | 4950 LOC | Low |
766-
| `plugin/host` | 21.1% | 533 LOC | Low |
767-
| `cmd/server` | 13.7% | 424 LOC | Low |
768-
769-
**Overall: 35.7%** (main module packages only). See [TESTING.md](TESTING.md) for detailed information.
755+
| `shared` | 86.8% | 244 LOC | High |
756+
| `plugin/license` | 85.4% | 241 LOC | High |
757+
| `client/crypto` | 79.5% | 347 LOC | High |
758+
| `config` | 73.2% | 330 LOC | High |
759+
| `plugin/host` | 63.2% | 617 LOC | Medium |
760+
| `client/config` | 57.0% | 1988 LOC | Medium |
761+
| `internal/doctor` | 50.2% | 737 LOC | Medium |
762+
| `plugin/store` | 47.0% | 552 LOC | Medium |
763+
| `cmd/license` | 42.2% | 160 LOC | Medium |
764+
| `server` | 35.4% | 7153 LOC | Low |
765+
| `plugin/manager` | 32.1% | 747 LOC | Low |
766+
| `client` | 23.1% | 5499 LOC | Low |
767+
| `cmd/server` | 13.7% | 484 LOC | Low |
768+
769+
**Overall: 37.3%** (main module packages only). See [TESTING.md](TESTING.md) for detailed information.
770770

771771
## Contributing
772772

TESTING.md

Lines changed: 48 additions & 39 deletions
Large diffs are not rendered by default.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"runtime"
11+
"strings"
12+
"testing"
13+
"time"
14+
15+
"github.com/Cod-e-Codes/marchat/internal/doctor"
16+
)
17+
18+
func marchatModuleRoot(t *testing.T) string {
19+
t.Helper()
20+
_, file, _, ok := runtime.Caller(0)
21+
if !ok {
22+
t.Fatal("runtime.Caller failed")
23+
}
24+
dir := filepath.Dir(file)
25+
for {
26+
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
27+
return dir
28+
}
29+
parent := filepath.Dir(dir)
30+
if parent == dir {
31+
t.Fatal("go.mod not found above cmd/server")
32+
}
33+
dir = parent
34+
}
35+
}
36+
37+
func TestSubprocessDoctorPlain(t *testing.T) {
38+
root := marchatModuleRoot(t)
39+
cfgDir := t.TempDir()
40+
41+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
42+
defer cancel()
43+
44+
cmd := exec.CommandContext(ctx, "go", "run", "./cmd/server", "-doctor", "-config-dir", cfgDir)
45+
cmd.Dir = root
46+
cmd.Env = append(os.Environ(),
47+
"MARCHAT_DOCTOR_NO_NETWORK=1",
48+
"NO_COLOR=1",
49+
"CGO_ENABLED=0",
50+
)
51+
out, err := cmd.CombinedOutput()
52+
if err != nil {
53+
t.Fatalf("go run -doctor: %v\n%s", err, out)
54+
}
55+
if len(bytes.TrimSpace(out)) == 0 {
56+
t.Fatal("expected non-empty doctor output")
57+
}
58+
}
59+
60+
func TestSubprocessDoctorJSON(t *testing.T) {
61+
root := marchatModuleRoot(t)
62+
cfgDir := t.TempDir()
63+
64+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
65+
defer cancel()
66+
67+
cmd := exec.CommandContext(ctx, "go", "run", "./cmd/server", "-doctor-json", "-config-dir", cfgDir)
68+
cmd.Dir = root
69+
cmd.Env = append(os.Environ(),
70+
"MARCHAT_DOCTOR_NO_NETWORK=1",
71+
"NO_COLOR=1",
72+
"CGO_ENABLED=0",
73+
)
74+
out, err := cmd.CombinedOutput()
75+
if err != nil {
76+
t.Fatalf("go run -doctor-json: %v\n%s", err, out)
77+
}
78+
79+
trim := bytes.TrimSpace(out)
80+
var rep doctor.Report
81+
if err := json.Unmarshal(trim, &rep); err != nil {
82+
t.Fatalf("invalid JSON doctor output: %v\n%s", err, string(trim))
83+
}
84+
if rep.Role != "server" {
85+
t.Fatalf("report.role = %q, want server", rep.Role)
86+
}
87+
if !strings.HasPrefix(rep.GoVersion, "go") {
88+
t.Fatalf("expected go_version, got %q", rep.GoVersion)
89+
}
90+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package host
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"runtime"
9+
"testing"
10+
11+
"github.com/Cod-e-Codes/marchat/plugin/sdk"
12+
)
13+
14+
// minimalPluginMain is a tiny stdin/stdout JSON plugin that answers init and shutdown.
15+
const minimalPluginMain = `package main
16+
17+
import (
18+
"encoding/json"
19+
"os"
20+
)
21+
22+
func main() {
23+
dec := json.NewDecoder(os.Stdin)
24+
enc := json.NewEncoder(os.Stdout)
25+
for {
26+
var req map[string]interface{}
27+
if err := dec.Decode(&req); err != nil {
28+
return
29+
}
30+
t, _ := req["type"].(string)
31+
switch t {
32+
case "init":
33+
_ = enc.Encode(map[string]interface{}{"type": "response", "success": true})
34+
case "shutdown":
35+
return
36+
default:
37+
_ = enc.Encode(map[string]interface{}{"type": "response", "success": true})
38+
}
39+
}
40+
}
41+
`
42+
43+
func buildMinimalPluginBinary(t *testing.T, pluginName, pluginRoot string) {
44+
t.Helper()
45+
srcDir := filepath.Join(pluginRoot, "_build_"+pluginName)
46+
if err := os.MkdirAll(srcDir, 0o755); err != nil {
47+
t.Fatalf("mkdir src: %v", err)
48+
}
49+
mainPath := filepath.Join(srcDir, "main.go")
50+
if err := os.WriteFile(mainPath, []byte(minimalPluginMain), 0o644); err != nil {
51+
t.Fatalf("write main: %v", err)
52+
}
53+
// go build requires a module boundary when GO111MODULE=on (default).
54+
if err := os.WriteFile(filepath.Join(srcDir, "go.mod"), []byte("module marchat_test_minimal_plugin\n\ngo 1.21\n"), 0o644); err != nil {
55+
t.Fatalf("write go.mod: %v", err)
56+
}
57+
58+
pluginPath := filepath.Join(pluginRoot, pluginName)
59+
if err := os.MkdirAll(pluginPath, 0o755); err != nil {
60+
t.Fatalf("mkdir plugin: %v", err)
61+
}
62+
63+
outName := pluginName
64+
if runtime.GOOS == "windows" {
65+
outName += ".exe"
66+
}
67+
outPath := filepath.Join(pluginPath, outName)
68+
69+
cmd := exec.Command("go", "build", "-o", outPath, ".")
70+
cmd.Dir = srcDir
71+
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
72+
if out, err := cmd.CombinedOutput(); err != nil {
73+
t.Fatalf("go build plugin: %v\n%s", err, out)
74+
}
75+
76+
manifest := sdk.PluginManifest{
77+
Name: pluginName,
78+
Version: "1.0.0",
79+
Description: "minimal test plugin",
80+
Author: "test",
81+
License: "MIT",
82+
}
83+
data, err := json.Marshal(manifest)
84+
if err != nil {
85+
t.Fatalf("marshal manifest: %v", err)
86+
}
87+
if err := os.WriteFile(filepath.Join(pluginPath, "plugin.json"), data, 0o644); err != nil {
88+
t.Fatalf("write manifest: %v", err)
89+
}
90+
}
91+
92+
func TestPluginHostStartStopLifecycle(t *testing.T) {
93+
pluginDir := t.TempDir()
94+
dataDir := t.TempDir()
95+
const name = "lifecycleplug"
96+
buildMinimalPluginBinary(t, name, pluginDir)
97+
98+
h := NewPluginHost(pluginDir, dataDir)
99+
if err := h.LoadPlugin(name); err != nil {
100+
t.Fatalf("LoadPlugin: %v", err)
101+
}
102+
if err := h.StartPlugin(name); err != nil {
103+
t.Fatalf("StartPlugin: %v", err)
104+
}
105+
inst := h.GetPlugin(name)
106+
if inst == nil || inst.Process == nil {
107+
t.Fatal("expected running process after StartPlugin")
108+
}
109+
if err := h.StopPlugin(name); err != nil {
110+
t.Fatalf("StopPlugin: %v", err)
111+
}
112+
if inst := h.GetPlugin(name); inst == nil || inst.Process != nil {
113+
t.Fatal("expected Process cleared after StopPlugin")
114+
}
115+
}
116+
117+
func TestPluginHostStartPluginAlreadyRunning(t *testing.T) {
118+
pluginDir := t.TempDir()
119+
dataDir := t.TempDir()
120+
const name = "doublestart"
121+
buildMinimalPluginBinary(t, name, pluginDir)
122+
123+
h := NewPluginHost(pluginDir, dataDir)
124+
if err := h.LoadPlugin(name); err != nil {
125+
t.Fatalf("LoadPlugin: %v", err)
126+
}
127+
if err := h.StartPlugin(name); err != nil {
128+
t.Fatalf("StartPlugin: %v", err)
129+
}
130+
defer func() { _ = h.StopPlugin(name) }()
131+
132+
if err := h.StartPlugin(name); err == nil {
133+
t.Fatal("expected error when StartPlugin on already running plugin")
134+
}
135+
}
136+
137+
func TestPluginHostStopPluginIdempotent(t *testing.T) {
138+
pluginDir := t.TempDir()
139+
dataDir := t.TempDir()
140+
const name = "stopnoop"
141+
buildMinimalPluginBinary(t, name, pluginDir)
142+
143+
h := NewPluginHost(pluginDir, dataDir)
144+
if err := h.LoadPlugin(name); err != nil {
145+
t.Fatalf("LoadPlugin: %v", err)
146+
}
147+
if err := h.StopPlugin(name); err != nil {
148+
t.Fatalf("StopPlugin on non-started: %v", err)
149+
}
150+
}
151+
152+
func TestPluginHostExecuteCommandWhenRunning(t *testing.T) {
153+
pluginDir := t.TempDir()
154+
dataDir := t.TempDir()
155+
const name = "cmdplug"
156+
buildMinimalPluginBinary(t, name, pluginDir)
157+
158+
h := NewPluginHost(pluginDir, dataDir)
159+
if err := h.LoadPlugin(name); err != nil {
160+
t.Fatalf("LoadPlugin: %v", err)
161+
}
162+
if err := h.StartPlugin(name); err != nil {
163+
t.Fatalf("StartPlugin: %v", err)
164+
}
165+
defer func() { _ = h.StopPlugin(name) }()
166+
167+
if err := h.ExecuteCommand(name, "ping", nil); err != nil {
168+
t.Fatalf("ExecuteCommand: %v", err)
169+
}
170+
}

0 commit comments

Comments
 (0)