From 39c1ee1138ed2dbea04e524be988b3053e4ce066 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:03:31 -0400 Subject: [PATCH 01/13] test: extract shared fakeDatastore into fake_datastore_test.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure refactor — moves the fakeDatastore struct, all method impls, milpacs panic stubs, and withTicketsKey helper from tickets_test.go into a new shared file so the upcoming milpacs_test.go can reuse the same scaffolding. Zero behavior change; all existing tests pass. --- servers/grpc/fake_datastore_test.go | 73 +++++++++++++++++++++++++++++ servers/grpc/tickets_test.go | 59 ----------------------- 2 files changed, 73 insertions(+), 59 deletions(-) create mode 100644 servers/grpc/fake_datastore_test.go diff --git a/servers/grpc/fake_datastore_test.go b/servers/grpc/fake_datastore_test.go new file mode 100644 index 0000000..45f3f9c --- /dev/null +++ b/servers/grpc/fake_datastore_test.go @@ -0,0 +1,73 @@ +package grpc + +import ( + "context" + + "github.com/7cav/api/datastores" + "github.com/7cav/api/proto" + "github.com/7cav/api/xenforo" +) + +// fakeDatastore is the shared in-process Datastore stub used by both +// tickets_test.go and milpacs_test.go. Each handler-under-test gets the +// matching function field populated; everything else either panics +// (methods this PR does not exercise) or, for milpacs methods, nil-derefs +// on call so an accidentally-untouched test fails loudly with a clear +// stack rather than a silent zero-value response. +type fakeDatastore struct { + // Tickets — configurable function fields used by tickets_test.go. + listTickets func(*datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) + getTicket func(uint32) (*proto.Ticket, error) + getByRef func(string) (*proto.Ticket, error) + firstMsgs func(uint32, int) ([]*proto.Message, uint32, error) + listMsgs func(uint32, string, uint32, bool) ([]*proto.Message, string, bool, error) + listCats func() ([]*proto.Category, error) +} + +func (f *fakeDatastore) ListTickets(_ context.Context, _ datastores.TicketReferenceCache, fi *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + return f.listTickets(fi) +} +func (f *fakeDatastore) GetTicket(_ context.Context, _ datastores.TicketReferenceCache, id uint32, _ string) (*proto.Ticket, error) { + return f.getTicket(id) +} +func (f *fakeDatastore) GetTicketByRef(_ context.Context, _ datastores.TicketReferenceCache, ref string, _ string) (*proto.Ticket, error) { + return f.getByRef(ref) +} +func (f *fakeDatastore) GetTicketFirstMessages(_ context.Context, id uint32, n int, _ bool) ([]*proto.Message, uint32, error) { + return f.firstMsgs(id, n) +} +func (f *fakeDatastore) ListTicketMessages(_ context.Context, id uint32, after string, per uint32, hidden bool) ([]*proto.Message, string, bool, error) { + return f.listMsgs(id, after, per, hidden) +} +func (f *fakeDatastore) ListCategories(_ context.Context, _ datastores.TicketReferenceCache) ([]*proto.Category, error) { + return f.listCats() +} + +// Milpacs methods — left panicking in this task; converted to configurable +// function fields in Task 2. +func (f *fakeDatastore) FindProfilesById(_ ...uint64) ([]*proto.Profile, error) { panic("unused") } +func (f *fakeDatastore) FindProfilesByUsername(string) ([]*proto.Profile, error) { panic("unused") } +func (f *fakeDatastore) FindRosterByType(proto.RosterType) (*proto.Roster, error) { panic("unused") } +func (f *fakeDatastore) FindLiteRosterByType(proto.RosterType) (*proto.LiteRoster, error) { panic("unused") } +func (f *fakeDatastore) FindProfileByKeycloakID(string) (*proto.Profile, error) { panic("unused") } +func (f *fakeDatastore) FindProfileByDiscordID(string) (*proto.Profile, error) { panic("unused") } +func (f *fakeDatastore) FindProfilesByPosition(string) (*proto.LiteRoster, error) { panic("unused") } +func (f *fakeDatastore) FindS1UniformsRosterByType(proto.RosterType) (*proto.S1UniformsRoster, error) { + panic("unused") +} +func (f *fakeDatastore) FindAllRanks() ([]*proto.RankExpanded, error) { panic("unused") } +func (f *fakeDatastore) FindAllPositionGroups() ([]*proto.PositionGroup, error) { panic("unused") } +func (f *fakeDatastore) FindAwol() ([]*proto.Awol, error) { panic("unused") } +func (f *fakeDatastore) GetTableUpdates() ([]xenforo.TableInfo, error) { panic("unused") } +func (f *fakeDatastore) FindProfileByGamertag(string) (*proto.Profile, error) { panic("unused") } +func (f *fakeDatastore) ValidateApiKey(string) (*datastores.ApiKeyResult, error) { panic("unused") } + +// withTicketsKey builds a context carrying an ApiKeyResult with the given +// scope set. Mirrors the auth middleware's behavior in production. +func withTicketsKey(scopes ...string) context.Context { + m := map[string]struct{}{} + for _, s := range scopes { + m[s] = struct{}{} + } + return ContextWithKey(context.Background(), &datastores.ApiKeyResult{Scopes: m}) +} diff --git a/servers/grpc/tickets_test.go b/servers/grpc/tickets_test.go index 8089130..6b2c3e7 100644 --- a/servers/grpc/tickets_test.go +++ b/servers/grpc/tickets_test.go @@ -1,7 +1,6 @@ package grpc import ( - "context" "errors" "fmt" "testing" @@ -9,7 +8,6 @@ import ( "github.com/7cav/api/datastores" "github.com/7cav/api/proto" "github.com/7cav/api/referencecache" - "github.com/7cav/api/xenforo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -18,63 +16,6 @@ import ( "gorm.io/gorm" ) -// fakeDatastore implements datastores.Datastore with just the methods we need -// for tickets-handler tests stubbed. Other methods panic so the test surface -// stays explicit. -type fakeDatastore struct { - listTickets func(*datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) - getTicket func(uint32) (*proto.Ticket, error) - getByRef func(string) (*proto.Ticket, error) - firstMsgs func(uint32, int) ([]*proto.Message, uint32, error) - listMsgs func(uint32, string, uint32, bool) ([]*proto.Message, string, bool, error) - listCats func() ([]*proto.Category, error) -} - -func (f *fakeDatastore) ListTickets(_ context.Context, _ datastores.TicketReferenceCache, fi *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { - return f.listTickets(fi) -} -func (f *fakeDatastore) GetTicket(_ context.Context, _ datastores.TicketReferenceCache, id uint32, _ string) (*proto.Ticket, error) { - return f.getTicket(id) -} -func (f *fakeDatastore) GetTicketByRef(_ context.Context, _ datastores.TicketReferenceCache, ref string, _ string) (*proto.Ticket, error) { - return f.getByRef(ref) -} -func (f *fakeDatastore) GetTicketFirstMessages(_ context.Context, id uint32, n int, _ bool) ([]*proto.Message, uint32, error) { - return f.firstMsgs(id, n) -} -func (f *fakeDatastore) ListTicketMessages(_ context.Context, id uint32, after string, per uint32, hidden bool) ([]*proto.Message, string, bool, error) { - return f.listMsgs(id, after, per, hidden) -} -func (f *fakeDatastore) ListCategories(_ context.Context, _ datastores.TicketReferenceCache) ([]*proto.Category, error) { - return f.listCats() -} - -// Milpacs methods omitted; tickets tests don't use them. -func (f *fakeDatastore) FindProfilesById(_ ...uint64) ([]*proto.Profile, error) { panic("unused") } -func (f *fakeDatastore) FindProfilesByUsername(string) ([]*proto.Profile, error) { panic("unused") } -func (f *fakeDatastore) FindRosterByType(proto.RosterType) (*proto.Roster, error) { panic("unused") } -func (f *fakeDatastore) FindLiteRosterByType(proto.RosterType) (*proto.LiteRoster, error) { panic("unused") } -func (f *fakeDatastore) FindProfileByKeycloakID(string) (*proto.Profile, error) { panic("unused") } -func (f *fakeDatastore) FindProfileByDiscordID(string) (*proto.Profile, error) { panic("unused") } -func (f *fakeDatastore) FindProfilesByPosition(string) (*proto.LiteRoster, error) { panic("unused") } -func (f *fakeDatastore) FindS1UniformsRosterByType(proto.RosterType) (*proto.S1UniformsRoster, error) { - panic("unused") -} -func (f *fakeDatastore) FindAllRanks() ([]*proto.RankExpanded, error) { panic("unused") } -func (f *fakeDatastore) FindAllPositionGroups() ([]*proto.PositionGroup, error) { panic("unused") } -func (f *fakeDatastore) FindAwol() ([]*proto.Awol, error) { panic("unused") } -func (f *fakeDatastore) GetTableUpdates() ([]xenforo.TableInfo, error) { panic("unused") } -func (f *fakeDatastore) FindProfileByGamertag(string) (*proto.Profile, error) { panic("unused") } -func (f *fakeDatastore) ValidateApiKey(string) (*datastores.ApiKeyResult, error) { panic("unused") } - -func withTicketsKey(scopes ...string) context.Context { - m := map[string]struct{}{} - for _, s := range scopes { - m[s] = struct{}{} - } - return ContextWithKey(context.Background(), &datastores.ApiKeyResult{Scopes: m}) -} - func TestListTickets_RequiresScope(t *testing.T) { svc := &TicketsService{Datastore: &fakeDatastore{}, ReferenceCache: &referencecache.Cache{}} ctx := withTicketsKey() // no scopes From 79b7b1cc2ef3deee97248d8fd1024b2d47900abc Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:07:15 -0400 Subject: [PATCH 02/13] test: clarify fakeDatastore struct comment Co-Authored-By: Claude Sonnet 4.6 --- servers/grpc/fake_datastore_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/servers/grpc/fake_datastore_test.go b/servers/grpc/fake_datastore_test.go index 45f3f9c..dcc2797 100644 --- a/servers/grpc/fake_datastore_test.go +++ b/servers/grpc/fake_datastore_test.go @@ -10,10 +10,8 @@ import ( // fakeDatastore is the shared in-process Datastore stub used by both // tickets_test.go and milpacs_test.go. Each handler-under-test gets the -// matching function field populated; everything else either panics -// (methods this PR does not exercise) or, for milpacs methods, nil-derefs -// on call so an accidentally-untouched test fails loudly with a clear -// stack rather than a silent zero-value response. +// matching function field populated; milpacs methods panic until Task 2 +// converts them to configurable function fields. type fakeDatastore struct { // Tickets — configurable function fields used by tickets_test.go. listTickets func(*datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) From d1832a637853b113a435e22db7c29a4fa2ae83c4 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:09:15 -0400 Subject: [PATCH 03/13] test: extend shared fake with configurable milpacs fields Add 8 function fields to fakeDatastore for the milpacs methods exercised by this PR, delegate their method bodies to those fields, add withMilpacsKey helper, and update the struct doc comment to drop the stale "until Task 2" caveat. Co-Authored-By: Claude Sonnet 4.6 --- servers/grpc/fake_datastore_test.go | 76 +++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/servers/grpc/fake_datastore_test.go b/servers/grpc/fake_datastore_test.go index dcc2797..cd3ebe7 100644 --- a/servers/grpc/fake_datastore_test.go +++ b/servers/grpc/fake_datastore_test.go @@ -10,8 +10,10 @@ import ( // fakeDatastore is the shared in-process Datastore stub used by both // tickets_test.go and milpacs_test.go. Each handler-under-test gets the -// matching function field populated; milpacs methods panic until Task 2 -// converts them to configurable function fields. +// matching function field populated; everything else either panics (methods +// this PR does not exercise) or nil-derefs on call (unset configurable +// fields) so an accidentally-untouched test fails loudly with a clear stack +// rather than a silent zero-value response. type fakeDatastore struct { // Tickets — configurable function fields used by tickets_test.go. listTickets func(*datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) @@ -20,6 +22,17 @@ type fakeDatastore struct { firstMsgs func(uint32, int) ([]*proto.Message, uint32, error) listMsgs func(uint32, string, uint32, bool) ([]*proto.Message, string, bool, error) listCats func() ([]*proto.Category, error) + + // Milpacs — configurable function fields. Unset fields nil-deref on call; + // same diagnostic level as tickets fields. + findProfilesById func(...uint64) ([]*proto.Profile, error) + findProfilesByUsername func(string) ([]*proto.Profile, error) + findProfileByKeycloakID func(string) (*proto.Profile, error) + findProfileByDiscordID func(string) (*proto.Profile, error) + findProfileByGamertag func(string) (*proto.Profile, error) + findRosterByType func(proto.RosterType) (*proto.Roster, error) + findLiteRosterByType func(proto.RosterType) (*proto.LiteRoster, error) + findS1UniformsRosterByType func(proto.RosterType) (*proto.S1UniformsRoster, error) } func (f *fakeDatastore) ListTickets(_ context.Context, _ datastores.TicketReferenceCache, fi *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { @@ -41,24 +54,38 @@ func (f *fakeDatastore) ListCategories(_ context.Context, _ datastores.TicketRef return f.listCats() } -// Milpacs methods — left panicking in this task; converted to configurable -// function fields in Task 2. -func (f *fakeDatastore) FindProfilesById(_ ...uint64) ([]*proto.Profile, error) { panic("unused") } -func (f *fakeDatastore) FindProfilesByUsername(string) ([]*proto.Profile, error) { panic("unused") } -func (f *fakeDatastore) FindRosterByType(proto.RosterType) (*proto.Roster, error) { panic("unused") } -func (f *fakeDatastore) FindLiteRosterByType(proto.RosterType) (*proto.LiteRoster, error) { panic("unused") } -func (f *fakeDatastore) FindProfileByKeycloakID(string) (*proto.Profile, error) { panic("unused") } -func (f *fakeDatastore) FindProfileByDiscordID(string) (*proto.Profile, error) { panic("unused") } +func (f *fakeDatastore) FindProfilesById(ids ...uint64) ([]*proto.Profile, error) { + return f.findProfilesById(ids...) +} +func (f *fakeDatastore) FindProfilesByUsername(u string) ([]*proto.Profile, error) { + return f.findProfilesByUsername(u) +} +func (f *fakeDatastore) FindRosterByType(t proto.RosterType) (*proto.Roster, error) { + return f.findRosterByType(t) +} +func (f *fakeDatastore) FindLiteRosterByType(t proto.RosterType) (*proto.LiteRoster, error) { + return f.findLiteRosterByType(t) +} +func (f *fakeDatastore) FindProfileByKeycloakID(k string) (*proto.Profile, error) { + return f.findProfileByKeycloakID(k) +} +func (f *fakeDatastore) FindProfileByDiscordID(d string) (*proto.Profile, error) { + return f.findProfileByDiscordID(d) +} +func (f *fakeDatastore) FindS1UniformsRosterByType(t proto.RosterType) (*proto.S1UniformsRoster, error) { + return f.findS1UniformsRosterByType(t) +} +func (f *fakeDatastore) FindProfileByGamertag(g string) (*proto.Profile, error) { + return f.findProfileByGamertag(g) +} + +// Milpacs methods this PR does not exercise — stay panicking. func (f *fakeDatastore) FindProfilesByPosition(string) (*proto.LiteRoster, error) { panic("unused") } -func (f *fakeDatastore) FindS1UniformsRosterByType(proto.RosterType) (*proto.S1UniformsRoster, error) { - panic("unused") -} -func (f *fakeDatastore) FindAllRanks() ([]*proto.RankExpanded, error) { panic("unused") } -func (f *fakeDatastore) FindAllPositionGroups() ([]*proto.PositionGroup, error) { panic("unused") } -func (f *fakeDatastore) FindAwol() ([]*proto.Awol, error) { panic("unused") } -func (f *fakeDatastore) GetTableUpdates() ([]xenforo.TableInfo, error) { panic("unused") } -func (f *fakeDatastore) FindProfileByGamertag(string) (*proto.Profile, error) { panic("unused") } -func (f *fakeDatastore) ValidateApiKey(string) (*datastores.ApiKeyResult, error) { panic("unused") } +func (f *fakeDatastore) FindAllRanks() ([]*proto.RankExpanded, error) { panic("unused") } +func (f *fakeDatastore) FindAllPositionGroups() ([]*proto.PositionGroup, error) { panic("unused") } +func (f *fakeDatastore) FindAwol() ([]*proto.Awol, error) { panic("unused") } +func (f *fakeDatastore) GetTableUpdates() ([]xenforo.TableInfo, error) { panic("unused") } +func (f *fakeDatastore) ValidateApiKey(string) (*datastores.ApiKeyResult, error) { panic("unused") } // withTicketsKey builds a context carrying an ApiKeyResult with the given // scope set. Mirrors the auth middleware's behavior in production. @@ -69,3 +96,14 @@ func withTicketsKey(scopes ...string) context.Context { } return ContextWithKey(context.Background(), &datastores.ApiKeyResult{Scopes: m}) } + +// withMilpacsKey builds a context carrying an ApiKeyResult with the given +// scope set, mirroring withTicketsKey but for milpacs-handler tests. +// Pass "read" to satisfy RequireScope; pass nothing for permission-denied paths. +func withMilpacsKey(scopes ...string) context.Context { + m := map[string]struct{}{} + for _, s := range scopes { + m[s] = struct{}{} + } + return ContextWithKey(context.Background(), &datastores.ApiKeyResult{Scopes: m}) +} From 9b0ef7af0d03b4b82d7f00d86ba71a95f7dbe13f Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:12:14 -0400 Subject: [PATCH 04/13] test: consolidate test ctx helpers via shared makeKeyCtx Co-Authored-By: Claude Sonnet 4.6 --- servers/grpc/fake_datastore_test.go | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/servers/grpc/fake_datastore_test.go b/servers/grpc/fake_datastore_test.go index cd3ebe7..9c4f3b0 100644 --- a/servers/grpc/fake_datastore_test.go +++ b/servers/grpc/fake_datastore_test.go @@ -87,9 +87,10 @@ func (f *fakeDatastore) FindAwol() ([]*proto.Awol, error) func (f *fakeDatastore) GetTableUpdates() ([]xenforo.TableInfo, error) { panic("unused") } func (f *fakeDatastore) ValidateApiKey(string) (*datastores.ApiKeyResult, error) { panic("unused") } -// withTicketsKey builds a context carrying an ApiKeyResult with the given -// scope set. Mirrors the auth middleware's behavior in production. -func withTicketsKey(scopes ...string) context.Context { +// makeKeyCtx builds a context carrying an ApiKeyResult with the given scope +// set. The withTicketsKey / withMilpacsKey wrappers stay as named entry +// points so a test reader sees which handler family the scope applies to. +func makeKeyCtx(scopes ...string) context.Context { m := map[string]struct{}{} for _, s := range scopes { m[s] = struct{}{} @@ -97,13 +98,10 @@ func withTicketsKey(scopes ...string) context.Context { return ContextWithKey(context.Background(), &datastores.ApiKeyResult{Scopes: m}) } -// withMilpacsKey builds a context carrying an ApiKeyResult with the given -// scope set, mirroring withTicketsKey but for milpacs-handler tests. +// withTicketsKey builds a context carrying an ApiKeyResult with the given +// scope set. Mirrors the auth middleware's behavior in production. +func withTicketsKey(scopes ...string) context.Context { return makeKeyCtx(scopes...) } + +// withMilpacsKey is the milpacs-handler equivalent of withTicketsKey. // Pass "read" to satisfy RequireScope; pass nothing for permission-denied paths. -func withMilpacsKey(scopes ...string) context.Context { - m := map[string]struct{}{} - for _, s := range scopes { - m[s] = struct{}{} - } - return ContextWithKey(context.Background(), &datastores.ApiKeyResult{Scopes: m}) -} +func withMilpacsKey(scopes ...string) context.Context { return makeKeyCtx(scopes...) } From 7314bfd0064b133ca0b873fad0a005f21a778a9d Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:14:10 -0400 Subject: [PATCH 05/13] fix(milpacs): route GetProfile-by-username to NotFound vs Internal Propagate gorm.ErrRecordNotFound unwrapped from FindProfilesByUsername so the handler can distinguish 404 (no such user) from 500 (datastore failure) via errors.Is; adds two TDD tests anchoring the behaviour. Co-Authored-By: Claude Sonnet 4.6 --- datastores/mysql.go | 3 --- servers/grpc/grpc.go | 6 +++++- servers/grpc/milpacs_test.go | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 servers/grpc/milpacs_test.go diff --git a/datastores/mysql.go b/datastores/mysql.go index 347be3e..d7edf94 100644 --- a/datastores/mysql.go +++ b/datastores/mysql.go @@ -60,9 +60,6 @@ func (ds Mysql) FindProfilesByUsername(username string) ([]*proto.Profile, error First(&profile) if result.Error != nil { - if result.Error == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("no profile found for username: %s", username) - } return nil, result.Error } diff --git a/servers/grpc/grpc.go b/servers/grpc/grpc.go index 0711ff4..61d08df 100644 --- a/servers/grpc/grpc.go +++ b/servers/grpc/grpc.go @@ -26,6 +26,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" + "gorm.io/gorm" "log" "os" ) @@ -52,7 +53,10 @@ func (server *MilpacsService) GetProfile(ctx context.Context, request *proto.Pro Info.Println("GetProfile, Requested via username") profiles, err = server.Datastore.FindProfilesByUsername(request.Username) if err != nil { - return &proto.Profile{}, status.Errorf(codes.NotFound, "no profile found for username: %s", request.Username) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Errorf(codes.NotFound, "no profile found for username: %s", request.Username) + } + return nil, status.Errorf(codes.Internal, "fetch profile by username: %v", err) } } else if request.UserId != 0 { Info.Println("GetProfile, requested via userid") diff --git a/servers/grpc/milpacs_test.go b/servers/grpc/milpacs_test.go new file mode 100644 index 0000000..c0d4378 --- /dev/null +++ b/servers/grpc/milpacs_test.go @@ -0,0 +1,39 @@ +package grpc + +import ( + "errors" + "testing" + + "github.com/7cav/api/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gorm.io/gorm" +) + +func TestGetProfile_ByUsername_NotFound(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findProfilesByUsername: func(string) ([]*proto.Profile, error) { + return nil, gorm.ErrRecordNotFound + }, + }} + _, err := svc.GetProfile(withMilpacsKey("read"), &proto.ProfileRequest{Username: "ghost"}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.NotFound, st.Code()) +} + +func TestGetProfile_ByUsername_DatastoreError(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findProfilesByUsername: func(string) ([]*proto.Profile, error) { + return nil, errors.New("boom") + }, + }} + _, err := svc.GetProfile(withMilpacsKey("read"), &proto.ProfileRequest{Username: "anyone"}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) +} From f83242e6e1fb0a31cde5c0099e961a5172603c06 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:17:58 -0400 Subject: [PATCH 06/13] style: group grpc.go imports per repo convention Co-Authored-By: Claude Sonnet 4.6 --- servers/grpc/grpc.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/servers/grpc/grpc.go b/servers/grpc/grpc.go index 61d08df..17c5c86 100644 --- a/servers/grpc/grpc.go +++ b/servers/grpc/grpc.go @@ -21,14 +21,15 @@ package grpc import ( "context" "errors" + "log" + "os" + "github.com/7cav/api/datastores" "github.com/7cav/api/proto" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" "gorm.io/gorm" - "log" - "os" ) type MilpacsService struct { From 3a806d9caa459b8870f9ba5913b06022fadb8584 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:19:59 -0400 Subject: [PATCH 07/13] fix(milpacs): route GetProfile-by-user-id to NotFound vs Internal Co-Authored-By: Claude Sonnet 4.6 --- datastores/mysql.go | 3 --- servers/grpc/grpc.go | 7 +++++-- servers/grpc/milpacs_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/datastores/mysql.go b/datastores/mysql.go index d7edf94..98dcd67 100644 --- a/datastores/mysql.go +++ b/datastores/mysql.go @@ -32,9 +32,6 @@ func (ds Mysql) FindProfilesById(userIds ...uint64) ([]*proto.Profile, error) { First(&profile, userIds[0]) if result.Error != nil { - if result.Error == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("no profile found for userid: %d", userIds) - } return nil, result.Error } diff --git a/servers/grpc/grpc.go b/servers/grpc/grpc.go index 17c5c86..ca0d740 100644 --- a/servers/grpc/grpc.go +++ b/servers/grpc/grpc.go @@ -63,10 +63,13 @@ func (server *MilpacsService) GetProfile(ctx context.Context, request *proto.Pro Info.Println("GetProfile, requested via userid") profiles, err = server.Datastore.FindProfilesById(request.UserId) if err != nil { - return &proto.Profile{}, status.Errorf(codes.NotFound, "no profile found for user ID: %d", request.UserId) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Errorf(codes.NotFound, "no profile found for user ID: %d", request.UserId) + } + return nil, status.Errorf(codes.Internal, "fetch profile by user id: %v", err) } } else { - return &proto.Profile{}, status.Errorf(codes.InvalidArgument, "no username or user ID provided") + return nil, status.Errorf(codes.InvalidArgument, "no username or user ID provided") } return profiles[0], nil diff --git a/servers/grpc/milpacs_test.go b/servers/grpc/milpacs_test.go index c0d4378..8aabae4 100644 --- a/servers/grpc/milpacs_test.go +++ b/servers/grpc/milpacs_test.go @@ -37,3 +37,29 @@ func TestGetProfile_ByUsername_DatastoreError(t *testing.T) { require.True(t, ok) assert.Equal(t, codes.Internal, st.Code()) } + +func TestGetProfile_ByUserID_NotFound(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findProfilesById: func(...uint64) ([]*proto.Profile, error) { + return nil, gorm.ErrRecordNotFound + }, + }} + _, err := svc.GetProfile(withMilpacsKey("read"), &proto.ProfileRequest{UserId: 99999}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.NotFound, st.Code()) +} + +func TestGetProfile_ByUserID_DatastoreError(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findProfilesById: func(...uint64) ([]*proto.Profile, error) { + return nil, errors.New("boom") + }, + }} + _, err := svc.GetProfile(withMilpacsKey("read"), &proto.ProfileRequest{UserId: 42}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) +} From e222575563ea88b1c594de758404637aef2e575e Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:24:16 -0400 Subject: [PATCH 08/13] fix(milpacs): route GetUserViaKeycloakId to NotFound vs Internal Co-Authored-By: Claude Sonnet 4.6 --- datastores/mysql.go | 3 --- servers/grpc/grpc.go | 5 ++++- servers/grpc/milpacs_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/datastores/mysql.go b/datastores/mysql.go index 98dcd67..1e1e8e1 100644 --- a/datastores/mysql.go +++ b/datastores/mysql.go @@ -102,9 +102,6 @@ func (ds Mysql) FindProfileByKeycloakID(keycloakId string) (*proto.Profile, erro First(&profile) if result.Error != nil { - if result.Error == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("no profile found for KeycloakID: %s", keycloakId) - } return nil, result.Error } diff --git a/servers/grpc/grpc.go b/servers/grpc/grpc.go index ca0d740..f3a6b0f 100644 --- a/servers/grpc/grpc.go +++ b/servers/grpc/grpc.go @@ -104,7 +104,10 @@ func (server *MilpacsService) GetUserViaKeycloakId(ctx context.Context, request profile, err := server.Datastore.FindProfileByKeycloakID(request.GetKeycloakId()) if err != nil { - return &proto.Profile{}, status.Errorf(codes.NotFound, "no user found for keycloakid: %s", request.GetKeycloakId()) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Errorf(codes.NotFound, "no user found for keycloakid: %s", request.GetKeycloakId()) + } + return nil, status.Errorf(codes.Internal, "fetch profile by keycloak id: %v", err) } return profile, nil diff --git a/servers/grpc/milpacs_test.go b/servers/grpc/milpacs_test.go index 8aabae4..01c6125 100644 --- a/servers/grpc/milpacs_test.go +++ b/servers/grpc/milpacs_test.go @@ -63,3 +63,29 @@ func TestGetProfile_ByUserID_DatastoreError(t *testing.T) { require.True(t, ok) assert.Equal(t, codes.Internal, st.Code()) } + +func TestGetUserViaKeycloakId_NotFound(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findProfileByKeycloakID: func(string) (*proto.Profile, error) { + return nil, gorm.ErrRecordNotFound + }, + }} + _, err := svc.GetUserViaKeycloakId(withMilpacsKey("read"), &proto.KeycloakIdRequest{KeycloakId: "ghost-uuid"}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.NotFound, st.Code()) +} + +func TestGetUserViaKeycloakId_DatastoreError(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findProfileByKeycloakID: func(string) (*proto.Profile, error) { + return nil, errors.New("boom") + }, + }} + _, err := svc.GetUserViaKeycloakId(withMilpacsKey("read"), &proto.KeycloakIdRequest{KeycloakId: "any"}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) +} From e7b72c3949602cd94bab9e33f10b5989480fe643 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:28:07 -0400 Subject: [PATCH 09/13] fix(milpacs): route GetUserViaDiscordId to NotFound vs Internal --- datastores/mysql.go | 3 --- servers/grpc/grpc.go | 5 ++++- servers/grpc/milpacs_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/datastores/mysql.go b/datastores/mysql.go index 1e1e8e1..a7900ca 100644 --- a/datastores/mysql.go +++ b/datastores/mysql.go @@ -126,9 +126,6 @@ func (ds Mysql) FindProfileByDiscordID(discordId string) (*proto.Profile, error) First(&profile) if result.Error != nil { - if result.Error == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("no profile found for discordID: %s", discordId) - } return nil, result.Error } diff --git a/servers/grpc/grpc.go b/servers/grpc/grpc.go index f3a6b0f..a80c356 100644 --- a/servers/grpc/grpc.go +++ b/servers/grpc/grpc.go @@ -125,7 +125,10 @@ func (server *MilpacsService) GetUserViaDiscordId(ctx context.Context, request * profile, err := server.Datastore.FindProfileByDiscordID(request.GetDiscordId()) if err != nil { - return &proto.Profile{}, status.Errorf(codes.NotFound, "no user found for discordid: %s", request.GetDiscordId()) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Errorf(codes.NotFound, "no user found for discordid: %s", request.GetDiscordId()) + } + return nil, status.Errorf(codes.Internal, "fetch profile by discord id: %v", err) } return profile, nil diff --git a/servers/grpc/milpacs_test.go b/servers/grpc/milpacs_test.go index 01c6125..ef14bee 100644 --- a/servers/grpc/milpacs_test.go +++ b/servers/grpc/milpacs_test.go @@ -89,3 +89,29 @@ func TestGetUserViaKeycloakId_DatastoreError(t *testing.T) { require.True(t, ok) assert.Equal(t, codes.Internal, st.Code()) } + +func TestGetUserViaDiscordId_NotFound(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findProfileByDiscordID: func(string) (*proto.Profile, error) { + return nil, gorm.ErrRecordNotFound + }, + }} + _, err := svc.GetUserViaDiscordId(withMilpacsKey("read"), &proto.DiscordIdRequest{DiscordId: "404"}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.NotFound, st.Code()) +} + +func TestGetUserViaDiscordId_DatastoreError(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findProfileByDiscordID: func(string) (*proto.Profile, error) { + return nil, errors.New("boom") + }, + }} + _, err := svc.GetUserViaDiscordId(withMilpacsKey("read"), &proto.DiscordIdRequest{DiscordId: "any"}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) +} From 29caa36d3361af9bb48caad032ff475050a5fcf8 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:32:13 -0400 Subject: [PATCH 10/13] fix(milpacs): route GetGamertagProfile to NotFound vs Internal Co-Authored-By: Claude Sonnet 4.6 --- datastores/mysql.go | 3 --- servers/grpc/grpc.go | 7 +++++-- servers/grpc/milpacs_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/datastores/mysql.go b/datastores/mysql.go index a7900ca..c0d6ae3 100644 --- a/datastores/mysql.go +++ b/datastores/mysql.go @@ -738,9 +738,6 @@ func (ds Mysql) FindProfileByGamertag(gamertag string) (*proto.Profile, error) { First(&profile) if result.Error != nil { - if result.Error == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("no profile found for gamertag: %s", gamertag) - } return nil, result.Error } diff --git a/servers/grpc/grpc.go b/servers/grpc/grpc.go index a80c356..53eff01 100644 --- a/servers/grpc/grpc.go +++ b/servers/grpc/grpc.go @@ -229,13 +229,16 @@ func (server *MilpacsService) GetGamertagProfile(ctx context.Context, request *p } if request.GetGamertag() == "" { Warn.Println("Empty Gamertag provided, cannot return profile") - return &proto.Profile{}, status.Errorf(codes.InvalidArgument, "gamertag cannot be empty") + return nil, status.Errorf(codes.InvalidArgument, "gamertag cannot be empty") } profile, err := server.Datastore.FindProfileByGamertag(request.GetGamertag()) if err != nil { - return &proto.Profile{}, status.Errorf(codes.NotFound, "no user found for gamertag: %v", request.GetGamertag()) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Errorf(codes.NotFound, "no user found for gamertag: %v", request.GetGamertag()) + } + return nil, status.Errorf(codes.Internal, "fetch profile by gamertag: %v", err) } return profile, nil } diff --git a/servers/grpc/milpacs_test.go b/servers/grpc/milpacs_test.go index ef14bee..684bb6e 100644 --- a/servers/grpc/milpacs_test.go +++ b/servers/grpc/milpacs_test.go @@ -115,3 +115,29 @@ func TestGetUserViaDiscordId_DatastoreError(t *testing.T) { require.True(t, ok) assert.Equal(t, codes.Internal, st.Code()) } + +func TestGetGamertagProfile_NotFound(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findProfileByGamertag: func(string) (*proto.Profile, error) { + return nil, gorm.ErrRecordNotFound + }, + }} + _, err := svc.GetGamertagProfile(withMilpacsKey("read"), &proto.GamertagRequest{Gamertag: "Nobody#0001"}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.NotFound, st.Code()) +} + +func TestGetGamertagProfile_DatastoreError(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findProfileByGamertag: func(string) (*proto.Profile, error) { + return nil, errors.New("boom") + }, + }} + _, err := svc.GetGamertagProfile(withMilpacsKey("read"), &proto.GamertagRequest{Gamertag: "any"}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) +} From c8d3d1ce00163e7eb2badb96267cc151d308bedf Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:36:29 -0400 Subject: [PATCH 11/13] fix(milpacs): capture roster Find() errors and map to Internal Co-Authored-By: Claude Sonnet 4.6 --- datastores/mysql.go | 5 ++++- servers/grpc/grpc.go | 2 +- servers/grpc/milpacs_test.go | 13 +++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/datastores/mysql.go b/datastores/mysql.go index c0d6ae3..03c60fe 100644 --- a/datastores/mysql.go +++ b/datastores/mysql.go @@ -73,11 +73,14 @@ func (ds Mysql) FindRosterByType(rosterType proto.RosterType) (*proto.Roster, er var rosterProfiles []milpacs.Profile Info.Println("Searching for roster: ", rosterType.String(), "id:", uint(rosterType.Number())) - ds.Db.Preload(clause.Associations). + result := ds.Db.Preload(clause.Associations). Preload("AwardRecords.Award"). Joins(xenforo.ConnectedAccountJoin). Where(map[string]interface{}{"roster_id": uint(rosterType.Number())}). Find(&rosterProfiles) + if result.Error != nil { + return nil, fmt.Errorf("find roster %s: %w", rosterType, result.Error) + } profiles, err := ds.processProfiles(rosterProfiles) if err != nil { diff --git a/servers/grpc/grpc.go b/servers/grpc/grpc.go index 53eff01..227a4d1 100644 --- a/servers/grpc/grpc.go +++ b/servers/grpc/grpc.go @@ -86,7 +86,7 @@ func (server *MilpacsService) GetRoster(ctx context.Context, request *proto.Rost roster, err := server.Datastore.FindRosterByType(request.Roster) if err != nil { - return &proto.Roster{}, status.Errorf(codes.NotFound, "no roster found for %s", request.Roster) + return nil, status.Errorf(codes.Internal, "fetch roster %s: %v", request.Roster, err) } return roster, nil diff --git a/servers/grpc/milpacs_test.go b/servers/grpc/milpacs_test.go index 684bb6e..9f2a463 100644 --- a/servers/grpc/milpacs_test.go +++ b/servers/grpc/milpacs_test.go @@ -141,3 +141,16 @@ func TestGetGamertagProfile_DatastoreError(t *testing.T) { require.True(t, ok) assert.Equal(t, codes.Internal, st.Code()) } + +func TestGetRoster_DatastoreError(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findRosterByType: func(proto.RosterType) (*proto.Roster, error) { + return nil, errors.New("boom") + }, + }} + _, err := svc.GetRoster(withMilpacsKey("read"), &proto.RosterRequest{Roster: proto.RosterType_ROSTER_TYPE_COMBAT}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) +} From 1375dead86d2e507f36e176a7c36335c062919d7 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:40:14 -0400 Subject: [PATCH 12/13] fix(milpacs): capture lite-roster Find() errors and map to Internal Co-Authored-By: Claude Sonnet 4.6 --- datastores/mysql.go | 5 ++++- servers/grpc/grpc.go | 2 +- servers/grpc/milpacs_test.go | 13 +++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/datastores/mysql.go b/datastores/mysql.go index 03c60fe..5acb1ab 100644 --- a/datastores/mysql.go +++ b/datastores/mysql.go @@ -257,11 +257,14 @@ func (ds Mysql) FindLiteRosterByType(rosterType proto.RosterType) (*proto.LiteRo var rosterProfiles []milpacs.Profile Info.Println("Searching for lite roster: ", rosterType.String(), "id:", uint(rosterType.Number())) - ds.Db.Preload(clause.Associations). + result := ds.Db.Preload(clause.Associations). Omit("Records", "AwardRecords"). Joins(xenforo.ConnectedAccountJoin). Where(map[string]interface{}{"roster_id": uint(rosterType.Number())}). Find(&rosterProfiles) + if result.Error != nil { + return nil, fmt.Errorf("find lite roster %s: %w", rosterType, result.Error) + } profiles, err := ds.processLiteProfiles(rosterProfiles) if err != nil { diff --git a/servers/grpc/grpc.go b/servers/grpc/grpc.go index 227a4d1..73f0642 100644 --- a/servers/grpc/grpc.go +++ b/servers/grpc/grpc.go @@ -144,7 +144,7 @@ func (server *MilpacsService) GetLiteRoster(ctx context.Context, request *proto. roster, err := server.Datastore.FindLiteRosterByType(request.Roster) if err != nil { - return &proto.LiteRoster{}, status.Errorf(codes.NotFound, "no roster found for %s", request.Roster) + return nil, status.Errorf(codes.Internal, "fetch lite roster %s: %v", request.Roster, err) } return roster, nil diff --git a/servers/grpc/milpacs_test.go b/servers/grpc/milpacs_test.go index 9f2a463..acddc85 100644 --- a/servers/grpc/milpacs_test.go +++ b/servers/grpc/milpacs_test.go @@ -154,3 +154,16 @@ func TestGetRoster_DatastoreError(t *testing.T) { require.True(t, ok) assert.Equal(t, codes.Internal, st.Code()) } + +func TestGetLiteRoster_DatastoreError(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findLiteRosterByType: func(proto.RosterType) (*proto.LiteRoster, error) { + return nil, errors.New("boom") + }, + }} + _, err := svc.GetLiteRoster(withMilpacsKey("read"), &proto.RosterRequest{Roster: proto.RosterType_ROSTER_TYPE_COMBAT}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) +} From e2225a4954eab92d69925cd7a672f66f1ecacb49 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Wed, 20 May 2026 16:43:49 -0400 Subject: [PATCH 13/13] fix(milpacs): capture s1-uniforms Find() errors and map to Internal --- datastores/mysql.go | 5 ++++- servers/grpc/grpc.go | 2 +- servers/grpc/milpacs_test.go | 13 +++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/datastores/mysql.go b/datastores/mysql.go index 5acb1ab..d3e3921 100644 --- a/datastores/mysql.go +++ b/datastores/mysql.go @@ -341,12 +341,15 @@ func (ds Mysql) FindS1UniformsRosterByType(rosterType proto.RosterType) (*proto. var rosterProfiles []milpacs.Profile Info.Println("Searching for S1 Uniforms roster: ", rosterType.String(), "id:", uint(rosterType.Number())) - ds.Db.Preload(clause.Associations). + result := ds.Db.Preload(clause.Associations). Preload("Primary.Group"). Preload("AwardRecords.Award"). Joins(xenforo.ConnectedAccountJoin). Where(map[string]interface{}{"roster_id": uint(rosterType.Number())}). Find(&rosterProfiles) + if result.Error != nil { + return nil, fmt.Errorf("find s1 uniforms roster %s: %w", rosterType, result.Error) + } var profiles = make(map[uint64]*proto.S1UniformsProfile, len(rosterProfiles)) for _, profile := range rosterProfiles { diff --git a/servers/grpc/grpc.go b/servers/grpc/grpc.go index 73f0642..fd59b10 100644 --- a/servers/grpc/grpc.go +++ b/servers/grpc/grpc.go @@ -176,7 +176,7 @@ func (server *MilpacsService) GetS1UniformsRoster(ctx context.Context, request * roster, err := server.Datastore.FindS1UniformsRosterByType(request.Roster) if err != nil { - return &proto.S1UniformsRoster{}, status.Errorf(codes.NotFound, "no roster found for %s", request.Roster) + return nil, status.Errorf(codes.Internal, "fetch s1 uniforms roster %s: %v", request.Roster, err) } return roster, nil } diff --git a/servers/grpc/milpacs_test.go b/servers/grpc/milpacs_test.go index acddc85..2f64718 100644 --- a/servers/grpc/milpacs_test.go +++ b/servers/grpc/milpacs_test.go @@ -167,3 +167,16 @@ func TestGetLiteRoster_DatastoreError(t *testing.T) { require.True(t, ok) assert.Equal(t, codes.Internal, st.Code()) } + +func TestGetS1UniformsRoster_DatastoreError(t *testing.T) { + svc := &MilpacsService{Datastore: &fakeDatastore{ + findS1UniformsRosterByType: func(proto.RosterType) (*proto.S1UniformsRoster, error) { + return nil, errors.New("boom") + }, + }} + _, err := svc.GetS1UniformsRoster(withMilpacsKey("read"), &proto.RosterRequest{Roster: proto.RosterType_ROSTER_TYPE_COMBAT}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) +}