This commit is contained in:
2026-10-12 15:58:24 +07:00
parent 0e20c3e151
commit 7fe8c6a5f0
18 changed files with 1314 additions and 149 deletions
+43
View File
@@ -0,0 +1,43 @@
package app
import (
"context"
"mime"
"evening_detective_server/internal/modules/roles"
"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 (s *server) DownloadGamePrintMaterials(ctx context.Context, req *proto.GetGameReq) (*httpbody.HttpBody, error) {
claims, err := claimsFromContext(ctx)
if err != nil {
return nil, err
}
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
return nil, status.Error(codes.PermissionDenied, "нет доступа к материалам игры")
}
if req == nil || req.ID <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор игры")
}
data, filename, err := s.gamePrintService.Download(ctx, int(req.ID))
if err != nil {
return nil, gamePrintError(err)
}
if err := grpc.SetHeader(ctx, metadata.Pairs(
"Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}),
"X-Content-Type-Options", "nosniff",
"Cache-Control", "no-store",
)); err != nil {
return nil, status.Error(codes.Internal, "не удалось установить заголовки архива")
}
return &httpbody.HttpBody{
ContentType: "application/zip",
Data: data,
}, nil
}
+35
View File
@@ -0,0 +1,35 @@
package app
import (
"context"
"errors"
"log"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/services/game_print_service"
"evening_detective_server/internal/services/scenarios_service"
"evening_detective_server/internal/services/team_service"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func gamePrintError(err error) error {
switch {
case errors.Is(err, team_service.ErrGameNotFound), errors.Is(err, scenarios_service.ErrScenarioNotFound):
return status.Error(codes.NotFound, "игра или сценарий не найдены")
case errors.Is(err, game_print_service.ErrPackageTooLarge):
return status.Error(codes.ResourceExhausted, game_print_service.ErrPackageTooLarge.Error())
case errors.Is(err, game_print_service.ErrInvalidPrintText):
return status.Error(codes.FailedPrecondition, "текст не помещается на лист или содержит неподдерживаемые символы")
case errors.Is(err, file_storage.ErrFileNotFound):
return status.Error(codes.FailedPrecondition, "не найден файл улики; проверьте материалы сценария")
case errors.Is(err, context.Canceled):
return status.Error(codes.Canceled, "скачивание отменено")
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "превышено время подготовки материалов")
default:
log.Printf("game print materials: %v", err)
return status.Error(codes.Internal, "не удалось подготовить материалы для печати")
}
}
+111
View File
@@ -0,0 +1,111 @@
package app
import (
"bytes"
"context"
"errors"
"fmt"
"testing"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/services/game_print_service"
"evening_detective_server/internal/services/scenarios_service"
"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 printGameStub struct{}
func (printGameStub) GetGameByID(context.Context, int) (*repos.Game, error) {
return &repos.Game{ID: 1, Name: "Дело №1", Format: "offline"}, nil
}
func (printGameStub) GetFullScenarioByID(context.Context, int) (*scenarios_service.Scenario, error) {
return &scenarios_service.Scenario{}, nil
}
func (printGameStub) GetActiveTeamsByGameID(context.Context, int) ([]*repos.Team, error) {
return nil, nil
}
func TestDownloadGamePrintMaterialsHeaders(t *testing.T) {
for _, role := range []string{"organizer", "author", "admin"} {
t.Run(role, func(t *testing.T) {
stub := printGameStub{}
s := &server{gamePrintService: game_print_service.NewGamePrintService(stub, stub, stub, nil, nil, "")}
stream := &mockServerTransportStream{}
ctx := grpc.NewContextWithServerTransportStream(ctxWithClaims(role), stream)
body, err := s.DownloadGamePrintMaterials(ctx, &proto.GetGameReq{ID: 1})
if err != nil {
t.Fatal(err)
}
if body.ContentType != "application/zip" || !bytes.HasPrefix(body.Data, []byte("PK")) {
t.Fatal("invalid ZIP response")
}
for key, want := range map[string]string{
"content-disposition": "attachment; filename=game-1-print-materials.zip",
"cache-control": "no-store",
"x-content-type-options": "nosniff",
} {
values := stream.header.Get(key)
if len(values) != 1 || values[0] != want {
t.Errorf("%s = %v, want %s", key, values, want)
}
}
})
}
}
func TestDownloadGamePrintMaterialsAccess(t *testing.T) {
for _, tc := range []struct {
name string
ctx context.Context
request *proto.GetGameReq
want codes.Code
}{
{"anonymous", context.Background(), &proto.GetGameReq{ID: 1}, codes.Unauthenticated},
{"player", ctxWithClaims("user"), &proto.GetGameReq{ID: 1}, codes.PermissionDenied},
{"developer", ctxWithClaims("developer"), &proto.GetGameReq{ID: 1}, codes.PermissionDenied},
{"organizer invalid ID", ctxWithClaims("organizer"), &proto.GetGameReq{}, codes.InvalidArgument},
{"author invalid ID", ctxWithClaims("author"), &proto.GetGameReq{ID: -1}, codes.InvalidArgument},
{"admin nil request", ctxWithClaims("admin"), nil, codes.InvalidArgument},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := (&server{}).DownloadGamePrintMaterials(tc.ctx, tc.request)
if status.Code(err) != tc.want {
t.Fatalf("code = %v, want %v", status.Code(err), tc.want)
}
})
}
}
func TestGamePrintErrorMapping(t *testing.T) {
for _, tc := range []struct {
err error
want codes.Code
}{
{team_service.ErrGameNotFound, codes.NotFound},
{scenarios_service.ErrScenarioNotFound, codes.NotFound},
{game_print_service.ErrPackageTooLarge, codes.ResourceExhausted},
{game_print_service.ErrInvalidPrintText, codes.FailedPrecondition},
{file_storage.ErrFileNotFound, codes.FailedPrecondition},
{context.Canceled, codes.Canceled},
{context.DeadlineExceeded, codes.DeadlineExceeded},
{errors.New("private storage detail"), codes.Internal},
} {
t.Run(tc.err.Error(), func(t *testing.T) {
err := gamePrintError(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() != "не удалось подготовить материалы для печати" {
t.Fatal("private error exposed")
}
})
}
}
+4
View File
@@ -6,6 +6,7 @@ import (
"evening_detective_server/internal/services/file_service"
"evening_detective_server/internal/services/game_export_service"
"evening_detective_server/internal/services/game_import_service"
"evening_detective_server/internal/services/game_print_service"
"evening_detective_server/internal/services/game_results_package_service"
"evening_detective_server/internal/services/game_review_service"
"evening_detective_server/internal/services/game_service"
@@ -40,6 +41,7 @@ type server struct {
gameResultsService *game_results_package_service.GameResultsPackageService
gameResultsApplyService *game_results_package_service.GameResultsApplyService
gameService *game_service.GameService
gamePrintService *game_print_service.GamePrintService
gameplayService *gameplay_service.GameplayService
answerService *answer_service.AnswerService
reviewService *game_review_service.GameReviewService
@@ -76,8 +78,10 @@ func NewServer(
gameResultsApplyService *game_results_package_service.GameResultsApplyService,
devicePresenceService *device_presence_service.DevicePresenceService,
wifiService *wifi_service.Service,
gamePrintService *game_print_service.GamePrintService,
) proto.EveningDetectiveServerServer {
return &server{
gamePrintService: gamePrintService,
devicePresenceService: devicePresenceService,
wifiService: wifiService,
gameImportService: gameImportService,
+6 -1
View File
@@ -25,6 +25,11 @@ type Card struct {
//go:embed fonts/DejaVuSansCondensed.ttf
var font []byte
// RegisterFont adds the existing Cyrillic print font to a PDF document.
func RegisterFont(pdf *fpdf.Fpdf) {
pdf.AddUTF8FontFromBytes("teams", "", font)
}
// Generate returns a PDF containing team QR-code cards.
func Generate(cards []Card) ([]byte, error) {
if len(cards) == 0 {
@@ -33,7 +38,7 @@ func Generate(cards []Card) ([]byte, error) {
pdf := fpdf.New("P", "mm", "A4", "")
pdf.SetAutoPageBreak(false, 0)
pdf.SetCellMargin(0)
pdf.AddUTF8FontFromBytes("teams", "", font)
RegisterFont(pdf)
pdf.SetFillColor(0, 0, 0)
const margin, width, height, qrSize = 10.0, 190.0 / 3, 277.0 / 4, 36.0
for i, card := range cards {
@@ -378,7 +378,7 @@ func verifyExportedGameReads(t *testing.T, f serviceFixture, worker *game_export
games := game_service.NewGameService(f.games, f.teams, f.scenarios, nil, "", actions_repo.NewActionsRepo(f.pool), applications_repo.NewApplicationsRepo(f.pool))
mapRepo := game_map_repo.NewGameMapRepo(f.pool)
server := app.NewServer(nil, nil, nil, nil, games, nil, nil, nil, nil, nil, nil,
screen_service.NewScreenService(screen_sessions_repo.NewScreenSessionsRepo(f.pool)), nil, nil, map_service.NewMapService(mapRepo), nil, worker, nil, nil, nil, nil, nil)
screen_service.NewScreenService(screen_sessions_repo.NewScreenSessionsRepo(f.pool)), nil, nil, map_service.NewMapService(mapRepo), nil, worker, nil, nil, nil, nil, nil, nil)
ctx := processor_jwt.ContextWithClaims(context.Background(), &processor_jwt.JWTClaims{UserID: f.userID, Roles: []string{"organizer"}})
game, err := server.GetGame(ctx, &proto.GetGameReq{ID: int32(f.gameID)})
check(t, err)
+209
View File
@@ -0,0 +1,209 @@
package game_print_service
import (
"bytes"
"fmt"
"strings"
"unicode/utf8"
"evening_detective_server/internal/modules/cleaner"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/modules/team_qr_pdf"
"evening_detective_server/internal/repos"
"github.com/go-pdf/fpdf"
)
func newPDF(orientation string) *fpdf.Fpdf {
pdf := fpdf.New(orientation, "mm", "A4", "")
pdf.SetMargins(15, 15, 15)
pdf.SetAutoPageBreak(true, 15)
team_qr_pdf.RegisterFont(pdf)
pdf.SetFont("teams", "", 11)
pdf.SetFooterFunc(func() {
pdf.SetY(-12)
pdf.SetFont("teams", "", 8)
pdf.CellFormat(0, 5, fmt.Sprint(pdf.PageNo()), "", 0, "C", false, 0, "")
})
pdf.AddPage()
return pdf
}
func printText(pdf *fpdf.Fpdf, text string) string {
if !utf8.ValidString(text) {
pdf.SetError(ErrInvalidPrintText)
return ""
}
for _, r := range text {
// shortcut: fpdf supports only BMP text; replace the renderer when emoji printing is required.
if r > 0xffff {
pdf.SetError(ErrInvalidPrintText)
return ""
}
}
return strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\t", " ")
}
func heading(pdf *fpdf.Fpdf, text string) {
pdf.SetFont("teams", "", 15)
pdf.MultiCell(0, 7, printText(pdf, text), "", "L", false)
pdf.Ln(3)
pdf.SetFont("teams", "", 11)
}
func body(pdf *fpdf.Fpdf, text string) {
pdf.MultiCell(0, 6, printText(pdf, text), "", "L", false)
pdf.Ln(3)
}
func pdfBytes(pdf *fpdf.Fpdf) ([]byte, error) {
var output bytes.Buffer
if err := pdf.Output(&output); err != nil {
return nil, err
}
return output.Bytes(), nil
}
func textPDF(game, title, text string) ([]byte, error) {
pdf := newPDF("P")
heading(pdf, title)
body(pdf, game)
body(pdf, text)
return pdfBytes(pdf)
}
func rulesPDF(rules string) ([]byte, error) {
pdf := newPDF("P")
for _, paragraph := range strings.Split(rules, "\n\n") {
if strings.HasPrefix(paragraph, "#") {
if pdf.GetY() > 245 {
pdf.AddPage()
}
heading(pdf, strings.TrimSpace(strings.TrimLeft(paragraph, "#")))
} else {
body(pdf, paragraph)
}
}
return pdfBytes(pdf)
}
func questionsPDF(game string, questions []*storytelling.Question) ([]byte, error) {
pdf := newPDF("P")
heading(pdf, "Вопросы дела")
body(pdf, game)
body(pdf, "Команда: __________________________________________________")
textCleaner := cleaner.NewCleaner()
for i, question := range questions {
if pdf.GetY() > 235 {
pdf.AddPage()
}
body(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text)))
if pdf.GetY() > 247 {
pdf.AddPage()
}
for range 4 {
y := pdf.GetY() + 7
pdf.Line(15, y, 195, y)
pdf.SetY(y)
}
pdf.Ln(6)
}
return pdfBytes(pdf)
}
func draftPDF(game, title string) ([]byte, error) {
pdf := newPDF("P")
heading(pdf, title)
body(pdf, game)
body(pdf, "Команда: __________________________________________________")
pdf.SetDrawColor(180, 180, 180)
for y := pdf.GetY() + 8; y < 277; y += 8 {
pdf.Line(15, y, 195, y)
}
return pdfBytes(pdf)
}
func answersPDF(game string, story *storytelling.Story) ([]byte, error) {
pdf := newPDF("P")
heading(pdf, "Для ведущего: ответы и развязка")
body(pdf, game)
textCleaner := cleaner.NewCleaner()
for i, question := range story.Questions {
body(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text)))
body(pdf, "Ответ: "+question.Answer)
}
if story.Conclusion != nil {
heading(pdf, "Развязка")
body(pdf, story.Conclusion.Text)
}
return pdfBytes(pdf)
}
func issuancePDF(game string, teams []*repos.Team, clues []*storytelling.Application) ([]byte, error) {
pdf := newPDF("L")
pdf.SetAutoPageBreak(false, 0)
const columnsPerPage = 8
const teamWidth, clueWidth, lineHeight = 51.0, 27.0, 4.0
if len(clues) == 0 {
clues = []*storytelling.Application{{Name: "Улика"}}
}
if len(teams) == 0 {
for range 12 {
teams = append(teams, &repos.Team{})
}
}
for start := 0; start < len(clues); start += columnsPerPage {
end := min(start+columnsPerPage, len(clues))
labels := []string{"Команда / улика"}
for _, clue := range clues[start:end] {
labels = append(labels, clue.Name)
}
for row := 0; row < len(teams); {
if start > 0 || row > 0 {
pdf.AddPage()
}
heading(pdf, "Лист выдачи улик")
body(pdf, game)
pdf.SetFont("teams", "", 9)
widths := []float64{teamWidth}
for range labels[1:] {
widths = append(widths, clueWidth)
}
headerHeight := 10.0
for i, label := range labels {
headerHeight = max(headerHeight, float64(len(pdf.SplitText(printText(pdf, label), widths[i]-4)))*lineHeight+4)
}
if pdf.GetY()+headerHeight+10 > 195 {
return nil, fmt.Errorf("issuance header does not fit: %w", ErrInvalidPrintText)
}
tableRow(pdf, labels, widths, headerHeight)
pageStartRow := row
for row < len(teams) {
name := printText(pdf, teams[row].Name)
height := max(10.0, float64(len(pdf.SplitText(name, teamWidth-4)))*lineHeight+4)
if pdf.GetY()+height > 195 {
if row == pageStartRow {
return nil, fmt.Errorf("team name does not fit: %w", ErrInvalidPrintText)
}
break
}
cells := make([]string, len(labels))
cells[0] = name
tableRow(pdf, cells, widths, height)
row++
}
}
}
return pdfBytes(pdf)
}
func tableRow(pdf *fpdf.Fpdf, cells []string, widths []float64, height float64) {
x, y := 15.0, pdf.GetY()
for i, cell := range cells {
pdf.Rect(x, y, widths[i], height, "D")
pdf.SetXY(x+2, y+2)
pdf.MultiCell(widths[i]-4, 4, printText(pdf, cell), "", "L", false)
x += widths[i]
}
pdf.SetXY(15, y+height)
}
@@ -0,0 +1,234 @@
// Package game_print_service builds printable game materials.
package game_print_service
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"net/url"
"path"
"strings"
"unicode"
"evening_detective_server/docs"
"evening_detective_server/internal/modules/cleaner"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/services/scenarios_service"
"evening_detective_server/internal/services/team_service"
)
const (
playerFolder = "Для игрока/"
hostFolder = "Для ведущего/"
maxPackageSize = 256 << 20
)
var (
ErrPackageTooLarge = errors.New("материалы для печати превышают 256 МБ")
ErrInvalidPrintText = errors.New("текст материалов содержит неподдерживаемые символы")
)
// IGamesReader supplies the game used in a print package.
type IGamesReader interface {
GetGameByID(context.Context, int) (*repos.Game, error)
}
// IScenariosReader supplies the complete story, including organizer answers.
type IScenariosReader interface {
GetFullScenarioByID(context.Context, int) (*scenarios_service.Scenario, error)
}
// ITeamsReader supplies participating teams, excluding cancelled registrations.
type ITeamsReader interface {
GetActiveTeamsByGameID(context.Context, int) ([]*repos.Team, error)
}
// ITeamQRGenerator uses the same team list as the issuance sheet.
type ITeamQRGenerator interface {
GenerateTeamQR(context.Context, *repos.Game, []*repos.Team) ([]byte, string, error)
}
// GamePrintService builds an archive without persisting generated documents.
type GamePrintService struct {
games IGamesReader
scenarios IScenariosReader
teams ITeamsReader
qr ITeamQRGenerator
files file_storage.IFileStorage
domain string
}
// NewGamePrintService creates the print package service.
func NewGamePrintService(games IGamesReader, scenarios IScenariosReader, teams ITeamsReader, qr ITeamQRGenerator, files file_storage.IFileStorage, domain string) *GamePrintService {
return &GamePrintService{
games: games,
scenarios: scenarios,
teams: teams,
qr: qr,
files: files,
domain: domain,
}
}
// Download builds current player and organizer materials for a game.
func (s *GamePrintService) Download(ctx context.Context, gameID int) ([]byte, string, error) {
if err := ctx.Err(); err != nil {
return nil, "", err
}
game, err := s.games.GetGameByID(ctx, gameID)
if err != nil {
if errors.Is(err, games_repo.ErrGameNotFound) {
return nil, "", team_service.ErrGameNotFound
}
return nil, "", err
}
scenario, err := s.scenarios.GetFullScenarioByID(ctx, game.ScenarioID)
if err != nil {
return nil, "", err
}
teams, err := s.teams.GetActiveTeamsByGameID(ctx, gameID)
if err != nil {
return nil, "", err
}
story := scenario.Story
if story == nil {
story = &storytelling.Story{}
}
clues := collectClues(story)
var output bytes.Buffer
archive := zip.NewWriter(&output)
defer archive.Close()
size := 0
add := func(name string, data []byte) error {
if err := ctx.Err(); err != nil {
return err
}
size += len(data)
if size > maxPackageSize {
return ErrPackageTooLarge
}
entry, err := archive.Create(name)
if err != nil {
return err
}
_, err = entry.Write(data)
return err
}
intro := ""
if story.Introduction != nil {
intro = cleaner.NewCleaner().ClearText(story.Introduction.Text)
}
documents := []struct {
name string
generate func() ([]byte, error)
}{
{playerFolder + "Правила.pdf", func() ([]byte, error) { return rulesPDF(docs.GameRules(game.Format)) }},
{playerFolder + "Введение.pdf", func() ([]byte, error) { return textPDF(game.Name, "Введение", intro) }},
{playerFolder + "Вопросы.pdf", func() ([]byte, error) { return questionsPDF(game.Name, story.Questions) }},
{playerFolder + "Список пидарасов.pdf", func() ([]byte, error) { return draftPDF(game.Name, "Список пидарасов") }},
{playerFolder + "Черновик расследования.pdf", func() ([]byte, error) { return draftPDF(game.Name, "Черновик расследования") }},
{hostFolder + "Ответы и развязка.pdf", func() ([]byte, error) { return answersPDF(game.Name, story) }},
{hostFolder + "Лист выдачи улик.pdf", func() ([]byte, error) { return issuancePDF(game.Name, teams, clues) }},
}
for _, document := range documents {
if err := ctx.Err(); err != nil {
return nil, "", err
}
data, err := document.generate()
if err != nil {
return nil, "", fmt.Errorf("generate %s: %w", document.name, err)
}
if err := add(document.name, data); err != nil {
return nil, "", err
}
}
if len(teams) > 0 {
data, _, err := s.qr.GenerateTeamQR(ctx, game, teams)
if err != nil {
return nil, "", fmt.Errorf("generate team QR: %w", err)
}
if err := add(hostFolder+"QR команд.pdf", data); err != nil {
return nil, "", err
}
}
if err := add(playerFolder+"Улики/", nil); err != nil {
return nil, "", err
}
var external []string
for i, clue := range clues {
ref := strings.TrimPrefix(clue.Image, s.domain)
if ref == "" {
continue
}
parsed, err := url.Parse(ref)
if err != nil {
return nil, "", fmt.Errorf("invalid clue reference: %w", err)
}
if parsed.IsAbs() || parsed.Host != "" {
external = append(external, clue.Name+": "+clue.Image)
continue
}
file, err := s.files.Get(ctx, ref)
if err != nil {
return nil, "", fmt.Errorf("load clue %d: %w", i+1, err)
}
filename := fmt.Sprintf("%03d - %s%s", i+1, safeName(clue.Name), path.Ext(path.Base(ref)))
if err := add(playerFolder+"Улики/"+filename, file.Data); err != nil {
return nil, "", err
}
}
if len(external) > 0 {
data, err := textPDF(game.Name, "Материалы для ручного скачивания", strings.Join(external, "\n\n"))
if err != nil {
return nil, "", err
}
if err := add(hostFolder+"Внешние материалы.pdf", data); err != nil {
return nil, "", err
}
}
if err := archive.Close(); err != nil {
return nil, "", err
}
return output.Bytes(), fmt.Sprintf("game-%d-print-materials.zip", gameID), nil
}
func collectClues(story *storytelling.Story) []*storytelling.Application {
var clues []*storytelling.Application
seen := make(map[string]bool)
for _, place := range story.Places {
for _, clue := range place.Applications {
key := clue.Image
if key == "" {
key = "name:" + clue.Name
}
if !seen[key] {
seen[key] = true
clues = append(clues, clue)
}
}
}
return clues
}
func safeName(name string) string {
name = strings.Map(func(r rune) rune {
if unicode.IsControl(r) || strings.ContainsRune(`\/:*?"<>|`, r) {
return '_'
}
return r
}, name)
name = strings.Trim(name, " .")
if name == "" {
return "Улика"
}
runes := []rune(name)
if len(runes) > 80 {
name = string(runes[:80])
}
return name
}
@@ -0,0 +1,335 @@
package game_print_service
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"path"
"strings"
"testing"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/services/scenarios_service"
"evening_detective_server/internal/services/team_service"
)
type printFixture struct {
file_storage.IFileStorage
game *repos.Game
story *storytelling.Story
teams []*repos.Team
files map[string][]byte
gameErr error
scenarioErr error
teamsErr error
fileErr error
qrCalls int
}
func (f *printFixture) GetGameByID(context.Context, int) (*repos.Game, error) {
return f.game, f.gameErr
}
func (f *printFixture) GetFullScenarioByID(context.Context, int) (*scenarios_service.Scenario, error) {
return &scenarios_service.Scenario{Story: f.story}, f.scenarioErr
}
func (f *printFixture) GetActiveTeamsByGameID(context.Context, int) ([]*repos.Team, error) {
var teams []*repos.Team
for _, team := range f.teams {
if team.ParticipationStatus != "cancelled" {
teams = append(teams, team)
}
}
return teams, f.teamsErr
}
func (f *printFixture) GenerateTeamQR(ctx context.Context, game *repos.Game, teams []*repos.Team) ([]byte, string, error) {
f.qrCalls++
return team_service.NewTeamService(nil, nil, nil, "https://game.test", nil).GenerateTeamQR(ctx, game, teams)
}
func (f *printFixture) Get(ctx context.Context, name string) (*file_storage.File, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if f.fileErr != nil {
return nil, f.fileErr
}
data, ok := f.files[name]
if !ok {
return nil, file_storage.ErrFileNotFound
}
return &file_storage.File{Data: data}, nil
}
func newPrintFixture() *printFixture {
clue := &storytelling.Application{Name: "Письмо / улика", Image: "https://files.test/clue.pdf"}
return &printFixture{
game: &repos.Game{ID: 1, Name: "Дело №1", Format: "offline", ScenarioID: 2},
story: &storytelling.Story{
Introduction: &storytelling.Introduction{Text: "Начало расследования"},
Questions: []*storytelling.Question{{Text: "Кто виновен?", Answer: "СЕКРЕТНЫЙ ОТВЕТ"}},
Conclusion: &storytelling.Conclusion{Text: "СЕКРЕТНАЯ РАЗВЯЗКА"},
Places: []*storytelling.Place{{Applications: []*storytelling.Application{clue, clue}}},
},
teams: []*repos.Team{
{ID: "team-1", Name: "Детективы", Password: "secret", ParticipationStatus: "confirmed"},
{ID: "team-2", Name: "Ожидающая команда", Password: "secret", ParticipationStatus: "pending"},
{ID: "team-3", Name: "Отменённая команда", Password: "secret", ParticipationStatus: "cancelled"},
},
files: map[string][]byte{"clue.pdf": []byte("%PDF-original-clue")},
}
}
func (f *printFixture) service() *GamePrintService {
return NewGamePrintService(f, f, f, f, f, "https://files.test/")
}
func readPrintArchive(t *testing.T, raw []byte) map[string][]byte {
t.Helper()
archive, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
entries := make(map[string][]byte)
for _, entry := range archive.File {
if !strings.HasPrefix(entry.Name, playerFolder) && !strings.HasPrefix(entry.Name, hostFolder) {
t.Fatalf("unexpected root entry %s", entry.Name)
}
if _, exists := entries[entry.Name]; exists {
t.Fatalf("duplicate entry %s", entry.Name)
}
reader, err := entry.Open()
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(reader)
reader.Close()
if err != nil {
t.Fatal(err)
}
entries[entry.Name] = data
}
return entries
}
func TestDownloadPrintMaterials(t *testing.T) {
for _, tc := range []struct {
name string
change func(*printFixture)
wantQR bool
wantExternal bool
}{
{"complete", func(*printFixture) {}, true, false},
{"without teams", func(f *printFixture) { f.teams = nil }, false, false},
{"only cancelled teams", func(f *printFixture) { f.teams = f.teams[2:] }, false, false},
{"external clue", func(f *printFixture) { f.story.Places[0].Applications[0].Image = "https://external.test/clue.pdf" }, true, true},
{"empty story", func(f *printFixture) { f.story = nil }, true, false},
} {
t.Run(tc.name, func(t *testing.T) {
f := newPrintFixture()
tc.change(f)
data, name, err := f.service().Download(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if name != "game-1-print-materials.zip" {
t.Fatalf("filename = %s", name)
}
entries := readPrintArchive(t, data)
for _, name := range []string{
playerFolder + "Правила.pdf", playerFolder + "Введение.pdf", playerFolder + "Вопросы.pdf",
playerFolder + "Список пидарасов.pdf", playerFolder + "Черновик расследования.pdf",
hostFolder + "Ответы и развязка.pdf", hostFolder + "Лист выдачи улик.pdf",
} {
if !bytes.HasPrefix(entries[name], []byte("%PDF-")) {
t.Errorf("missing PDF %s", name)
}
}
_, hasQR := entries[hostFolder+"QR команд.pdf"]
_, hasExternal := entries[hostFolder+"Внешние материалы.pdf"]
if hasQR != tc.wantQR || hasExternal != tc.wantExternal {
t.Fatalf("QR = %v, external = %v", hasQR, hasExternal)
}
if !tc.wantQR && f.qrCalls != 0 {
t.Fatal("generated QR for an empty team list")
}
if f.story != nil && !tc.wantExternal {
if !bytes.Equal(entries[playerFolder+"Улики/001 - Письмо _ улика.pdf"], f.files["clue.pdf"]) {
t.Fatal("original clue was changed or missing")
}
wantEntries := 9
if tc.wantQR {
wantEntries++
}
if len(entries) != wantEntries {
t.Fatalf("unexpected entry count, duplicated clues: %d", len(entries))
}
}
})
}
}
func TestDownloadPrintErrors(t *testing.T) {
broken := errors.New("private storage detail")
for _, tc := range []struct {
name string
change func(*printFixture)
want error
}{
{"game not found", func(f *printFixture) { f.gameErr = games_repo.ErrGameNotFound }, team_service.ErrGameNotFound},
{"scenario not found", func(f *printFixture) { f.scenarioErr = scenarios_service.ErrScenarioNotFound }, scenarios_service.ErrScenarioNotFound},
{"team read error", func(f *printFixture) { f.teamsErr = broken }, broken},
{"missing clue", func(f *printFixture) { delete(f.files, "clue.pdf") }, file_storage.ErrFileNotFound},
{"file read error", func(f *printFixture) { f.fileErr = broken }, broken},
{"unsupported text", func(f *printFixture) { f.game.Name = "Дело 🕵️" }, ErrInvalidPrintText},
} {
t.Run(tc.name, func(t *testing.T) {
f := newPrintFixture()
tc.change(f)
data, _, err := f.service().Download(context.Background(), 1)
if !errors.Is(err, tc.want) || len(data) != 0 {
t.Fatalf("data = %d bytes, error = %v; want %v", len(data), err, tc.want)
}
})
}
}
func TestIssuanceSheetPagination(t *testing.T) {
for _, tc := range []struct{ teams, clues int }{{0, 0}, {1, 1}, {40, 17}} {
t.Run(fmt.Sprintf("%d teams %d clues", tc.teams, tc.clues), func(t *testing.T) {
var teams []*repos.Team
var clues []*storytelling.Application
for i := range tc.teams {
teams = append(teams, &repos.Team{Name: fmt.Sprintf("Команда %d с длинным названием", i+1)})
}
for i := range tc.clues {
clues = append(clues, &storytelling.Application{Name: fmt.Sprintf("Улика %d с длинной подписью", i+1)})
}
data, err := issuancePDF("Дело №1", teams, clues)
if err != nil {
t.Fatal(err)
}
pages := bytes.Count(data, []byte("/Type /Page\n"))
if pages < 1 || (tc.clues > 8 && pages < 6) {
t.Fatalf("page count = %d", pages)
}
})
}
}
func extractedPDFText(t *testing.T, data []byte) string {
t.Helper()
command := exec.Command("pdftotext", "-", "-")
command.Stdin = bytes.NewReader(data)
text, err := command.CombinedOutput()
if err != nil {
t.Fatalf("extract PDF text: %v: %s", err, text)
}
return string(text)
}
func TestPrintedContents(t *testing.T) {
if _, err := exec.LookPath("pdftotext"); err != nil {
t.Skip("pdftotext is needed only for optional rendered-content checks")
}
for _, format := range []string{"offline", "online"} {
t.Run(format, func(t *testing.T) {
f := newPrintFixture()
f.game.Format = format
data, _, err := f.service().Download(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
entries := readPrintArchive(t, data)
for name, data := range entries {
if !strings.HasSuffix(name, ".pdf") || strings.Contains(name, "Улики/") {
continue
}
text := extractedPDFText(t, data)
if strings.HasPrefix(name, playerFolder) && !strings.Contains(strings.ToLower(text), strings.ToLower(strings.TrimSuffix(path.Base(name), ".pdf"))) {
t.Fatalf("missing heading in %s", name)
}
if strings.HasPrefix(name, playerFolder) && strings.Contains(text, "СЕКРЕТ") {
t.Fatalf("organizer secrets in %s", name)
}
if strings.Contains(name, "Ответы и развязка") && (!strings.Contains(text, "СЕКРЕТНЫЙ ОТВЕТ") || !strings.Contains(text, "СЕКРЕТНАЯ РАЗВЯЗКА")) {
t.Fatal("missing organizer secrets")
}
if strings.Contains(name, "QR команд") || strings.Contains(name, "Лист выдачи") {
if strings.Contains(text, "Отменённая") || !strings.Contains(text, "Детективы") || !strings.Contains(text, "Ожидающая") {
t.Fatalf("wrong participating teams in %s: %s", name, text)
}
}
if strings.Contains(name, "Правила") {
want := "правила онлайн-игры"
if format == "offline" {
want = "правила очной игры"
}
if !strings.Contains(text, want) || strings.Contains(text, "##") {
t.Fatalf("wrong printed rules: %s", text)
}
}
}
})
}
}
func TestPrintCluesPreserveOriginalFiles(t *testing.T) {
for _, extension := range []string{"pdf", "png", "webp", "mp3"} {
t.Run(extension, func(t *testing.T) {
f := newPrintFixture()
original := []byte("original bytes without conversion")
f.story.Places[0].Applications[0].Image = "https://files.test/file." + extension
f.files["file."+extension] = original
data, _, err := f.service().Download(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
entries := readPrintArchive(t, data)
if !bytes.Equal(entries[playerFolder+"Улики/001 - Письмо _ улика."+extension], original) {
t.Fatal("original bytes were changed")
}
})
}
}
func TestPrintHonorsCancellation(t *testing.T) {
for _, tc := range []struct {
name string
want error
}{{"cancelled", context.Canceled}} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err := newPrintFixture().service().Download(ctx, 1)
if !errors.Is(err, tc.want) {
t.Fatalf("error = %v, want %v", err, tc.want)
}
})
}
}
func TestPrintRejectsOversizedPackage(t *testing.T) {
for _, tc := range []struct {
name string
size int
}{{"original exceeds package budget", maxPackageSize}} {
t.Run(tc.name, func(t *testing.T) {
f := newPrintFixture()
f.files["clue.pdf"] = make([]byte, tc.size)
data, _, err := f.service().Download(context.Background(), 1)
if !errors.Is(err, ErrPackageTooLarge) || len(data) != 0 {
t.Fatalf("data = %d bytes, error = %v", len(data), err)
}
})
}
}
@@ -159,6 +159,14 @@ func (s *TeamService) DownloadTeamQR(ctx context.Context, id int) ([]byte, strin
if len(teams) == 0 {
return nil, "", ErrNoCards
}
return s.GenerateTeamQR(ctx, game, teams)
}
// GenerateTeamQR uses the provided teams so a print package shares one team list.
func (s *TeamService) GenerateTeamQR(ctx context.Context, game *repos.Game, teams []*repos.Team) ([]byte, string, error) {
if err := ctx.Err(); err != nil {
return nil, "", err
}
cards, filename, err := teamQRData(s.appBaseURL, game, teams)
if err != nil {
return nil, "", err