generated from VLADIMIR/template
add zip
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@ steps:
|
||||
- set GOOS=linux
|
||||
- set GOARCH=amd64
|
||||
- set CGO_ENABLED=0
|
||||
- go build -a -ldflags '-extldflags "-static"' -o evening_detective_server cmd/evening_detective_server/main.go
|
||||
- go build -a -ldflags '-extldflags "-static"' -o evening_detective_server ./cmd/evening_detective_server
|
||||
|
||||
- name: test
|
||||
image: golang
|
||||
|
||||
@@ -10,7 +10,7 @@ generate:
|
||||
./api/main.proto
|
||||
|
||||
run:
|
||||
go run ./cmd/evening_detective_server/main.go
|
||||
go run ./cmd/evening_detective_server
|
||||
|
||||
build-builder:
|
||||
docker build -f Dockerfile.builder -t my-go-builder .
|
||||
@@ -20,7 +20,7 @@ build-linux:
|
||||
-v "$$PWD":/app \
|
||||
-w /app \
|
||||
my-go-builder sh -c \
|
||||
"GOOS=linux GOARCH=arm64 go build -o bin/evening_detective_server cmd/evening_detective_server/main.go"
|
||||
"GOOS=linux GOARCH=arm64 go build -o bin/evening_detective_server ./cmd/evening_detective_server"
|
||||
|
||||
test:
|
||||
go test -count=1 ./...
|
||||
|
||||
@@ -365,6 +365,27 @@ service EveningDetectiveServer {
|
||||
};
|
||||
}
|
||||
|
||||
rpc DownloadScenarioArchive(DownloadScenarioArchiveReq) returns (google.api.HttpBody) {
|
||||
option (google.api.http) = {
|
||||
get: "/api/scenarios/{id}/archive"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags : "Сценарии";
|
||||
summary: "Скачать сценарий архивом (со всеми материалами и картинками)";
|
||||
};
|
||||
}
|
||||
|
||||
rpc UploadScenarioArchive(google.api.HttpBody) returns (UploadScenarioArchiveRsp) {
|
||||
option (google.api.http) = {
|
||||
post: "/api/scenarios/archive"
|
||||
body: "*"
|
||||
};
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags : "Сценарии";
|
||||
summary: "Создать сценарий из архива";
|
||||
};
|
||||
}
|
||||
|
||||
rpc AddGame(AddGameReq) returns (AddGameRsp) {
|
||||
option (google.api.http) = {
|
||||
post: "/api/games"
|
||||
@@ -847,6 +868,15 @@ message DeleteScenarioPlaceRsp {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message DownloadScenarioArchiveReq {
|
||||
int32 id = 1;
|
||||
}
|
||||
|
||||
message UploadScenarioArchiveRsp {
|
||||
string error = 1;
|
||||
int32 id = 2;
|
||||
}
|
||||
|
||||
message AddGameReq {
|
||||
string name = 1;
|
||||
string description = 2;
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
proto "evening_detective_server/proto"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// archiveStub — минимальный gRPC-сервер, реализующий только архивные RPC.
|
||||
type archiveStub struct {
|
||||
proto.UnimplementedEveningDetectiveServerServer
|
||||
|
||||
mu sync.Mutex
|
||||
uploaded []byte
|
||||
}
|
||||
|
||||
func (s *archiveStub) UploadScenarioArchive(_ context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.uploaded = append([]byte(nil), req.Data...)
|
||||
return &proto.UploadScenarioArchiveRsp{Id: 42}, nil
|
||||
}
|
||||
|
||||
func (s *archiveStub) DownloadScenarioArchive(ctx context.Context, _ *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
|
||||
_ = grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", `attachment; filename="test.zip"`))
|
||||
return &httpbody.HttpBody{
|
||||
Data: []byte("zip-data"),
|
||||
ContentType: "application/zip",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *archiveStub) getUploaded() []byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]byte(nil), s.uploaded...)
|
||||
}
|
||||
|
||||
// newTestGateway поднимает gRPC-сервер со stub и grpc-gateway с теми же
|
||||
// опциями, что и в main.go (outgoing matcher + rawBodyMarshaler).
|
||||
func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
gs := grpc.NewServer()
|
||||
proto.RegisterEveningDetectiveServerServer(gs, stub)
|
||||
go func() { _ = gs.Serve(lis) }()
|
||||
t.Cleanup(gs.Stop)
|
||||
|
||||
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
mux := runtime.NewServeMux(
|
||||
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
|
||||
if strings.EqualFold(key, "Content-Disposition") {
|
||||
return "Content-Disposition", true
|
||||
}
|
||||
return runtime.DefaultHeaderMatcher(key)
|
||||
}),
|
||||
runtime.WithMarshalerOption("application/zip", &rawBodyMarshaler{
|
||||
Marshaler: &runtime.HTTPBodyMarshaler{Marshaler: &runtime.JSONPb{}},
|
||||
}),
|
||||
)
|
||||
if err := proto.RegisterEveningDetectiveServerHandler(context.Background(), mux, conn); err != nil {
|
||||
t.Fatalf("register gateway: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func TestGatewayUploadRawZip(t *testing.T) {
|
||||
stub := &archiveStub{}
|
||||
ts := newTestGateway(t, stub)
|
||||
|
||||
raw := []byte("PK\x03\x04raw-zip-bytes")
|
||||
resp, err := http.Post(ts.URL+"/api/scenarios/archive", "application/zip", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), `"id":42`) {
|
||||
t.Errorf("ответ = %s, want id 42", body)
|
||||
}
|
||||
if got := stub.getUploaded(); !bytes.Equal(got, raw) {
|
||||
t.Errorf("сервер получил %q, want %q", got, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
|
||||
stub := &archiveStub{}
|
||||
ts := newTestGateway(t, stub)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/scenarios/1/archive")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
|
||||
t.Errorf("Content-Type = %q, want application/zip", ct)
|
||||
}
|
||||
if cd := resp.Header.Get("Content-Disposition"); cd != `attachment; filename="test.zip"` {
|
||||
t.Errorf("Content-Disposition = %q", cd)
|
||||
}
|
||||
if string(body) != "zip-data" {
|
||||
t.Errorf("body = %q, want zip-data", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSExposesContentDisposition — фронт через fetch должен читать
|
||||
// Content-Disposition (имя файла архива) из ответа.
|
||||
func TestCORSExposesContentDisposition(t *testing.T) {
|
||||
h := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/scenarios/1/archive", nil))
|
||||
if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "Content-Disposition" {
|
||||
t.Errorf("Access-Control-Expose-Headers = %q, want Content-Disposition", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLimitArchiveUploadBody — тело загрузки архива ограничено на HTTP-слое
|
||||
// (защита от OOM), прочие маршруты не затронуты.
|
||||
func TestLimitArchiveUploadBody(t *testing.T) {
|
||||
h := limitArchiveUploadBody(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
t.Run("upload route rejected when over limit", func(t *testing.T) {
|
||||
big := bytes.Repeat([]byte("x"), scenario_archive.MaxArchiveSize()+1)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scenarios/archive", bytes.NewReader(big))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other routes not limited", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/files/upload", bytes.NewReader([]byte("small")))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/password_generator"
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos/actions_repo"
|
||||
"evening_detective_server/internal/repos/applications_repo"
|
||||
@@ -35,6 +36,7 @@ import (
|
||||
"github.com/joho/godotenv"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"github.com/swaggest/swgui/v5emb"
|
||||
)
|
||||
@@ -95,6 +97,7 @@ func main() {
|
||||
scenariosRepo,
|
||||
cleaner,
|
||||
os.Getenv("FILE_PREFIX_DOMAIN"),
|
||||
fileStorage,
|
||||
)
|
||||
gameRepo := games_repo.NewGamesRepo(dbpool)
|
||||
teamRepo := teams_repo.NewTeamsRepo(dbpool)
|
||||
@@ -178,6 +181,15 @@ func main() {
|
||||
// акцепте соглашений (ст. 9 152-ФЗ). X-Password — подтверждение паролем
|
||||
// при удалении аккаунта (не в query, чтобы не светить креденшел в URL
|
||||
// и логах).
|
||||
// Сырая загрузка архива (без base64) для распространённых Content-Type;
|
||||
// JSON-вариант {"data": "<base64>"} работает через application/json.
|
||||
rawBody := &rawBodyMarshaler{
|
||||
Marshaler: &runtime.HTTPBodyMarshaler{
|
||||
Marshaler: &runtime.JSONPb{
|
||||
UnmarshalOptions: protojson.UnmarshalOptions{DiscardUnknown: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
gwmux := runtime.NewServeMux(
|
||||
runtime.WithIncomingHeaderMatcher(func(key string) (string, bool) {
|
||||
switch {
|
||||
@@ -188,6 +200,16 @@ func main() {
|
||||
}
|
||||
return runtime.DefaultHeaderMatcher(key)
|
||||
}),
|
||||
// Проброс Content-Disposition от сервиса в HTTP-ответ.
|
||||
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
|
||||
if strings.EqualFold(key, "Content-Disposition") {
|
||||
return "Content-Disposition", true
|
||||
}
|
||||
return runtime.DefaultHeaderMatcher(key)
|
||||
}),
|
||||
runtime.WithMarshalerOption("application/zip", rawBody),
|
||||
runtime.WithMarshalerOption("application/octet-stream", rawBody),
|
||||
runtime.WithMarshalerOption("application/x-zip-compressed", rawBody),
|
||||
)
|
||||
// Register Greeter
|
||||
err = proto.RegisterEveningDetectiveServerHandler(context.Background(), gwmux, conn)
|
||||
@@ -203,7 +225,9 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(swaggerJSON)
|
||||
})
|
||||
mainMux.Handle("/api/", gwmux)
|
||||
// MaxBytesReader на HTTP-слое: gateway буферизует тело целиком, без лимита
|
||||
// проверки в хендлере не спасут от OOM.
|
||||
mainMux.Handle("/api/", limitArchiveUploadBody(gwmux))
|
||||
|
||||
gwServer := &http.Server{
|
||||
Addr: ":8090",
|
||||
@@ -220,9 +244,22 @@ func cors(h http.Handler) http.Handler {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, ResponseType, X-Id, X-Password")
|
||||
// Content-Disposition нужен фронту, чтобы прочитать имя файла архива.
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// limitArchiveUploadBody ограничивает тело запроса загрузки архива
|
||||
// (см. rawBodyDecoder: gateway читает тело в память целиком).
|
||||
func limitArchiveUploadBody(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/api/scenarios/archive" {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(scenario_archive.MaxArchiveSize()))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -735,6 +735,40 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/archive": {
|
||||
"post": {
|
||||
"summary": "Создать сценарий из архива",
|
||||
"operationId": "EveningDetectiveServer_UploadScenarioArchive",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/evening_detective_serverUploadScenarioArchiveRsp"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "body",
|
||||
"description": "Message that represents an arbitrary HTTP body. It should only be used for\npayload formats that can't be represented as JSON, such as raw binary or\nan HTML page.\n\n\nThis message can be used both in streaming and non-streaming API methods in\nthe request as well as the response.\n\nIt can be used as a top-level request field, which is convenient if one\nwants to extract parameters from either the URL or HTTP template into the\nrequest fields and also want access to the raw HTTP body.\n\nExample:\n\n message GetResourceRequest {\n // A unique request id.\n string request_id = 1;\n\n // The raw HTTP body is bound to this field.\n google.api.HttpBody http_body = 2;\n\n }\n\n service ResourceService {\n rpc GetResource(GetResourceRequest)\n returns (google.api.HttpBody);\n rpc UpdateResource(google.api.HttpBody)\n returns (google.protobuf.Empty);\n\n }\n\nExample with streaming methods:\n\n service CaldavService {\n rpc GetCalendar(stream google.api.HttpBody)\n returns (stream google.api.HttpBody);\n rpc UpdateCalendar(stream google.api.HttpBody)\n returns (stream google.api.HttpBody);\n\n }\n\nUse of this type only changes how the request and response bodies are\nhandled, all other features will continue to work unchanged.",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/apiHttpBody"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Сценарии"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/{id}": {
|
||||
"get": {
|
||||
"summary": "Получить сценарий по id",
|
||||
@@ -835,6 +869,38 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/{id}/archive": {
|
||||
"get": {
|
||||
"summary": "Скачать сценарий архивом (со всеми материалами и картинками)",
|
||||
"operationId": "EveningDetectiveServer_DownloadScenarioArchive",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/apiHttpBody"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Сценарии"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/scenarios/{id}/draft": {
|
||||
"put": {
|
||||
"summary": "Снять с публикации сценарий",
|
||||
@@ -2477,6 +2543,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverUploadScenarioArchiveRsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"evening_detective_serverUser": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
)
|
||||
|
||||
// rawBodyMarshaler — маршалер для бинарного тела (application/zip и др.):
|
||||
// запрос попадает в HttpBody.Data без base64-JSON, для остального делегирует
|
||||
// JSON-маршалеру (работает и {"data": "<base64>"}).
|
||||
type rawBodyMarshaler struct {
|
||||
runtime.Marshaler
|
||||
}
|
||||
|
||||
func (m *rawBodyMarshaler) NewDecoder(r io.Reader) runtime.Decoder {
|
||||
return rawBodyDecoder{
|
||||
Decoder: m.Marshaler.NewDecoder(r),
|
||||
r: r,
|
||||
}
|
||||
}
|
||||
|
||||
// rawBodyDecoder — Decoder: для *httpbody.HttpBody читает тело целиком в Data,
|
||||
// для protobuf-сообщений декодирует JSON как обычно.
|
||||
type rawBodyDecoder struct {
|
||||
runtime.Decoder
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (d rawBodyDecoder) Decode(v interface{}) error {
|
||||
if body, ok := v.(*httpbody.HttpBody); ok {
|
||||
data, err := io.ReadAll(d.r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body.Data = data
|
||||
return nil
|
||||
}
|
||||
return d.Decoder.Decode(v)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
proto "evening_detective_server/proto"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
)
|
||||
|
||||
func newTestRawBodyMarshaler() *rawBodyMarshaler {
|
||||
return &rawBodyMarshaler{
|
||||
Marshaler: &runtime.HTTPBodyMarshaler{
|
||||
Marshaler: &runtime.JSONPb{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawBodyDecoderDecodesHttpBody(t *testing.T) {
|
||||
m := newTestRawBodyMarshaler()
|
||||
raw := []byte("PK\x03\x04fake-zip-bytes")
|
||||
|
||||
protoReq := &httpbody.HttpBody{}
|
||||
if err := m.NewDecoder(bytes.NewReader(raw)).Decode(protoReq); err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if !bytes.Equal(protoReq.Data, raw) {
|
||||
t.Errorf("Data = %q, want %q", protoReq.Data, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawBodyMarshalerDelegatesToJSONForProtos(t *testing.T) {
|
||||
m := newTestRawBodyMarshaler()
|
||||
|
||||
// Для protobuf-сообщения (не HttpBody) декодирование идёт через JSON.
|
||||
req := &proto.EchoReq{}
|
||||
if err := m.NewDecoder(strings.NewReader(`{"text":"hi"}`)).Decode(req); err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if req.Text != "hi" {
|
||||
t.Errorf("Text = %q, want hi", req.Text)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
@@ -9,6 +11,9 @@ import (
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"evening_detective_server/internal/modules/roles"
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
"evening_detective_server/internal/modules/string_tools"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
"evening_detective_server/internal/services/file_service"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
"evening_detective_server/internal/services/scenarios_service"
|
||||
@@ -17,6 +22,7 @@ import (
|
||||
proto "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"
|
||||
@@ -571,6 +577,82 @@ func (s *server) DeleteScenarioPlace(ctx context.Context, req *proto.DeleteScena
|
||||
return &proto.DeleteScenarioPlaceRsp{}, nil
|
||||
}
|
||||
|
||||
// DownloadScenarioArchive отдаёт ZIP-архив сценария; ошибки — gRPC-статусами.
|
||||
func (s *server) DownloadScenarioArchive(ctx context.Context, req *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
data, name, err := s.scenarioService.DownloadArchive(
|
||||
ctx,
|
||||
int(req.Id),
|
||||
claims.UserID,
|
||||
roles.HasRole(claims, roles.Admin),
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, scenarios_repo.ErrScenarioNotFound):
|
||||
return nil, status.Errorf(codes.NotFound, "%v", err)
|
||||
case errors.Is(err, scenarios_service.ErrScenarioNotOwner):
|
||||
return nil, status.Errorf(codes.PermissionDenied, "%v", err)
|
||||
default:
|
||||
return nil, status.Errorf(codes.Internal, "%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя файла транслитерируется (заголовки HTTP — только ASCII) и
|
||||
// санитайзится от header injection; заголовок пробрасывается gateway.
|
||||
transliterated := sanitizeFilename(string_tools.Transliterate(name))
|
||||
if transliterated == "" {
|
||||
transliterated = "scenario"
|
||||
}
|
||||
filename := transliterated + ".zip"
|
||||
if err := grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to set response header: %v", err)
|
||||
}
|
||||
|
||||
return &httpbody.HttpBody{
|
||||
Data: data,
|
||||
ContentType: "application/zip",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadScenarioArchive создаёт сценарий из ZIP-архива. Тело — сырые байты;
|
||||
// JSON base64 тоже поддерживается.
|
||||
func (s *server) UploadScenarioArchive(ctx context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if req == nil || len(req.Data) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "пустое тело запроса")
|
||||
}
|
||||
if len(req.Data) > scenario_archive.MaxArchiveSize() {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "архив больше максимального размера (%d байт)", scenario_archive.MaxArchiveSize())
|
||||
}
|
||||
|
||||
id, err := s.scenarioService.UploadArchive(ctx, req.Data, claims.UserID)
|
||||
if err != nil {
|
||||
return &proto.UploadScenarioArchiveRsp{
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
return &proto.UploadScenarioArchiveRsp{
|
||||
Id: int32(id),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sanitizeFilename — защита от инъекции заголовков в Content-Disposition.
|
||||
func sanitizeFilename(name string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`"`, "_",
|
||||
"\r", "_",
|
||||
"\n", "_",
|
||||
)
|
||||
return replacer.Replace(name)
|
||||
}
|
||||
|
||||
func (s *server) AddGame(ctx context.Context, req *proto.AddGameReq) (*proto.AddGameRsp, error) {
|
||||
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
|
||||
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
|
||||
|
||||
@@ -11,4 +11,6 @@ type File struct {
|
||||
type IFileStorage interface {
|
||||
Put(ctx context.Context, file *File) error
|
||||
Get(ctx context.Context, filename string) (*File, error)
|
||||
Delete(ctx context.Context, filename string) error
|
||||
MimeType(filename string) string
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func NewRustFSStorage(
|
||||
}
|
||||
|
||||
func (s *storage) Put(ctx context.Context, file *File) error {
|
||||
mime := s.getMimeType(file.Name)
|
||||
mime := s.MimeType(file.Name)
|
||||
return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mime)
|
||||
}
|
||||
|
||||
@@ -59,11 +59,15 @@ func (s *storage) Get(ctx context.Context, filename string) (*File, error) {
|
||||
return &File{
|
||||
Name: filename,
|
||||
Data: data,
|
||||
Mime: s.getMimeType(filename),
|
||||
Mime: s.MimeType(filename),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *storage) getMimeType(filename string) string {
|
||||
func (s *storage) Delete(ctx context.Context, filename string) error {
|
||||
return s.client.DeleteObject(ctx, s.bucket, filename)
|
||||
}
|
||||
|
||||
func (s *storage) MimeType(filename string) string {
|
||||
if mimeType := mime.TypeByExtension(filepath.Ext(filename)); mimeType != "" {
|
||||
return mimeType
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// Пакет scenario_archive — сборка и разбор ZIP-архива сценария.
|
||||
// Архив: scenario.json (описание и история) + images/<имя> (файлы изображений).
|
||||
// Все пути внутри архива относительные.
|
||||
package scenario_archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
// Имя файла с описанием сценария внутри архива.
|
||||
const FileName = "scenario.json"
|
||||
|
||||
// Текущая версия формата scenario.json.
|
||||
const Version = 1
|
||||
|
||||
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
|
||||
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
|
||||
var (
|
||||
maxArchiveSize = 32 << 20 // 32 МБ — сырой размер архива
|
||||
maxUnpackedSize = 128 << 20 // 128 МБ — суммарный объём распакованного
|
||||
maxEntries = 500 // число записей
|
||||
maxEntrySize = 32 << 20 // 32 МБ — размер одной записи
|
||||
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
|
||||
)
|
||||
|
||||
// MaxArchiveSize — максимальный размер входящего архива (для проверок
|
||||
// в хендлере и на HTTP-слое).
|
||||
func MaxArchiveSize() int {
|
||||
return maxArchiveSize
|
||||
}
|
||||
|
||||
// ScenarioJSON — описание сценария в архиве; Story — в формате колонки
|
||||
// scenarios.scenario в БД.
|
||||
type ScenarioJSON struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Story *storytelling.Story `json:"story"`
|
||||
}
|
||||
|
||||
// Bundle — результат разбора входящего архива.
|
||||
type Bundle struct {
|
||||
Scenario *ScenarioJSON
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
// ImagePath возвращает путь изображения внутри архива (images/<имя>) и true
|
||||
// для ссылок нашего хранилища: относительное имя или URL с доменом (legacy).
|
||||
// Внешние URL и пустые ссылки — ("", false): в архив не кладутся.
|
||||
func ImagePath(ref, domain string) (string, bool) {
|
||||
ref = strings.TrimPrefix(ref, domain)
|
||||
if ref == "" {
|
||||
return "", false
|
||||
}
|
||||
low := strings.ToLower(ref)
|
||||
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
|
||||
return "", false
|
||||
}
|
||||
return "images/" + path.Base(ref), true
|
||||
}
|
||||
|
||||
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
|
||||
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
|
||||
func Pack(
|
||||
ctx context.Context,
|
||||
scenario *repos.Scenario,
|
||||
domain string,
|
||||
getFile func(ctx context.Context, name string) ([]byte, error),
|
||||
) ([]byte, error) {
|
||||
if scenario == nil {
|
||||
return nil, errors.New("сценарий не задан")
|
||||
}
|
||||
|
||||
story := &storytelling.Story{}
|
||||
if scenario.Scenario != "" {
|
||||
if err := json.Unmarshal([]byte(scenario.Scenario), story); err != nil {
|
||||
return nil, fmt.Errorf("не удалось разобрать историю сценария: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя в хранилище → путь в архиве; конфликт базовых имён — ошибка.
|
||||
archivePathOf := map[string]string{}
|
||||
addRef := func(ref string) error {
|
||||
if ref == "" {
|
||||
return nil
|
||||
}
|
||||
archivePath, ok := ImagePath(ref, domain)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if _, exists := archivePathOf[ref]; exists {
|
||||
return nil
|
||||
}
|
||||
for storageName, existingPath := range archivePathOf {
|
||||
if existingPath == archivePath {
|
||||
return fmt.Errorf("конфликт имён изображений в архиве: %q и %q", storageName, ref)
|
||||
}
|
||||
}
|
||||
archivePathOf[ref] = archivePath
|
||||
return nil
|
||||
}
|
||||
if scenario.Image != nil {
|
||||
if err := addRef(*scenario.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, place := range story.Places {
|
||||
if err := addRef(place.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, application := range place.Applications {
|
||||
if err := addRef(application.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем файлы из хранилища (имя = ссылка без домена), с суммарным лимитом.
|
||||
images := make(map[string][]byte, len(archivePathOf))
|
||||
var totalPacked int64
|
||||
for ref, archivePath := range archivePathOf {
|
||||
storageName := strings.TrimPrefix(ref, domain)
|
||||
data, err := getFile(ctx, storageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось получить изображение %q: %w", storageName, err)
|
||||
}
|
||||
totalPacked += int64(len(data))
|
||||
if totalPacked > int64(maxPackSize) {
|
||||
return nil, fmt.Errorf("суммарный объём изображений больше %d байт", maxPackSize)
|
||||
}
|
||||
images[archivePath] = data
|
||||
}
|
||||
|
||||
for _, place := range story.Places {
|
||||
place.Image = rewriteRef(place.Image, archivePathOf)
|
||||
for _, application := range place.Applications {
|
||||
application.Image = rewriteRef(application.Image, archivePathOf)
|
||||
}
|
||||
}
|
||||
|
||||
doc := ScenarioJSON{
|
||||
Version: Version,
|
||||
Name: scenario.Name,
|
||||
Description: strPtrValue(scenario.Description),
|
||||
Story: story,
|
||||
}
|
||||
if scenario.Image != nil {
|
||||
doc.Image = rewriteRef(*scenario.Image, archivePathOf)
|
||||
}
|
||||
docJSON, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось собрать %s: %w", FileName, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
if err := writeZipEntry(zw, FileName, docJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(images))
|
||||
for name := range images {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
if err := writeZipEntry(zw, name, images[name]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("не удалось закрыть архив: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
|
||||
header := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
}
|
||||
// Нулевое время — детерминизм архива между запусками.
|
||||
header.Modified = time.Time{}
|
||||
w, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось создать запись %q: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return fmt.Errorf("не удалось записать %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rewriteRef(ref string, archivePathOf map[string]string) string {
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
if archivePath, ok := archivePathOf[ref]; ok {
|
||||
return archivePath
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func strPtrValue(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||
func Unpack(data []byte) (*Bundle, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("архив пуст")
|
||||
}
|
||||
if len(data) > maxArchiveSize {
|
||||
return nil, fmt.Errorf("архив больше максимального размера (%d байт)", maxArchiveSize)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось открыть архив: %w", err)
|
||||
}
|
||||
if len(zr.File) > maxEntries {
|
||||
return nil, fmt.Errorf("в архиве больше %d записей", maxEntries)
|
||||
}
|
||||
|
||||
bundle := &Bundle{Files: make(map[string][]byte, len(zr.File))}
|
||||
var totalUnpacked int64
|
||||
var scenarioRaw []byte
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := validateEntryPath(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось открыть запись %q: %w", name, err)
|
||||
}
|
||||
content, err := readEntry(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("запись %q: %w", name, err)
|
||||
}
|
||||
|
||||
totalUnpacked += int64(len(content))
|
||||
if totalUnpacked > int64(maxUnpackedSize) {
|
||||
return nil, fmt.Errorf("суммарный размер распакованного архива больше %d байт", maxUnpackedSize)
|
||||
}
|
||||
|
||||
// Дубли записей отвергаем — «последний победил» портит данные.
|
||||
if name == FileName {
|
||||
if scenarioRaw != nil {
|
||||
return nil, fmt.Errorf("в архиве несколько записей %s", FileName)
|
||||
}
|
||||
scenarioRaw = content
|
||||
continue
|
||||
}
|
||||
if _, exists := bundle.Files[name]; exists {
|
||||
return nil, fmt.Errorf("в архиве несколько записей %q", name)
|
||||
}
|
||||
bundle.Files[name] = content
|
||||
}
|
||||
|
||||
if scenarioRaw == nil {
|
||||
return nil, fmt.Errorf("в архиве нет файла %s", FileName)
|
||||
}
|
||||
|
||||
scenario := &ScenarioJSON{}
|
||||
if err := json.Unmarshal(scenarioRaw, scenario); err != nil {
|
||||
return nil, fmt.Errorf("не удалось разобрать %s: %w", FileName, err)
|
||||
}
|
||||
if scenario.Version != Version {
|
||||
return nil, fmt.Errorf("неподдерживаемая версия архива: %d", scenario.Version)
|
||||
}
|
||||
if scenario.Name == "" {
|
||||
return nil, errors.New("в сценарии не указано название")
|
||||
}
|
||||
if scenario.Story == nil {
|
||||
return nil, errors.New("в сценарии не указана история")
|
||||
}
|
||||
|
||||
bundle.Scenario = scenario
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// readEntry читает запись с лимитом по фактическим байтам (заголовки ZIP
|
||||
// подделываются тривиально).
|
||||
func readEntry(r io.Reader) ([]byte, error) {
|
||||
lr := io.LimitReader(r, int64(maxEntrySize)+1)
|
||||
content, err := io.ReadAll(lr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось прочитать: %w", err)
|
||||
}
|
||||
if int64(len(content)) > int64(maxEntrySize) {
|
||||
return nil, fmt.Errorf("размер больше максимального (%d байт)", maxEntrySize)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// validateEntryPath отклоняет небезопасные пути записей.
|
||||
func validateEntryPath(name string) error {
|
||||
if name == "" {
|
||||
return errors.New("пустое имя записи в архиве")
|
||||
}
|
||||
if strings.Contains(name, "\\") {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
if strings.HasPrefix(name, "/") {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".." {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
}
|
||||
if clean := path.Clean(name); clean != name {
|
||||
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package scenario_archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
const testDomain = "http://storage.test/api/files/"
|
||||
|
||||
// buildScenario собирает сценарий с историей и ссылками на изображения.
|
||||
func buildScenario() *repos.Scenario {
|
||||
description := "Детективная история"
|
||||
image := "cover.png"
|
||||
return &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Ночной клуб",
|
||||
Description: &description,
|
||||
Image: &image,
|
||||
Scenario: `{"places":[
|
||||
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
|
||||
{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
|
||||
]}`,
|
||||
}
|
||||
}
|
||||
|
||||
// memoryFiles — замена хранилища для Pack: имя → содержимое.
|
||||
type memoryFiles map[string][]byte
|
||||
|
||||
func (m memoryFiles) get(_ context.Context, name string) ([]byte, error) {
|
||||
data, ok := m[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file not found: %s", name)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func newMemoryFiles() memoryFiles {
|
||||
return memoryFiles{
|
||||
"cover.png": []byte("cover-bytes"),
|
||||
"club.png": []byte("club-bytes"),
|
||||
"ticket.jpg": []byte("ticket-bytes"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackUnpackRoundTrip(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
data, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
|
||||
bundle, err := Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
|
||||
if bundle.Scenario.Version != Version {
|
||||
t.Errorf("Version = %d, want %d", bundle.Scenario.Version, Version)
|
||||
}
|
||||
if bundle.Scenario.Name != "Ночной клуб" {
|
||||
t.Errorf("Name = %q, want %q", bundle.Scenario.Name, "Ночной клуб")
|
||||
}
|
||||
if bundle.Scenario.Description != "Детективная история" {
|
||||
t.Errorf("Description = %q", bundle.Scenario.Description)
|
||||
}
|
||||
if bundle.Scenario.Image != "images/cover.png" {
|
||||
t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
|
||||
}
|
||||
if bundle.Scenario.Story == nil || len(bundle.Scenario.Story.Places) != 2 {
|
||||
t.Fatalf("Story.Places = %+v, want 2 places", bundle.Scenario.Story)
|
||||
}
|
||||
|
||||
club := bundle.Scenario.Story.Places[0]
|
||||
if club.Image != "images/club.png" {
|
||||
t.Errorf("place image = %q, want %q", club.Image, "images/club.png")
|
||||
}
|
||||
if len(club.Applications) != 1 || club.Applications[0].Image != "images/ticket.jpg" {
|
||||
t.Errorf("application image = %+v, want images/ticket.jpg", club.Applications)
|
||||
}
|
||||
|
||||
for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
|
||||
if _, ok := bundle.Files[want]; !ok {
|
||||
t.Errorf("в архиве нет файла %q", want)
|
||||
}
|
||||
}
|
||||
// Дублирующаяся ссылка (club.png в двух точках) кладётся в архив один раз.
|
||||
if len(bundle.Files) != 3 {
|
||||
t.Errorf("Files = %v, want 3 files", bundle.Files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Внешний",
|
||||
Description: &description,
|
||||
Image: ptr("https://example.com/cover.png"),
|
||||
Scenario: `{"places":[
|
||||
{"code":"p","name":"P","text":"t","image":"http://other.example/x.png"}
|
||||
]}`,
|
||||
}
|
||||
|
||||
files := newMemoryFiles()
|
||||
var fetched []string
|
||||
getFile := func(ctx context.Context, name string) ([]byte, error) {
|
||||
fetched = append(fetched, name)
|
||||
return files.get(ctx, name)
|
||||
}
|
||||
|
||||
data, err := Pack(context.Background(), scenario, testDomain, getFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
if len(fetched) != 0 {
|
||||
t.Errorf("getFile вызван для внешних URL: %v", fetched)
|
||||
}
|
||||
|
||||
bundle, err := Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
if bundle.Scenario.Image != "https://example.com/cover.png" {
|
||||
t.Errorf("Image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Image)
|
||||
}
|
||||
if bundle.Scenario.Story.Places[0].Image != "http://other.example/x.png" {
|
||||
t.Errorf("place image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Story.Places[0].Image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackLegacyFullURLImage(t *testing.T) {
|
||||
// Legacy-данные: полный URL хранилища срезается до базового имени файла.
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Legacy",
|
||||
Description: &description,
|
||||
Image: ptr(testDomain + "cover.png"),
|
||||
Scenario: `{"places":[]}`,
|
||||
}
|
||||
data, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
bundle, err := Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
if bundle.Scenario.Image != "images/cover.png" {
|
||||
t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackMissingImage(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
delete(files, "ticket.jpg")
|
||||
|
||||
_, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err == nil {
|
||||
t.Fatal("Pack должен вернуть ошибку при недоступном изображении")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ticket.jpg") {
|
||||
t.Errorf("ошибка должна содержать имя файла: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackDeterministic(t *testing.T) {
|
||||
files := newMemoryFiles()
|
||||
first, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
second, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
|
||||
if err != nil {
|
||||
t.Fatalf("Pack: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Error("архив недетерминирован: повторный Pack дал другие байты")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackNilScenario(t *testing.T) {
|
||||
_, err := Pack(context.Background(), nil, testDomain, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Pack(nil) должен вернуть ошибку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackImageNameCollision(t *testing.T) {
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Конфликт",
|
||||
Description: &description,
|
||||
Scenario: `{"places":[
|
||||
{"code":"a","name":"A","text":"t","image":"a.png"},
|
||||
{"code":"b","name":"B","text":"t","image":"dir/a.png"}
|
||||
]}`,
|
||||
}
|
||||
_, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
|
||||
if err == nil {
|
||||
t.Fatal("Pack должен вернуть ошибку при конфликте имён изображений")
|
||||
}
|
||||
}
|
||||
|
||||
// writeZip собирает архив с заданными записями (для негативных тестов Unpack).
|
||||
func writeZip(t *testing.T, entries map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, content := range entries {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q): %v", name, err)
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
t.Fatalf("Write(%q): %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// validScenarioJSON — минимальный корректный scenario.json.
|
||||
func validScenarioJSON() []byte {
|
||||
b, _ := json.Marshal(ScenarioJSON{
|
||||
Version: Version,
|
||||
Name: "Тест",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
func TestUnpackRejectsBadPaths(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"../evil",
|
||||
"a/../../evil",
|
||||
"/abs",
|
||||
"dir\\evil",
|
||||
"a/../b",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/x.png": []byte("x"),
|
||||
name: []byte("evil"),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatalf("Unpack должен отклонить путь %q", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEmpty(t *testing.T) {
|
||||
if _, err := Unpack(nil); err == nil {
|
||||
t.Fatal("Unpack(nil) должен вернуть ошибку")
|
||||
}
|
||||
if _, err := Unpack([]byte{}); err == nil {
|
||||
t.Fatal("Unpack(пусто) должен вернуть ошибку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackNotAZip(t *testing.T) {
|
||||
if _, err := Unpack([]byte("this is not a zip")); err == nil {
|
||||
t.Fatal("Unpack должен отклонить не-zip данные")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackMissingScenarioJSON(t *testing.T) {
|
||||
data := writeZip(t, map[string][]byte{"images/x.png": []byte("x")})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен требовать scenario.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackInvalidScenarioJSON(t *testing.T) {
|
||||
data := writeZip(t, map[string][]byte{FileName: []byte("{not json")})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить битый scenario.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackBadVersion(t *testing.T) {
|
||||
b, _ := json.Marshal(ScenarioJSON{Version: 99, Name: "X", Story: &storytelling.Story{}})
|
||||
data := writeZip(t, map[string][]byte{FileName: b})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить неизвестную версию")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEmptyName(t *testing.T) {
|
||||
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "", Story: &storytelling.Story{}})
|
||||
data := writeZip(t, map[string][]byte{FileName: b})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен требовать название")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackNilStory(t *testing.T) {
|
||||
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "X"})
|
||||
data := writeZip(t, map[string][]byte{FileName: b})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен требовать story")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEntrySizeLimit(t *testing.T) {
|
||||
prev := maxEntrySize
|
||||
maxEntrySize = 4
|
||||
defer func() { maxEntrySize = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/big.png": bytes.Repeat([]byte("x"), 8),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить запись больше maxEntrySize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackTotalSizeLimit(t *testing.T) {
|
||||
prev := maxUnpackedSize
|
||||
maxUnpackedSize = 6
|
||||
defer func() { maxUnpackedSize = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/a.png": []byte("aaa"),
|
||||
"images/b.png": []byte("bbb"),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить суммарный объём больше maxUnpackedSize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackArchiveSizeLimit(t *testing.T) {
|
||||
prev := maxArchiveSize
|
||||
maxArchiveSize = 4
|
||||
defer func() { maxArchiveSize = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{FileName: validScenarioJSON()})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить архив больше maxArchiveSize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackEntriesLimit(t *testing.T) {
|
||||
prev := maxEntries
|
||||
maxEntries = 2
|
||||
defer func() { maxEntries = prev }()
|
||||
|
||||
data := writeZip(t, map[string][]byte{
|
||||
FileName: validScenarioJSON(),
|
||||
"images/a.png": []byte("a"),
|
||||
"images/b.png": []byte("b"),
|
||||
})
|
||||
if _, err := Unpack(data); err == nil {
|
||||
t.Fatal("Unpack должен отклонить архив с числом записей больше maxEntries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackRejectsDuplicateScenarioJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for i := 0; i < 2; i++ {
|
||||
w, err := zw.Create(FileName)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := w.Write(validScenarioJSON()); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Unpack(buf.Bytes()); err == nil {
|
||||
t.Fatal("Unpack должен отклонить дубли scenario.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackRejectsDuplicateFiles(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for i := 0; i < 3; i++ {
|
||||
name := FileName
|
||||
content := validScenarioJSON()
|
||||
switch i {
|
||||
case 1:
|
||||
name = "images/x.png"
|
||||
content = []byte("x")
|
||||
case 2:
|
||||
// Дубль записи images/x.png.
|
||||
name = "images/x.png"
|
||||
content = []byte("y")
|
||||
}
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Unpack(buf.Bytes()); err == nil {
|
||||
t.Fatal("Unpack должен отклонить дубли файлов")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackTotalSizeLimit(t *testing.T) {
|
||||
prev := maxPackSize
|
||||
maxPackSize = 8
|
||||
defer func() { maxPackSize = prev }()
|
||||
|
||||
description := "desc"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Большой",
|
||||
Description: &description,
|
||||
Scenario: `{"places":[
|
||||
{"code":"a","name":"A","text":"t","image":"a.png"},
|
||||
{"code":"b","name":"B","text":"t","image":"b.png"}
|
||||
]}`,
|
||||
}
|
||||
files := memoryFiles{
|
||||
"a.png": bytes.Repeat([]byte("x"), 6),
|
||||
"b.png": bytes.Repeat([]byte("y"), 6),
|
||||
}
|
||||
if _, err := Pack(context.Background(), scenario, testDomain, files.get); err == nil {
|
||||
t.Fatal("Pack должен отклонить суммарный объём больше maxPackSize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
ref string
|
||||
domain string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"cover.png", "", "images/cover.png", true},
|
||||
{"dir/cover.png", "", "images/cover.png", true},
|
||||
{testDomain + "cover.png", testDomain, "images/cover.png", true},
|
||||
{"", "", "", false},
|
||||
{"http://example.com/x.png", "", "", false},
|
||||
{"https://example.com/x.png", testDomain, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := ImagePath(tc.ref, tc.domain)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Errorf("ImagePath(%q, %q) = (%q, %v), want (%q, %v)",
|
||||
tc.ref, tc.domain, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
@@ -2,28 +2,51 @@ package scenarios_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"evening_detective_server/internal/modules/cleaner"
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scenariosRepository — граница доступа к данным сценариев, реализуемая
|
||||
// *scenarios_repo.ScenariosRepo (интерфейс — для тестов без БД).
|
||||
type scenariosRepository interface {
|
||||
AddScenario(ctx context.Context, name string, authorId int) (int, error)
|
||||
GetScenariosByAuthorID(ctx context.Context, authorId int) ([]*repos.Scenario, error)
|
||||
GetScenariosByStatus(ctx context.Context, status string) ([]*repos.Scenario, error)
|
||||
GetScenarioByID(ctx context.Context, id int) (*repos.Scenario, error)
|
||||
UpdateScenarioByID(ctx context.Context, id int, name string, description string, image string) error
|
||||
UpdateScenarioStatusByID(ctx context.Context, id int, status string, allowPublished bool) (bool, error)
|
||||
DeleteScenarioByID(ctx context.Context, id int, allowPublished bool) (bool, error)
|
||||
GetStoryByScenarioID(ctx context.Context, id int) (string, error)
|
||||
UpdateStoryByScenarioID(ctx context.Context, id int, story string) error
|
||||
}
|
||||
|
||||
type ScenarioService struct {
|
||||
scenariosRepo *scenarios_repo.ScenariosRepo
|
||||
scenariosRepo scenariosRepository
|
||||
cleaner cleaner.ICleaner
|
||||
fileStorage file_storage.IFileStorage
|
||||
domain string
|
||||
}
|
||||
|
||||
func NewScenarioService(
|
||||
scenariosRepo *scenarios_repo.ScenariosRepo,
|
||||
scenariosRepo scenariosRepository,
|
||||
cleaner cleaner.ICleaner,
|
||||
domain string,
|
||||
fileStorage file_storage.IFileStorage,
|
||||
) *ScenarioService {
|
||||
return &ScenarioService{
|
||||
scenariosRepo: scenariosRepo,
|
||||
cleaner: cleaner,
|
||||
fileStorage: fileStorage,
|
||||
domain: domain,
|
||||
}
|
||||
}
|
||||
@@ -260,14 +283,160 @@ func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.S
|
||||
return mapStory(storyString, s.domain)
|
||||
}
|
||||
|
||||
// DownloadArchive собирает ZIP-архив сценария
|
||||
func (s *ScenarioService) DownloadArchive(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
actorId int,
|
||||
isAdmin bool,
|
||||
) ([]byte, string, error) {
|
||||
scenario, err := s.getScenarioForChange(ctx, id, actorId, isAdmin)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data, err := scenario_archive.Pack(
|
||||
ctx,
|
||||
scenario,
|
||||
s.domain,
|
||||
func(ctx context.Context, name string) ([]byte, error) {
|
||||
file, err := s.fileStorage.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file.Data, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, scenario.Name, nil
|
||||
}
|
||||
|
||||
// UploadArchive импортирует сценарий из ZIP-архива: изображения сохраняются
|
||||
// под новыми случайными именами (не перетирают чужие файлы), ссылки
|
||||
// переписываются. Новый сценарий всегда draft — статус из архива игнорируется.
|
||||
func (s *ScenarioService) UploadArchive(
|
||||
ctx context.Context,
|
||||
data []byte,
|
||||
authorId int,
|
||||
) (int, error) {
|
||||
bundle, err := scenario_archive.Unpack(data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
scenario := bundle.Scenario
|
||||
|
||||
// При ошибке удаляем загруженные файлы (best-effort) — без orphan-объектов.
|
||||
uploaded := map[string]string{}
|
||||
ok := false
|
||||
defer func() {
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
for _, name := range uploaded {
|
||||
_ = s.fileStorage.Delete(ctx, name)
|
||||
}
|
||||
}()
|
||||
|
||||
rewrite := func(ref string) (string, error) {
|
||||
// Берём только ссылки images/... из архива; остальное — как есть.
|
||||
archiveRef := strings.TrimPrefix(ref, s.domain)
|
||||
if !strings.HasPrefix(archiveRef, "images/") {
|
||||
return ref, nil
|
||||
}
|
||||
if name, exists := uploaded[archiveRef]; exists {
|
||||
return name, nil
|
||||
}
|
||||
content, exists := bundle.Files[archiveRef]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("изображение %q не найдено в архиве", archiveRef)
|
||||
}
|
||||
name, err := newStorageName(archiveRef)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.fileStorage.Put(ctx, &file_storage.File{
|
||||
Name: name,
|
||||
Data: content,
|
||||
Mime: s.fileStorage.MimeType(name),
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("не удалось сохранить изображение %q: %w", archiveRef, err)
|
||||
}
|
||||
uploaded[archiveRef] = name
|
||||
return name, nil
|
||||
}
|
||||
|
||||
image, err := rewrite(scenario.Image)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, place := range scenario.Story.Places {
|
||||
place.Image, err = rewrite(place.Image)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, application := range place.Applications {
|
||||
application.Image, err = rewrite(application.Image)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
storyJSON, err := normalizeStory(scenario.Story)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
id, err := s.scenariosRepo.AddScenario(ctx, scenario.Name, authorId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := s.scenariosRepo.UpdateScenarioByID(ctx, id, scenario.Name, scenario.Description, image); err != nil {
|
||||
// Зачищаем строку сценария (best-effort), как и файлы.
|
||||
_, _ = s.scenariosRepo.DeleteScenarioByID(ctx, id, true)
|
||||
return 0, err
|
||||
}
|
||||
if err := s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyJSON); err != nil {
|
||||
_, _ = s.scenariosRepo.DeleteScenarioByID(ctx, id, true)
|
||||
return 0, err
|
||||
}
|
||||
ok = true
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// newStorageName — случайное имя в хранилище (hex + расширение): исключает
|
||||
// коллизии с файлами других сценариев в общем бакете.
|
||||
func newStorageName(archivePath string) (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("не удалось сгенерировать имя файла: %w", err)
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(archivePath))
|
||||
return hex.EncodeToString(b[:]) + ext, nil
|
||||
}
|
||||
|
||||
func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storytelling.Story) error {
|
||||
mapCodes := map[string]struct{}{}
|
||||
for _, place := range story.Places {
|
||||
mapCodes[place.Code] = struct{}{}
|
||||
place.Image = strings.TrimPrefix(place.Image, s.domain)
|
||||
}
|
||||
if len(mapCodes) != len(story.Places) {
|
||||
return errors.New("Такой код точки уже существует")
|
||||
storyString, err := normalizeStory(story)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
|
||||
}
|
||||
|
||||
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды
|
||||
// и возвращает JSON истории. Общая для редактирования и импорта.
|
||||
func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
codes := map[string]struct{}{}
|
||||
for _, place := range story.Places {
|
||||
codes[place.Code] = struct{}{}
|
||||
}
|
||||
if len(codes) != len(story.Places) {
|
||||
return "", errors.New("Такой код точки уже существует")
|
||||
}
|
||||
|
||||
cleanPlaces := make([]*storytelling.Place, 0, len(story.Places))
|
||||
@@ -279,10 +448,5 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt
|
||||
}
|
||||
story.Places = cleanPlaces
|
||||
|
||||
storyString, err := convertStory(story)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
|
||||
return convertStory(story)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
package scenarios_service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/modules/scenario_archive"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/scenarios_repo"
|
||||
)
|
||||
|
||||
const testDomain = "http://storage.test/api/files/"
|
||||
|
||||
// fakeStorage — in-memory реализация IFileStorage.
|
||||
type fakeStorage struct {
|
||||
mu sync.Mutex
|
||||
files map[string]*file_storage.File
|
||||
getErr error
|
||||
}
|
||||
|
||||
func newFakeStorage() *fakeStorage {
|
||||
return &fakeStorage{files: map[string]*file_storage.File{}}
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cp := *f
|
||||
cp.Data = append([]byte(nil), f.Data...)
|
||||
s.files[f.Name] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Get(_ context.Context, name string) (*file_storage.File, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.getErr != nil {
|
||||
return nil, s.getErr
|
||||
}
|
||||
f, ok := s.files[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file not found: %s", name)
|
||||
}
|
||||
cp := *f
|
||||
cp.Data = append([]byte(nil), f.Data...)
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) Delete(_ context.Context, name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.files, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStorage) MimeType(filename string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *fakeStorage) names() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
res := make([]string, 0, len(s.files))
|
||||
for name := range s.files {
|
||||
res = append(res, name)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// fakeScenariosRepo — минимальная in-memory реализация scenariosRepository.
|
||||
type fakeScenariosRepo struct {
|
||||
mu sync.Mutex
|
||||
byID map[int]*repos.Scenario
|
||||
nextID int
|
||||
addErr error
|
||||
updateErr error
|
||||
storyErr error
|
||||
}
|
||||
|
||||
func newFakeScenariosRepo() *fakeScenariosRepo {
|
||||
return &fakeScenariosRepo{
|
||||
byID: map[int]*repos.Scenario{},
|
||||
nextID: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) AddScenario(_ context.Context, name string, authorId int) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.addErr != nil {
|
||||
return 0, r.addErr
|
||||
}
|
||||
r.nextID++
|
||||
description := ""
|
||||
r.byID[r.nextID] = &repos.Scenario{
|
||||
ID: r.nextID,
|
||||
Name: name,
|
||||
Description: &description,
|
||||
Author: &repos.User{ID: authorId},
|
||||
Status: "draft",
|
||||
}
|
||||
return r.nextID, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetScenariosByAuthorID(_ context.Context, authorId int) ([]*repos.Scenario, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var res []*repos.Scenario
|
||||
for _, s := range r.byID {
|
||||
if s.Author != nil && s.Author.ID == authorId {
|
||||
res = append(res, s)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetScenariosByStatus(_ context.Context, status string) ([]*repos.Scenario, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var res []*repos.Scenario
|
||||
for _, s := range r.byID {
|
||||
if s.Status == status {
|
||||
res = append(res, s)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetScenarioByID(_ context.Context, id int) (*repos.Scenario, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return nil, scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) UpdateScenarioByID(_ context.Context, id int, name string, description string, image string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.updateErr != nil {
|
||||
return r.updateErr
|
||||
}
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
s.Name = name
|
||||
s.Description = &description
|
||||
s.Image = &image
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) UpdateScenarioStatusByID(_ context.Context, id int, status string, allowPublished bool) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return false, scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
if !allowPublished && s.Status == "public" {
|
||||
return false, nil
|
||||
}
|
||||
s.Status = status
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) DeleteScenarioByID(_ context.Context, id int, allowPublished bool) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return false, scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
if !allowPublished && s.Status == "public" {
|
||||
return false, nil
|
||||
}
|
||||
s.IsDeleted = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) GetStoryByScenarioID(_ context.Context, id int) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.storyErr != nil {
|
||||
return "", r.storyErr
|
||||
}
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return "", scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
return s.Scenario, nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) UpdateStoryByScenarioID(_ context.Context, id int, story string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.byID[id]
|
||||
if !ok {
|
||||
return scenarios_repo.ErrScenarioNotFound
|
||||
}
|
||||
s.Scenario = story
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeScenariosRepo) get(id int) *repos.Scenario {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.byID[id]
|
||||
}
|
||||
|
||||
func newTestService(repo *fakeScenariosRepo, storage *fakeStorage) *ScenarioService {
|
||||
if repo == nil {
|
||||
repo = newFakeScenariosRepo()
|
||||
}
|
||||
if storage == nil {
|
||||
storage = newFakeStorage()
|
||||
}
|
||||
return NewScenarioService(repo, nil, testDomain, storage)
|
||||
}
|
||||
|
||||
// seedScenario кладёт в repo сценарий с историей и изображениями в storage.
|
||||
func seedScenario(t *testing.T, repo *fakeScenariosRepo, storage *fakeStorage) *repos.Scenario {
|
||||
t.Helper()
|
||||
description := "Детективная история"
|
||||
image := "cover.png"
|
||||
scenario := &repos.Scenario{
|
||||
ID: 1,
|
||||
Name: "Ночной клуб",
|
||||
Description: &description,
|
||||
Image: &image,
|
||||
Author: &repos.User{ID: 7},
|
||||
Status: "draft",
|
||||
Scenario: `{"places":[
|
||||
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
|
||||
{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
|
||||
]}`,
|
||||
}
|
||||
repo.byID[scenario.ID] = scenario
|
||||
_ = storage.Put(context.Background(), &file_storage.File{Name: "cover.png", Data: []byte("cover-bytes")})
|
||||
_ = storage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")})
|
||||
_ = storage.Put(context.Background(), &file_storage.File{Name: "ticket.jpg", Data: []byte("ticket-bytes")})
|
||||
return scenario
|
||||
}
|
||||
|
||||
func TestUploadArchive(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
srcStorage := newFakeStorage()
|
||||
seedScenario(t, repo, srcStorage)
|
||||
|
||||
// Собираем архив как при скачивании (Pack через storage).
|
||||
svc := newTestService(repo, srcStorage)
|
||||
archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadArchive: %v", err)
|
||||
}
|
||||
|
||||
// Импортируем в «чистые» repo и storage.
|
||||
importRepo := newFakeScenariosRepo()
|
||||
importStorage := newFakeStorage()
|
||||
importSvc := newTestService(importRepo, importStorage)
|
||||
|
||||
id, err := importSvc.UploadArchive(context.Background(), archive, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArchive: %v", err)
|
||||
}
|
||||
if id != 1 {
|
||||
t.Errorf("id = %d, want 1", id)
|
||||
}
|
||||
|
||||
saved := importRepo.get(id)
|
||||
if saved == nil {
|
||||
t.Fatal("сценарий не создан")
|
||||
}
|
||||
if saved.Name != "Ночной клуб" {
|
||||
t.Errorf("Name = %q", saved.Name)
|
||||
}
|
||||
if saved.Description == nil || *saved.Description != "Детективная история" {
|
||||
t.Errorf("Description = %v", saved.Description)
|
||||
}
|
||||
if saved.Status != "draft" {
|
||||
t.Errorf("Status = %q, want draft", saved.Status)
|
||||
}
|
||||
if saved.Author == nil || saved.Author.ID != 42 {
|
||||
t.Errorf("Author = %+v, want id 42", saved.Author)
|
||||
}
|
||||
|
||||
// Ссылки переписаны на новые имена без префикса images/.
|
||||
if saved.Image == nil || strings.HasPrefix(*saved.Image, "images/") || *saved.Image == "cover.png" {
|
||||
t.Errorf("Image = %v, want новое случайное имя без images/", saved.Image)
|
||||
}
|
||||
story := &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(saved.Scenario), story); err != nil {
|
||||
t.Fatalf("story не разобрался: %v", err)
|
||||
}
|
||||
if len(story.Places) != 2 {
|
||||
t.Fatalf("places = %d, want 2", len(story.Places))
|
||||
}
|
||||
club := story.Places[0]
|
||||
if club.Image == "" || strings.HasPrefix(club.Image, "images/") || club.Image == "club.png" {
|
||||
t.Errorf("place image = %q, want новое имя", club.Image)
|
||||
}
|
||||
if len(club.Applications) != 1 || strings.HasPrefix(club.Applications[0].Image, "images/") {
|
||||
t.Errorf("application image = %+v, want новое имя", club.Applications)
|
||||
}
|
||||
// Одна и та же картинка в двух точках — одно новое имя.
|
||||
if story.Places[1].Image != club.Image {
|
||||
t.Errorf("дублирующаяся картинка переписана по-разному: %q и %q", story.Places[1].Image, club.Image)
|
||||
}
|
||||
|
||||
// Файлы загружены в storage под новыми именами с правильным содержимым.
|
||||
files := importStorage.names()
|
||||
if len(files) != 3 {
|
||||
t.Fatalf("storage files = %v, want 3", files)
|
||||
}
|
||||
contents := map[string]bool{}
|
||||
for _, name := range files {
|
||||
file, err := importStorage.Get(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
}
|
||||
contents[string(file.Data)] = true
|
||||
}
|
||||
for _, want := range []string{"cover-bytes", "club-bytes", "ticket-bytes"} {
|
||||
if !contents[want] {
|
||||
t.Errorf("в storage нет содержимого %q, есть: %v", want, contents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveMissingImage(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Архив ссылается на images/missing.png, файла в архиве нет.
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "images/missing.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
}),
|
||||
})
|
||||
|
||||
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
|
||||
t.Fatal("UploadArchive должен вернуть ошибку при отсутствующем файле")
|
||||
}
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("storage не должен содержать файлов: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveExternalURLKept(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "https://example.com/cover.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P", Image: "http://other.example/x.png"}}},
|
||||
}),
|
||||
})
|
||||
|
||||
id, err := svc.UploadArchive(context.Background(), archive, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadArchive: %v", err)
|
||||
}
|
||||
saved := repo.get(id)
|
||||
if saved.Image == nil || *saved.Image != "https://example.com/cover.png" {
|
||||
t.Errorf("Image = %v, внешний URL должен остаться без изменений", saved.Image)
|
||||
}
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("storage не должен содержать файлов: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveCleanupOnRepoError(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
repo.addErr = errors.New("db is down")
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "images/x.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
}),
|
||||
"images/x.png": []byte("x"),
|
||||
})
|
||||
|
||||
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
|
||||
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
|
||||
}
|
||||
// Загруженные файлы удалены (best-effort), orphan-объектов не осталось.
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("после ошибки БД storage должен быть пуст: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArchiveDeletesRowOnPartialFailure(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
repo.updateErr = errors.New("db is down")
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
Name: "Тест",
|
||||
Image: "images/x.png",
|
||||
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
|
||||
}),
|
||||
"images/x.png": []byte("x"),
|
||||
})
|
||||
|
||||
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
|
||||
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
|
||||
}
|
||||
// Строка помечена удалённой (best-effort, soft delete как в проде), файлы зачищены.
|
||||
if got := repo.get(1); got == nil || !got.IsDeleted {
|
||||
t.Errorf("строка сценария должна быть удалена после частичного сбоя, осталась: %+v", got)
|
||||
}
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("после частичного сбоя storage должен быть пуст: %v", storage.names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchive(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
data, name, err := svc.DownloadArchive(context.Background(), 1, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadArchive: %v", err)
|
||||
}
|
||||
if name != "Ночной клуб" {
|
||||
t.Errorf("name = %q", name)
|
||||
}
|
||||
|
||||
bundle, err := scenario_archive.Unpack(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Unpack: %v", err)
|
||||
}
|
||||
if bundle.Scenario.Name != "Ночной клуб" {
|
||||
t.Errorf("scenario name = %q", bundle.Scenario.Name)
|
||||
}
|
||||
if bundle.Scenario.Image != "images/cover.png" {
|
||||
t.Errorf("scenario image = %q", bundle.Scenario.Image)
|
||||
}
|
||||
for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
|
||||
if _, ok := bundle.Files[want]; !ok {
|
||||
t.Errorf("в архиве нет %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchiveNotOwner(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, false); !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
// Админ может скачать чужой сценарий.
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, true); err != nil {
|
||||
t.Errorf("админ: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchiveNotFound(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 404, 1, true); !errors.Is(err, scenarios_repo.ErrScenarioNotFound) {
|
||||
t.Errorf("err = %v, want ErrScenarioNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArchiveMissingImage(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
seedScenario(t, repo, storage)
|
||||
_ = storage.Delete(context.Background(), "ticket.jpg")
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 1, 7, false); err == nil {
|
||||
t.Fatal("DownloadArchive должен вернуть ошибку при недоступном изображении")
|
||||
}
|
||||
}
|
||||
|
||||
// buildRawArchive собирает zip из произвольных записей (для тестов импорта).
|
||||
func buildRawArchive(t *testing.T, entries map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, content := range entries {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q): %v", name, err)
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
t.Fatalf("Write(%q): %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v scenario_archive.ScenarioJSON) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
+342
-231
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/utilities"
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/grpclog"
|
||||
@@ -1023,6 +1024,72 @@ func local_request_EveningDetectiveServer_DeleteScenarioPlace_0(ctx context.Cont
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_DownloadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq DownloadScenarioArchiveReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
msg, err := client.DownloadScenarioArchive(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_DownloadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq DownloadScenarioArchiveReq
|
||||
metadata runtime.ServerMetadata
|
||||
err error
|
||||
)
|
||||
val, ok := pathParams["id"]
|
||||
if !ok {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
|
||||
}
|
||||
protoReq.Id, err = runtime.Int32(val)
|
||||
if err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
|
||||
}
|
||||
msg, err := server.DownloadScenarioArchive(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_UploadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq httpbody.HttpBody
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
msg, err := client.UploadScenarioArchive(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_EveningDetectiveServer_UploadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq httpbody.HttpBody
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.UploadScenarioArchive(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_EveningDetectiveServer_AddGame_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq AddGameReq
|
||||
@@ -2318,6 +2385,46 @@ func RegisterEveningDetectiveServerHandlerServer(ctx context.Context, mux *runti
|
||||
}
|
||||
forward_EveningDetectiveServer_DeleteScenarioPlace_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_DownloadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/DownloadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/{id}/archive"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_UploadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/UploadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/archive"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_AddGame_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -3208,6 +3315,40 @@ func RegisterEveningDetectiveServerHandlerClient(ctx context.Context, mux *runti
|
||||
}
|
||||
forward_EveningDetectiveServer_DeleteScenarioPlace_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_DownloadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/DownloadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/{id}/archive"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_UploadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/UploadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/archive"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_AddGame_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -3531,6 +3672,8 @@ var (
|
||||
pattern_EveningDetectiveServer_AddScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "places"}, ""))
|
||||
pattern_EveningDetectiveServer_UpdateScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "scenarios", "id", "places", "code"}, ""))
|
||||
pattern_EveningDetectiveServer_DeleteScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "scenarios", "id", "places", "code"}, ""))
|
||||
pattern_EveningDetectiveServer_DownloadScenarioArchive_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "archive"}, ""))
|
||||
pattern_EveningDetectiveServer_UploadScenarioArchive_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "scenarios", "archive"}, ""))
|
||||
pattern_EveningDetectiveServer_AddGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "games"}, ""))
|
||||
pattern_EveningDetectiveServer_GetGames_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "games"}, ""))
|
||||
pattern_EveningDetectiveServer_GetGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "games", "id"}, ""))
|
||||
@@ -3581,6 +3724,8 @@ var (
|
||||
forward_EveningDetectiveServer_AddScenarioPlace_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_UpdateScenarioPlace_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_DeleteScenarioPlace_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_DownloadScenarioArchive_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_UploadScenarioArchive_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_AddGame_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_GetGames_0 = runtime.ForwardResponseMessage
|
||||
forward_EveningDetectiveServer_GetGame_0 = runtime.ForwardResponseMessage
|
||||
|
||||
@@ -50,6 +50,8 @@ const (
|
||||
EveningDetectiveServer_AddScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddScenarioPlace"
|
||||
EveningDetectiveServer_UpdateScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenarioPlace"
|
||||
EveningDetectiveServer_DeleteScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteScenarioPlace"
|
||||
EveningDetectiveServer_DownloadScenarioArchive_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DownloadScenarioArchive"
|
||||
EveningDetectiveServer_UploadScenarioArchive_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UploadScenarioArchive"
|
||||
EveningDetectiveServer_AddGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddGame"
|
||||
EveningDetectiveServer_GetGames_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetGames"
|
||||
EveningDetectiveServer_GetGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetGame"
|
||||
@@ -103,6 +105,8 @@ type EveningDetectiveServerClient interface {
|
||||
AddScenarioPlace(ctx context.Context, in *AddScenarioPlaceReq, opts ...grpc.CallOption) (*AddScenarioPlaceRsp, error)
|
||||
UpdateScenarioPlace(ctx context.Context, in *UpdateScenarioPlaceReq, opts ...grpc.CallOption) (*UpdateScenarioPlaceRsp, error)
|
||||
DeleteScenarioPlace(ctx context.Context, in *DeleteScenarioPlaceReq, opts ...grpc.CallOption) (*DeleteScenarioPlaceRsp, error)
|
||||
DownloadScenarioArchive(ctx context.Context, in *DownloadScenarioArchiveReq, opts ...grpc.CallOption) (*httpbody.HttpBody, error)
|
||||
UploadScenarioArchive(ctx context.Context, in *httpbody.HttpBody, opts ...grpc.CallOption) (*UploadScenarioArchiveRsp, error)
|
||||
AddGame(ctx context.Context, in *AddGameReq, opts ...grpc.CallOption) (*AddGameRsp, error)
|
||||
GetGames(ctx context.Context, in *GetGamesReq, opts ...grpc.CallOption) (*GetGamesRsp, error)
|
||||
GetGame(ctx context.Context, in *GetGameReq, opts ...grpc.CallOption) (*GetGameRsp, error)
|
||||
@@ -430,6 +434,26 @@ func (c *eveningDetectiveServerClient) DeleteScenarioPlace(ctx context.Context,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) DownloadScenarioArchive(ctx context.Context, in *DownloadScenarioArchiveReq, opts ...grpc.CallOption) (*httpbody.HttpBody, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(httpbody.HttpBody)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_DownloadScenarioArchive_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) UploadScenarioArchive(ctx context.Context, in *httpbody.HttpBody, opts ...grpc.CallOption) (*UploadScenarioArchiveRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UploadScenarioArchiveRsp)
|
||||
err := c.cc.Invoke(ctx, EveningDetectiveServer_UploadScenarioArchive_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *eveningDetectiveServerClient) AddGame(ctx context.Context, in *AddGameReq, opts ...grpc.CallOption) (*AddGameRsp, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AddGameRsp)
|
||||
@@ -634,6 +658,8 @@ type EveningDetectiveServerServer interface {
|
||||
AddScenarioPlace(context.Context, *AddScenarioPlaceReq) (*AddScenarioPlaceRsp, error)
|
||||
UpdateScenarioPlace(context.Context, *UpdateScenarioPlaceReq) (*UpdateScenarioPlaceRsp, error)
|
||||
DeleteScenarioPlace(context.Context, *DeleteScenarioPlaceReq) (*DeleteScenarioPlaceRsp, error)
|
||||
DownloadScenarioArchive(context.Context, *DownloadScenarioArchiveReq) (*httpbody.HttpBody, error)
|
||||
UploadScenarioArchive(context.Context, *httpbody.HttpBody) (*UploadScenarioArchiveRsp, error)
|
||||
AddGame(context.Context, *AddGameReq) (*AddGameRsp, error)
|
||||
GetGames(context.Context, *GetGamesReq) (*GetGamesRsp, error)
|
||||
GetGame(context.Context, *GetGameReq) (*GetGameRsp, error)
|
||||
@@ -751,6 +777,12 @@ func (UnimplementedEveningDetectiveServerServer) UpdateScenarioPlace(context.Con
|
||||
func (UnimplementedEveningDetectiveServerServer) DeleteScenarioPlace(context.Context, *DeleteScenarioPlaceReq) (*DeleteScenarioPlaceRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method DeleteScenarioPlace not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) DownloadScenarioArchive(context.Context, *DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method DownloadScenarioArchive not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) UploadScenarioArchive(context.Context, *httpbody.HttpBody) (*UploadScenarioArchiveRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UploadScenarioArchive not implemented")
|
||||
}
|
||||
func (UnimplementedEveningDetectiveServerServer) AddGame(context.Context, *AddGameReq) (*AddGameRsp, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AddGame not implemented")
|
||||
}
|
||||
@@ -1364,6 +1396,42 @@ func _EveningDetectiveServer_DeleteScenarioPlace_Handler(srv interface{}, ctx co
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_DownloadScenarioArchive_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DownloadScenarioArchiveReq)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).DownloadScenarioArchive(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_DownloadScenarioArchive_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).DownloadScenarioArchive(ctx, req.(*DownloadScenarioArchiveReq))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_UploadScenarioArchive_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(httpbody.HttpBody)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EveningDetectiveServerServer).UploadScenarioArchive(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EveningDetectiveServer_UploadScenarioArchive_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EveningDetectiveServerServer).UploadScenarioArchive(ctx, req.(*httpbody.HttpBody))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _EveningDetectiveServer_AddGame_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AddGameReq)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -1797,6 +1865,14 @@ var EveningDetectiveServer_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "DeleteScenarioPlace",
|
||||
Handler: _EveningDetectiveServer_DeleteScenarioPlace_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DownloadScenarioArchive",
|
||||
Handler: _EveningDetectiveServer_DownloadScenarioArchive_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UploadScenarioArchive",
|
||||
Handler: _EveningDetectiveServer_UploadScenarioArchive_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AddGame",
|
||||
Handler: _EveningDetectiveServer_AddGame_Handler,
|
||||
|
||||
Reference in New Issue
Block a user