Skip to content
14 changes: 6 additions & 8 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,20 @@
package cmd

import (
"fmt"
"github.com/7cav/api/servers"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

// serveCmd represents the serve command
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Launches the api servers",
Short: "Launches the api server",
Run: func(cmd *cobra.Command, args []string) {
// PORT is the port the HTTP gateway dials to reach the in-process gRPC server.
// It is NOT a listen port: both the gRPC server (:10000) and HTTP gateway (:11000) listen
// ports are hardcoded in servers/server.go. PORT must match the hardcoded gRPC port (10000)
// for the gateway-to-gRPC dial to succeed.
server := servers.New(fmt.Sprintf("0.0.0.0:%s", viper.GetString("port")))
// The public (:11000) and internal metrics (:9090) listen ports are
// constants in servers/server.go. The old PORT env var was only the
// gateway's gRPC dial target; the gRPC server is gone (#134), so PORT is
// no longer read.
server := servers.New()
server.Start()
},
}
Expand Down
177 changes: 84 additions & 93 deletions contract/fake_datastore_test.go

Large diffs are not rendered by default.

123 changes: 31 additions & 92 deletions contract/harness_test.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
package contract

import (
"fmt"
"io"
"net"
"net/http"
"os"
"testing"
"time"

"github.com/7cav/api/datastores"
"github.com/7cav/api/proto"
"github.com/7cav/api/referencecache"
"github.com/7cav/api/rest"
"github.com/7cav/api/servers/gateway"
grpcServices "github.com/7cav/api/servers/grpc"
"google.golang.org/grpc"
)

// Compile-time guarantee the recording fake implements the full interface.
Expand All @@ -23,29 +17,29 @@ var _ datastores.Datastore = recordingDatastore{}
var (
stackHandler http.Handler
stackErr error
// grpcServeErr receives the srv.Serve result if Serve ever returns
// (buffered 1, written exactly once). Peeked via pendingServeErr so a
// startup or mid-run server death is reported instead of discarded.
grpcServeErr chan error
)

