phase 0 complete
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
// Package config loads and validates SoloPM's environment-variable configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds every environment-driven setting for the SoloPM server.
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
HTTPPort string
|
||||
BaseURL string
|
||||
SessionSecret string
|
||||
UploadDir string
|
||||
MaxUploadMB int
|
||||
|
||||
MeiliURL string
|
||||
MeiliKey string
|
||||
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPass string
|
||||
SMTPFrom string
|
||||
|
||||
GitHubClientID string
|
||||
GitHubClientSecret string
|
||||
|
||||
GiteaURL string
|
||||
GiteaClientID string
|
||||
GiteaClientSecret string
|
||||
|
||||
CullAfterDays int
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
// SearchEnabled reports whether Meilisearch is configured.
|
||||
func (c *Config) SearchEnabled() bool {
|
||||
return c.MeiliURL != "" && c.MeiliKey != ""
|
||||
}
|
||||
|
||||
// EmailEnabled reports whether SMTP is configured.
|
||||
func (c *Config) EmailEnabled() bool {
|
||||
return c.SMTPHost != ""
|
||||
}
|
||||
|
||||
// GitHubOAuthEnabled reports whether GitHub OAuth is configured.
|
||||
func (c *Config) GitHubOAuthEnabled() bool {
|
||||
return c.GitHubClientID != "" && c.GitHubClientSecret != ""
|
||||
}
|
||||
|
||||
// GiteaOAuthEnabled reports whether Gitea OAuth is configured.
|
||||
func (c *Config) GiteaOAuthEnabled() bool {
|
||||
return c.GiteaURL != "" && c.GiteaClientID != "" && c.GiteaClientSecret != ""
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment, applying defaults and
|
||||
// failing fast (returning an error) if a required variable is missing or
|
||||
// malformed.
|
||||
func Load() (*Config, error) {
|
||||
var missing []string
|
||||
required := func(key string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
c := &Config{
|
||||
DatabaseURL: required("DATABASE_URL"),
|
||||
HTTPPort: getDefault("HTTP_PORT", "8080"),
|
||||
BaseURL: required("BASE_URL"),
|
||||
SessionSecret: required("SESSION_SECRET"),
|
||||
UploadDir: getDefault("UPLOAD_DIR", "./uploads"),
|
||||
|
||||
MeiliURL: os.Getenv("MEILI_URL"),
|
||||
MeiliKey: os.Getenv("MEILI_KEY"),
|
||||
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: os.Getenv("SMTP_PORT"),
|
||||
SMTPUser: os.Getenv("SMTP_USER"),
|
||||
SMTPPass: os.Getenv("SMTP_PASS"),
|
||||
SMTPFrom: os.Getenv("SMTP_FROM"),
|
||||
|
||||
GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"),
|
||||
GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
|
||||
|
||||
GiteaURL: os.Getenv("GITEA_URL"),
|
||||
GiteaClientID: os.Getenv("GITEA_CLIENT_ID"),
|
||||
GiteaClientSecret: os.Getenv("GITEA_CLIENT_SECRET"),
|
||||
|
||||
LogLevel: getDefault("LOG_LEVEL", "info"),
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("config: missing required environment variable(s): %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
if len(c.SessionSecret) < 32 {
|
||||
return nil, fmt.Errorf("config: SESSION_SECRET must be at least 32 bytes, got %d", len(c.SessionSecret))
|
||||
}
|
||||
|
||||
maxUploadMB, err := getIntDefault("MAX_UPLOAD_MB", 25)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.MaxUploadMB = maxUploadMB
|
||||
|
||||
cullAfterDays, err := getIntDefault("CULL_AFTER_DAYS", 30)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CullAfterDays = cullAfterDays
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getIntDefault(key string, def int) (int, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config: %s must be an integer, got %q", key, v)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func clearEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
keys := []string{
|
||||
"DATABASE_URL", "HTTP_PORT", "BASE_URL", "SESSION_SECRET", "UPLOAD_DIR",
|
||||
"MAX_UPLOAD_MB", "MEILI_URL", "MEILI_KEY", "SMTP_HOST", "SMTP_PORT",
|
||||
"SMTP_USER", "SMTP_PASS", "SMTP_FROM", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET",
|
||||
"GITEA_URL", "GITEA_CLIENT_ID", "GITEA_CLIENT_SECRET", "CULL_AFTER_DAYS", "LOG_LEVEL",
|
||||
}
|
||||
for _, k := range keys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
func validRequired(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("DATABASE_URL", "postgres://localhost/solopm")
|
||||
t.Setenv("BASE_URL", "http://localhost:8080")
|
||||
t.Setenv("SESSION_SECRET", "01234567890123456789012345678901")
|
||||
}
|
||||
|
||||
func TestLoad_MissingRequired(t *testing.T) {
|
||||
clearEnv(t)
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing required vars, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ShortSessionSecret(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("DATABASE_URL", "postgres://localhost/solopm")
|
||||
t.Setenv("BASE_URL", "http://localhost:8080")
|
||||
t.Setenv("SESSION_SECRET", "tooshort")
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for short SESSION_SECRET, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
clearEnv(t)
|
||||
validRequired(t)
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c.HTTPPort != "8080" {
|
||||
t.Errorf("HTTPPort = %q, want 8080", c.HTTPPort)
|
||||
}
|
||||
if c.UploadDir != "./uploads" {
|
||||
t.Errorf("UploadDir = %q, want ./uploads", c.UploadDir)
|
||||
}
|
||||
if c.MaxUploadMB != 25 {
|
||||
t.Errorf("MaxUploadMB = %d, want 25", c.MaxUploadMB)
|
||||
}
|
||||
if c.CullAfterDays != 30 {
|
||||
t.Errorf("CullAfterDays = %d, want 30", c.CullAfterDays)
|
||||
}
|
||||
if c.LogLevel != "info" {
|
||||
t.Errorf("LogLevel = %q, want info", c.LogLevel)
|
||||
}
|
||||
if c.SearchEnabled() {
|
||||
t.Error("SearchEnabled() = true, want false when MEILI_URL/KEY unset")
|
||||
}
|
||||
if c.EmailEnabled() {
|
||||
t.Error("EmailEnabled() = true, want false when SMTP_HOST unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_SearchEnabled(t *testing.T) {
|
||||
clearEnv(t)
|
||||
validRequired(t)
|
||||
t.Setenv("MEILI_URL", "http://meilisearch:7700")
|
||||
t.Setenv("MEILI_KEY", "key")
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !c.SearchEnabled() {
|
||||
t.Error("SearchEnabled() = false, want true when MEILI_URL/KEY set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidInt(t *testing.T) {
|
||||
clearEnv(t)
|
||||
validRequired(t)
|
||||
t.Setenv("MAX_UPLOAD_MB", "not-a-number")
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-integer MAX_UPLOAD_MB, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package db holds the embedded golang-migrate migrations and the sqlc
|
||||
// generated query code (internal/db/sqlcgen).
|
||||
package db
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// newMigrate builds a *migrate.Migrate over the embedded migration files and
|
||||
// the given database connection string.
|
||||
func newMigrate(databaseURL string) (*migrate.Migrate, error) {
|
||||
sourceDriver, err := iofs.New(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: loading embedded migrations: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithSourceInstance("iofs", sourceDriver, wrapDatabaseURL(databaseURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: initializing migrate: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// wrapDatabaseURL swaps the postgres(ql):// scheme for pgx5:// as required
|
||||
// by golang-migrate's pgx/v5 database driver registration.
|
||||
func wrapDatabaseURL(databaseURL string) string {
|
||||
u, err := url.Parse(databaseURL)
|
||||
if err != nil {
|
||||
return databaseURL
|
||||
}
|
||||
u.Scheme = "pgx5"
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// MigrateUp applies all pending migrations. It returns nil if there is
|
||||
// nothing to do.
|
||||
func MigrateUp(databaseURL string) error {
|
||||
m, err := newMigrate(databaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _, _ = m.Close() }()
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("db: migrate up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateDown rolls back all migrations. It returns nil if there is nothing
|
||||
// to do.
|
||||
func MigrateDown(databaseURL string) error {
|
||||
m, err := newMigrate(databaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _, _ = m.Close() }()
|
||||
|
||||
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("db: migrate down: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
DROP TYPE IF EXISTS email_mode;
|
||||
DROP TYPE IF EXISTS delivery_status;
|
||||
DROP TYPE IF EXISTS receipt_status;
|
||||
DROP TYPE IF EXISTS job_status;
|
||||
DROP TYPE IF EXISTS resource_kind;
|
||||
DROP TYPE IF EXISTS auth_provider;
|
||||
DROP TYPE IF EXISTS member_role;
|
||||
DROP TYPE IF EXISTS priority;
|
||||
DROP TYPE IF EXISTS issue_status;
|
||||
DROP TYPE IF EXISTS epic_status;
|
||||
DROP TYPE IF EXISTS project_status;
|
||||
|
||||
DROP EXTENSION IF EXISTS citext;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
|
||||
CREATE TYPE project_status AS ENUM ('backlog', 'planned', 'in_progress', 'completed', 'canceled');
|
||||
CREATE TYPE epic_status AS ENUM ('backlog', 'planned', 'in_progress', 'completed', 'canceled');
|
||||
CREATE TYPE issue_status AS ENUM ('backlog', 'planned', 'in_progress', 'ready_for_review', 'done', 'canceled', 'duplicate');
|
||||
CREATE TYPE priority AS ENUM ('low', 'medium', 'high', 'urgent', 'frantic');
|
||||
CREATE TYPE member_role AS ENUM ('owner', 'member');
|
||||
CREATE TYPE auth_provider AS ENUM ('github', 'gitea');
|
||||
CREATE TYPE resource_kind AS ENUM ('project', 'issue', 'epic', 'comment', 'wiki_page', 'user', 'label', 'attachment', 'link', 'member', 'webhook', 'auth');
|
||||
CREATE TYPE job_status AS ENUM ('pending', 'running', 'done', 'failed', 'dead');
|
||||
CREATE TYPE receipt_status AS ENUM ('pending', 'processed', 'failed', 'ignored');
|
||||
CREATE TYPE delivery_status AS ENUM ('pending', 'success', 'failed');
|
||||
CREATE TYPE email_mode AS ENUM ('off', 'instant', 'daily_digest');
|
||||
@@ -0,0 +1,2 @@
|
||||
-- name: Ping :one
|
||||
SELECT 1::int AS ok;
|
||||
@@ -0,0 +1,32 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AuthProvider string
|
||||
|
||||
const (
|
||||
AuthProviderGithub AuthProvider = "github"
|
||||
AuthProviderGitea AuthProvider = "gitea"
|
||||
)
|
||||
|
||||
func (e *AuthProvider) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = AuthProvider(s)
|
||||
case string:
|
||||
*e = AuthProvider(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for AuthProvider: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullAuthProvider struct {
|
||||
AuthProvider AuthProvider `json:"auth_provider"`
|
||||
Valid bool `json:"valid"` // Valid is true if AuthProvider is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullAuthProvider) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.AuthProvider, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.AuthProvider.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullAuthProvider) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.AuthProvider), nil
|
||||
}
|
||||
|
||||
type DeliveryStatus string
|
||||
|
||||
const (
|
||||
DeliveryStatusPending DeliveryStatus = "pending"
|
||||
DeliveryStatusSuccess DeliveryStatus = "success"
|
||||
DeliveryStatusFailed DeliveryStatus = "failed"
|
||||
)
|
||||
|
||||
func (e *DeliveryStatus) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = DeliveryStatus(s)
|
||||
case string:
|
||||
*e = DeliveryStatus(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for DeliveryStatus: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullDeliveryStatus struct {
|
||||
DeliveryStatus DeliveryStatus `json:"delivery_status"`
|
||||
Valid bool `json:"valid"` // Valid is true if DeliveryStatus is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullDeliveryStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.DeliveryStatus, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.DeliveryStatus.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullDeliveryStatus) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.DeliveryStatus), nil
|
||||
}
|
||||
|
||||
type EmailMode string
|
||||
|
||||
const (
|
||||
EmailModeOff EmailMode = "off"
|
||||
EmailModeInstant EmailMode = "instant"
|
||||
EmailModeDailyDigest EmailMode = "daily_digest"
|
||||
)
|
||||
|
||||
func (e *EmailMode) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = EmailMode(s)
|
||||
case string:
|
||||
*e = EmailMode(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for EmailMode: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullEmailMode struct {
|
||||
EmailMode EmailMode `json:"email_mode"`
|
||||
Valid bool `json:"valid"` // Valid is true if EmailMode is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullEmailMode) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.EmailMode, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.EmailMode.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullEmailMode) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.EmailMode), nil
|
||||
}
|
||||
|
||||
type EpicStatus string
|
||||
|
||||
const (
|
||||
EpicStatusBacklog EpicStatus = "backlog"
|
||||
EpicStatusPlanned EpicStatus = "planned"
|
||||
EpicStatusInProgress EpicStatus = "in_progress"
|
||||
EpicStatusCompleted EpicStatus = "completed"
|
||||
EpicStatusCanceled EpicStatus = "canceled"
|
||||
)
|
||||
|
||||
func (e *EpicStatus) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = EpicStatus(s)
|
||||
case string:
|
||||
*e = EpicStatus(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for EpicStatus: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullEpicStatus struct {
|
||||
EpicStatus EpicStatus `json:"epic_status"`
|
||||
Valid bool `json:"valid"` // Valid is true if EpicStatus is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullEpicStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.EpicStatus, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.EpicStatus.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullEpicStatus) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.EpicStatus), nil
|
||||
}
|
||||
|
||||
type IssueStatus string
|
||||
|
||||
const (
|
||||
IssueStatusBacklog IssueStatus = "backlog"
|
||||
IssueStatusPlanned IssueStatus = "planned"
|
||||
IssueStatusInProgress IssueStatus = "in_progress"
|
||||
IssueStatusReadyForReview IssueStatus = "ready_for_review"
|
||||
IssueStatusDone IssueStatus = "done"
|
||||
IssueStatusCanceled IssueStatus = "canceled"
|
||||
IssueStatusDuplicate IssueStatus = "duplicate"
|
||||
)
|
||||
|
||||
func (e *IssueStatus) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = IssueStatus(s)
|
||||
case string:
|
||||
*e = IssueStatus(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for IssueStatus: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullIssueStatus struct {
|
||||
IssueStatus IssueStatus `json:"issue_status"`
|
||||
Valid bool `json:"valid"` // Valid is true if IssueStatus is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullIssueStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.IssueStatus, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.IssueStatus.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullIssueStatus) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.IssueStatus), nil
|
||||
}
|
||||
|
||||
type JobStatus string
|
||||
|
||||
const (
|
||||
JobStatusPending JobStatus = "pending"
|
||||
JobStatusRunning JobStatus = "running"
|
||||
JobStatusDone JobStatus = "done"
|
||||
JobStatusFailed JobStatus = "failed"
|
||||
JobStatusDead JobStatus = "dead"
|
||||
)
|
||||
|
||||
func (e *JobStatus) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = JobStatus(s)
|
||||
case string:
|
||||
*e = JobStatus(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for JobStatus: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullJobStatus struct {
|
||||
JobStatus JobStatus `json:"job_status"`
|
||||
Valid bool `json:"valid"` // Valid is true if JobStatus is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullJobStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.JobStatus, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.JobStatus.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullJobStatus) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.JobStatus), nil
|
||||
}
|
||||
|
||||
type MemberRole string
|
||||
|
||||
const (
|
||||
MemberRoleOwner MemberRole = "owner"
|
||||
MemberRoleMember MemberRole = "member"
|
||||
)
|
||||
|
||||
func (e *MemberRole) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = MemberRole(s)
|
||||
case string:
|
||||
*e = MemberRole(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for MemberRole: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullMemberRole struct {
|
||||
MemberRole MemberRole `json:"member_role"`
|
||||
Valid bool `json:"valid"` // Valid is true if MemberRole is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullMemberRole) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.MemberRole, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.MemberRole.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullMemberRole) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.MemberRole), nil
|
||||
}
|
||||
|
||||
type Priority string
|
||||
|
||||
const (
|
||||
PriorityLow Priority = "low"
|
||||
PriorityMedium Priority = "medium"
|
||||
PriorityHigh Priority = "high"
|
||||
PriorityUrgent Priority = "urgent"
|
||||
PriorityFrantic Priority = "frantic"
|
||||
)
|
||||
|
||||
func (e *Priority) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = Priority(s)
|
||||
case string:
|
||||
*e = Priority(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for Priority: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullPriority struct {
|
||||
Priority Priority `json:"priority"`
|
||||
Valid bool `json:"valid"` // Valid is true if Priority is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullPriority) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.Priority, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.Priority.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullPriority) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.Priority), nil
|
||||
}
|
||||
|
||||
type ProjectStatus string
|
||||
|
||||
const (
|
||||
ProjectStatusBacklog ProjectStatus = "backlog"
|
||||
ProjectStatusPlanned ProjectStatus = "planned"
|
||||
ProjectStatusInProgress ProjectStatus = "in_progress"
|
||||
ProjectStatusCompleted ProjectStatus = "completed"
|
||||
ProjectStatusCanceled ProjectStatus = "canceled"
|
||||
)
|
||||
|
||||
func (e *ProjectStatus) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = ProjectStatus(s)
|
||||
case string:
|
||||
*e = ProjectStatus(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for ProjectStatus: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullProjectStatus struct {
|
||||
ProjectStatus ProjectStatus `json:"project_status"`
|
||||
Valid bool `json:"valid"` // Valid is true if ProjectStatus is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullProjectStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.ProjectStatus, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.ProjectStatus.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullProjectStatus) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.ProjectStatus), nil
|
||||
}
|
||||
|
||||
type ReceiptStatus string
|
||||
|
||||
const (
|
||||
ReceiptStatusPending ReceiptStatus = "pending"
|
||||
ReceiptStatusProcessed ReceiptStatus = "processed"
|
||||
ReceiptStatusFailed ReceiptStatus = "failed"
|
||||
ReceiptStatusIgnored ReceiptStatus = "ignored"
|
||||
)
|
||||
|
||||
func (e *ReceiptStatus) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = ReceiptStatus(s)
|
||||
case string:
|
||||
*e = ReceiptStatus(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for ReceiptStatus: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullReceiptStatus struct {
|
||||
ReceiptStatus ReceiptStatus `json:"receipt_status"`
|
||||
Valid bool `json:"valid"` // Valid is true if ReceiptStatus is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullReceiptStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.ReceiptStatus, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.ReceiptStatus.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullReceiptStatus) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.ReceiptStatus), nil
|
||||
}
|
||||
|
||||
type ResourceKind string
|
||||
|
||||
const (
|
||||
ResourceKindProject ResourceKind = "project"
|
||||
ResourceKindIssue ResourceKind = "issue"
|
||||
ResourceKindEpic ResourceKind = "epic"
|
||||
ResourceKindComment ResourceKind = "comment"
|
||||
ResourceKindWikiPage ResourceKind = "wiki_page"
|
||||
ResourceKindUser ResourceKind = "user"
|
||||
ResourceKindLabel ResourceKind = "label"
|
||||
ResourceKindAttachment ResourceKind = "attachment"
|
||||
ResourceKindLink ResourceKind = "link"
|
||||
ResourceKindMember ResourceKind = "member"
|
||||
ResourceKindWebhook ResourceKind = "webhook"
|
||||
ResourceKindAuth ResourceKind = "auth"
|
||||
)
|
||||
|
||||
func (e *ResourceKind) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = ResourceKind(s)
|
||||
case string:
|
||||
*e = ResourceKind(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for ResourceKind: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullResourceKind struct {
|
||||
ResourceKind ResourceKind `json:"resource_kind"`
|
||||
Valid bool `json:"valid"` // Valid is true if ResourceKind is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullResourceKind) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.ResourceKind, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.ResourceKind.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullResourceKind) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.ResourceKind), nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
Ping(ctx context.Context) (int32, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -0,0 +1,21 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: system.sql
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const ping = `-- name: Ping :one
|
||||
SELECT 1::int AS ok
|
||||
`
|
||||
|
||||
func (q *Queries) Ping(ctx context.Context) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, ping)
|
||||
var ok int32
|
||||
err := row.Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"solopm.com/solopm-server/internal/config"
|
||||
)
|
||||
|
||||
// DBPinger is the subset of *pgxpool.Pool used for health checks.
|
||||
type DBPinger interface {
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Database string `json:"database"`
|
||||
Meilisearch string `json:"meilisearch"`
|
||||
SMTP string `json:"smtp"`
|
||||
}
|
||||
|
||||
func healthzHandler(cfg *config.Config, db DBPinger, version string) http.HandlerFunc {
|
||||
httpClient := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp := healthResponse{
|
||||
Status: "ok",
|
||||
Version: version,
|
||||
Meilisearch: "absent",
|
||||
SMTP: "absent",
|
||||
}
|
||||
|
||||
if err := db.Ping(ctx); err != nil {
|
||||
resp.Status = "error"
|
||||
resp.Database = "unreachable"
|
||||
} else {
|
||||
resp.Database = "ok"
|
||||
}
|
||||
|
||||
if cfg.SearchEnabled() {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.MeiliURL+"/health", nil)
|
||||
if err == nil {
|
||||
if hr, err := httpClient.Do(req); err == nil {
|
||||
_ = hr.Body.Close()
|
||||
if hr.StatusCode == http.StatusOK {
|
||||
resp.Meilisearch = "ok"
|
||||
} else {
|
||||
resp.Meilisearch = "unreachable"
|
||||
}
|
||||
} else {
|
||||
resp.Meilisearch = "unreachable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.EmailEnabled() {
|
||||
resp.SMTP = "ok"
|
||||
}
|
||||
|
||||
status := http.StatusOK
|
||||
if resp.Status != "ok" {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"solopm.com/solopm-server/internal/config"
|
||||
)
|
||||
|
||||
type stubPinger struct{ err error }
|
||||
|
||||
func (s stubPinger) Ping(ctx context.Context) error { return s.err }
|
||||
|
||||
func TestHealthzHandler(t *testing.T) {
|
||||
cfg := &config.Config{} // no MEILI_URL/SMTP_HOST set: both report "absent"
|
||||
|
||||
t.Run("db ok", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
|
||||
healthzHandler(cfg, stubPinger{}, "test")(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var body healthResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if body.Status != "ok" || body.Database != "ok" {
|
||||
t.Fatalf("body = %+v, want status=ok database=ok", body)
|
||||
}
|
||||
if body.Meilisearch != "absent" || body.SMTP != "absent" {
|
||||
t.Fatalf("body = %+v, want meilisearch/smtp absent", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db unreachable", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
|
||||
healthzHandler(cfg, stubPinger{err: errors.New("boom")}, "test")(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
var body healthResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if body.Status != "error" || body.Database != "unreachable" {
|
||||
t.Fatalf("body = %+v, want status=error database=unreachable", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// RequestLogger logs one structured line per request: method, path, status,
|
||||
// duration, and (once auth exists) user id.
|
||||
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
logger.Info("http_request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.Status(),
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"request_id", middleware.GetReqID(r.Context()),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
|
||||
"solopm.com/solopm-server/internal/config"
|
||||
)
|
||||
|
||||
// Options bundles the dependencies the router needs to wire routes.
|
||||
type Options struct {
|
||||
Config *config.Config
|
||||
Logger *slog.Logger
|
||||
DB DBPinger
|
||||
Version string
|
||||
Dev bool // permissive CORS in dev; same-origin default otherwise
|
||||
SPA fs.FS // web/dist, rooted so index.html is at the top level; nil disables SPA serving
|
||||
}
|
||||
|
||||
// New builds the chi router with the full middleware chain (minus the auth
|
||||
// resolver, which is added in Phase 1) and registers Phase 0 routes.
|
||||
func New(opts Options) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP) //nolint:staticcheck // middleware order is spec-mandated (01-architecture.md); deploy behind a trusted reverse proxy
|
||||
r.Use(RequestLogger(opts.Logger))
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(corsMiddleware(opts.Dev))
|
||||
|
||||
r.Get("/healthz", healthzHandler(opts.Config, opts.DB, opts.Version))
|
||||
|
||||
if opts.SPA != nil {
|
||||
r.Get("/*", spaHandler(opts.SPA))
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func corsMiddleware(dev bool) func(http.Handler) http.Handler {
|
||||
if dev {
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Content-Type", "Authorization"},
|
||||
AllowCredentials: false,
|
||||
})
|
||||
}
|
||||
// Same-origin default: no CORS headers granted, browser same-origin
|
||||
// requests work regardless since no cross-origin request needs them.
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// spaHandler serves the embedded Vue SPA out of distFS (already rooted at
|
||||
// web/dist, i.e. index.html is at its top level). Any request path that
|
||||
// doesn't match a real file falls back to index.html so client-side routes
|
||||
// (deep links) resolve correctly.
|
||||
func spaHandler(distFS fs.FS) http.HandlerFunc {
|
||||
fileServer := http.FileServer(http.FS(distFS))
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
upath := r.URL.Path
|
||||
if upath == "" || upath == "/" {
|
||||
upath = "index.html"
|
||||
} else {
|
||||
upath = upath[1:] // strip leading slash for fs.Stat
|
||||
}
|
||||
|
||||
if _, err := fs.Stat(distFS, upath); err != nil {
|
||||
r = cloneWithPath(r, "/")
|
||||
}
|
||||
|
||||
fileServer.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// cloneWithPath returns a shallow copy of r with URL.Path replaced, so the
|
||||
// original request (and its URL) is left untouched.
|
||||
func cloneWithPath(r *http.Request, path string) *http.Request {
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = path
|
||||
return r2
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func testDistFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte("<!doctype html><div id=app></div>")},
|
||||
"assets/app.js": {Data: []byte("console.log('app')")},
|
||||
"favicon.svg": {Data: []byte("<svg></svg>")},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAHandler(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantBody string
|
||||
}{
|
||||
{"root serves index", "/", "<!doctype html><div id=app></div>"},
|
||||
{"real file served as-is", "/assets/app.js", "console.log('app')"},
|
||||
{"real top-level file served as-is", "/favicon.svg", "<svg></svg>"},
|
||||
{"deep link falls back to index", "/projects/42/board", "<!doctype html><div id=app></div>"},
|
||||
{"unknown asset path falls back to index", "/assets/missing.js", "<!doctype html><div id=app></div>"},
|
||||
}
|
||||
|
||||
handler := spaHandler(testDistFS())
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != tc.wantBody {
|
||||
t.Fatalf("body = %q, want %q", got, tc.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user