From f3e24ed8d504a7049e196bf8788f8dbdb8b95674 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:53:51 -0400 Subject: [PATCH 01/10] add user sales download endpoints --- api/server.go | 4 + api/v1_users_sales_download.go | 215 +++++++++++++++++++++++++++++++++ config/config.go | 5 +- 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 api/v1_users_sales_download.go diff --git a/api/server.go b/api/server.go index a578e90f..08890d00 100644 --- a/api/server.go +++ b/api/server.go @@ -190,6 +190,7 @@ func NewApiServer(config config.Config) *ApiServer { }), commsRpcProcessor: commsRpcProcessor, env: config.Env, + audiusAppUrl: config.AudiusAppUrl, skipAuthCheck: skipAuthCheck, pool: pool, writePool: writePool, @@ -361,6 +362,8 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/purchases", app.v1UsersPurchases) g.Get("/users/:userId/purchases/count", app.v1UsersPurchasesCount) g.Get("/users/:userId/sales", app.v1UsersSales) + g.Get("/users/:userId/sales/download", app.v1UsersSalesDownloadCsv) + g.Get("/users/:userId/sales/download/json", app.v1UsersSalesDownloadJson) g.Get("/users/:userId/sales/count", app.v1UsersSalesCount) g.Get("/users/:userId/sales/aggregate", app.v1UsersSalesAggregate) g.Get("/users/:userId/muted", app.v1UsersMuted) @@ -607,6 +610,7 @@ type ApiServer struct { validators []config.Node env string auds *sdk.AudiusdSDK + audiusAppUrl string skipAuthCheck bool // set to true in a test if you don't care about auth middleware metricsCollector *MetricsCollector birdeyeClient BirdeyeClient diff --git a/api/v1_users_sales_download.go b/api/v1_users_sales_download.go new file mode 100644 index 00000000..620ceee5 --- /dev/null +++ b/api/v1_users_sales_download.go @@ -0,0 +1,215 @@ +package api + +import ( + "encoding/csv" + "fmt" + "strconv" + "strings" + "time" + + "api.audius.co/trashid" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type GetUsersSalesDownloadQueryParams struct { + GranteeUserID *trashid.HashId `query:"grantee_user_id"` +} + +type UsdcPurchaseWithEmailResponse struct { + Sales []UsdcPurchaseWithEmail `json:"sales"` +} + +type UsdcPurchaseWithEmail struct { + Title string `db:"title" json:"title"` + Link string `db:"link" json:"link"` + PurchasedBy string `db:"purchased_by" json:"purchased_by"` + CreatedAt time.Time `db:"created_at" json:"date"` + SalePrice float64 `db:"sale_price" json:"sale_price"` + NetworkFee float64 `db:"-" json:"network_fee"` + PayExtra float64 `db:"pay_extra" json:"pay_extra"` + Splits []Split `db:"splits" json:"-"` + Total float64 `db:"-" json:"total"` + Country string `db:"country" json:"country"` + EncryptedEmail *string `db:"encrypted_email" json:"encrypted_email"` + EncryptedKey *string `db:"encrypted_key" json:"encrypted_key"` + BuyerUserID *int `db:"buyer_user_id" json:"buyer_user_id"` + IsInitial *bool `db:"is_initial" json:"is_initial"` + PubkeyBase64 *string `db:"pubkey_base64" json:"pubkey_base64"` +} + +func (app *ApiServer) userSalesForDownload(c *fiber.Ctx) ([]UsdcPurchaseWithEmail, error) { + userId := app.getUserId(c) + params := GetUsersSalesDownloadQueryParams{} + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return nil, err + } + + emailAccessJoin := `LEFT OUTER JOIN email_access ON + email_access.email_owner_user_id = purchases_with_content.buyer_user_id + AND email_access.receiving_user_id = @sellerUserId + AND email_access.grantor_user_id = purchases_with_content.buyer_user_id` + if params.GranteeUserID != nil { + emailAccessJoin = `LEFT OUTER JOIN email_access ON + email_access.email_owner_user_id = purchases_with_content.buyer_user_id + AND email_access.receiving_user_id = @granteeUserId + AND email_access.grantor_user_id = @sellerUserId` + } + + var sellerHandle string + err := app.pool.QueryRow(c.Context(), "SELECT handle FROM users WHERE user_id = @sellerUserId", pgx.NamedArgs{ + "sellerUserId": userId, + }).Scan(&sellerHandle) + if err != nil { + return nil, err + } + + sql := ` + WITH purchases AS ( + SELECT * FROM usdc_purchases + WHERE seller_user_id = @sellerUserId + ), + purchases_with_content AS ( + -- Playlists + SELECT purchases.*, + playlists.playlist_name AS content_title, + playlists.playlist_owner_id AS owner_id, + playlist_routes.slug AS content_slug + FROM purchases + JOIN playlists ON playlists.playlist_id = purchases.content_id + JOIN playlist_routes ON playlist_routes.playlist_id = purchases.content_id + WHERE (content_type = 'playlist' OR content_type = 'album') + -- Tracks + UNION ALL ( + SELECT purchases.*, + tracks.title AS content_title, + tracks.owner_id AS owner_id, + track_routes.slug AS content_slug + FROM purchases + JOIN tracks ON tracks.track_id = purchases.content_id + JOIN track_routes ON track_routes.track_id = purchases.content_id + WHERE content_type = 'track' + ) + ) + SELECT + purchases_with_content.content_title AS title, + COALESCE(@linkBasePath || purchases_with_content.content_slug, '') AS link, + purchases_with_content.created_at, + purchases_with_content.amount / 1000000 AS sale_price, + purchases_with_content.extra_amount / 1000000 AS pay_extra, + purchases_with_content.splits, + COALESCE(purchases_with_content.country, '') AS country, + COALESCE(users.name, '') AS purchased_by, + users.user_id AS buyer_user_id, + encrypted_emails.encrypted_email, + email_access.encrypted_key, + email_access.is_initial, + user_pubkeys.pubkey_base64 + FROM purchases_with_content + JOIN users ON users.user_id = purchases_with_content.buyer_user_id + LEFT OUTER JOIN encrypted_emails ON encrypted_emails.email_owner_user_id = purchases_with_content.buyer_user_id + ` + emailAccessJoin + ` + LEFT OUTER JOIN user_pubkeys ON user_pubkeys.user_id = purchases_with_content.buyer_user_id + ORDER BY purchases_with_content.created_at DESC;` + + rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ + "sellerUserId": userId, + "granteeUserId": params.GranteeUserID, + "linkBasePath": fmt.Sprintf("%s/%s/", app.audiusAppUrl, sellerHandle), + }) + if err != nil { + return nil, err + } + + sales, err := pgx.CollectRows(rows, pgx.RowToStructByName[UsdcPurchaseWithEmail]) + if err != nil { + return nil, err + } + + for i := range sales { + total := 0 + networkFee := 0 + for _, split := range sales[i].Splits { + if split.PayoutWallet == app.solanaConfig.StakingBridgeUsdcTokenAccount.String() { + networkFee -= int(split.Amount) + } else if split.UserID != nil && int(userId) == *split.UserID { + total += int(split.Amount) + } + } + sales[i].Total = float64(total) / 1000000 + sales[i].NetworkFee = float64(networkFee) / 1000000 + } + + return sales, nil +} + +func (app *ApiServer) v1UsersSalesDownloadJson(c *fiber.Ctx) error { + sales, err := app.userSalesForDownload(c) + if err != nil { + return err + } + + return c.JSON(fiber.Map{ + "data": UsdcPurchaseWithEmailResponse{ + Sales: sales, + }, + }) +} + +func (app *ApiServer) v1UsersSalesDownloadCsv(c *fiber.Ctx) error { + sales, err := app.userSalesForDownload(c) + if err != nil { + return err + } + + // Set CSV content type header + c.Set("Content-Type", "text/csv") + c.Set("Content-Disposition", "attachment; filename=\"sales.csv\"") + + // Create CSV writer + var csvBuilder strings.Builder + writer := csv.NewWriter(&csvBuilder) + + // Note: csv headers use spaces instead of underscores + headers := []string{ + "title", + "link", + "purchased by", + "date", + "sale price", + "network_fee", + "pay extra", + "total", + "country", + } + + if err := writer.Write(headers); err != nil { + return err + } + + // Write data rows + for _, sale := range sales { + record := []string{ + sale.Title, + sale.Link, + sale.PurchasedBy, + sale.CreatedAt.Format(time.RFC3339), + strconv.FormatFloat(sale.SalePrice, 'f', 6, 64), + strconv.FormatFloat(sale.NetworkFee, 'f', 6, 64), + strconv.FormatFloat(sale.PayExtra, 'f', 6, 64), + strconv.FormatFloat(sale.Total, 'f', 6, 64), + sale.Country, + } + + if err := writer.Write(record); err != nil { + return err + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + return err + } + + return c.SendString(csvBuilder.String()) +} diff --git a/config/config.go b/config/config.go index 821e0a27..009f1a0f 100644 --- a/config/config.go +++ b/config/config.go @@ -40,6 +40,7 @@ type Config struct { CommsMessagePush bool AudiusdChainID uint AudiusdEntityManagerAddress string + AudiusAppUrl string } var Cfg = Config{ @@ -91,6 +92,7 @@ func init() { Cfg.AudiusdChainID = core_config.DevAcdcChainID Cfg.AudiusdEntityManagerAddress = core_config.DevAcdcAddress + Cfg.AudiusAppUrl = "http://localhost:3000" case "stage": fallthrough case "staging": @@ -106,6 +108,7 @@ func init() { Cfg.AudiusdChainID = core_config.StageAcdcChainID Cfg.AudiusdEntityManagerAddress = core_config.StageAcdcAddress + Cfg.AudiusAppUrl = "https://staging.audius.co" case "prod": fallthrough case "production": @@ -120,9 +123,9 @@ func init() { Cfg.Rewards = core_config.MakeRewards(core_config.ProdClaimAuthorities, core_config.ProdRewardExtensions) Cfg.AudiusdURL = "creatornode.audius.co" Cfg.ChainId = "audius-mainnet-alpha-beta" - Cfg.AudiusdChainID = core_config.ProdAcdcChainID Cfg.AudiusdEntityManagerAddress = core_config.ProdAcdcAddress + Cfg.AudiusAppUrl = "https://audius.co" default: log.Fatalf("Unknown environment: %s", env) } From 0fbe4464f11b412e2a47dd7f86e9c82a060313dd Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:09:10 -0400 Subject: [PATCH 02/10] check wallet auth on sales/purchases endpoints --- api/auth_middleware.go | 24 ++++++++++++++++++++++++ api/server.go | 14 +++++++------- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/api/auth_middleware.go b/api/auth_middleware.go index 77278f69..76fee028 100644 --- a/api/auth_middleware.go +++ b/api/auth_middleware.go @@ -56,6 +56,10 @@ func (app *ApiServer) isAuthorizedRequest(ctx context.Context, userId int32, aut return true } + if authedWallet == "" { + return false + } + cacheKey := fmt.Sprintf("%d:%s", userId, authedWallet) if hit, ok := app.resolveGrantCache.Get(cacheKey); ok { return hit @@ -131,6 +135,26 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { return c.Next() } +// Middleware to require auth for the userId in the route params +// Returns a 403 if the authedWallet is not authorized to act on behalf of the userId +// Should be placed after authMiddleware +func (app *ApiServer) requireAuthForUserId(c *fiber.Ctx) error { + wallet := c.Locals("authedWallet").(string) + userId := app.getUserId(c) + if !app.isAuthorizedRequest(c.Context(), userId, wallet) { + return fiber.NewError( + fiber.StatusForbidden, + fmt.Sprintf( + "You are not authorized to make this request authedWallet=%s userId=%d", + wallet, + userId, + ), + ) + } + + return c.Next() +} + // Middleware that asserts that there is an authed wallet func (app *ApiServer) requireAuthMiddleware(c *fiber.Ctx) error { authedWallet := app.getAuthedWallet(c) diff --git a/api/server.go b/api/server.go index 08890d00..47f49a32 100644 --- a/api/server.go +++ b/api/server.go @@ -359,13 +359,13 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/transactions/usdc/count", app.v1UsersTransactionsUsdcCount) g.Get("/users/:userId/history/tracks", app.v1UsersHistory) g.Get("/users/:userId/listen_counts_monthly", app.v1UsersListenCountsMonthly) - g.Get("/users/:userId/purchases", app.v1UsersPurchases) - g.Get("/users/:userId/purchases/count", app.v1UsersPurchasesCount) - g.Get("/users/:userId/sales", app.v1UsersSales) - g.Get("/users/:userId/sales/download", app.v1UsersSalesDownloadCsv) - g.Get("/users/:userId/sales/download/json", app.v1UsersSalesDownloadJson) - g.Get("/users/:userId/sales/count", app.v1UsersSalesCount) - g.Get("/users/:userId/sales/aggregate", app.v1UsersSalesAggregate) + g.Get("/users/:userId/purchases", app.requireAuthForUserId, app.v1UsersPurchases) + g.Get("/users/:userId/purchases/count", app.requireAuthForUserId, app.v1UsersPurchasesCount) + g.Get("/users/:userId/sales", app.requireAuthForUserId, app.v1UsersSales) + g.Get("/users/:userId/sales/download", app.requireAuthForUserId, app.v1UsersSalesDownloadCsv) + g.Get("/users/:userId/sales/download/json", app.requireAuthForUserId, app.v1UsersSalesDownloadJson) + g.Get("/users/:userId/sales/count", app.requireAuthForUserId, app.v1UsersSalesCount) + g.Get("/users/:userId/sales/aggregate", app.requireAuthForUserId, app.v1UsersSalesAggregate) g.Get("/users/:userId/muted", app.v1UsersMuted) g.Get("/users/:userId/subscribers", app.v1UsersSubscribers) g.Get("/users/:userId/remixers", app.v1UsersRemixers) From 7cc4c3276aa64f62932f3228964e254d7caf85e8 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:20:10 -0400 Subject: [PATCH 03/10] add purchases download endpoint --- api/server.go | 2 + api/v1_users_purchases_download.go | 193 +++++++++++++++++++++++++++++ api/v1_users_sales_download.go | 27 ++-- 3 files changed, 209 insertions(+), 13 deletions(-) create mode 100644 api/v1_users_purchases_download.go diff --git a/api/server.go b/api/server.go index 47f49a32..1af4dbc1 100644 --- a/api/server.go +++ b/api/server.go @@ -360,6 +360,8 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/history/tracks", app.v1UsersHistory) g.Get("/users/:userId/listen_counts_monthly", app.v1UsersListenCountsMonthly) g.Get("/users/:userId/purchases", app.requireAuthForUserId, app.v1UsersPurchases) + g.Get("/users/:userId/purchases/download", app.requireAuthForUserId, app.v1UsersPurchasesDownloadCsv) + g.Get("/users/:userId/purchases/download/json", app.requireAuthForUserId, app.v1UsersPurchasesDownloadJson) g.Get("/users/:userId/purchases/count", app.requireAuthForUserId, app.v1UsersPurchasesCount) g.Get("/users/:userId/sales", app.requireAuthForUserId, app.v1UsersSales) g.Get("/users/:userId/sales/download", app.requireAuthForUserId, app.v1UsersSalesDownloadCsv) diff --git a/api/v1_users_purchases_download.go b/api/v1_users_purchases_download.go new file mode 100644 index 00000000..696a3341 --- /dev/null +++ b/api/v1_users_purchases_download.go @@ -0,0 +1,193 @@ +package api + +import ( + "encoding/csv" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type UsdcPurchasesDownloadResponse struct { + Purchases []UsdcPurchaseForDownload `json:"purchases"` +} + +type UsdcPurchaseForDownload struct { + Title string `db:"title" json:"title"` + Link string `db:"link" json:"link"` + SellerName string `db:"seller_name" json:"seller_name"` + SellerUserID int `db:"seller_user_id" json:"seller_user_id"` + CreatedAt time.Time `db:"created_at" json:"date"` + SalePrice float64 `db:"sale_price" json:"sale_price"` + PaidToArtist float64 `db:"-" json:"paid_to_artist"` + NetworkFee float64 `db:"-" json:"network_fee"` + PayExtra float64 `db:"pay_extra" json:"pay_extra"` + Total float64 `db:"-" json:"total"` + + Splits []Split `db:"splits" json:"-"` +} + +func (app *ApiServer) userPurchasesForDownload(c *fiber.Ctx) ([]UsdcPurchaseForDownload, error) { + userId := app.getUserId(c) + params := GetUsersSalesDownloadQueryParams{} + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return nil, err + } + + var sellerHandle string + err := app.pool.QueryRow(c.Context(), "SELECT handle FROM users WHERE user_id = @buyerUserId", pgx.NamedArgs{ + "buyerUserId": userId, + }).Scan(&sellerHandle) + if err != nil { + return nil, err + } + + sql := ` + WITH purchases AS ( + SELECT * FROM usdc_purchases + WHERE buyer_user_id = @buyerUserId + ), + purchases_with_content AS ( + -- Playlists + SELECT purchases.*, + users.handle AS seller_handle, + users.name AS seller_name, + playlists.playlist_name AS content_title, + playlists.playlist_owner_id AS owner_id, + playlist_routes.slug AS content_slug + FROM purchases + JOIN playlists ON playlists.playlist_id = purchases.content_id + JOIN playlist_routes ON playlist_routes.playlist_id = purchases.content_id + JOIN users ON users.user_id = purchases.seller_user_id + WHERE (content_type = 'playlist' OR content_type = 'album') + -- Tracks + UNION ALL ( + SELECT purchases.*, + users.handle AS seller_handle, + users.name AS seller_name, + users.user_id AS seller_user_id, + tracks.title AS content_title, + tracks.owner_id AS owner_id, + track_routes.slug AS content_slug, + FROM purchases + JOIN tracks ON tracks.track_id = purchases.content_id + JOIN track_routes ON track_routes.track_id = purchases.content_id + JOIN users ON users.user_id = purchases.seller_user_id + WHERE content_type = 'track' + ) + ) + SELECT + purchases_with_content.content_title AS title, + COALESCE(@linkBasePath || '/' || purchases_with_content.seller_handle || '/' || purchases_with_content.content_slug, '') AS link, + purchases_with_content.created_at, + purchases_with_content.seller_name AS seller_name, + purchases_with_content.amount / 1000000 AS sale_price, + purchases_with_content.extra_amount / 1000000 AS pay_extra, + purchases_with_content.splits, + purchases_with_content.seller_user_id AS seller_user_id, + FROM purchases_with_content + JOIN users ON users.user_id = purchases_with_content.buyer_user_id + ORDER BY purchases_with_content.created_at DESC;` + + rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ + "buyerUserId": userId, + "linkBasePath": app.audiusAppUrl, + }) + if err != nil { + return nil, err + } + + sales, err := pgx.CollectRows(rows, pgx.RowToStructByName[UsdcPurchaseForDownload]) + if err != nil { + return nil, err + } + + for i := range sales { + paidToArtist := 0.0 + networkFee := 0.0 + sellerUserId := int(sales[i].SellerUserID) + for _, split := range sales[i].Splits { + if split.PayoutWallet == app.solanaConfig.StakingBridgeUsdcTokenAccount.String() { + networkFee = float64(split.Amount) / 1000000 + } else if split.UserID != nil && sellerUserId == *split.UserID { + paidToArtist = float64(split.Amount) / 1000000 + } + } + sales[i].PaidToArtist = paidToArtist + sales[i].NetworkFee = networkFee + sales[i].Total = sales[i].SalePrice + sales[i].PayExtra + } + + return sales, nil +} + +func (app *ApiServer) v1UsersPurchasesDownloadJson(c *fiber.Ctx) error { + purchases, err := app.userPurchasesForDownload(c) + if err != nil { + return err + } + + return c.JSON(fiber.Map{ + "data": UsdcPurchasesDownloadResponse{ + Purchases: purchases, + }, + }) +} + +func (app *ApiServer) v1UsersPurchasesDownloadCsv(c *fiber.Ctx) error { + sales, err := app.userPurchasesForDownload(c) + if err != nil { + return err + } + + // Set CSV content type header + c.Set("Content-Type", "text/csv") + c.Set("Content-Disposition", "attachment; filename=\"sales.csv\"") + + // Create CSV writer + var csvBuilder strings.Builder + writer := csv.NewWriter(&csvBuilder) + + // Note: csv headers use spaces instead of underscores + headers := []string{ + "title", + "link", + "artist", + "date", + "paid to artist", + "network_fee", + "pay extra", + "total", + } + + if err := writer.Write(headers); err != nil { + return err + } + + // Write data rows + for _, sale := range sales { + record := []string{ + sale.Title, + sale.Link, + sale.SellerName, + sale.CreatedAt.Format(time.RFC3339), + strconv.FormatFloat(sale.PaidToArtist, 'f', 6, 64), + strconv.FormatFloat(sale.NetworkFee, 'f', 6, 64), + strconv.FormatFloat(sale.PayExtra, 'f', 6, 64), + strconv.FormatFloat(sale.Total, 'f', 6, 64), + } + + if err := writer.Write(record); err != nil { + return err + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + return err + } + + return c.SendString(csvBuilder.String()) +} diff --git a/api/v1_users_sales_download.go b/api/v1_users_sales_download.go index 620ceee5..44dce9c7 100644 --- a/api/v1_users_sales_download.go +++ b/api/v1_users_sales_download.go @@ -16,11 +16,11 @@ type GetUsersSalesDownloadQueryParams struct { GranteeUserID *trashid.HashId `query:"grantee_user_id"` } -type UsdcPurchaseWithEmailResponse struct { - Sales []UsdcPurchaseWithEmail `json:"sales"` +type UsdcSaleWithEmailResponse struct { + Sales []UsdcSaleWithEmail `json:"sales"` } -type UsdcPurchaseWithEmail struct { +type UsdcSaleWithEmail struct { Title string `db:"title" json:"title"` Link string `db:"link" json:"link"` PurchasedBy string `db:"purchased_by" json:"purchased_by"` @@ -28,7 +28,6 @@ type UsdcPurchaseWithEmail struct { SalePrice float64 `db:"sale_price" json:"sale_price"` NetworkFee float64 `db:"-" json:"network_fee"` PayExtra float64 `db:"pay_extra" json:"pay_extra"` - Splits []Split `db:"splits" json:"-"` Total float64 `db:"-" json:"total"` Country string `db:"country" json:"country"` EncryptedEmail *string `db:"encrypted_email" json:"encrypted_email"` @@ -36,9 +35,11 @@ type UsdcPurchaseWithEmail struct { BuyerUserID *int `db:"buyer_user_id" json:"buyer_user_id"` IsInitial *bool `db:"is_initial" json:"is_initial"` PubkeyBase64 *string `db:"pubkey_base64" json:"pubkey_base64"` + + Splits []Split `db:"splits" json:"-"` } -func (app *ApiServer) userSalesForDownload(c *fiber.Ctx) ([]UsdcPurchaseWithEmail, error) { +func (app *ApiServer) userSalesForDownload(c *fiber.Ctx) ([]UsdcSaleWithEmail, error) { userId := app.getUserId(c) params := GetUsersSalesDownloadQueryParams{} if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { @@ -121,23 +122,23 @@ func (app *ApiServer) userSalesForDownload(c *fiber.Ctx) ([]UsdcPurchaseWithEmai return nil, err } - sales, err := pgx.CollectRows(rows, pgx.RowToStructByName[UsdcPurchaseWithEmail]) + sales, err := pgx.CollectRows(rows, pgx.RowToStructByName[UsdcSaleWithEmail]) if err != nil { return nil, err } for i := range sales { - total := 0 - networkFee := 0 + networkFee := 0.0 + total := 0.0 for _, split := range sales[i].Splits { if split.PayoutWallet == app.solanaConfig.StakingBridgeUsdcTokenAccount.String() { - networkFee -= int(split.Amount) + networkFee = 0 - float64(split.Amount)/1000000 } else if split.UserID != nil && int(userId) == *split.UserID { - total += int(split.Amount) + total = float64(split.Amount)/1000000 + sales[i].PayExtra } } - sales[i].Total = float64(total) / 1000000 - sales[i].NetworkFee = float64(networkFee) / 1000000 + sales[i].NetworkFee = networkFee + sales[i].Total = total } return sales, nil @@ -150,7 +151,7 @@ func (app *ApiServer) v1UsersSalesDownloadJson(c *fiber.Ctx) error { } return c.JSON(fiber.Map{ - "data": UsdcPurchaseWithEmailResponse{ + "data": UsdcSaleWithEmailResponse{ Sales: sales, }, }) From 4b58d673c7b9d145c1d694322c28fe1de2b841e8 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 10 Oct 2025 10:47:33 -0400 Subject: [PATCH 04/10] fix sql --- api/auth_middleware.go | 1 + api/v1_users_purchases_download.go | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/api/auth_middleware.go b/api/auth_middleware.go index 76fee028..39cc6949 100644 --- a/api/auth_middleware.go +++ b/api/auth_middleware.go @@ -139,6 +139,7 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { // Returns a 403 if the authedWallet is not authorized to act on behalf of the userId // Should be placed after authMiddleware func (app *ApiServer) requireAuthForUserId(c *fiber.Ctx) error { + return c.Next() wallet := c.Locals("authedWallet").(string) userId := app.getUserId(c) if !app.isAuthorizedRequest(c.Context(), userId, wallet) { diff --git a/api/v1_users_purchases_download.go b/api/v1_users_purchases_download.go index 696a3341..ddef51f0 100644 --- a/api/v1_users_purchases_download.go +++ b/api/v1_users_purchases_download.go @@ -64,18 +64,17 @@ func (app *ApiServer) userPurchasesForDownload(c *fiber.Ctx) ([]UsdcPurchaseForD WHERE (content_type = 'playlist' OR content_type = 'album') -- Tracks UNION ALL ( - SELECT purchases.*, + SELECT purchases.*, users.handle AS seller_handle, users.name AS seller_name, - users.user_id AS seller_user_id, - tracks.title AS content_title, - tracks.owner_id AS owner_id, - track_routes.slug AS content_slug, - FROM purchases - JOIN tracks ON tracks.track_id = purchases.content_id - JOIN track_routes ON track_routes.track_id = purchases.content_id - JOIN users ON users.user_id = purchases.seller_user_id - WHERE content_type = 'track' + tracks.title AS content_title, + tracks.owner_id AS owner_id, + track_routes.slug AS content_slug + FROM purchases + JOIN tracks ON tracks.track_id = purchases.content_id + JOIN track_routes ON track_routes.track_id = purchases.content_id + JOIN users ON users.user_id = purchases.seller_user_id + WHERE content_type = 'track' ) ) SELECT @@ -86,7 +85,7 @@ func (app *ApiServer) userPurchasesForDownload(c *fiber.Ctx) ([]UsdcPurchaseForD purchases_with_content.amount / 1000000 AS sale_price, purchases_with_content.extra_amount / 1000000 AS pay_extra, purchases_with_content.splits, - purchases_with_content.seller_user_id AS seller_user_id, + purchases_with_content.seller_user_id AS seller_user_id FROM purchases_with_content JOIN users ON users.user_id = purchases_with_content.buyer_user_id ORDER BY purchases_with_content.created_at DESC;` From de699386105c920beefe9f8c704717bca1d14160 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:13:20 -0400 Subject: [PATCH 05/10] generic csv writer --- api/v1_users_purchases_download.go | 61 ++------ api/v1_users_sales_download.go | 74 +++------ api/write_csv.go | 244 +++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 100 deletions(-) create mode 100644 api/write_csv.go diff --git a/api/v1_users_purchases_download.go b/api/v1_users_purchases_download.go index ddef51f0..73ba4a89 100644 --- a/api/v1_users_purchases_download.go +++ b/api/v1_users_purchases_download.go @@ -1,9 +1,6 @@ package api import ( - "encoding/csv" - "strconv" - "strings" "time" "github.com/gofiber/fiber/v2" @@ -15,18 +12,18 @@ type UsdcPurchasesDownloadResponse struct { } type UsdcPurchaseForDownload struct { - Title string `db:"title" json:"title"` - Link string `db:"link" json:"link"` - SellerName string `db:"seller_name" json:"seller_name"` - SellerUserID int `db:"seller_user_id" json:"seller_user_id"` - CreatedAt time.Time `db:"created_at" json:"date"` - SalePrice float64 `db:"sale_price" json:"sale_price"` - PaidToArtist float64 `db:"-" json:"paid_to_artist"` - NetworkFee float64 `db:"-" json:"network_fee"` - PayExtra float64 `db:"pay_extra" json:"pay_extra"` - Total float64 `db:"-" json:"total"` - - Splits []Split `db:"splits" json:"-"` + Title string `db:"title" json:"title" csv:"title"` + Link string `db:"link" json:"link" csv:"link"` + SellerName string `db:"seller_name" json:"seller_name" csv:"artist"` + SellerUserID int `db:"seller_user_id" json:"seller_user_id" csv:"-"` + CreatedAt time.Time `db:"created_at" json:"date" csv:"date"` + SalePrice float64 `db:"sale_price" json:"sale_price" csv:"-"` + PaidToArtist float64 `db:"-" json:"paid_to_artist" csv:"paid to artist"` + NetworkFee float64 `db:"-" json:"network_fee" csv:"network_fee"` + PayExtra float64 `db:"pay_extra" json:"pay_extra" csv:"pay extra"` + Total float64 `db:"-" json:"total" csv:"total"` + + Splits []Split `db:"splits" json:"-" csv:"-"` } func (app *ApiServer) userPurchasesForDownload(c *fiber.Ctx) ([]UsdcPurchaseForDownload, error) { @@ -141,15 +138,9 @@ func (app *ApiServer) v1UsersPurchasesDownloadCsv(c *fiber.Ctx) error { return err } - // Set CSV content type header c.Set("Content-Type", "text/csv") c.Set("Content-Disposition", "attachment; filename=\"sales.csv\"") - // Create CSV writer - var csvBuilder strings.Builder - writer := csv.NewWriter(&csvBuilder) - - // Note: csv headers use spaces instead of underscores headers := []string{ "title", "link", @@ -161,32 +152,10 @@ func (app *ApiServer) v1UsersPurchasesDownloadCsv(c *fiber.Ctx) error { "total", } - if err := writer.Write(headers); err != nil { - return err - } - - // Write data rows - for _, sale := range sales { - record := []string{ - sale.Title, - sale.Link, - sale.SellerName, - sale.CreatedAt.Format(time.RFC3339), - strconv.FormatFloat(sale.PaidToArtist, 'f', 6, 64), - strconv.FormatFloat(sale.NetworkFee, 'f', 6, 64), - strconv.FormatFloat(sale.PayExtra, 'f', 6, 64), - strconv.FormatFloat(sale.Total, 'f', 6, 64), - } - - if err := writer.Write(record); err != nil { - return err - } - } - - writer.Flush() - if err := writer.Error(); err != nil { + csvContent, err := WriteCSVFromStructs(sales, headers) + if err != nil { return err } - return c.SendString(csvBuilder.String()) + return c.SendString(csvContent) } diff --git a/api/v1_users_sales_download.go b/api/v1_users_sales_download.go index 44dce9c7..9a3c5fcc 100644 --- a/api/v1_users_sales_download.go +++ b/api/v1_users_sales_download.go @@ -1,10 +1,7 @@ package api import ( - "encoding/csv" "fmt" - "strconv" - "strings" "time" "api.audius.co/trashid" @@ -21,22 +18,22 @@ type UsdcSaleWithEmailResponse struct { } type UsdcSaleWithEmail struct { - Title string `db:"title" json:"title"` - Link string `db:"link" json:"link"` - PurchasedBy string `db:"purchased_by" json:"purchased_by"` - CreatedAt time.Time `db:"created_at" json:"date"` - SalePrice float64 `db:"sale_price" json:"sale_price"` - NetworkFee float64 `db:"-" json:"network_fee"` - PayExtra float64 `db:"pay_extra" json:"pay_extra"` - Total float64 `db:"-" json:"total"` - Country string `db:"country" json:"country"` - EncryptedEmail *string `db:"encrypted_email" json:"encrypted_email"` - EncryptedKey *string `db:"encrypted_key" json:"encrypted_key"` - BuyerUserID *int `db:"buyer_user_id" json:"buyer_user_id"` - IsInitial *bool `db:"is_initial" json:"is_initial"` - PubkeyBase64 *string `db:"pubkey_base64" json:"pubkey_base64"` - - Splits []Split `db:"splits" json:"-"` + Title string `db:"title" json:"title" csv:"title"` + Link string `db:"link" json:"link" csv:"link"` + PurchasedBy string `db:"purchased_by" json:"purchased_by" csv:"purchased by"` + CreatedAt time.Time `db:"created_at" json:"date" csv:"date"` + SalePrice float64 `db:"sale_price" json:"sale_price" csv:"sale price"` + NetworkFee float64 `db:"-" json:"network_fee" csv:"network_fee"` + PayExtra float64 `db:"pay_extra" json:"pay_extra" csv:"pay extra"` + Total float64 `db:"-" json:"total" csv:"total"` + Country string `db:"country" json:"country" csv:"country"` + EncryptedEmail *string `db:"encrypted_email" json:"encrypted_email" csv:"-"` + EncryptedKey *string `db:"encrypted_key" json:"encrypted_key" csv:"-"` + BuyerUserID *int `db:"buyer_user_id" json:"buyer_user_id" csv:"-"` + IsInitial *bool `db:"is_initial" json:"is_initial" csv:"-"` + PubkeyBase64 *string `db:"pubkey_base64" json:"pubkey_base64" csv:"-"` + + Splits []Split `db:"splits" json:"-" csv:"-"` } func (app *ApiServer) userSalesForDownload(c *fiber.Ctx) ([]UsdcSaleWithEmail, error) { @@ -163,16 +160,10 @@ func (app *ApiServer) v1UsersSalesDownloadCsv(c *fiber.Ctx) error { return err } - // Set CSV content type header c.Set("Content-Type", "text/csv") c.Set("Content-Disposition", "attachment; filename=\"sales.csv\"") - // Create CSV writer - var csvBuilder strings.Builder - writer := csv.NewWriter(&csvBuilder) - - // Note: csv headers use spaces instead of underscores - headers := []string{ + csvContent, err := WriteCSVFromStructs(sales, []string{ "title", "link", "purchased by", @@ -182,35 +173,10 @@ func (app *ApiServer) v1UsersSalesDownloadCsv(c *fiber.Ctx) error { "pay extra", "total", "country", - } - - if err := writer.Write(headers); err != nil { - return err - } - - // Write data rows - for _, sale := range sales { - record := []string{ - sale.Title, - sale.Link, - sale.PurchasedBy, - sale.CreatedAt.Format(time.RFC3339), - strconv.FormatFloat(sale.SalePrice, 'f', 6, 64), - strconv.FormatFloat(sale.NetworkFee, 'f', 6, 64), - strconv.FormatFloat(sale.PayExtra, 'f', 6, 64), - strconv.FormatFloat(sale.Total, 'f', 6, 64), - sale.Country, - } - - if err := writer.Write(record); err != nil { - return err - } - } - - writer.Flush() - if err := writer.Error(); err != nil { + }) + if err != nil { return err } - return c.SendString(csvBuilder.String()) + return c.SendString(csvContent) } diff --git a/api/write_csv.go b/api/write_csv.go new file mode 100644 index 00000000..6177993e --- /dev/null +++ b/api/write_csv.go @@ -0,0 +1,244 @@ +package api + +import ( + "encoding/csv" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + "api.audius.co/trashid" +) + +// WriteCSVFromStructs writes a CSV string from a slice of structs using reflection +// It uses the provided headers to determine which fields to include and their order +func WriteCSVFromStructs(items interface{}, headers []string) (string, error) { + val := reflect.ValueOf(items) + if val.Kind() != reflect.Slice { + return "", fmt.Errorf("input must be a slice") + } + + if val.Len() == 0 { + return "", nil + } + + // Get the type of the first element to determine struct fields + elemType := val.Type().Elem() + if elemType.Kind() != reflect.Struct { + return "", fmt.Errorf("slice elements must be structs") + } + + // Map header names to field indices + fieldIndices, err := mapHeadersToFields(elemType, headers) + if err != nil { + return "", err + } + if len(fieldIndices) == 0 { + return "", fmt.Errorf("no matching fields found for headers") + } + + // Create CSV writer + var csvBuilder strings.Builder + writer := csv.NewWriter(&csvBuilder) + + // Write headers + if err := writer.Write(headers); err != nil { + return "", err + } + + // Write data rows + for i := 0; i < val.Len(); i++ { + item := val.Index(i) + record := make([]string, len(headers)) + + for j, header := range headers { + if fieldIndex, exists := fieldIndices[header]; exists { + field := item.Field(fieldIndex) + record[j] = convertFieldToString(field) + } else { + record[j] = "" // Empty string for missing fields + } + } + + if err := writer.Write(record); err != nil { + return "", err + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + return "", err + } + + return csvBuilder.String(), nil +} + +// WriteCSVFromStructsAuto writes a CSV string from a slice of structs using reflection +// It automatically extracts field names from the 'csv' struct tag +// Header/column order will be determined by struct field order. +// Use WriteCSVFromStructs if you want to manually specify the headers. +func WriteCSVFromStructsAuto(items interface{}) (string, error) { + // Get the reflection value of the input + val := reflect.ValueOf(items) + if val.Kind() != reflect.Slice { + return "", fmt.Errorf("input must be a slice") + } + + if val.Len() == 0 { + return "", nil + } + + // Get the type of the first element to determine struct fields + elemType := val.Type().Elem() + if elemType.Kind() != reflect.Struct { + return "", fmt.Errorf("slice elements must be structs") + } + + // Extract CSV headers from struct tags + headers, fieldIndices := extractCSVHeaders(elemType) + if len(headers) == 0 { + return "", fmt.Errorf("no fields with csv tags found") + } + + // Create CSV writer + var csvBuilder strings.Builder + writer := csv.NewWriter(&csvBuilder) + + // Write headers + if err := writer.Write(headers); err != nil { + return "", err + } + + // Write data rows + for i := 0; i < val.Len(); i++ { + item := val.Index(i) + record := make([]string, len(fieldIndices)) + + for j, fieldIndex := range fieldIndices { + field := item.Field(fieldIndex) + record[j] = convertFieldToString(field) + } + + if err := writer.Write(record); err != nil { + return "", err + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + return "", err + } + + return csvBuilder.String(), nil +} + +// extractCSVHeaders extracts CSV headers and field indices from struct tags +func extractCSVHeaders(structType reflect.Type) ([]string, []int) { + var headers []string + var fieldIndices []int + + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) + csvTag := field.Tag.Get("csv") + + // Skip fields without csv tag or with csv:"-" + if csvTag == "" || csvTag == "-" { + continue + } + + headers = append(headers, csvTag) + fieldIndices = append(fieldIndices, i) + } + + return headers, fieldIndices +} + +// mapHeadersToFields maps header names to field indices by looking up csv tags +func mapHeadersToFields(structType reflect.Type, headers []string) (map[string]int, error) { + fieldIndices := make(map[string]int) + + // Build a map of csv tag names to field indices + csvTagToFieldIndex := make(map[string]int) + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) + csvTag := field.Tag.Get("csv") + + // Skip fields without csv tag or with csv:"-" + if csvTag == "" || csvTag == "-" { + continue + } + + csvTagToFieldIndex[csvTag] = i + } + + // Map provided headers to field indices and check for missing headers + var missingHeaders []string + for _, header := range headers { + if fieldIndex, exists := csvTagToFieldIndex[header]; exists { + fieldIndices[header] = fieldIndex + } else { + missingHeaders = append(missingHeaders, header) + } + } + + // Return error if any headers don't have corresponding fields + if len(missingHeaders) > 0 { + return nil, fmt.Errorf("no field found with csv tag for headers: %v", missingHeaders) + } + + return fieldIndices, nil +} + +// convertFieldToString converts a reflect.Value to its string representation +func convertFieldToString(field reflect.Value) string { + if !field.IsValid() { + return "" + } + + // Handle pointer types + if field.Kind() == reflect.Ptr { + if field.IsNil() { + return "" + } + field = field.Elem() + } + + // Handle trashid.HashId specially - it needs to be encoded like in JSON marshaling + if field.Type() == reflect.TypeOf(trashid.HashId(0)) { + if hashId, ok := field.Interface().(trashid.HashId); ok { + if encoded, err := trashid.EncodeHashId(int(hashId)); err == nil { + return encoded + } + } + return field.String() + } + + switch field.Kind() { + case reflect.String: + return field.String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(field.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(field.Uint(), 10) + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(field.Float(), 'f', 6, 64) + case reflect.Bool: + return strconv.FormatBool(field.Bool()) + default: + // Handle time.Time and other types that implement Stringer + if field.Type() == reflect.TypeOf(time.Time{}) { + if timeVal, ok := field.Interface().(time.Time); ok { + return timeVal.Format(time.RFC3339) + } + } + + // Try to use String() method + if stringer, ok := field.Interface().(fmt.Stringer); ok { + return stringer.String() + } + + // Fallback to default string representation + return fmt.Sprintf("%v", field.Interface()) + } +} From 317de6b18ddd468fed46682272f7e835833fbba9 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:35:20 -0400 Subject: [PATCH 06/10] migrate withdrawals endpoint --- api/server.go | 2 + api/v1_users_purchases_download.go | 12 +---- api/v1_users_withdrawals_download.go | 80 ++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 api/v1_users_withdrawals_download.go diff --git a/api/server.go b/api/server.go index 1af4dbc1..cdf444b1 100644 --- a/api/server.go +++ b/api/server.go @@ -382,6 +382,8 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/authorized-apps", app.v1UsersAuthorizedApps) g.Get("/users/:userId/developer_apps", app.v1UsersDeveloperApps) g.Get("/users/:userId/developer-apps", app.v1UsersDeveloperApps) + g.Get("/users/:userId/withdrawals/download", app.requireAuthForUserId, app.v1UsersWithdrawalsDownloadCsv) + g.Get("/users/:userId/withdrawals/download/json", app.requireAuthForUserId, app.v1UsersWithdrawalsDownloadJson) // Tracks g.Get("/tracks", app.v1Tracks) diff --git a/api/v1_users_purchases_download.go b/api/v1_users_purchases_download.go index 73ba4a89..984ceda4 100644 --- a/api/v1_users_purchases_download.go +++ b/api/v1_users_purchases_download.go @@ -7,10 +7,6 @@ import ( "github.com/jackc/pgx/v5" ) -type UsdcPurchasesDownloadResponse struct { - Purchases []UsdcPurchaseForDownload `json:"purchases"` -} - type UsdcPurchaseForDownload struct { Title string `db:"title" json:"title" csv:"title"` Link string `db:"link" json:"link" csv:"link"` @@ -28,10 +24,6 @@ type UsdcPurchaseForDownload struct { func (app *ApiServer) userPurchasesForDownload(c *fiber.Ctx) ([]UsdcPurchaseForDownload, error) { userId := app.getUserId(c) - params := GetUsersSalesDownloadQueryParams{} - if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { - return nil, err - } var sellerHandle string err := app.pool.QueryRow(c.Context(), "SELECT handle FROM users WHERE user_id = @buyerUserId", pgx.NamedArgs{ @@ -126,9 +118,7 @@ func (app *ApiServer) v1UsersPurchasesDownloadJson(c *fiber.Ctx) error { } return c.JSON(fiber.Map{ - "data": UsdcPurchasesDownloadResponse{ - Purchases: purchases, - }, + "data": purchases, }) } diff --git a/api/v1_users_withdrawals_download.go b/api/v1_users_withdrawals_download.go new file mode 100644 index 00000000..587f052d --- /dev/null +++ b/api/v1_users_withdrawals_download.go @@ -0,0 +1,80 @@ +package api + +import ( + "time" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type WithdrawalForDownload struct { + DestinationWallet string `db:"destination_wallet" json:"destination_wallet" csv:"destination wallet"` + Date time.Time `db:"date" json:"date" csv:"date"` + Amount float64 `db:"amount" json:"amount" csv:"amount"` +} + +func (app *ApiServer) userWithdrawalsForDownload(c *fiber.Ctx) ([]WithdrawalForDownload, error) { + userId := app.getUserId(c) + + sql := ` + SELECT + uth.tx_metadata AS destination_wallet, + uth.transaction_created_at AS date, + ABS(uth.change) / 1000000 AS amount + FROM users + JOIN usdc_user_bank_accounts uba ON uba.ethereum_address = users.wallet + JOIN usdc_transactions_history uth ON uth.user_bank = uba.bank_account + WHERE users.user_id = @userId + AND users.is_current = TRUE + AND uth.transaction_type = 'transfer' + AND uth.method = 'send' + ORDER BY uth.transaction_created_at DESC;` + + rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ + "userId": userId, + }) + if err != nil { + return nil, err + } + + withdrawals, err := pgx.CollectRows(rows, pgx.RowToStructByName[WithdrawalForDownload]) + if err != nil { + return nil, err + } + + return withdrawals, nil +} + +func (app *ApiServer) v1UsersWithdrawalsDownloadJson(c *fiber.Ctx) error { + withdrawals, err := app.userWithdrawalsForDownload(c) + if err != nil { + return err + } + + return c.JSON(fiber.Map{ + "data": withdrawals, + }) +} + +func (app *ApiServer) v1UsersWithdrawalsDownloadCsv(c *fiber.Ctx) error { + withdrawals, err := app.userWithdrawalsForDownload(c) + if err != nil { + return err + } + + c.Set("Content-Type", "text/csv") + c.Set("Content-Disposition", "attachment; filename=\"withdrawals.csv\"") + + headers := []string{ + "destination wallet", + "date", + "amount", + } + + csvContent, err := WriteCSVFromStructs(withdrawals, headers) + if err != nil { + return err + } + + return c.SendString(csvContent) +} From 087002955d594cd69c99f88fdabb723a57dbb060 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:45:51 -0400 Subject: [PATCH 07/10] first test working --- api/auth_middleware.go | 1 - api/server.go | 7 +++- api/server_test.go | 14 ++++--- api/v1_users_sales_download_test.go | 58 +++++++++++++++++++++++++++++ config/config.go | 2 + 5 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 api/v1_users_sales_download_test.go diff --git a/api/auth_middleware.go b/api/auth_middleware.go index 39cc6949..76fee028 100644 --- a/api/auth_middleware.go +++ b/api/auth_middleware.go @@ -139,7 +139,6 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { // Returns a 403 if the authedWallet is not authorized to act on behalf of the userId // Should be placed after authMiddleware func (app *ApiServer) requireAuthForUserId(c *fiber.Ctx) error { - return c.Next() wallet := c.Locals("authedWallet").(string) userId := app.getUserId(c) if !app.isAuthorizedRequest(c.Context(), userId, wallet) { diff --git a/api/server.go b/api/server.go index cdf444b1..735ef45a 100644 --- a/api/server.go +++ b/api/server.go @@ -177,8 +177,11 @@ func NewApiServer(config config.Config) *ApiServer { panic(err) } - contentNodeMonitor := NewContentNodeMonitor(config, logger) - contentNodeMonitor.Start() + var contentNodeMonitor *ContentNodeMonitor + if config.ContentNodeMonitor { + contentNodeMonitor = NewContentNodeMonitor(config, logger) + contentNodeMonitor.Start() + } app := &ApiServer{ App: fiber.New(fiber.Config{ diff --git a/api/server_test.go b/api/server_test.go index 4e9c6c6a..a3fd03b3 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/tidwall/gjson" + "github.com/gagliardetto/solana-go" _ "github.com/jackc/pgx/v5/stdlib" ) @@ -30,14 +31,15 @@ func emptyTestApp(t *testing.T) *ApiServer { pool := database.CreateTestDatabase(t, "test_api") app := NewApiServer(config.Config{ - Env: "test", - ReadDbUrl: pool.Config().ConnString(), - WriteDbUrl: pool.Config().ConnString(), - RunMigrations: false, - EsUrl: "http://localhost:21401", + Env: "test", + ReadDbUrl: pool.Config().ConnString(), + WriteDbUrl: pool.Config().ConnString(), + RunMigrations: false, + ContentNodeMonitor: false, + EsUrl: "http://localhost:21401", // Dummy key DelegatePrivateKey: "0633fddb74e32b3cbc64382e405146319c11a1a52dc96598e557c5dbe2f31468", - SolanaConfig: config.SolanaConfig{RpcProviders: []string{""}}, + SolanaConfig: config.SolanaConfig{RpcProviders: []string{""}, StakingBridgeUsdcTokenAccount: solana.MustPublicKeyFromBase58(config.DevStakingBridgeUsdcTokenAccount)}, // Disable message push by default. Tests for it can create // an RPC processor directly. CommsMessagePush: false, diff --git a/api/v1_users_sales_download_test.go b/api/v1_users_sales_download_test.go new file mode 100644 index 00000000..b1ec69e2 --- /dev/null +++ b/api/v1_users_sales_download_test.go @@ -0,0 +1,58 @@ +package api + +import ( + "testing" + + "api.audius.co/database" + "github.com/stretchr/testify/assert" +) + +const user1Wallet = "0x7d273271690538cf855e5b3002a0dd8c154bb060" + +func TestV1UsersSalesDownloadJson(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "seller", "wallet": user1Wallet}, + {"user_id": 2, "handle": "buyer1", "name": "buyer1"}, + {"user_id": 3, "handle": "buyer2", "name": "buyer2"}, + }, + "tracks": []map[string]any{ + {"track_id": 1, "title": "track1", "owner_id": 1}, + }, + "track_routes": []map[string]any{ + { + "track_id": 1, + "owner_id": 1, + "slug": "track1", + "title_slug": "track1", + "collision_id": 0, + }, + }, + "usdc_purchases": []map[string]any{ + { + "seller_user_id": 1, + "buyer_user_id": 2, + "content_type": "track", + "content_id": 1, + "amount": 1000000, + "extra_amount": 0, + "created_at": "2024-06-04 00:00:00", + "signature": "def", + "splits": []map[string]any{ + {"user_id": 2, "payout_wallet": "buyer1wallet", "amount": 1000000}, + {"payout_wallet": "testUSDCStakingBridge", "amount": 100000, "percentage": 10}, + }, + }, + }, + } + + database.Seed(app.writePool, fixtures) + + { + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/download/json", user1Wallet) + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{"data.sales.#": 1}) + } +} diff --git a/config/config.go b/config/config.go index 009f1a0f..740c5942 100644 --- a/config/config.go +++ b/config/config.go @@ -41,6 +41,7 @@ type Config struct { AudiusdChainID uint AudiusdEntityManagerAddress string AudiusAppUrl string + ContentNodeMonitor bool } var Cfg = Config{ @@ -61,6 +62,7 @@ var Cfg = Config{ SolanaIndexerWorkers: 50, SolanaIndexerRetryInterval: 5 * time.Minute, CommsMessagePush: true, + ContentNodeMonitor: true, } func init() { From b660d8e1863e3013d3e63365ae0ad809cb799452 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:04:54 -0400 Subject: [PATCH 08/10] fix tests --- api/v1_users_sales_aggregate_test.go | 18 ++++++++++++++---- api/v1_users_sales_count_test.go | 19 +++++++++++++------ api/v1_users_sales_download_test.go | 3 +-- api/v1_users_sales_test.go | 19 +++++++++++++------ 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/api/v1_users_sales_aggregate_test.go b/api/v1_users_sales_aggregate_test.go index 14442c65..fc20c8cf 100644 --- a/api/v1_users_sales_aggregate_test.go +++ b/api/v1_users_sales_aggregate_test.go @@ -9,6 +9,8 @@ import ( ) func TestV1UsersSalesAggregate(t *testing.T) { + user1Wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" + user2Wallet := "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0" app := emptyTestApp(t) fixtures := database.FixtureMap{ "tracks": []map[string]any{ @@ -33,11 +35,13 @@ func TestV1UsersSalesAggregate(t *testing.T) { "users": []map[string]any{ { "user_id": 1, + "wallet": user1Wallet, "handle": "seller", "handle_lc": "seller", }, { "user_id": 2, + "wallet": user2Wallet, "handle": "buyer1", "handle_lc": "buyer1", }, @@ -126,7 +130,7 @@ func TestV1UsersSalesAggregate(t *testing.T) { // Test getting all sales aggregate for user 1 (seller) { - status, _ := testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/sales/aggregate", &response) + status, _ := testGetWithWallet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/sales/aggregate", user1Wallet, &response) assert.Equal(t, 200, status) assert.Len(t, response.Data, 3) @@ -149,7 +153,7 @@ func TestV1UsersSalesAggregate(t *testing.T) { // Test limit parameter { - status, _ := testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/sales/aggregate?limit=2", &response) + status, _ := testGetWithWallet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/sales/aggregate?limit=2", user1Wallet, &response) assert.Equal(t, 200, status) assert.Len(t, response.Data, 2) @@ -162,7 +166,7 @@ func TestV1UsersSalesAggregate(t *testing.T) { // Test offset parameter { - status, _ := testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/sales/aggregate?offset=1&limit=2", &response) + status, _ := testGetWithWallet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/sales/aggregate?offset=1&limit=2", user1Wallet, &response) assert.Equal(t, 200, status) assert.Len(t, response.Data, 2) @@ -175,8 +179,14 @@ func TestV1UsersSalesAggregate(t *testing.T) { // Test user with no sales { - status, _ := testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(2)+"/sales/aggregate", &response) + status, _ := testGetWithWallet(t, app, "/v1/users/"+trashid.MustEncodeHashID(2)+"/sales/aggregate", user2Wallet, &response) assert.Equal(t, 200, status) assert.Len(t, response.Data, 0) } + + // should 403 with bad wallet + { + status, _ := testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/sales/aggregate") + assert.Equal(t, 403, status) + } } diff --git a/api/v1_users_sales_count_test.go b/api/v1_users_sales_count_test.go index 06f66b8c..2713c50d 100644 --- a/api/v1_users_sales_count_test.go +++ b/api/v1_users_sales_count_test.go @@ -9,11 +9,12 @@ import ( ) func TestV1UsersSalesCount(t *testing.T) { + user1Wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" app := emptyTestApp(t) fixtures := database.FixtureMap{ "users": []map[string]any{ - {"user_id": 1, "handle": "seller"}, + {"user_id": 1, "handle": "seller", "wallet": user1Wallet}, {"user_id": 2, "handle": "buyer1", "name": "c"}, {"user_id": 3, "handle": "buyer2", "name": "a"}, {"user_id": 4, "handle": "buyer3", "name": "b"}, @@ -108,36 +109,42 @@ func TestV1UsersSalesCount(t *testing.T) { database.Seed(app.pool.Replicas[0], fixtures) { - status, body := testGet(t, app, "/v1/users/7eP5n/sales/count") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/count", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data": 6}) } // with content id filters { - status, body := testGet(t, app, "/v1/users/7eP5n/sales/count?content_ids=7eP5n&content_ids=ML51L") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/count?content_ids=7eP5n&content_ids=ML51L", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data": 4}) } // with content type filter (playlist) { - status, body := testGet(t, app, "/v1/users/7eP5n/sales/count?content_type=playlist") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/count?content_type=playlist", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data": 1}) } // with content type filter (track) { - status, body := testGet(t, app, "/v1/users/7eP5n/sales/count?content_type=track") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/count?content_type=track", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data": 4}) } // with content type filter (album) { - status, body := testGet(t, app, "/v1/users/7eP5n/sales/count?content_type=album") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/count?content_type=album", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data": 1}) } + + // should 403 with bad wallet + { + status, _ := testGet(t, app, "/v1/users/7eP5n/sales/count") + assert.Equal(t, 403, status) + } } diff --git a/api/v1_users_sales_download_test.go b/api/v1_users_sales_download_test.go index b1ec69e2..77f06e8d 100644 --- a/api/v1_users_sales_download_test.go +++ b/api/v1_users_sales_download_test.go @@ -7,9 +7,8 @@ import ( "github.com/stretchr/testify/assert" ) -const user1Wallet = "0x7d273271690538cf855e5b3002a0dd8c154bb060" - func TestV1UsersSalesDownloadJson(t *testing.T) { + user1Wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" app := emptyTestApp(t) fixtures := database.FixtureMap{ diff --git a/api/v1_users_sales_test.go b/api/v1_users_sales_test.go index 4b39ba77..9c01d0c4 100644 --- a/api/v1_users_sales_test.go +++ b/api/v1_users_sales_test.go @@ -9,11 +9,12 @@ import ( ) func TestV1UsersSales(t *testing.T) { + user1Wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" app := emptyTestApp(t) fixtures := database.FixtureMap{ "users": []map[string]any{ - {"user_id": 1, "handle": "seller"}, + {"user_id": 1, "handle": "seller", "wallet": user1Wallet}, {"user_id": 2, "handle": "buyer1", "name": "c"}, {"user_id": 3, "handle": "buyer2", "name": "a"}, {"user_id": 4, "handle": "buyer3", "name": "b"}, @@ -109,7 +110,7 @@ func TestV1UsersSales(t *testing.T) { // default sort, check all fields of a couple { - status, body := testGet(t, app, "/v1/users/7eP5n/sales") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.seller_user_id": "7eP5n"}) jsonAssert(t, body, map[string]any{"data.0.buyer_user_id": "lebQD"}) @@ -143,7 +144,7 @@ func TestV1UsersSales(t *testing.T) { // reverse sort (asc) { - status, body := testGet(t, app, "/v1/users/7eP5n/sales?sort_direction=asc") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales?sort_direction=asc", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.content_id": "7eP5n", "data.0.content_type": "playlist"}) jsonAssert(t, body, map[string]any{"data.1.content_id": "ML51L", "data.1.content_type": "album"}) @@ -155,7 +156,7 @@ func TestV1UsersSales(t *testing.T) { // content title sort (asc) { - status, body := testGet(t, app, "/v1/users/7eP5n/sales?sort_method=content_title&sort_direction=asc") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales?sort_method=content_title&sort_direction=asc", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.content_id": "ELKzn"}) jsonAssert(t, body, map[string]any{"data.1.content_id": "7eP5n"}) @@ -167,7 +168,7 @@ func TestV1UsersSales(t *testing.T) { // content title sort (desc) { - status, body := testGet(t, app, "/v1/users/7eP5n/sales?sort_method=content_title&sort_direction=desc") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales?sort_method=content_title&sort_direction=desc", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.content_id": "ML51L"}) jsonAssert(t, body, map[string]any{"data.1.content_id": "7eP5n"}) @@ -179,10 +180,16 @@ func TestV1UsersSales(t *testing.T) { // content filters { - status, body := testGet(t, app, "/v1/users/7eP5n/sales?content_ids=lebQD&content_ids=ML51L&content_type=track") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales?content_ids=lebQD&content_ids=ML51L&content_type=track", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.content_id": "ML51L"}) jsonAssert(t, body, map[string]any{"data.1.content_id": "lebQD"}) jsonAssert(t, body, map[string]any{"data.2.content_id": nil}) } + + // should 403 with bad wallet + { + status, _ := testGet(t, app, "/v1/users/7eP5n/sales") + assert.Equal(t, 403, status) + } } From ac0ec036a5c38e59ea0cdd051a291f38aefe83ce Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:10:22 -0400 Subject: [PATCH 09/10] fix a few missed tests --- api/v1_users_purchases_count_test.go | 19 +++++++++++++------ api/v1_users_purchases_test.go | 23 +++++++++++++++-------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/api/v1_users_purchases_count_test.go b/api/v1_users_purchases_count_test.go index 12531d19..0cd110f1 100644 --- a/api/v1_users_purchases_count_test.go +++ b/api/v1_users_purchases_count_test.go @@ -9,11 +9,12 @@ import ( ) func TestV1UsersPurchasesCount(t *testing.T) { + user1Wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" app := emptyTestApp(t) fixtures := database.FixtureMap{ "users": []map[string]any{ - {"user_id": 1, "handle": "buyer"}, + {"user_id": 1, "handle": "buyer", "wallet": user1Wallet}, {"user_id": 2, "handle": "seller1", "name": "c"}, {"user_id": 3, "handle": "seller2", "name": "a"}, {"user_id": 4, "handle": "seller3", "name": "b"}, @@ -108,36 +109,42 @@ func TestV1UsersPurchasesCount(t *testing.T) { database.Seed(app.pool.Replicas[0], fixtures) { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases/count") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases/count", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data": 6}) } // with content id filters { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases/count?content_ids=7eP5n&content_ids=ML51L") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases/count?content_ids=7eP5n&content_ids=ML51L", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data": 4}) } // with content type filter (playlist) { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases/count?content_type=playlist") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases/count?content_type=playlist", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data": 1}) } // with content type filter (track) { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases/count?content_type=track") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases/count?content_type=track", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data": 4}) } // with content type filter (album) { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases/count?content_type=album") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases/count?content_type=album", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data": 1}) } + + // should 403 with bad wallet + { + status, _ := testGet(t, app, "/v1/users/7eP5n/purchases/count") + assert.Equal(t, 403, status) + } } diff --git a/api/v1_users_purchases_test.go b/api/v1_users_purchases_test.go index 466ffd3a..c29fd85e 100644 --- a/api/v1_users_purchases_test.go +++ b/api/v1_users_purchases_test.go @@ -9,11 +9,12 @@ import ( ) func TestV1UsersPurchases(t *testing.T) { + user1Wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" app := emptyTestApp(t) fixtures := database.FixtureMap{ "users": []map[string]any{ - {"user_id": 1, "handle": "buyer"}, + {"user_id": 1, "handle": "buyer", "wallet": user1Wallet}, {"user_id": 2, "handle": "seller1", "name": "c"}, {"user_id": 3, "handle": "seller2", "name": "a"}, {"user_id": 4, "handle": "seller3", "name": "b"}, @@ -109,7 +110,7 @@ func TestV1UsersPurchases(t *testing.T) { // default sort, check all fields of a couple { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.buyer_user_id": "7eP5n"}) jsonAssert(t, body, map[string]any{"data.0.seller_user_id": "lebQD"}) @@ -143,7 +144,7 @@ func TestV1UsersPurchases(t *testing.T) { // reverse sort (asc) { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases?sort_direction=asc") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases?sort_direction=asc", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.content_id": "7eP5n", "data.0.content_type": "playlist"}) jsonAssert(t, body, map[string]any{"data.1.content_id": "ML51L", "data.1.content_type": "album"}) @@ -155,7 +156,7 @@ func TestV1UsersPurchases(t *testing.T) { // artist name sort (asc) { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases?sort_method=artist_name&sort_direction=asc") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases?sort_method=artist_name&sort_direction=asc", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.seller_user_id": "lebQD"}) jsonAssert(t, body, map[string]any{"data.1.seller_user_id": "lebQD"}) @@ -167,7 +168,7 @@ func TestV1UsersPurchases(t *testing.T) { // artist name sort (desc) { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases?sort_method=artist_name&sort_direction=desc") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases?sort_method=artist_name&sort_direction=desc", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.seller_user_id": "pnagD"}) jsonAssert(t, body, map[string]any{"data.1.seller_user_id": "pnagD"}) @@ -179,7 +180,7 @@ func TestV1UsersPurchases(t *testing.T) { // content title sort (asc) { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases?sort_method=content_title&sort_direction=asc") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases?sort_method=content_title&sort_direction=asc", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.content_id": "ELKzn"}) jsonAssert(t, body, map[string]any{"data.1.content_id": "7eP5n"}) @@ -191,7 +192,7 @@ func TestV1UsersPurchases(t *testing.T) { // content title sort (desc) { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases?sort_method=content_title&sort_direction=desc") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases?sort_method=content_title&sort_direction=desc", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.content_id": "ML51L"}) jsonAssert(t, body, map[string]any{"data.1.content_id": "7eP5n"}) @@ -203,10 +204,16 @@ func TestV1UsersPurchases(t *testing.T) { // content filters { - status, body := testGet(t, app, "/v1/users/7eP5n/purchases?content_ids=lebQD&content_ids=ML51L&content_type=track") + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/purchases?content_ids=lebQD&content_ids=ML51L&content_type=track", user1Wallet) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{"data.0.content_id": "ML51L"}) jsonAssert(t, body, map[string]any{"data.1.content_id": "lebQD"}) jsonAssert(t, body, map[string]any{"data.2.content_id": nil}) } + + // should 403 with bad wallet + { + status, _ := testGet(t, app, "/v1/users/7eP5n/purchases") + assert.Equal(t, 403, status) + } } From 2ee0718a53428e13b0db0c26e2426f6a22e1219f Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:25:31 -0400 Subject: [PATCH 10/10] moar tests --- api/server_test.go | 1 + api/test_util.go | 19 ++ api/v1_users_purchases_download.go | 6 +- api/v1_users_purchases_download_test.go | 259 +++++++++++++++++++ api/v1_users_sales_download.go | 6 +- api/v1_users_sales_download_test.go | 289 +++++++++++++++++++++- api/v1_users_withdrawals_download_test.go | 230 +++++++++++++++++ database/seed.go | 7 + 8 files changed, 806 insertions(+), 11 deletions(-) create mode 100644 api/v1_users_purchases_download_test.go create mode 100644 api/v1_users_withdrawals_download_test.go diff --git a/api/server_test.go b/api/server_test.go index a3fd03b3..9704583e 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -40,6 +40,7 @@ func emptyTestApp(t *testing.T) *ApiServer { // Dummy key DelegatePrivateKey: "0633fddb74e32b3cbc64382e405146319c11a1a52dc96598e557c5dbe2f31468", SolanaConfig: config.SolanaConfig{RpcProviders: []string{""}, StakingBridgeUsdcTokenAccount: solana.MustPublicKeyFromBase58(config.DevStakingBridgeUsdcTokenAccount)}, + AudiusAppUrl: "http://localhost:1323", // Disable message push by default. Tests for it can create // an RPC processor directly. CommsMessagePush: false, diff --git a/api/test_util.go b/api/test_util.go index 63234d4b..a71650c8 100644 --- a/api/test_util.go +++ b/api/test_util.go @@ -1,6 +1,8 @@ package api import ( + "encoding/csv" + "strings" "testing" "time" ) @@ -19,3 +21,20 @@ func parseTimeWithLayout(t *testing.T, timeStr string, layout string) time.Time } return parsed } + +// parseCSVLines parses CSV content and returns the header row and data rows +func parseCSVLines(csvContent string) ([]string, [][]string, error) { + reader := csv.NewReader(strings.NewReader(csvContent)) + records, err := reader.ReadAll() + if err != nil { + return nil, nil, err + } + + if len(records) == 0 { + return nil, nil, nil + } + + header := records[0] + data := records[1:] + return header, data, nil +} diff --git a/api/v1_users_purchases_download.go b/api/v1_users_purchases_download.go index 984ceda4..2ab53912 100644 --- a/api/v1_users_purchases_download.go +++ b/api/v1_users_purchases_download.go @@ -15,7 +15,7 @@ type UsdcPurchaseForDownload struct { CreatedAt time.Time `db:"created_at" json:"date" csv:"date"` SalePrice float64 `db:"sale_price" json:"sale_price" csv:"-"` PaidToArtist float64 `db:"-" json:"paid_to_artist" csv:"paid to artist"` - NetworkFee float64 `db:"-" json:"network_fee" csv:"network_fee"` + NetworkFee float64 `db:"-" json:"network_fee" csv:"network fee"` PayExtra float64 `db:"pay_extra" json:"pay_extra" csv:"pay extra"` Total float64 `db:"-" json:"total" csv:"total"` @@ -72,7 +72,7 @@ func (app *ApiServer) userPurchasesForDownload(c *fiber.Ctx) ([]UsdcPurchaseForD purchases_with_content.created_at, purchases_with_content.seller_name AS seller_name, purchases_with_content.amount / 1000000 AS sale_price, - purchases_with_content.extra_amount / 1000000 AS pay_extra, + purchases_with_content.extra_amount / 1000000.0 AS pay_extra, purchases_with_content.splits, purchases_with_content.seller_user_id AS seller_user_id FROM purchases_with_content @@ -137,7 +137,7 @@ func (app *ApiServer) v1UsersPurchasesDownloadCsv(c *fiber.Ctx) error { "artist", "date", "paid to artist", - "network_fee", + "network fee", "pay extra", "total", } diff --git a/api/v1_users_purchases_download_test.go b/api/v1_users_purchases_download_test.go new file mode 100644 index 00000000..ab935e2f --- /dev/null +++ b/api/v1_users_purchases_download_test.go @@ -0,0 +1,259 @@ +package api + +import ( + "testing" + "time" + + "api.audius.co/database" + "github.com/stretchr/testify/assert" +) + +func TestV1UsersPurchasesDownload(t *testing.T) { + user1Wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" + user2Wallet := "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0" + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "seller", "name": "seller", "wallet": user1Wallet}, + {"user_id": 2, "handle": "buyer1", "name": "buyer1", "wallet": user2Wallet}, + {"user_id": 3, "handle": "buyer2", "name": "buyer2"}, + }, + "tracks": []map[string]any{ + {"track_id": 1, "title": "track1", "owner_id": 1}, + }, + "track_routes": []map[string]any{ + { + "track_id": 1, + "owner_id": 1, + "slug": "track1", + "title_slug": "track1", + "collision_id": 0, + }, + }, + "usdc_purchases": []map[string]any{ + { + "seller_user_id": 1, + "buyer_user_id": 2, + "country": "US", + "content_type": "track", + "content_id": 1, + "amount": 1000000, + "extra_amount": 500000, + "created_at": time.Date(2024, 6, 4, 0, 0, 0, 0, time.UTC), + "signature": "def", + "splits": []map[string]any{ + { + "user_id": 1, + "eth_wallet": user1Wallet, + "payout_wallet": user1Wallet, + "amount": 900000, + "percentage": 100, + }, + { + "payout_wallet": app.solanaConfig.StakingBridgeUsdcTokenAccount.String(), + "amount": 100000, + "percentage": 10, + }, + }, + }, + }, + } + + database.Seed(app.writePool, fixtures) + + // json + { + status, body := testGetWithWallet(t, app, "/v1/users/ML51L/purchases/download/json", user2Wallet) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{"data.#": 1}) + jsonAssert(t, body, map[string]any{"data.0.title": "track1"}) + jsonAssert(t, body, map[string]any{"data.0.link": "http://localhost:1323/seller/track1"}) + jsonAssert(t, body, map[string]any{"data.0.seller_name": "seller"}) + jsonAssert(t, body, map[string]any{"data.0.seller_user_id": 1}) + jsonAssert(t, body, map[string]any{"data.0.date": "2024-06-04T00:00:00Z"}) + jsonAssert(t, body, map[string]any{"data.0.sale_price": "1"}) + jsonAssert(t, body, map[string]any{"data.0.paid_to_artist": "0.9"}) + jsonAssert(t, body, map[string]any{"data.0.network_fee": "0.1"}) + jsonAssert(t, body, map[string]any{"data.0.pay_extra": "0.5"}) + jsonAssert(t, body, map[string]any{"data.0.total": "1.5"}) + } + + // csv + { + status, body := testGetWithWallet(t, app, "/v1/users/ML51L/purchases/download", user2Wallet) + assert.Equal(t, 200, status) + + headers, dataRows, err := parseCSVLines(string(body)) + assert.NoError(t, err) + + // Verify headers + expectedHeaders := []string{ + "title", + "link", + "artist", + "date", + "paid to artist", + "network fee", + "pay extra", + "total", + } + assert.Equal(t, expectedHeaders, headers) + + assert.Equal(t, 1, len(dataRows)) + row := dataRows[0] + assert.Equal(t, "track1", row[0]) // title + assert.Equal(t, "http://localhost:1323/seller/track1", row[1]) // link + assert.Equal(t, "seller", row[2]) // artist + assert.Equal(t, "2024-06-04T00:00:00Z", row[3]) // date + assert.Equal(t, "0.900000", row[4]) // paid to artist + assert.Equal(t, "0.100000", row[5]) // network fee + assert.Equal(t, "0.500000", row[6]) // pay extra + assert.Equal(t, "1.500000", row[7]) // total + } + + // Test 403 with no wallet + { + status, _ := testGet(t, app, "/v1/users/ML51L/purchases/download/json") + assert.Equal(t, 403, status) + } + + // Test 403 with unauthorized wallet + { + unauthorizedWallet := "0x855d28d495ec1b06364bb7a521212753e2190b95" // wallet without grants + status, _ := testGetWithWallet(t, app, "/v1/users/ML51L/purchases/download/json", unauthorizedWallet) + assert.Equal(t, 403, status) + } +} + +func TestV1UsersPurchasesDownloadWithGrantee(t *testing.T) { + user1Wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" + user2Wallet := "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0" + user3Wallet := "0x4954d18926ba0ed9378938444731be4e622537b2" + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "seller", "name": "seller", "wallet": user1Wallet}, + {"user_id": 2, "handle": "buyer1", "name": "buyer1", "wallet": user2Wallet}, + {"user_id": 3, "handle": "grantee", "name": "grantee", "wallet": user3Wallet}, + }, + "tracks": []map[string]any{ + {"track_id": 1, "title": "track1", "owner_id": 1}, + }, + "track_routes": []map[string]any{ + { + "track_id": 1, + "owner_id": 1, + "slug": "track1", + "title_slug": "track1", + "collision_id": 0, + }, + }, + "usdc_purchases": []map[string]any{ + { + "seller_user_id": 1, + "buyer_user_id": 2, + "country": "US", + "content_type": "track", + "content_id": 1, + "amount": 1000000, + "extra_amount": 500000, + "created_at": time.Date(2024, 6, 4, 0, 0, 0, 0, time.UTC), + "signature": "def", + "splits": []map[string]any{ + { + "user_id": 1, + "eth_wallet": user1Wallet, + "payout_wallet": user1Wallet, + "amount": 900000, + "percentage": 100, + }, + { + "payout_wallet": app.solanaConfig.StakingBridgeUsdcTokenAccount.String(), + "amount": 100000, + "percentage": 10, + }, + }, + }, + }, + "grants": []map[string]any{ + { + "user_id": 2, // buyer1 + "grantee_address": user3Wallet, // grantee wallet + "is_current": true, + "is_approved": true, + "is_revoked": false, + "created_at": time.Now(), + "updated_at": time.Now(), + }, + }, + } + + database.Seed(app.writePool, fixtures) + + // Test JSON with grantee wallet + { + status, body := testGetWithWallet(t, app, "/v1/users/ML51L/purchases/download/json", user3Wallet) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{"data.#": 1}) + jsonAssert(t, body, map[string]any{"data.0.title": "track1"}) + jsonAssert(t, body, map[string]any{"data.0.link": "http://localhost:1323/seller/track1"}) + jsonAssert(t, body, map[string]any{"data.0.seller_name": "seller"}) + jsonAssert(t, body, map[string]any{"data.0.seller_user_id": 1}) + jsonAssert(t, body, map[string]any{"data.0.date": "2024-06-04T00:00:00Z"}) + jsonAssert(t, body, map[string]any{"data.0.sale_price": "1"}) + jsonAssert(t, body, map[string]any{"data.0.paid_to_artist": "0.9"}) + jsonAssert(t, body, map[string]any{"data.0.network_fee": "0.1"}) + jsonAssert(t, body, map[string]any{"data.0.pay_extra": "0.5"}) + jsonAssert(t, body, map[string]any{"data.0.total": "1.5"}) + } + + // Test CSV with grantee wallet + { + status, body := testGetWithWallet(t, app, "/v1/users/ML51L/purchases/download", user3Wallet) + assert.Equal(t, 200, status) + + headers, dataRows, err := parseCSVLines(string(body)) + assert.NoError(t, err) + + // Verify headers + expectedHeaders := []string{ + "title", + "link", + "artist", + "date", + "paid to artist", + "network fee", + "pay extra", + "total", + } + assert.Equal(t, expectedHeaders, headers) + + assert.Equal(t, 1, len(dataRows)) + row := dataRows[0] + assert.Equal(t, "track1", row[0]) // title + assert.Equal(t, "http://localhost:1323/seller/track1", row[1]) // link + assert.Equal(t, "seller", row[2]) // artist + assert.Equal(t, "2024-06-04T00:00:00Z", row[3]) // date + assert.Equal(t, "0.900000", row[4]) // paid to artist + assert.Equal(t, "0.100000", row[5]) // network fee + assert.Equal(t, "0.500000", row[6]) // pay extra + assert.Equal(t, "1.500000", row[7]) // total + } + + // Test 403 with no wallet + { + status, _ := testGet(t, app, "/v1/users/ML51L/purchases/download/json") + assert.Equal(t, 403, status) + } + + // Test 403 with unauthorized wallet (wallet without grants) + { + unauthorizedWallet := "0x855d28d495ec1b06364bb7a521212753e2190b95" // wallet without grants + status, _ := testGetWithWallet(t, app, "/v1/users/ML51L/purchases/download/json", unauthorizedWallet) + assert.Equal(t, 403, status) + } +} diff --git a/api/v1_users_sales_download.go b/api/v1_users_sales_download.go index 9a3c5fcc..075b857d 100644 --- a/api/v1_users_sales_download.go +++ b/api/v1_users_sales_download.go @@ -23,7 +23,7 @@ type UsdcSaleWithEmail struct { PurchasedBy string `db:"purchased_by" json:"purchased_by" csv:"purchased by"` CreatedAt time.Time `db:"created_at" json:"date" csv:"date"` SalePrice float64 `db:"sale_price" json:"sale_price" csv:"sale price"` - NetworkFee float64 `db:"-" json:"network_fee" csv:"network_fee"` + NetworkFee float64 `db:"-" json:"network_fee" csv:"network fee"` PayExtra float64 `db:"pay_extra" json:"pay_extra" csv:"pay extra"` Total float64 `db:"-" json:"total" csv:"total"` Country string `db:"country" json:"country" csv:"country"` @@ -94,7 +94,7 @@ func (app *ApiServer) userSalesForDownload(c *fiber.Ctx) ([]UsdcSaleWithEmail, e COALESCE(@linkBasePath || purchases_with_content.content_slug, '') AS link, purchases_with_content.created_at, purchases_with_content.amount / 1000000 AS sale_price, - purchases_with_content.extra_amount / 1000000 AS pay_extra, + purchases_with_content.extra_amount / 1000000.0 AS pay_extra, purchases_with_content.splits, COALESCE(purchases_with_content.country, '') AS country, COALESCE(users.name, '') AS purchased_by, @@ -169,7 +169,7 @@ func (app *ApiServer) v1UsersSalesDownloadCsv(c *fiber.Ctx) error { "purchased by", "date", "sale price", - "network_fee", + "network fee", "pay extra", "total", "country", diff --git a/api/v1_users_sales_download_test.go b/api/v1_users_sales_download_test.go index 77f06e8d..8422d333 100644 --- a/api/v1_users_sales_download_test.go +++ b/api/v1_users_sales_download_test.go @@ -2,12 +2,14 @@ package api import ( "testing" + "time" "api.audius.co/database" + "api.audius.co/trashid" "github.com/stretchr/testify/assert" ) -func TestV1UsersSalesDownloadJson(t *testing.T) { +func TestV1UsersSalesDownload(t *testing.T) { user1Wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" app := emptyTestApp(t) @@ -33,18 +35,56 @@ func TestV1UsersSalesDownloadJson(t *testing.T) { { "seller_user_id": 1, "buyer_user_id": 2, + "country": "US", "content_type": "track", "content_id": 1, "amount": 1000000, - "extra_amount": 0, - "created_at": "2024-06-04 00:00:00", + "extra_amount": 500000, + "created_at": time.Date(2024, 6, 4, 0, 0, 0, 0, time.UTC), "signature": "def", "splits": []map[string]any{ - {"user_id": 2, "payout_wallet": "buyer1wallet", "amount": 1000000}, - {"payout_wallet": "testUSDCStakingBridge", "amount": 100000, "percentage": 10}, + { + "user_id": 1, + "eth_wallet": user1Wallet, + "payout_wallet": user1Wallet, + "amount": 900000, + "percentage": 100, + }, + { + "payout_wallet": app.solanaConfig.StakingBridgeUsdcTokenAccount.String(), + "amount": 100000, + "percentage": 10, + }, }, }, }, + "encrypted_emails": []map[string]any{ + { + "id": 1, + "email_owner_user_id": 2, // buyer1 + "encrypted_email": "encrypted_email_data_123", + "created_at": time.Now(), + "updated_at": time.Now(), + }, + }, + "email_access": []map[string]any{ + { + "id": 1, + "email_owner_user_id": 2, // buyer1 + "receiving_user_id": 1, // seller + "grantor_user_id": 2, // buyer1 + "encrypted_key": "encrypted_key_data_456", + "is_initial": true, + "created_at": time.Now(), + "updated_at": time.Now(), + }, + }, + "user_pubkeys": []map[string]any{ + { + "user_id": 2, // buyer1 + "pubkey_base64": "pubkey_base64_data_789", + }, + }, } database.Seed(app.writePool, fixtures) @@ -52,6 +92,245 @@ func TestV1UsersSalesDownloadJson(t *testing.T) { { status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/download/json", user1Wallet) assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{"data.sales.#": 1}) + jsonAssert(t, body, map[string]any{"data.sales.0.title": "track1"}) + jsonAssert(t, body, map[string]any{"data.sales.0.link": "http://localhost:1323/seller/track1"}) + jsonAssert(t, body, map[string]any{"data.sales.0.purchased_by": "buyer1"}) + jsonAssert(t, body, map[string]any{"data.sales.0.date": "2024-06-04T00:00:00Z"}) + jsonAssert(t, body, map[string]any{"data.sales.0.sale_price": "1"}) + jsonAssert(t, body, map[string]any{"data.sales.0.network_fee": "-0.1"}) + jsonAssert(t, body, map[string]any{"data.sales.0.pay_extra": "0.5"}) + jsonAssert(t, body, map[string]any{"data.sales.0.total": "1.4"}) + jsonAssert(t, body, map[string]any{"data.sales.0.country": "US"}) + jsonAssert(t, body, map[string]any{"data.sales.0.encrypted_email": "encrypted_email_data_123"}) + jsonAssert(t, body, map[string]any{"data.sales.0.encrypted_key": "encrypted_key_data_456"}) + jsonAssert(t, body, map[string]any{"data.sales.0.is_initial": true}) + jsonAssert(t, body, map[string]any{"data.sales.0.pubkey_base64": "pubkey_base64_data_789"}) + } + + // csv + { + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/download", user1Wallet) + assert.Equal(t, 200, status) + + headers, dataRows, err := parseCSVLines(string(body)) + assert.NoError(t, err) + + // Verify headers + expectedHeaders := []string{ + "title", + "link", + "purchased by", + "date", + "sale price", + "network fee", + "pay extra", + "total", + "country", + } + assert.Equal(t, expectedHeaders, headers) + + assert.Equal(t, 1, len(dataRows)) + row := dataRows[0] + assert.Equal(t, "track1", row[0]) // title + assert.Equal(t, "http://localhost:1323/seller/track1", row[1]) // link + assert.Equal(t, "buyer1", row[2]) // purchased by + assert.Equal(t, "2024-06-04T00:00:00Z", row[3]) // date + assert.Equal(t, "1.000000", row[4]) // sale price + assert.Equal(t, "-0.100000", row[5]) // network fee + assert.Equal(t, "0.500000", row[6]) // pay extra + assert.Equal(t, "1.400000", row[7]) // total + assert.Equal(t, "US", row[8]) // country + } + + // Test 403 with no wallet + { + status, _ := testGet(t, app, "/v1/users/7eP5n/sales/download/json") + assert.Equal(t, 403, status) + } + + // Test 403 with unauthorized wallet + { + unauthorizedWallet := "0x855d28d495ec1b06364bb7a521212753e2190b95" // wallet without grants + status, _ := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/download/json", unauthorizedWallet) + assert.Equal(t, 403, status) } } + +func TestV1UsersSalesDownloadWithGrantee(t *testing.T) { + user1Wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" + user2Wallet := "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0" + user3Wallet := "0x4954d18926ba0ed9378938444731be4e622537b2" + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "seller", "wallet": user1Wallet}, + {"user_id": 2, "handle": "buyer1", "name": "buyer1", "wallet": user2Wallet}, + {"user_id": 3, "handle": "grantee", "name": "grantee", "wallet": user3Wallet}, + }, + "tracks": []map[string]any{ + {"track_id": 1, "title": "track1", "owner_id": 1}, + }, + "track_routes": []map[string]any{ + { + "track_id": 1, + "owner_id": 1, + "slug": "track1", + "title_slug": "track1", + "collision_id": 0, + }, + }, + "usdc_purchases": []map[string]any{ + { + "seller_user_id": 1, + "buyer_user_id": 2, + "country": "US", + "content_type": "track", + "content_id": 1, + "amount": 1000000, + "extra_amount": 500000, + "created_at": time.Date(2024, 6, 4, 0, 0, 0, 0, time.UTC), + "signature": "def", + "splits": []map[string]any{ + { + "user_id": 1, + "eth_wallet": user1Wallet, + "payout_wallet": user1Wallet, + "amount": 900000, + "percentage": 100, + }, + { + "payout_wallet": app.solanaConfig.StakingBridgeUsdcTokenAccount.String(), + "amount": 100000, + "percentage": 10, + }, + }, + }, + }, + "encrypted_emails": []map[string]any{ + { + "id": 1, + "email_owner_user_id": 2, // buyer1 + "encrypted_email": "encrypted_email_data_123", + "created_at": time.Now(), + "updated_at": time.Now(), + }, + }, + "email_access": []map[string]any{ + { + "id": 1, + "email_owner_user_id": 2, // buyer1 + "receiving_user_id": 3, // grantee (not seller) + "grantor_user_id": 1, // seller + "encrypted_key": "encrypted_key_data_456", + "is_initial": true, + "created_at": time.Now(), + "updated_at": time.Now(), + }, + { + "id": 2, + "email_owner_user_id": 2, // buyer1 + "receiving_user_id": 1, // seller (for direct access) + "grantor_user_id": 2, // buyer1 + "encrypted_key": "encrypted_key_data_789", + "is_initial": false, + "created_at": time.Now(), + "updated_at": time.Now(), + }, + }, + "user_pubkeys": []map[string]any{ + { + "user_id": 2, // buyer1 + "pubkey_base64": "pubkey_base64_data_789", + }, + }, + "grants": []map[string]any{ + { + "user_id": 1, // seller + "grantee_address": user3Wallet, // grantee wallet + "is_current": true, + "is_approved": true, + "is_revoked": false, + "created_at": time.Now(), + "updated_at": time.Now(), + }, + }, + } + + database.Seed(app.writePool, fixtures) + + // Test with grantee_user_id parameter + { + granteeHashId := trashid.MustEncodeHashID(3) + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/download/json?grantee_user_id="+granteeHashId, user3Wallet) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{"data.sales.#": 1}) + jsonAssert(t, body, map[string]any{"data.sales.0.title": "track1"}) + jsonAssert(t, body, map[string]any{"data.sales.0.link": "http://localhost:1323/seller/track1"}) + jsonAssert(t, body, map[string]any{"data.sales.0.purchased_by": "buyer1"}) + jsonAssert(t, body, map[string]any{"data.sales.0.date": "2024-06-04T00:00:00Z"}) + jsonAssert(t, body, map[string]any{"data.sales.0.sale_price": "1"}) + jsonAssert(t, body, map[string]any{"data.sales.0.network_fee": "-0.1"}) + jsonAssert(t, body, map[string]any{"data.sales.0.pay_extra": "0.5"}) + jsonAssert(t, body, map[string]any{"data.sales.0.total": "1.4"}) + jsonAssert(t, body, map[string]any{"data.sales.0.country": "US"}) + // Email access should be available to the grantee + jsonAssert(t, body, map[string]any{"data.sales.0.encrypted_email": "encrypted_email_data_123"}) + jsonAssert(t, body, map[string]any{"data.sales.0.encrypted_key": "encrypted_key_data_456"}) + jsonAssert(t, body, map[string]any{"data.sales.0.is_initial": true}) + jsonAssert(t, body, map[string]any{"data.sales.0.pubkey_base64": "pubkey_base64_data_789"}) + } + + // csv + { + granteeHashId := trashid.MustEncodeHashID(3) + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/download?grantee_user_id="+granteeHashId, user3Wallet) + assert.Equal(t, 200, status) + + headers, dataRows, err := parseCSVLines(string(body)) + assert.NoError(t, err) + + // Verify headers + expectedHeaders := []string{ + "title", + "link", + "purchased by", + "date", + "sale price", + "network fee", + "pay extra", + "total", + "country", + } + assert.Equal(t, expectedHeaders, headers) + + assert.Equal(t, 1, len(dataRows)) + row := dataRows[0] + assert.Equal(t, "track1", row[0]) // title + assert.Equal(t, "http://localhost:1323/seller/track1", row[1]) // link + assert.Equal(t, "buyer1", row[2]) // purchased by + assert.Equal(t, "2024-06-04T00:00:00Z", row[3]) // date + assert.Equal(t, "1.000000", row[4]) // sale price + assert.Equal(t, "-0.100000", row[5]) // network_fee + assert.Equal(t, "0.500000", row[6]) // pay extra + assert.Equal(t, "1.400000", row[7]) // total + assert.Equal(t, "US", row[8]) // country + } + + // Test 403 with no wallet + { + status, _ := testGet(t, app, "/v1/users/7eP5n/sales/download/json") + assert.Equal(t, 403, status) + } + + // Test 403 with unauthorized wallet (wallet without grants) + { + unauthorizedWallet := "0x855d28d495ec1b06364bb7a521212753e2190b95" // wallet without grants + status, _ := testGetWithWallet(t, app, "/v1/users/7eP5n/sales/download/json", unauthorizedWallet) + assert.Equal(t, 403, status) + } + +} diff --git a/api/v1_users_withdrawals_download_test.go b/api/v1_users_withdrawals_download_test.go new file mode 100644 index 00000000..d84a3fd8 --- /dev/null +++ b/api/v1_users_withdrawals_download_test.go @@ -0,0 +1,230 @@ +package api + +import ( + "testing" + "time" + + "api.audius.co/database" + "github.com/stretchr/testify/assert" +) + +func TestV1UsersWithdrawalsDownload(t *testing.T) { + userWallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "user1", "name": "user1", "wallet": userWallet, "is_current": true}, + }, + "usdc_user_bank_accounts": []map[string]any{ + { + "bank_account": "bank123", + "ethereum_address": userWallet, + "created_at": time.Now(), + "signature": "sig123", + }, + }, + "usdc_transactions_history": []map[string]any{ + { + "user_bank": "bank123", + "slot": 101, + "signature": "tx_sig_1", + "transaction_type": "transfer", + "method": "send", + "created_at": time.Now(), + "updated_at": time.Now(), + "transaction_created_at": time.Date(2024, 6, 4, 0, 0, 0, 0, time.UTC), + "tx_metadata": "0x1234567890abcdef1234567890abcdef12345678", + "change": -500000, + "balance": 1000000, + }, + { + "user_bank": "bank123", + "slot": 102, + "signature": "tx_sig_2", + "transaction_type": "transfer", + "method": "send", + "created_at": time.Now(), + "updated_at": time.Now(), + "transaction_created_at": time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC), + "tx_metadata": "0xabcdef1234567890abcdef1234567890abcdef12", + "change": -1000000, + "balance": 500000, + }, + }, + } + + database.Seed(app.writePool, fixtures) + + // json + { + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/withdrawals/download/json", userWallet) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{"data.#": 2}) + jsonAssert(t, body, map[string]any{"data.0.destination_wallet": "0xabcdef1234567890abcdef1234567890abcdef12"}) + jsonAssert(t, body, map[string]any{"data.0.date": "2024-06-05T00:00:00Z"}) + jsonAssert(t, body, map[string]any{"data.0.amount": "1"}) + jsonAssert(t, body, map[string]any{"data.1.destination_wallet": "0x1234567890abcdef1234567890abcdef12345678"}) + jsonAssert(t, body, map[string]any{"data.1.date": "2024-06-04T00:00:00Z"}) + jsonAssert(t, body, map[string]any{"data.1.amount": "0.5"}) + } + + // csv + { + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/withdrawals/download", userWallet) + assert.Equal(t, 200, status) + + headers, dataRows, err := parseCSVLines(string(body)) + assert.NoError(t, err) + + expectedHeaders := []string{ + "destination wallet", + "date", + "amount", + } + assert.Equal(t, expectedHeaders, headers) + + assert.Equal(t, 2, len(dataRows)) + + row1 := dataRows[0] + assert.Equal(t, "0xabcdef1234567890abcdef1234567890abcdef12", row1[0]) // destination wallet + assert.Equal(t, "2024-06-05T00:00:00Z", row1[1]) // date + assert.Equal(t, "1.000000", row1[2]) // amount + + row2 := dataRows[1] + assert.Equal(t, "0x1234567890abcdef1234567890abcdef12345678", row2[0]) // destination wallet + assert.Equal(t, "2024-06-04T00:00:00Z", row2[1]) // date + assert.Equal(t, "0.500000", row2[2]) // amount + } + + // Test 403 with no wallet + { + status, _ := testGet(t, app, "/v1/users/7eP5n/withdrawals/download/json") + assert.Equal(t, 403, status) + } + + // Test 403 with unauthorized wallet + { + unauthorizedWallet := "0x855d28d495ec1b06364bb7a521212753e2190b95" // wallet without grants + status, _ := testGetWithWallet(t, app, "/v1/users/7eP5n/withdrawals/download/json", unauthorizedWallet) + assert.Equal(t, 403, status) + } +} + +func TestV1UsersWithdrawalsDownloadWithGrantee(t *testing.T) { + user1Wallet := "0x7d273271690538cf855e5b3002a0dd8c154bb060" + user3Wallet := "0x4954d18926ba0ed9378938444731be4e622537b2" + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "user1", "name": "user1", "wallet": user1Wallet, "is_current": true}, + {"user_id": 2, "handle": "grantee", "name": "grantee", "wallet": user3Wallet}, + }, + "usdc_user_bank_accounts": []map[string]any{ + { + "bank_account": "bank123", + "ethereum_address": user1Wallet, + "created_at": time.Now(), + "signature": "sig123", + }, + }, + "usdc_transactions_history": []map[string]any{ + { + "user_bank": "bank123", + "slot": 101, + "signature": "tx_sig_1", + "transaction_type": "transfer", + "method": "send", + "created_at": time.Now(), + "updated_at": time.Now(), + "transaction_created_at": time.Date(2024, 6, 4, 0, 0, 0, 0, time.UTC), + "tx_metadata": "0x1234567890abcdef1234567890abcdef12345678", + "change": -500000, + "balance": 1000000, + }, + { + "user_bank": "bank123", + "slot": 102, + "signature": "tx_sig_2", + "transaction_type": "transfer", + "method": "send", + "created_at": time.Now(), + "updated_at": time.Now(), + "transaction_created_at": time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC), + "tx_metadata": "0xabcdef1234567890abcdef1234567890abcdef12", + "change": -1000000, + "balance": 500000, + }, + }, + "grants": []map[string]any{ + { + "user_id": 1, // user1 + "grantee_address": user3Wallet, // grantee wallet + "is_current": true, + "is_approved": true, + "is_revoked": false, + "created_at": time.Now(), + "updated_at": time.Now(), + }, + }, + } + + database.Seed(app.writePool, fixtures) + + // Test JSON with grantee wallet + { + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/withdrawals/download/json", user3Wallet) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{"data.#": 2}) + jsonAssert(t, body, map[string]any{"data.0.destination_wallet": "0xabcdef1234567890abcdef1234567890abcdef12"}) + jsonAssert(t, body, map[string]any{"data.0.date": "2024-06-05T00:00:00Z"}) + jsonAssert(t, body, map[string]any{"data.0.amount": "1"}) + jsonAssert(t, body, map[string]any{"data.1.destination_wallet": "0x1234567890abcdef1234567890abcdef12345678"}) + jsonAssert(t, body, map[string]any{"data.1.date": "2024-06-04T00:00:00Z"}) + jsonAssert(t, body, map[string]any{"data.1.amount": "0.5"}) + } + + // Test CSV with grantee wallet + { + status, body := testGetWithWallet(t, app, "/v1/users/7eP5n/withdrawals/download", user3Wallet) + assert.Equal(t, 200, status) + + headers, dataRows, err := parseCSVLines(string(body)) + assert.NoError(t, err) + + expectedHeaders := []string{ + "destination wallet", + "date", + "amount", + } + assert.Equal(t, expectedHeaders, headers) + + assert.Equal(t, 2, len(dataRows)) + + row1 := dataRows[0] + assert.Equal(t, "0xabcdef1234567890abcdef1234567890abcdef12", row1[0]) // destination wallet + assert.Equal(t, "2024-06-05T00:00:00Z", row1[1]) // date + assert.Equal(t, "1.000000", row1[2]) // amount + + row2 := dataRows[1] + assert.Equal(t, "0x1234567890abcdef1234567890abcdef12345678", row2[0]) // destination wallet + assert.Equal(t, "2024-06-04T00:00:00Z", row2[1]) // date + assert.Equal(t, "0.500000", row2[2]) // amount + } + + // Test 403 with no wallet + { + status, _ := testGet(t, app, "/v1/users/7eP5n/withdrawals/download/json") + assert.Equal(t, 403, status) + } + + // Test 403 with unauthorized wallet (wallet without grants) + { + unauthorizedWallet := "0x855d28d495ec1b06364bb7a521212753e2190b95" // wallet without grants + status, _ := testGetWithWallet(t, app, "/v1/users/7eP5n/withdrawals/download/json", unauthorizedWallet) + assert.Equal(t, 403, status) + } +} diff --git a/database/seed.go b/database/seed.go index 29e3b5ee..ca0eaec5 100644 --- a/database/seed.go +++ b/database/seed.go @@ -562,6 +562,13 @@ var ( "created_at": time.Now(), "updated_at": time.Now(), }, + "encrypted_emails": { + "id": nil, + "email_owner_user_id": nil, + "encrypted_email": "test", + "created_at": time.Now(), + "updated_at": time.Now(), + }, "collectibles": { "user_id": nil, "data": nil,