// TestMain mounts the CURRENT production stack in-process exactly once:
// TestMain mounts the production stack in-process exactly once:
//
// httptest request → gateway.Service.Server().Handler (real /api routing,
// real auth middleware, real sentry/compression chain) → real gRPC
// client conn over TCP → real grpc.Server with the production interceptor
// chain (auth outer, sentry inner; mirrors servers.apiUnaryInterceptors)
// → MilpacsService + TicketsService handlers → recordingDatastore.
// httptest request → rest.New(ds, rc).ServeHTTP (real /api routing, real
// auth middleware, real sentry/gzip chain) → milpacs/tickets handlers →
// recordingDatastore.
//
// Since the single-listener cutover (#134) there is one stack: the stdlib
// net/http rest package. The gRPC server and the grpc-gateway translation
// layer are gone, so the harness mounts rest.New directly instead of dialing a
// gRPC server behind a gateway.
//
// Differences from production, all behavior-neutral by construction:
// - the datastore is the seeded fake (no MySQL),
// - SENTRY_DSN is unset (TestMain enforces it), so both sentry layers are
// pass-throughs,
// - the TicketsService reference cache is nil — the fake never touches it.
// - SENTRY_DSN is unset (TestMain enforces it), so the sentry layer is a
// pass-through,
// - the TicketReferenceCache is the no-op stub below — the fake bakes its
// reference-name resolution into the seeds and never consults the cache.
//
// There is no Redis anywhere: the response cache was deleted at Phase 2
// de-cache (#123 middleware, #124 package + Redis), so the stack mounts and
// serves with no cache backend at all exactly like production.
// de-cache (#123 middleware, #124 package + Redis), so the stack serves with
// no cache backend at all, exactly like production.
func TestMain(m *testing.M) {
quietProductionLoggers()
os.Unsetenv("SENTRY_DSN") // make the pass-through claim above true by construction
Expand All @@ -55,92 +49,37 @@ func TestMain(m *testing.M) {

func currentStack(t *testing.T) http.Handler {
t.Helper()
if stackErr == nil {
if err := pendingServeErr(); err != nil {
stackErr = fmt.Errorf("grpc server exited mid-run: %w", err)
}
}
if stackErr != nil {
t.Fatalf("mounting current stack: %v", stackErr)
}
return stackHandler
}

// pendingServeErr non-blockingly peeks at grpcServeErr and puts any value
// back, so the one Serve result stays observable by every later caller.
func pendingServeErr() error {
select {
case err := <-grpcServeErr:
grpcServeErr <- err
return err
default:
return nil
}
}

func mountCurrentStack() (http.Handler, error) {
ds := recordingDatastore{}
return rest.New(ds, stubReferenceCache{}), nil
}

lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("grpc listen: %w", err)
}

// Production interceptor chain order (servers.apiUnaryInterceptors):
// auth outer, sentry inner.
srv := grpc.NewServer(grpc.ChainUnaryInterceptor(
grpcServices.NewAuthInterceptor(ds),
grpcServices.NewSentryInterceptor(),
))
proto.RegisterMilpacServiceServer(srv, &grpcServices.MilpacsService{Datastore: ds})
proto.RegisterTicketsServiceServer(srv, &grpcServices.TicketsService{Datastore: ds})
grpcServeErr = make(chan error, 1)
go func() {
grpcServeErr <- srv.Serve(lis)
}()
// stubReferenceCache is the no-op TicketReferenceCache the corpus mounts:
// rest.New refuses nil, and recordingDatastore resolves all reference names in
// its seeds (it never calls back into the cache), so every method can return
// the zero value.
type stubReferenceCache struct{}

svc := gateway.Service{
Address: lis.Addr().String(),
Datastore: ds,
}
// gateway.Service.Server() dials with grpc.WithBlock() on
// context.Background() — an unbounded blocking dial. If the gRPC server
// never came up, that dial would hang TestMain forever with zero output,
// so run the mount in a goroutine and watchdog it: finish, see the Serve
// error, or time out — never hang silently.
const mountTimeout = 10 * time.Second
mounted := make(chan *http.Server, 1)
go func() {
mounted <- svc.Server()
}()
var httpSrv *http.Server
select {
case httpSrv = <-mounted:
case err := <-grpcServeErr:
grpcServeErr <- err // keep it observable for pendingServeErr
return nil, fmt.Errorf("grpc server exited before gateway mount; serve error: %v", err)
case <-time.After(mountTimeout):
return nil, fmt.Errorf("grpc dial did not become ready in %s; serve error: %v", mountTimeout, pendingServeErr())
}
if httpSrv == nil {
return nil, fmt.Errorf("gateway.Service.Server() returned nil (dial or registration failure)")
}
return httpSrv.Handler, nil
}
func (stubReferenceCache) StatusName(uint32) string { return "" }
func (stubReferenceCache) PriorityName(uint32) string { return "" }
func (stubReferenceCache) PrefixName(uint32) string { return "" }
func (stubReferenceCache) Category(uint32) *referencecache.CategoryRecord { return nil }
func (stubReferenceCache) CategoryAncestors(uint32) []uint32 { return nil }
func (stubReferenceCache) CategoryTree() []*referencecache.CategoryRecord { return nil }
func (stubReferenceCache) ExpandSubtree(ids []uint32) []uint32 { return ids }

