Monorepo for Tangled
tangled.org
1package state
2
3import (
4 "context"
5 "database/sql"
6 "log/slog"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10
11 "github.com/go-chi/chi/v5"
12 "tangled.org/core/appview/config"
13 "tangled.org/core/appview/models"
14)
15
16// routerFixture builds a minimal chi router wired to newDispatchHandler with
17// spy handlers. userPath and stdPath are set to the URL.Path received by
18// the respective spy after ServeHTTP returns.
19func routerFixture(cfg *config.Config, repoDB fakeRepoDB) (router http.Handler, userPath *string, stdPath *string) {
20 up := ""
21 sp := ""
22 userRouter := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23 up = r.URL.Path
24 w.WriteHeader(http.StatusOK)
25 })
26 stdRouter := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27 sp = r.URL.Path
28 w.WriteHeader(http.StatusOK)
29 })
30
31 r := chi.NewRouter()
32 r.HandleFunc("/*", newDispatchHandler(cfg, repoDB, slog.Default(), userRouter, stdRouter))
33 return r, &up, &sp
34}
35
36// fakeRepoDB implements db.Execer. The nil value is safe to use for test
37// cases that don't exercise the DID-resolution path.
38type fakeRepoDB map[string]*models.Repo
39
40func (f fakeRepoDB) Exec(query string, args ...any) (sql.Result, error) { return nil, nil }
41func (f fakeRepoDB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
42 return nil, nil
43}
44func (f fakeRepoDB) Query(query string, args ...any) (*sql.Rows, error) { return nil, nil }
45func (f fakeRepoDB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
46 return nil, nil
47}
48func (f fakeRepoDB) QueryRow(query string, args ...any) *sql.Row { return nil }
49func (f fakeRepoDB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
50 return nil
51}
52func (f fakeRepoDB) Prepare(query string) (*sql.Stmt, error) { return nil, nil }
53func (f fakeRepoDB) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
54 return nil, nil
55}
56
57// normalCfg returns a config with project mode disabled.
58func normalCfg() *config.Config {
59 return &config.Config{}
60}
61
62// projectCfg returns a config with project mode enabled for the given user.
63func projectCfg(user string) *config.Config {
64 return &config.Config{
65 Project: config.ProjectConfig{
66 Enabled: true,
67 User: user,
68 },
69 }
70}
71
72func TestDispatch_NormalMode(t *testing.T) {
73 cases := []struct {
74 name string
75 path string
76 wantUser string // expected URL seen by userRouter; empty means standardRouter should be hit
77 wantStd string // expected URL seen by standardRouter; empty means userRouter should be hit
78 wantCode int // expected HTTP status; 0 means 200
79 }{
80 {"handle", "/user.com/my-repo", "/user.com/my-repo", "", 0},
81 {"handle root", "/user.com", "/user.com", "", 0},
82 {"@handle redirect", "/@user.com/repo", "", "", http.StatusFound},
83 {"flattened did redirect", "/did-plc-abc123xyz", "", "", http.StatusFound},
84 {"settings", "/settings/profile", "", "/settings/profile", 0},
85 {"login", "/login", "", "/login", 0},
86 {"notifications", "/notifications", "", "/notifications", 0},
87 {"signup", "/signup/complete", "", "/signup/complete", 0},
88 {"root", "/", "", "/", 0},
89 {"unknown segment", "/not-a-handle", "", "/not-a-handle", 0},
90 }
91
92 for _, tc := range cases {
93 t.Run(tc.name, func(t *testing.T) {
94 router, userPath, stdPath := routerFixture(normalCfg(), nil)
95 req := httptest.NewRequest(http.MethodGet, tc.path, nil)
96 rr := httptest.NewRecorder()
97 router.ServeHTTP(rr, req)
98
99 wantCode := tc.wantCode
100 if wantCode == 0 {
101 wantCode = http.StatusOK
102 }
103 if rr.Code != wantCode {
104 t.Errorf("status = %d, want %d", rr.Code, wantCode)
105 }
106 if tc.wantUser != "" && *userPath != tc.wantUser {
107 t.Errorf("userRouter received %q, want %q", *userPath, tc.wantUser)
108 }
109 if tc.wantStd != "" && *stdPath != tc.wantStd {
110 t.Errorf("stdRouter received %q, want %q", *stdPath, tc.wantStd)
111 }
112 if tc.wantCode == http.StatusFound {
113 // neither spy should have been called
114 if *userPath != "" || *stdPath != "" {
115 t.Errorf("redirect case should not reach routers (user=%q std=%q)", *userPath, *stdPath)
116 }
117 }
118 })
119 }
120}
121
122func TestDispatch_ProjectMode(t *testing.T) {
123 const projectUser = "anirudh.fi"
124
125 cases := []struct {
126 name string
127 path string
128 wantUser string
129 wantStd string
130 wantCode int
131 }{
132 {"root becomes profile", "/", "/anirudh.fi", "", 0},
133 {"repo", "/my-repo", "/anirudh.fi/my-repo", "", 0},
134 {"repo with subpath", "/my-repo/issues/1", "/anirudh.fi/my-repo/issues/1", "", 0},
135 {"settings prefix", "/settings/profile", "", "/settings/profile", 0},
136 {"login prefix", "/login", "", "/login", 0},
137 {"notifications prefix", "/notifications", "", "/notifications", 0},
138 {"signup is standard prefix", "/signup", "", "/signup", 0},
139 {"search prefix", "/search", "", "/search", 0},
140 {"explicit handle still works", "/anirudh.fi/my-repo", "/anirudh.fi/my-repo", "", 0},
141 {"other user still works", "/other.user/their-repo", "/other.user/their-repo", "", 0},
142 {"@handle redirect", "/@anirudh.fi/repo", "", "", http.StatusFound},
143 }
144
145 for _, tc := range cases {
146 t.Run(tc.name, func(t *testing.T) {
147 router, userPath, stdPath := routerFixture(projectCfg(projectUser), nil)
148 req := httptest.NewRequest(http.MethodGet, tc.path, nil)
149 rr := httptest.NewRecorder()
150 router.ServeHTTP(rr, req)
151
152 wantCode := tc.wantCode
153 if wantCode == 0 {
154 wantCode = http.StatusOK
155 }
156 if rr.Code != wantCode {
157 t.Errorf("status = %d, want %d", rr.Code, wantCode)
158 }
159 if tc.wantUser != "" && *userPath != tc.wantUser {
160 t.Errorf("userRouter received %q, want %q", *userPath, tc.wantUser)
161 }
162 if tc.wantStd != "" && *stdPath != tc.wantStd {
163 t.Errorf("stdRouter received %q, want %q", *stdPath, tc.wantStd)
164 }
165 })
166 }
167}