package mynalog import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/google/uuid" ) // msk is the taxpayer's time zone in these tests. It matters: the offset a moment is rendered with // decides which tax month it lands in. var msk = time.FixedZone("MSK", 3*60*60) // capture records what the fake cabinet received, so a test can assert on the request the client // built. raw is kept alongside the decoded body because some assertions are about JSON *shape* — // notably that an amount is a number and not a string. type capture struct { method string path string query string bearer string raw string body map[string]any } // fakeCabinet serves one canned response and records the request, returning a client already // pointed at it. func fakeCabinet(t *testing.T, status int, response string) (*Client, *capture) { t.Helper() got := &capture{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got.method = r.Method got.path = r.URL.Path got.query = r.URL.RawQuery got.bearer = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if data, _ := io.ReadAll(r.Body); len(data) > 0 { got.raw = string(data) _ = json.Unmarshal(data, &got.body) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = io.WriteString(w, response) })) t.Cleanup(srv.Close) return NewClient(srv.URL, "device-1", srv.Client()), got } // liveSession is a session with a token that has not aged out. func liveSession() Session { return Session{Token: "access-1", RefreshToken: "refresh-1", INN: "770000000000", ExpiresAt: time.Now().Add(time.Hour)} } func TestAuthByPasswordBuildsTheRequest(t *testing.T) { c, got := fakeCabinet(t, http.StatusOK, `{ "token":"access-1","refreshToken":"refresh-1", "tokenExpireIn":"2026-07-28T15:04:05+03:00", "profile":{"inn":"770000000000"}}`) now := time.Date(2026, 7, 28, 14, 0, 0, 0, msk) sess, err := c.AuthByPassword(context.Background(), "770000000000", "hunter2", now) if err != nil { t.Fatalf("auth: %v", err) } if got.path != "/api/v1/auth/lkfl" { t.Fatalf("path = %q", got.path) } if got.body["username"] != "770000000000" || got.body["password"] != "hunter2" { t.Fatalf("credentials not sent: %v", got.body) } device, _ := got.body["deviceInfo"].(map[string]any) if device["sourceDeviceId"] != "device-1" || device["sourceType"] != "WEB" { t.Fatalf("deviceInfo = %v", device) } if sess.Token != "access-1" || sess.RefreshToken != "refresh-1" || sess.INN != "770000000000" { t.Fatalf("session = %+v", sess) } // The stated expiry, less the renewal slack. want := time.Date(2026, 7, 28, 15, 3, 5, 0, msk) if !sess.ExpiresAt.Equal(want) { t.Fatalf("expires = %v, want %v", sess.ExpiresAt, want) } } func TestAuthByPasswordFallsBackToTheLoginForTheINN(t *testing.T) { c, _ := fakeCabinet(t, http.StatusOK, `{"token":"access-1","refreshToken":"refresh-1"}`) now := time.Date(2026, 7, 28, 14, 0, 0, 0, msk) sess, err := c.AuthByPassword(context.Background(), "770000000000", "hunter2", now) if err != nil { t.Fatalf("auth: %v", err) } if sess.INN != "770000000000" { t.Fatalf("inn = %q", sess.INN) } if !sess.Valid(now) { t.Fatal("session should be valid right after authenticating") } } // A rejected password is a permanent failure, not an aged-out token: reporting it as ErrAuthExpired // would send the caller into a pointless refresh loop. func TestAuthByPasswordRejectionIsPermanent(t *testing.T) { c, _ := fakeCabinet(t, http.StatusUnauthorized, `{"message":"Неверный логин или пароль"}`) _, err := c.AuthByPassword(context.Background(), "770000000000", "wrong", time.Now()) if !errors.Is(err, ErrPermanent) { t.Fatalf("err = %v, want ErrPermanent", err) } if errors.Is(err, ErrAuthExpired) { t.Fatal("a bad password must not look like an expired token") } } func TestAuthByRefreshKeepsTheOldTokenWhenNotRotated(t *testing.T) { c, got := fakeCabinet(t, http.StatusOK, `{"token":"access-2"}`) sess, err := c.AuthByRefresh(context.Background(), "refresh-1", "770000000000", time.Now()) if err != nil { t.Fatalf("refresh: %v", err) } if got.path != "/api/v1/auth/token" || got.body["refreshToken"] != "refresh-1" { t.Fatalf("request = %s %s %v", got.method, got.path, got.body) } if sess.RefreshToken != "refresh-1" { t.Fatalf("refresh token = %q, want the supplied one carried forward", sess.RefreshToken) } } // The service sometimes issues a new refresh token; losing it would strand the next renewal. func TestAuthByRefreshSurfacesARotatedToken(t *testing.T) { c, _ := fakeCabinet(t, http.StatusOK, `{"token":"access-2","refreshToken":"refresh-2"}`) sess, err := c.AuthByRefresh(context.Background(), "refresh-1", "770000000000", time.Now()) if err != nil { t.Fatalf("refresh: %v", err) } if sess.RefreshToken != "refresh-2" { t.Fatalf("refresh token = %q, want the rotated one", sess.RefreshToken) } } func TestAuthByRefreshRejectionIsPermanent(t *testing.T) { c, _ := fakeCabinet(t, http.StatusUnauthorized, `{"message":"token is invalid"}`) _, err := c.AuthByRefresh(context.Background(), "stale", "770000000000", time.Now()) if !errors.Is(err, ErrPermanent) { t.Fatalf("err = %v, want ErrPermanent — only a fresh password login can recover", err) } } func TestAddIncomeBuildsTheRequest(t *testing.T) { c, got := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`) receipt, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{ Name: `Внутриигровая валюта: "Фишка", 100 шт. (ID: 0198c1f2)`, Amount: "149.50", OperationTime: time.Date(2026, 7, 28, 23, 30, 0, 0, msk), RequestTime: time.Date(2026, 7, 29, 0, 5, 0, 0, msk), }) if err != nil { t.Fatalf("add income: %v", err) } if receipt != "20abcdef01" { t.Fatalf("receipt = %q", receipt) } if got.path != "/api/v1/income" || got.bearer != "access-1" { t.Fatalf("request = %s %s bearer=%q", got.method, got.path, got.bearer) } if got.body["operationTime"] != "2026-07-28T23:30:00+03:00" { t.Fatalf("operationTime = %v — the offset decides the tax month", got.body["operationTime"]) } if got.body["requestTime"] != "2026-07-29T00:05:00+03:00" { t.Fatalf("requestTime = %v", got.body["requestTime"]) } if got.body["totalAmount"] != "149.50" { t.Fatalf("totalAmount = %v, want the exact decimal string", got.body["totalAmount"]) } if got.body["paymentType"] != paymentTypeCash { t.Fatalf("paymentType = %v", got.body["paymentType"]) } if got.body["ignoreMaxTotalIncomeRestriction"] != false { t.Fatal("the annual ceiling must stay enforced") } services, _ := got.body["services"].([]any) if len(services) != 1 { t.Fatalf("services = %v", got.body["services"]) } line, _ := services[0].(map[string]any) if line["quantity"] != float64(1) { t.Fatalf("quantity = %v", line["quantity"]) } payer, _ := got.body["client"].(map[string]any) if payer["incomeType"] != incomeTypeIndividual || payer["inn"] != nil { t.Fatalf("client = %v — a private buyer carries no INN", payer) } } // The amount must reach the tax service as a JSON number carrying the exact decimal we hold, with // no float32/float64 round-trip anywhere in between. func TestAddIncomeSendsTheAmountAsANumber(t *testing.T) { c, got := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`) if _, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{ Name: "x", Amount: "149.50", OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk), }); err != nil { t.Fatalf("add income: %v", err) } if !strings.Contains(got.raw, `"amount":149.50`) { t.Fatalf("body = %s, want a bare JSON number for amount", got.raw) } } func TestAddIncomeRejectsAnAmountThatIsNotDecimal(t *testing.T) { c, _ := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`) for _, bad := range []string{"", "1,50", "1e2", "-5", "149.50 RUB"} { _, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{ Name: "x", Amount: bad, OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk), }) if !errors.Is(err, ErrPermanent) { t.Fatalf("amount %q: err = %v, want ErrPermanent", bad, err) } } } // A 200 with no receipt id leaves the income unannullable, so it counts as ambiguous and the caller // must probe rather than assume it failed. func TestAddIncomeWithoutAReceiptIsTransient(t *testing.T) { c, _ := fakeCabinet(t, http.StatusOK, `{}`) _, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{ Name: "x", Amount: "10", OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk), }) if !errors.Is(err, ErrTransient) { t.Fatalf("err = %v, want ErrTransient", err) } } func TestErrorClassification(t *testing.T) { cases := []struct { name string status int body string want error notWant error }{ {"expired token", http.StatusUnauthorized, `{"message":"unauthorized"}`, ErrAuthExpired, ErrPermanent}, {"throttled", http.StatusTooManyRequests, `{"message":"slow down"}`, ErrThrottled, ErrPermanent}, {"server fault", http.StatusBadGateway, `{}`, ErrTransient, ErrPermanent}, {"bad request", http.StatusBadRequest, `{"message":"bad operationTime"}`, ErrPermanent, ErrTransient}, {"annual ceiling", http.StatusBadRequest, `{"message":"Превышен лимит дохода"}`, ErrIncomeLimit, ErrTransient}, } // The ceiling refines a permanent rejection rather than replacing it: a caller that only knows // about ErrPermanent must still stop on it. if !errors.Is(ErrIncomeLimit, ErrPermanent) { t.Fatal("ErrIncomeLimit must subsume ErrPermanent") } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { c, _ := fakeCabinet(t, tc.status, tc.body) _, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{ Name: "x", Amount: "10", OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk), }) if !errors.Is(err, tc.want) { t.Fatalf("err = %v, want %v", err, tc.want) } if errors.Is(err, tc.notWant) { t.Fatalf("err = %v must not also be %v", err, tc.notWant) } var apiErr *APIError if !errors.As(err, &apiErr) || apiErr.StatusCode != tc.status { t.Fatalf("err does not carry the status: %v", err) } }) } } // A connection that never answers is indistinguishable from one whose request was applied, so it // has to be transient — the caller probes before retrying. func TestUnreachableServiceIsTransient(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) url := srv.URL srv.Close() c := NewClient(url, "device-1", &http.Client{Timeout: time.Second}) _, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{ Name: "x", Amount: "10", OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk), }) if !errors.Is(err, ErrTransient) { t.Fatalf("err = %v, want ErrTransient", err) } } func TestCancelIncomeBuildsTheRequest(t *testing.T) { c, got := fakeCabinet(t, http.StatusOK, `{}`) now := time.Date(2026, 8, 1, 10, 0, 0, 0, msk) if err := c.CancelIncome(context.Background(), liveSession(), "20abcdef01", now); err != nil { t.Fatalf("cancel: %v", err) } if got.path != "/api/v1/cancel" { t.Fatalf("path = %q", got.path) } if got.body["receiptUuid"] != "20abcdef01" { t.Fatalf("receiptUuid = %v", got.body["receiptUuid"]) } if got.body["comment"] != cancelReasonRefund { t.Fatalf("comment = %v, want the reason the service recognises", got.body["comment"]) } if got.body["operationTime"] != "2026-08-01T10:00:00+03:00" { t.Fatalf("operationTime = %v", got.body["operationTime"]) } } func TestFindIncomeMatchesTheExactName(t *testing.T) { c, got := fakeCabinet(t, http.StatusOK, `{"content":[ {"name":"другая покупка","approvedReceiptUuid":"other"}, {"name":"нужный чек","approvedReceiptUuid":"wanted"}]}`) from := time.Date(2026, 7, 28, 0, 0, 0, 0, msk) to := time.Date(2026, 7, 29, 0, 0, 0, 0, msk) uuid, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек", from, to) if err != nil { t.Fatalf("find: %v", err) } if !found || uuid != "wanted" { t.Fatalf("found = %v, uuid = %q", found, uuid) } if got.path != "/api/v1/incomes" { t.Fatalf("path = %q", got.path) } if !strings.Contains(got.query, "from=2026-07-28T00%3A00%3A00.000%2B03%3A00") { t.Fatalf("query = %q", got.query) } } // An annulled receipt must never satisfy the probe: annulling it again would fail, and treating it // as ours would hide an income that really is missing. func TestFindIncomeSkipsAnnulledReceipts(t *testing.T) { c, _ := fakeCabinet(t, http.StatusOK, `{"content":[ {"name":"нужный чек","approvedReceiptUuid":"dead","cancellationInfo":{"comment":"Возврат средств"}}]}`) _, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек", time.Now().In(msk).Add(-24*time.Hour), time.Now().In(msk)) if err != nil { t.Fatalf("find: %v", err) } if found { t.Fatal("an annulled receipt must not be reported as ours") } } func TestFindIncomeReportsAbsence(t *testing.T) { c, _ := fakeCabinet(t, http.StatusOK, `{"content":[]}`) _, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек", time.Now().In(msk).Add(-24*time.Hour), time.Now().In(msk)) if err != nil { t.Fatalf("find: %v", err) } if found { t.Fatal("found = true on an empty list") } } func TestDeviceIDForLoginIsStable(t *testing.T) { first := DeviceIDForLogin("770000000000") if first != DeviceIDForLogin("770000000000") { t.Fatal("the device id must not change between runs") } if len(first) != deviceIDLength { t.Fatalf("device id = %q, want %d characters", first, deviceIDLength) } if first == DeviceIDForLogin("770000000001") { t.Fatal("different logins must yield different devices") } } func TestReceiptURL(t *testing.T) { c := NewClient("https://lknpd.example.test", "device-1", nil) got := c.ReceiptURL("770000000000", "20abcdef01") want := "https://lknpd.example.test/api/v1/receipt/770000000000/20abcdef01/print" if got != want { t.Fatalf("url = %q, want %q", got, want) } } func TestIncomeName(t *testing.T) { order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001") got := IncomeName(100, order, "") want := `Внутриигровая валюта: "Фишка", 100 шт. (ID: cafe0001)` if got != want { t.Fatalf("name = %q, want %q", got, want) } } // The marker must come from the random tail of a UUIDv7, not its clock-derived head: the first // eight hex digits are the high bits of a millisecond timestamp and are shared by every order placed // within about a minute. Two such orders would be indistinguishable to the recovery probe, which is // how an income ends up filed twice. func TestIncomeNameMarkerIsNotClockDerived(t *testing.T) { // Two ids one millisecond apart, differing only in their random tail. first := uuid.MustParse("0198c1f2-0001-7000-8000-000000000001") second := uuid.MustParse("0198c1f2-0002-7000-8000-000000000002") if IncomeName(100, first, "") == IncomeName(100, second, "") { t.Fatal("two orders in the same time bucket share a service name") } } func TestIncomeNameFallsBackToTheProductTitle(t *testing.T) { order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001") if got := IncomeName(0, order, "Подсказки"); got != "Подсказки (ID: cafe0001)" { t.Fatalf("name = %q", got) } if got := IncomeName(0, order, " "); got != defaultTitle+" (ID: cafe0001)" { t.Fatalf("name = %q, want the default description", got) } } // The marker is what makes the post-timeout probe exact, so the description gives way first and the // marker is never truncated. func TestIncomeNameCapsLengthButKeepsTheMarker(t *testing.T) { order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001") got := IncomeName(0, order, strings.Repeat("Ы", 400)) if n := len([]rune(got)); n != maxNameRunes { t.Fatalf("length = %d runes, want %d", n, maxNameRunes) } if !strings.HasSuffix(got, "(ID: cafe0001)") { t.Fatalf("name = %q, the marker must survive truncation", got) } } func TestCryptoRoundTrip(t *testing.T) { key, err := ParseKey("YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=") if err != nil { t.Fatalf("parse key: %v", err) } blob, err := Seal(key, "refresh-1") if err != nil { t.Fatalf("seal: %v", err) } if strings.Contains(string(blob), "refresh-1") { t.Fatal("the sealed blob still contains the plaintext") } got, err := Open(key, blob) if err != nil { t.Fatalf("open: %v", err) } if got != "refresh-1" { t.Fatalf("opened %q", got) } } // Losing or rotating the key must be a nuisance — sign in again — not a crash. func TestOpenWithAWrongKeyIsUndecryptable(t *testing.T) { good, _ := ParseKey("YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=") other, _ := ParseKey("MTIzNDU2NzhhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dzA=") blob, err := Seal(good, "refresh-1") if err != nil { t.Fatalf("seal: %v", err) } if _, err := Open(other, blob); !errors.Is(err, ErrUndecryptable) { t.Fatalf("err = %v, want ErrUndecryptable", err) } if _, err := Open(good, blob[:4]); !errors.Is(err, ErrUndecryptable) { t.Fatalf("truncated blob: err = %v, want ErrUndecryptable", err) } } func TestParseKey(t *testing.T) { if _, err := ParseKey("6162636465666768696a6b6c6d6e6f707172737475767778797a313233343536"); err != nil { t.Fatalf("hex key rejected: %v", err) } for _, bad := range []string{"", "short", "YWJj"} { if _, err := ParseKey(bad); err == nil { t.Fatalf("ParseKey(%q) accepted a key of the wrong size", bad) } } }