// quietProductionLoggers silences the chatty Info/Warn loggers of the stack
// under test so corpus runs stay readable. Errors stay visible.
//
// Do NOT silence gateway.Error (or the grpc Error loggers): gateway.Service.
// Server() reports dial/registration failure only via its Error log plus a
// nil return, so muting Error would reduce a mount failure to a bare
// "returned nil" with no cause.
func quietProductionLoggers() {
for _, l := range []interface{ SetOutput(io.Writer) }{
gateway.Info, gateway.Warn,
grpcServices.Info, grpcServices.Warn,
datastores.Info, datastores.Warn,
rest.Info, rest.Warn, // the gateway delegates auth/gzip to rest (#125)
rest.Info, rest.Warn,
} {
l.SetOutput(io.Discard)
}
Expand Down
42 changes: 20 additions & 22 deletions datastores/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import (
"os"
"strings"

"github.com/7cav/api/proto"
"github.com/7cav/api/referencecache"
"github.com/7cav/api/types"
)

var (
Expand Down Expand Up @@ -71,27 +71,26 @@ type Datastore interface {
// non-nil profiles on a nil error — no-match is gorm.ErrRecordNotFound,
// never an empty slice. Handlers index [0] under this invariant (with a
// defensive 500 guard for implementations that break it).
FindProfilesById(userId ...uint64) ([]*proto.Profile, error)
FindProfilesByUsername(username string) ([]*proto.Profile, error)
FindRosterByType(rosterType proto.RosterType) (*proto.Roster, error)
FindLiteRosterByType(rosterType proto.RosterType) (*proto.LiteRoster, error)
FindProfileByKeycloakID(keycloakId string) (*proto.Profile, error)
FindProfileByDiscordID(discordId string) (*proto.Profile, error)
FindProfilesByPosition(positionQuery string) (*proto.LiteRoster, error)
FindS1UniformsRosterByType(rosterType proto.RosterType) (*proto.S1UniformsRoster, error)
FindAllRanks() ([]*proto.RankExpanded, error)
FindAllPositionGroups() ([]*proto.PositionGroup, error)
FindAwol() ([]*proto.Awol, error)
FindProfileByGamertag(gamertag string) (*proto.Profile, error)
FindProfilesById(userId ...uint64) ([]*types.Profile, error)
FindProfilesByUsername(username string) ([]*types.Profile, error)
FindRosterByType(rosterType types.RosterType) (*types.Roster, error)
FindLiteRosterByType(rosterType types.RosterType) (*types.LiteRoster, error)
FindProfileByDiscordID(discordId string) (*types.Profile, error)
FindProfilesByPosition(positionQuery string) (*types.LiteRoster, error)
FindS1UniformsRosterByType(rosterType types.RosterType) (*types.S1UniformsRoster, error)
FindAllRanks() ([]*types.RankExpanded, error)
FindAllPositionGroups() ([]*types.PositionGroup, error)
FindAwol() ([]*types.Awol, error)
FindProfileByGamertag(gamertag string) (*types.Profile, error)
ValidateApiKey(rawKey string) (*ApiKeyResult, error)

// Tickets
ListTickets(ctx context.Context, rc TicketReferenceCache, filter *ListTicketsFilter) (tickets []*proto.Ticket, nextCursor string, hasMore bool, err error)
GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBaseURL string) (*proto.Ticket, error)
GetTicketByRef(ctx context.Context, rc TicketReferenceCache, ref string, forumBaseURL string) (*proto.Ticket, error)
GetTicketFirstMessages(ctx context.Context, ticketID uint32, n int, includeHidden bool) (msgs []*proto.Message, totalCount uint32, err error)
ListTicketMessages(ctx context.Context, ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) (msgs []*proto.Message, nextCursor string, hasMore bool, err error)
ListCategories(ctx context.Context, rc TicketReferenceCache) ([]*proto.Category, error)
ListTickets(ctx context.Context, rc TicketReferenceCache, filter *ListTicketsFilter) (tickets []*types.Ticket, nextCursor string, hasMore bool, err error)
GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBaseURL string) (*types.Ticket, error)
GetTicketByRef(ctx context.Context, rc TicketReferenceCache, ref string, forumBaseURL string) (*types.Ticket, error)
GetTicketFirstMessages(ctx context.Context, ticketID uint32, n int, includeHidden bool) (msgs []*types.Message, totalCount uint32, err error)
ListTicketMessages(ctx context.Context, ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) (msgs []*types.Message, nextCursor string, hasMore bool, err error)
ListCategories(ctx context.Context, rc TicketReferenceCache) ([]*types.Category, error)
}

// TicketReferenceCache is the slice of referencecache.ReferenceCache that
Expand All @@ -107,7 +106,6 @@ type TicketReferenceCache interface {
ExpandSubtree(ids []uint32) []uint32
}

// Conformance pin: the production cache satisfies the slice. Lives HERE (not
// next to the grpc server that also consumes the cache) so the check
// survives Phase 4's deletion of the grpc stack.
// Conformance pin: the production cache satisfies the slice. Kept here, on the
// interface it serves, so it lives with the only consumer.
var _ TicketReferenceCache = (*referencecache.Cache)(nil)
Loading