285 lines
10 KiB
Go
285 lines
10 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gl/application/explorer"
|
|
"gl/domain/ledger"
|
|
)
|
|
|
|
type explorerStub struct {
|
|
dashboard explorer.Dashboard
|
|
dashboardErr error
|
|
assets explorer.Assets
|
|
assetsErr error
|
|
listing explorer.TransactionListing
|
|
listingErr error
|
|
transaction []ledger.Journal
|
|
transactionErr error
|
|
account explorer.Account
|
|
accountErr error
|
|
holder explorer.HolderAccount
|
|
holderErr error
|
|
lastHash string
|
|
lastFilter explorer.TransactionFilter
|
|
lastAccount ledger.AccountReference
|
|
}
|
|
|
|
func (s *explorerStub) Dashboard(context.Context) (explorer.Dashboard, error) {
|
|
return s.dashboard, s.dashboardErr
|
|
}
|
|
|
|
func (s *explorerStub) Assets(context.Context) (explorer.Assets, error) {
|
|
return s.assets, s.assetsErr
|
|
}
|
|
|
|
func (s *explorerStub) Transactions(_ context.Context, filter explorer.TransactionFilter) (explorer.TransactionListing, error) {
|
|
s.lastFilter = filter
|
|
return s.listing, s.listingErr
|
|
}
|
|
|
|
func (s *explorerStub) Transaction(_ context.Context, hash string) ([]ledger.Journal, error) {
|
|
s.lastHash = hash
|
|
return s.transaction, s.transactionErr
|
|
}
|
|
|
|
func (s *explorerStub) Account(_ context.Context, account ledger.AccountReference) (explorer.Account, error) {
|
|
s.lastAccount = account
|
|
return s.account, s.accountErr
|
|
}
|
|
|
|
func (s *explorerStub) Holder(_ context.Context, reference explorer.HolderReference) (explorer.HolderAccount, error) {
|
|
s.holder.Reference = reference
|
|
return s.holder, s.holderErr
|
|
}
|
|
|
|
func TestDashboardRendersFullTemplPage(t *testing.T) {
|
|
service := &explorerStub{dashboard: explorer.Dashboard{
|
|
Stats: explorer.Stats{JournalCount: 1234, LastRecordedAt: time.Unix(10, 0).UTC()},
|
|
Journals: []ledger.Journal{{
|
|
ID: "journal-1", EffectKind: "deposit", SourceService: "wallet",
|
|
Blockchain: ledger.BlockchainReference{TransactionHash: "abc123"},
|
|
}},
|
|
}}
|
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
NewHandler(service).ServeHTTP(response, request)
|
|
|
|
body := response.Body.String()
|
|
for _, expected := range []string{"<!doctype html>", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Fatalf("response did not contain %q", expected)
|
|
}
|
|
}
|
|
if response.Header().Get("Content-Security-Policy") == "" {
|
|
t.Fatal("expected content security policy")
|
|
}
|
|
}
|
|
|
|
func TestDashboardLinksInternalTransactionsByJournalID(t *testing.T) {
|
|
const journalID = "5c1e31b0-0000-4000-8000-0000084accc8"
|
|
service := &explorerStub{dashboard: explorer.Dashboard{Journals: []ledger.Journal{{ID: journalID}}}}
|
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
NewHandler(service).ServeHTTP(response, request)
|
|
|
|
if !strings.Contains(response.Body.String(), `href="/transactions/`+journalID+`"`) {
|
|
t.Fatalf("internal transaction was not linked: %s", response.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAssetsPageRendersLinkedTopAssetHolders(t *testing.T) {
|
|
balance, err := ledger.ParseAmount("987.25")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := &explorerStub{assets: explorer.Assets{TopHolders: []explorer.Holder{{
|
|
Rank: 1, OwnerType: "user", OwnerID: "holder-42", AssetID: 7, Balance: balance,
|
|
}}}}
|
|
request := httptest.NewRequest(http.MethodGet, "/assets", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
NewHandler(service).ServeHTTP(response, request)
|
|
|
|
body := response.Body.String()
|
|
for _, expected := range []string{"Top asset holders", "holder-42", "Asset 7", "987.25", "/holders?asset_id=7&owner_id=holder-42&owner_type=user"} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Fatalf("top holder response did not contain %q: %s", expected, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHolderPageLoadsAggregateAccountDetail(t *testing.T) {
|
|
balance, err := ledger.ParseAmount("42.5")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := &explorerStub{holder: explorer.HolderAccount{Balance: balance}}
|
|
request := httptest.NewRequest(http.MethodGet, "/holders?owner_type=user&owner_id=holder-42&asset_id=7", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
NewHandler(service).ServeHTTP(response, request)
|
|
|
|
body := response.Body.String()
|
|
for _, expected := range []string{"holder-42", "Asset 7", "42.5", "AVAILABLE + FROZEN"} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Fatalf("holder response did not contain %q: %s", expected, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDashboardDefaultsToEnglishAndSupportsPersian(t *testing.T) {
|
|
service := &explorerStub{}
|
|
handler := NewHandler(service)
|
|
|
|
englishRequest := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
englishResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(englishResponse, englishRequest)
|
|
if !strings.Contains(englishResponse.Body.String(), `lang="en" dir="ltr"`) || !strings.Contains(englishResponse.Body.String(), "Every movement") {
|
|
t.Fatalf("dashboard did not default to English: %s", englishResponse.Body.String())
|
|
}
|
|
|
|
persianRequest := httptest.NewRequest(http.MethodGet, "/?lang=fa", nil)
|
|
persianResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(persianResponse, persianRequest)
|
|
if !strings.Contains(persianResponse.Body.String(), `lang="fa" dir="rtl"`) || !strings.Contains(persianResponse.Body.String(), "هر جابهجایی") {
|
|
t.Fatalf("dashboard did not render Persian: %s", persianResponse.Body.String())
|
|
}
|
|
if !strings.Contains(persianResponse.Header().Get("Set-Cookie"), "gl_locale=fa") {
|
|
t.Fatal("Persian locale was not persisted")
|
|
}
|
|
}
|
|
|
|
func TestDashboardUsesLocaleCookie(t *testing.T) {
|
|
request := httptest.NewRequest(http.MethodGet, "/transactions", nil)
|
|
request.AddCookie(&http.Cookie{Name: localeCookie, Value: "fa"})
|
|
response := httptest.NewRecorder()
|
|
|
|
NewHandler(&explorerStub{}).ServeHTTP(response, request)
|
|
|
|
if !strings.Contains(response.Body.String(), "کاوشگر تراکنش") {
|
|
t.Fatalf("locale cookie was ignored: %s", response.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestFaviconIsServedFromEmbeddedBrandAsset(t *testing.T) {
|
|
request := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
NewHandler(&explorerStub{}).ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK || response.Body.Len() < 1000 {
|
|
t.Fatalf("unexpected favicon response: status=%d bytes=%d", response.Code, response.Body.Len())
|
|
}
|
|
if !strings.Contains(response.Header().Get("Content-Type"), "image/") {
|
|
t.Fatalf("unexpected favicon content type: %s", response.Header().Get("Content-Type"))
|
|
}
|
|
}
|
|
|
|
func TestTransactionHTMXRequestRendersOnlyExplorerContent(t *testing.T) {
|
|
service := &explorerStub{transaction: []ledger.Journal{{
|
|
ID: "journal-1", EffectKind: "withdrawal",
|
|
Blockchain: ledger.BlockchainReference{TransactionHash: "tx-hash"},
|
|
}}}
|
|
request := httptest.NewRequest(http.MethodGet, "/transactions?q=tx-hash", nil)
|
|
request.Header.Set("HX-Request", "true")
|
|
response := httptest.NewRecorder()
|
|
|
|
NewHandler(service).ServeHTTP(response, request)
|
|
|
|
body := response.Body.String()
|
|
if strings.Contains(body, "<!doctype html>") || !strings.Contains(body, `id="explorer-content"`) {
|
|
t.Fatalf("unexpected HTMX fragment: %s", body)
|
|
}
|
|
if service.lastHash != "tx-hash" || !strings.Contains(body, "COMMITTED") {
|
|
t.Fatalf("transaction was not rendered: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestTransactionsPageShowsLatestTransactionsWithPagination(t *testing.T) {
|
|
service := &explorerStub{listing: explorer.TransactionListing{
|
|
Journals: []ledger.Journal{{ID: "latest-journal", EffectKind: "transfer"}},
|
|
Filter: explorer.TransactionFilter{Page: 2, Wallet: "wallet-42", EffectKind: "transfer"},
|
|
HasPrevious: true, HasNext: true,
|
|
}}
|
|
request := httptest.NewRequest(http.MethodGet, "/transactions?page=2&wallet=wallet-42&effect=transfer", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
NewHandler(service).ServeHTTP(response, request)
|
|
|
|
body := response.Body.String()
|
|
for _, expected := range []string{"Latest transactions", "latest-journal", "value=\"wallet-42\"", "value=\"transfer\"", "/transactions?effect=transfer&page=1&wallet=wallet-42", "/transactions?effect=transfer&page=3&wallet=wallet-42"} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Fatalf("transaction listing did not contain %q: %s", expected, body)
|
|
}
|
|
}
|
|
if service.lastFilter.Page != 2 || service.lastFilter.Wallet != "wallet-42" || service.lastFilter.EffectKind != "transfer" {
|
|
t.Fatalf("unexpected transaction filter: %+v", service.lastFilter)
|
|
}
|
|
}
|
|
|
|
func TestMissingTransactionShowsLatestTransactions(t *testing.T) {
|
|
service := &explorerStub{
|
|
transactionErr: ledger.ErrNotFound,
|
|
listing: explorer.TransactionListing{Journals: []ledger.Journal{{ID: "latest-journal"}}, Filter: explorer.TransactionFilter{Page: 1}},
|
|
}
|
|
request := httptest.NewRequest(http.MethodGet, "/transactions?q=missing", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
NewHandler(service).ServeHTTP(response, request)
|
|
|
|
body := response.Body.String()
|
|
if !strings.Contains(body, "No committed transaction matches") || !strings.Contains(body, "latest-journal") {
|
|
t.Fatalf("missing transaction did not include latest activity: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestAccountExplorerParsesStableAccountIdentity(t *testing.T) {
|
|
balance, err := ledger.ParseAmount("42.5")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := &explorerStub{account: explorer.Account{Balance: balance, Reference: ledger.AccountReference{
|
|
Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "17", AssetID: 9,
|
|
}}}
|
|
request := httptest.NewRequest(http.MethodGet, "/accounts?class=USER_AVAILABLE&owner_type=user&owner_id=17&asset_id=9", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
NewHandler(service).ServeHTTP(response, request)
|
|
|
|
if service.lastAccount.OwnerID != "17" || service.lastAccount.AssetID != 9 {
|
|
t.Fatalf("unexpected account lookup: %+v", service.lastAccount)
|
|
}
|
|
if !strings.Contains(response.Body.String(), "42.5") {
|
|
t.Fatal("account balance was not rendered")
|
|
}
|
|
}
|
|
|
|
func TestCanceledDashboardRequestDoesNotRenderAnErrorPage(t *testing.T) {
|
|
service := &explorerStub{dashboardErr: context.Canceled}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
request := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
|
response := httptest.NewRecorder()
|
|
|
|
NewHandler(service).ServeHTTP(response, request)
|
|
|
|
if response.Body.Len() != 0 {
|
|
t.Fatalf("expected canceled request to produce no response, got %q", response.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestFormatTimeUsesTehranTimezone(t *testing.T) {
|
|
value := time.Date(2026, time.August, 14, 0, 0, 0, 0, time.UTC)
|
|
if got := formatTime(value); got != "14 Aug 2026 · 03:30:00 Asia/Tehran" {
|
|
t.Fatalf("unexpected localized time: %q", got)
|
|
}
|
|
}
|