Skip to content

Commit 18f7071

Browse files
authored
Merge pull request #331 from smocker-dev/feat/init-mocks
feat(server): load mocks from a file at startup (--init-mocks)
2 parents 74d4a9d + 5a09564 commit 18f7071

5 files changed

Lines changed: 133 additions & 6 deletions

File tree

main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ func parseConfig() (c config.Config) {
4040
fs.StringVar(&c.StaticFiles, "static-files", "client", "Location of the static files to serve (index.html, etc.)")
4141
fs.IntVar(&c.HistoryMaxRetention, "history-retention", 0, "Maximum number of calls to keep in the history per session (0 = no limit)")
4242
fs.StringVar(&c.PersistenceDirectory, "persistence-directory", "", "If defined, the directory where the sessions will be synchronized")
43+
fs.StringVar(&c.InitMocks, "init-mocks", "",
44+
"If set, load mocks from a YAML file (POST /mocks format) into a session at startup; "+
45+
"mutually exclusive with --persistence-directory")
4346
fs.BoolVar(&c.TLSEnable, "tls-enable", false, "Enable TLS using the provided certificate")
4447
fs.StringVar(&c.TLSCertFile, "tls-cert-file", "/etc/smocker/tls/certs/cert.pem", "Path to TLS certificate file ")
4548
fs.StringVar(&c.TLSKeyFile, "tls-private-key-file", "/etc/smocker/tls/private/key.pem", "Path to TLS key file")
@@ -104,5 +107,15 @@ func setupLogger(logLevel string) {
104107
func main() {
105108
c := parseConfig()
106109
setupLogger(c.LogLevel)
110+
111+
// Loading mocks at startup and persisting sessions are opposite intents: a seed file is a
112+
// fixed, read-only starting point, while persistence resumes (and rewrites) whatever state it
113+
// last held. Both define the boot state, so allowing both would be ambiguous on restart.
114+
if c.InitMocks != "" && c.PersistenceDirectory != "" {
115+
slog.Error("--init-mocks and --persistence-directory are mutually exclusive: " +
116+
"seed mocks from a file (ephemeral) or persist sessions to a directory, not both")
117+
os.Exit(1)
118+
}
119+
107120
server.Serve(c)
108121
}

server/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type Config struct {
88
StaticFiles string
99
HistoryMaxRetention int
1010
PersistenceDirectory string
11+
InitMocks string
1112
TLSEnable bool
1213
TLSCertFile string
1314
TLSKeyFile string

server/mock_server.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package server
33
import (
44
"log/slog"
55
"net/http"
6+
"os"
67
"strconv"
78

89
"github.com/labstack/echo/v4"
@@ -18,7 +19,11 @@ func NewMockServer(cfg config.Config) (*http.Server, services.Mocks) {
1819
if err != nil {
1920
slog.Error("Unable to load sessions", "error", err)
2021
}
21-
mockServices := services.NewMocks(sessions, cfg.HistoryMaxRetention, persistence)
22+
mockServices, err := services.NewMocks(sessions, cfg.HistoryMaxRetention, persistence, cfg.InitMocks)
23+
if err != nil {
24+
slog.Error("Unable to load initial mocks", "error", err, "init-mocks", cfg.InitMocks)
25+
os.Exit(1)
26+
}
2227

2328
mockServerEngine.HideBanner = true
2429
mockServerEngine.HidePort = true

server/services/mocks.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ package services
22

33
import (
44
"fmt"
5+
"log/slog"
6+
"os"
57
"regexp"
68
"strings"
79
"sync"
810
"time"
911

1012
"github.com/smocker-dev/smocker/server/types"
13+
"gopkg.in/yaml.v3"
1114
)
1215

1316
var (
@@ -44,7 +47,7 @@ type mocks struct {
4447
persistence Persistence
4548
}
4649

47-
func NewMocks(sessions types.Sessions, historyRetention int, persistence Persistence) Mocks {
50+
func NewMocks(sessions types.Sessions, historyRetention int, persistence Persistence, initMocksFile string) (Mocks, error) {
4851
s := &mocks{
4952
sessions: types.Sessions{},
5053
historyRetention: historyRetention,
@@ -53,7 +56,42 @@ func NewMocks(sessions types.Sessions, historyRetention int, persistence Persist
5356
if sessions != nil {
5457
s.sessions = sessions
5558
}
56-
return s
59+
if initMocksFile != "" {
60+
if err := s.seedFromFile(initMocksFile); err != nil {
61+
return nil, err
62+
}
63+
}
64+
return s, nil
65+
}
66+
67+
// seedFromFile loads mocks from a YAML file (the POST /mocks format) into a fresh "init-mocks"
68+
// session. Backs the --init-mocks startup flag; the mocks are never written back.
69+
func (s *mocks) seedFromFile(path string) error {
70+
file, err := os.Open(path)
71+
if err != nil {
72+
return err
73+
}
74+
defer file.Close()
75+
76+
var loaded types.Mocks
77+
if err := yaml.NewDecoder(file).Decode(&loaded); err != nil {
78+
return err
79+
}
80+
for _, mock := range loaded {
81+
if err := mock.Validate(); err != nil {
82+
return err
83+
}
84+
}
85+
86+
sessionID := s.NewSession("init-mocks").ID
87+
for _, mock := range loaded {
88+
if _, err := s.AddMock(sessionID, mock); err != nil {
89+
return err
90+
}
91+
}
92+
93+
slog.Info("Loaded initial mocks", "count", len(loaded), "init-mocks", path)
94+
return nil
5795
}
5896

5997
func (s *mocks) AddMock(sessionID string, newMock *types.Mock) (*types.Mock, error) {

server/services/mocks_test.go

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
package services
22

33
import (
4+
"os"
5+
"path/filepath"
46
"testing"
57

68
"github.com/smocker-dev/smocker/server/types"
79
"gopkg.in/yaml.v3"
810
)
911

12+
func newTestMocks(t *testing.T) Mocks {
13+
t.Helper()
14+
svc, err := NewMocks(nil, 0, NewPersistence(""), "")
15+
if err != nil {
16+
t.Fatalf("NewMocks: %v", err)
17+
}
18+
return svc
19+
}
20+
1021
func mockFromYAML(t *testing.T, s string) *types.Mock {
1122
t.Helper()
1223
var m types.Mock
@@ -16,10 +27,69 @@ func mockFromYAML(t *testing.T, s string) *types.Mock {
1627
return &m
1728
}
1829

30+
// TestNewMocksInitFile covers the --init-mocks path: a YAML file of mocks (the POST /mocks format)
31+
// is loaded into a fresh "init-mocks" session when the service is constructed.
32+
func TestNewMocksInitFile(t *testing.T) {
33+
path := filepath.Join(t.TempDir(), "mocks.yml")
34+
content := `
35+
- request:
36+
method: GET
37+
path: /hello
38+
response:
39+
status: 200
40+
body: hi
41+
- request:
42+
method: GET
43+
path: /health
44+
response:
45+
status: 204
46+
`
47+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
48+
t.Fatal(err)
49+
}
50+
51+
svc, err := NewMocks(nil, 0, NewPersistence(""), path)
52+
if err != nil {
53+
t.Fatalf("NewMocks: %v", err)
54+
}
55+
56+
session := svc.GetLastSession()
57+
if session == nil {
58+
t.Fatal("expected a seeded session")
59+
}
60+
if session.Name != "init-mocks" {
61+
t.Fatalf("expected the seeded session to be named %q, got %q", "init-mocks", session.Name)
62+
}
63+
mocks, err := svc.GetMocks(session.ID)
64+
if err != nil {
65+
t.Fatalf("GetMocks: %v", err)
66+
}
67+
if len(mocks) != 2 {
68+
t.Fatalf("expected 2 seeded mocks, got %d", len(mocks))
69+
}
70+
}
71+
72+
func TestNewMocksInitFileMissing(t *testing.T) {
73+
if _, err := NewMocks(nil, 0, NewPersistence(""), filepath.Join(t.TempDir(), "nope.yml")); err == nil {
74+
t.Fatal("expected an error for a missing file")
75+
}
76+
}
77+
78+
func TestNewMocksInitFileInvalid(t *testing.T) {
79+
path := filepath.Join(t.TempDir(), "mocks.yml")
80+
// A mock with neither response, proxy, nor dynamic_response fails Validate().
81+
if err := os.WriteFile(path, []byte("- request: {path: /a}\n"), 0o644); err != nil {
82+
t.Fatal(err)
83+
}
84+
if _, err := NewMocks(nil, 0, NewPersistence(""), path); err == nil {
85+
t.Fatal("expected a validation error for an incomplete mock")
86+
}
87+
}
88+
1989
// TestUpdateAndDeleteMock covers the happy path for the edit/delete feature (issues #299/#266/#303)
2090
// while the session history is still empty.
2191
func TestUpdateAndDeleteMock(t *testing.T) {
22-
svc := NewMocks(nil, 0, NewPersistence(""))
92+
svc := newTestMocks(t)
2393
session := svc.NewSession("edit")
2494

2595
added, err := svc.AddMock(
@@ -71,7 +141,7 @@ func TestUpdateAndDeleteMock(t *testing.T) {
71141

72142
// TestDeleteSession covers removing a single session and the not-found path.
73143
func TestDeleteSession(t *testing.T) {
74-
svc := NewMocks(nil, 0, NewPersistence(""))
144+
svc := newTestMocks(t)
75145
keep := svc.NewSession("keep")
76146
drop := svc.NewSession("drop")
77147

@@ -95,7 +165,7 @@ func TestDeleteSession(t *testing.T) {
95165
// TestEditForbiddenOnceCalled locks the maintainers' position: once the session has received calls,
96166
// mocks are append-only (history entries are tied to the mock that answered them).
97167
func TestEditForbiddenOnceCalled(t *testing.T) {
98-
svc := NewMocks(nil, 0, NewPersistence(""))
168+
svc := newTestMocks(t)
99169
session := svc.NewSession("called")
100170
added, err := svc.AddMock(
101171
session.ID,

0 commit comments

Comments
 (0)