This commit is contained in:
2026-10-13 13:50:13 +07:00
parent e77cc34b4d
commit 57fee0cc06
26 changed files with 2202 additions and 181 deletions
+55
View File
@@ -0,0 +1,55 @@
package app
import (
"context"
"evening_detective_server/internal/modules/roles"
"evening_detective_server/internal/services/team_service"
"evening_detective_server/proto"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func receiptClaims(ctx context.Context) error {
claims, err := claimsFromContext(ctx)
if err != nil {
return err
}
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
return status.Error(codes.PermissionDenied, "нет доступа к печати чеков команды")
}
return nil
}
func (s *server) PreviewTeamReceipt(ctx context.Context, req *proto.TeamReceiptReq) (*httpbody.HttpBody, error) {
if err := receiptClaims(ctx); err != nil {
return nil, err
}
data, err := s.teamService.PreviewTeamReceipt(ctx, int(req.GetGameID()), req.GetTeamID())
if err != nil {
return nil, teamReceiptError(err)
}
if err := grpc.SetHeader(ctx, metadata.Pairs("Cache-Control", "no-store", "X-Content-Type-Options", "nosniff")); err != nil {
return nil, status.Error(codes.Internal, "не удалось установить заголовки чека")
}
return &httpbody.HttpBody{
ContentType: "image/png",
Data: data,
}, nil
}
func (s *server) PrintTeamReceipt(ctx context.Context, req *proto.TeamReceiptReq) (*proto.PrintTeamReceiptRsp, error) {
if err := receiptClaims(ctx); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, team_service.ReceiptTimeout)
defer cancel()
if err := s.teamService.PrintTeamReceipt(ctx, int(req.GetGameID()), req.GetTeamID()); err != nil {
return nil, teamReceiptError(err)
}
return &proto.PrintTeamReceiptRsp{}, nil
}
+45
View File
@@ -0,0 +1,45 @@
package app
import (
"context"
"errors"
"log"
"evening_detective_server/internal/services/team_service"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func teamReceiptError(err error) error {
switch {
case errors.Is(err, team_service.ErrInvalidReceiptRequest):
return status.Error(codes.InvalidArgument, team_service.ErrInvalidReceiptRequest.Error())
case errors.Is(err, team_service.ErrGameNotFound), errors.Is(err, team_service.ErrTeamNotFound):
return status.Error(codes.NotFound, "игра или команда не найдены")
case errors.Is(err, team_service.ErrReceiptVenueOnly):
return status.Error(codes.FailedPrecondition, team_service.ErrReceiptVenueOnly.Error())
case errors.Is(err, team_service.ErrReceiptTeamCancelled):
return status.Error(codes.FailedPrecondition, team_service.ErrReceiptTeamCancelled.Error())
case errors.Is(err, team_service.ErrInvalidReceipt):
return status.Error(codes.FailedPrecondition, team_service.ErrInvalidReceipt.Error())
case errors.Is(err, context.Canceled):
return status.Error(codes.Canceled, "печать прервана; проверьте, вышел ли чек, перед повторной печатью")
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "принтер не ответил вовремя; проверьте, вышел ли чек, перед повторной печатью")
}
code := team_service.ReceiptPrinterErrorCode(err)
known := team_service.ReceiptPrinterError(code)
if errors.Is(err, known) {
grpcCode := codes.FailedPrecondition
if code == "busy" {
grpcCode = codes.ResourceExhausted
}
if code == "unavailable" || code == "protocol" || code == "incomplete" {
grpcCode = codes.Unavailable
}
return status.Error(grpcCode, known.Error())
}
log.Printf("team receipt: %v", err)
return status.Error(codes.Internal, "не удалось напечатать чек; проверьте, вышел ли он, перед повторной печатью")
}
+143
View File
@@ -0,0 +1,143 @@
package app
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/services/team_service"
"evening_detective_server/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type receiptAppRepos struct{ team_service.ITeamsRepository }
func (receiptAppRepos) GetGameByID(context.Context, int) (*repos.Game, error) {
return &repos.Game{ID: 1}, nil
}
func (receiptAppRepos) GetTeamByID(context.Context, string) (*repos.Team, error) {
return &repos.Team{ID: "019a1234-5678-7abc-8def-000000000007", GameID: 1, Name: "Сыщики", Password: "test-qr"}, nil
}
type receiptAppPrinter struct{ calls int }
func (p *receiptAppPrinter) Print(context.Context, []byte) error { p.calls++; return nil }
func TestTeamReceiptAccess(t *testing.T) {
for _, tc := range []struct {
name string
ctx context.Context
venue bool
req *proto.TeamReceiptReq
want codes.Code
}{
{"anonymous", context.Background(), true, nil, codes.Unauthenticated},
{"user", ctxWithClaims("user"), true, nil, codes.PermissionDenied},
{"prod", ctxWithClaims("admin"), false, &proto.TeamReceiptReq{GameID: 1}, codes.FailedPrecondition},
{"empty", ctxWithClaims("organizer"), true, nil, codes.InvalidArgument},
{"bad team", ctxWithClaims("author"), true, &proto.TeamReceiptReq{GameID: 1, TeamID: "bad"}, codes.InvalidArgument},
} {
t.Run(tc.name, func(t *testing.T) {
s := &server{teamService: team_service.NewTeamService(nil, nil, nil, "", nil, tc.venue, nil)}
_, err := s.PreviewTeamReceipt(tc.ctx, tc.req)
if status.Code(err) != tc.want {
t.Fatalf("preview code=%v", status.Code(err))
}
_, err = s.PrintTeamReceipt(tc.ctx, tc.req)
if status.Code(err) != tc.want {
t.Fatalf("print code=%v", status.Code(err))
}
})
}
}
func TestTeamReceiptResponse(t *testing.T) {
for _, role := range []string{"admin", "organizer", "author"} {
t.Run(role, func(t *testing.T) {
repos := receiptAppRepos{}
printer := &receiptAppPrinter{}
s := &server{teamService: team_service.NewTeamService(repos, repos, nil, "https://game.test", nil, true, printer)}
stream := &mockServerTransportStream{}
ctx := grpc.NewContextWithServerTransportStream(ctxWithClaims(role), stream)
req := &proto.TeamReceiptReq{GameID: 1, TeamID: "019a1234-5678-7abc-8def-000000000007"}
body, err := s.PreviewTeamReceipt(ctx, req)
if err != nil {
t.Fatal(err)
}
if body.ContentType != "image/png" || !bytes.HasPrefix(body.Data, []byte("\x89PNG")) {
t.Fatal("not a PNG")
}
if values := stream.header.Get("cache-control"); len(values) != 1 || values[0] != "no-store" {
t.Fatal("preview may be cached")
}
if _, err := s.PrintTeamReceipt(ctx, req); err != nil {
t.Fatal(err)
}
if printer.calls != 1 {
t.Fatal("incorrect number of prints")
}
})
}
}
func TestTeamReceiptErrorMapping(t *testing.T) {
for _, tc := range []struct {
err error
want codes.Code
}{
{team_service.ErrInvalidReceiptRequest, codes.InvalidArgument},
{team_service.ErrGameNotFound, codes.NotFound},
{team_service.ErrTeamNotFound, codes.NotFound},
{team_service.ErrReceiptVenueOnly, codes.FailedPrecondition},
{team_service.ErrReceiptTeamCancelled, codes.FailedPrecondition},
{team_service.ErrInvalidReceipt, codes.FailedPrecondition},
{team_service.ErrPrinterNotConfigured, codes.FailedPrecondition},
{team_service.ErrPrinterBusy, codes.ResourceExhausted},
{team_service.ErrPrinterNoPaper, codes.FailedPrecondition},
{team_service.ErrPrinterOverheated, codes.FailedPrecondition},
{team_service.ErrPrinterLowBattery, codes.FailedPrecondition},
{team_service.ErrPrinterNotReady, codes.FailedPrecondition},
{team_service.ErrPrinterProtocol, codes.Unavailable},
{team_service.ErrPrinterIncomplete, codes.Unavailable},
{team_service.ErrPrinterUnavailable, codes.Unavailable},
{context.Canceled, codes.Canceled},
{context.DeadlineExceeded, codes.DeadlineExceeded},
{errors.New("private-device-address-and-token"), codes.Internal},
} {
t.Run(tc.err.Error(), func(t *testing.T) {
err := teamReceiptError(fmt.Errorf("wrapped: %w", tc.err))
if status.Code(err) != tc.want {
t.Fatalf("code=%v,want %v", status.Code(err), tc.want)
}
if tc.want == codes.Internal && status.Convert(err).Message() == tc.err.Error() {
t.Fatal("private error exposed")
}
})
}
}
func TestTeamReceiptDisconnectWarnsBeforeRetry(t *testing.T) {
for _, tc := range []struct {
name string
err error
}{
{"Bluetooth disconnect", io.EOF},
{"worker response lost", fmt.Errorf("worker: %w", team_service.ErrPrinterUnavailable)},
} {
t.Run(tc.name, func(t *testing.T) {
code := team_service.ReceiptPrinterErrorCode(tc.err)
err := teamReceiptError(team_service.ReceiptPrinterError(code))
if status.Code(err) != codes.Unavailable || !strings.Contains(status.Convert(err).Message(), "перед повторной печатью проверьте, вышел ли чек") {
t.Fatalf("missing retry warning: %v", err)
}
})
}
}