From 9d4397adf52e4bd3997ffb1a49b3a526eaf4255c Mon Sep 17 00:00:00 2001 From: Dharit Tantiviramanond Date: Mon, 29 Sep 2025 17:08:21 -0400 Subject: [PATCH 1/3] Update coin description + social links --- api/server.go | 1 + api/v1_coin.go | 8 + api/v1_coins.go | 24 +- api/v1_update_coin.go | 110 +++++ api/v1_update_coin_test.go | 381 ++++++++++++++++++ ...tist_coins_updated_at_and_social_links.sql | 53 +++ sql/01_schema.sql | 6 +- 7 files changed, 572 insertions(+), 11 deletions(-) create mode 100644 api/v1_update_coin.go create mode 100644 api/v1_update_coin_test.go create mode 100644 ddl/migrations/0165_artist_coins_updated_at_and_social_links.sql diff --git a/api/server.go b/api/server.go index d945ea1c..a578e90f 100644 --- a/api/server.go +++ b/api/server.go @@ -492,6 +492,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/coins/:mint/insights", app.v1CoinInsights) g.Get("/coins/:mint/members", app.v1CoinsMembers) g.Post("/coins", app.v1CreateCoin) + g.Post("/coins/:mint", app.v1UpdateCoin) } // Comms diff --git a/api/v1_coin.go b/api/v1_coin.go index f3a957cc..1bc05151 100644 --- a/api/v1_coin.go +++ b/api/v1_coin.go @@ -23,8 +23,12 @@ func (app *ApiServer) v1Coin(c *fiber.Ctx) error { artist_coins.logo_uri, artist_coins.description, artist_coins.website, + artist_coins.twitter, + artist_coins.instagram, + artist_coins.tiktok, artist_coins.has_discord, artist_coins.created_at, + artist_coins.updated_at as coin_updated_at, COALESCE(artist_coin_stats.market_cap, 0) as market_cap, COALESCE(artist_coin_stats.fdv, 0) as fdv, COALESCE(artist_coin_stats.liquidity, 0) as liquidity, @@ -116,8 +120,12 @@ func (app *ApiServer) v1CoinByTicker(c *fiber.Ctx) error { artist_coins.logo_uri, artist_coins.description, artist_coins.website, + artist_coins.twitter, + artist_coins.instagram, + artist_coins.tiktok, artist_coins.has_discord, artist_coins.created_at, + artist_coins.updated_at as coin_updated_at, COALESCE(artist_coin_stats.market_cap, 0) as market_cap, COALESCE(artist_coin_stats.fdv, 0) as fdv, COALESCE(artist_coin_stats.liquidity, 0) as liquidity, diff --git a/api/v1_coins.go b/api/v1_coins.go index b454e7f3..990a3960 100644 --- a/api/v1_coins.go +++ b/api/v1_coins.go @@ -10,16 +10,20 @@ import ( ) type ArtistCoin struct { - Name string `json:"name"` - Ticker string `json:"ticker"` - Mint string `json:"mint"` - Decimals int `json:"decimals"` - OwnerId trashid.HashId `db:"user_id" json:"owner_id"` - LogoUri *string `json:"logo_uri,omitempty"` - Description *string `json:"description,omitempty"` - Website *string `json:"website,omitempty"` - HasDiscord bool `json:"has_discord"` - CreatedAt time.Time `json:"created_at"` + Name string `json:"name"` + Ticker string `json:"ticker"` + Mint string `json:"mint"` + Decimals int `json:"decimals"` + OwnerId trashid.HashId `db:"user_id" json:"owner_id"` + LogoUri *string `json:"logo_uri,omitempty"` + Description *string `json:"description,omitempty"` + Website *string `json:"website,omitempty"` + Twitter *string `json:"twitter,omitempty"` + Instagram *string `json:"instagram,omitempty"` + Tiktok *string `json:"tiktok,omitempty"` + HasDiscord bool `json:"has_discord"` + CreatedAt time.Time `json:"created_at"` + CoinUpdatedAt time.Time `json:"coin_updated_at"` MarketCap float64 `json:"marketCap" db:"market_cap"` FDV float64 `json:"fdv" db:"fdv"` diff --git a/api/v1_update_coin.go b/api/v1_update_coin.go new file mode 100644 index 00000000..e59db1a4 --- /dev/null +++ b/api/v1_update_coin.go @@ -0,0 +1,110 @@ +package api + +import ( + "errors" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type UpdateCoinBody struct { + Description string `json:"description" validate:"max=2500"` + Twitter string `json:"twitter" validate:"omitempty,url"` + Instagram string `json:"instagram" validate:"omitempty,url"` + Tiktok string `json:"tiktok" validate:"omitempty,url"` + Website string `json:"website" validate:"omitempty,url"` +} + +func (app *ApiServer) v1UpdateCoin(c *fiber.Ctx) error { + mint := c.Params("mint") + if mint == "" { + return fiber.NewError(fiber.StatusBadRequest, "Mint parameter is required") + } + + body := UpdateCoinBody{} + if err := app.ParseAndValidateBody(c, &body); err != nil { + return err + } + + userID := app.getMyId(c) + + // Check if user owns the coin + var ownerID int32 + err := app.pool.QueryRow(c.Context(), ` + SELECT user_id FROM artist_coins + WHERE mint = $1 + `, mint).Scan(&ownerID) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(fiber.StatusNotFound, "Coin not found") + } + return err + } + + if ownerID != userID { + return fiber.NewError(fiber.StatusForbidden, "You do not own this coin") + } + + // Build dynamic UPDATE query based on provided fields + setParts := []string{"updated_at = NOW()"} + args := pgx.NamedArgs{"mint": mint} + + if body.Description != "" { + setParts = append(setParts, "description = @description") + args["description"] = body.Description + } + if body.Twitter != "" { + setParts = append(setParts, "twitter = @twitter") + args["twitter"] = body.Twitter + } + if body.Instagram != "" { + setParts = append(setParts, "instagram = @instagram") + args["instagram"] = body.Instagram + } + if body.Tiktok != "" { + setParts = append(setParts, "tiktok = @tiktok") + args["tiktok"] = body.Tiktok + } + if body.Website != "" { + setParts = append(setParts, "website = @website") + args["website"] = body.Website + } + + sql := ` + UPDATE artist_coins + SET ` + strings.Join(setParts, ", ") + ` + WHERE mint = @mint + RETURNING mint, ticker, user_id, decimals, name, logo_uri, description, twitter, instagram, tiktok, website, created_at, updated_at + ` + + row := app.writePool.QueryRow(c.Context(), sql, args) + + var result struct { + Mint string `json:"mint"` + Ticker string `json:"ticker"` + UserID int32 `json:"user_id"` + Decimals int32 `json:"decimals"` + Name string `json:"name"` + LogoUri *string `json:"logo_uri"` + Description *string `json:"description"` + Twitter *string `json:"twitter"` + Instagram *string `json:"instagram"` + Tiktok *string `json:"tiktok"` + Website *string `json:"website"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + + if err := row.Scan(&result.Mint, &result.Ticker, &result.UserID, &result.Decimals, &result.Name, &result.LogoUri, &result.Description, &result.Twitter, &result.Instagram, &result.Tiktok, &result.Website, &result.CreatedAt, &result.UpdatedAt); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to update coin", + }) + } + + return c.JSON(fiber.Map{ + "data": result, + }) +} diff --git a/api/v1_update_coin_test.go b/api/v1_update_coin_test.go new file mode 100644 index 00000000..af6b1ee7 --- /dev/null +++ b/api/v1_update_coin_test.go @@ -0,0 +1,381 @@ +package api + +import ( + "encoding/json" + "testing" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +func TestV1UpdateCoin(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": { + { + "user_id": 1, + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "is_verified": true, + }, + }, + "artist_coins": { + { + "mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "ticker": "$BEAR", + "user_id": 1, + "decimals": 9, + "name": "BEAR", + "logo_uri": "https://example.com/bear-logo.png", + "description": "Original description", + }, + }, + }) + + requestBody := UpdateCoinBody{ + Description: "Updated description for the bear token", + Twitter: "https://twitter.com/bear_token", + Instagram: "https://instagram.com/bear_token", + Tiktok: "https://tiktok.com/@bear_token", + Website: "https://bear-token.com", + } + requestBodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "data.ticker": "$BEAR", + "data.user_id": 1, + "data.decimals": 9, + "data.name": "BEAR", + "data.logo_uri": "https://example.com/bear-logo.png", + "data.description": "Updated description for the bear token", + "data.twitter": "https://twitter.com/bear_token", + "data.instagram": "https://instagram.com/bear_token", + "data.tiktok": "https://tiktok.com/@bear_token", + "data.website": "https://bear-token.com", + }) + + // Verify the coin was actually updated by fetching it via API + status, body = testGet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "data.ticker": "$BEAR", + "data.name": "BEAR", + "data.description": "Updated description for the bear token", + "data.twitter": "https://twitter.com/bear_token", + "data.instagram": "https://instagram.com/bear_token", + "data.tiktok": "https://tiktok.com/@bear_token", + "data.website": "https://bear-token.com", + }) +} + +func TestV1UpdateCoin_CoinNotFound(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": { + { + "user_id": 1, + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "is_verified": true, + }, + }, + }) + + requestBody := UpdateCoinBody{ + Description: "Updated description", + Twitter: "https://twitter.com/test", + Instagram: "https://instagram.com/test", + Tiktok: "https://tiktok.com/@test", + Website: "https://test.com", + } + requestBodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + status, body := testPostWithWallet(t, app, "/v1/coins/nonexistentMint?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 404, status) + jsonAssert(t, body, map[string]any{ + "error": "Coin not found", + }) +} + +func TestV1UpdateCoin_Unauthorized(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": { + { + "user_id": 1, + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "is_verified": true, + }, + { + "user_id": 2, + "wallet": "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0", + "is_verified": true, + }, + }, + "artist_coins": { + { + "mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "ticker": "$BEAR", + "user_id": 1, // Owned by user 1 + "decimals": 9, + "name": "BEAR", + "description": "Original description", + }, + }, + }) + + requestBody := UpdateCoinBody{ + Description: "Updated description", + Twitter: "https://twitter.com/test", + Instagram: "https://instagram.com/test", + Tiktok: "https://tiktok.com/@test", + Website: "https://test.com", + } + requestBodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + // Try to update with user 2 (who doesn't own the coin) + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(2), "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 403, status) + jsonAssert(t, body, map[string]any{ + "error": "You do not own this coin", + }) +} + +func TestV1UpdateCoin_Validation(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": { + { + "user_id": 1, + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "is_verified": true, + }, + }, + "artist_coins": { + { + "mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "ticker": "$BEAR", + "user_id": 1, + "decimals": 9, + "name": "BEAR", + "description": "Original description", + }, + }, + }) + + // Test with description that's too long (>2500 chars) + longDescription := "" + for len(longDescription) <= 2500 { + longDescription += "a" + } + + requestBody := UpdateCoinBody{ + Description: longDescription, + } + requestBodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + status, _ := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 400, status) + // The validation error will be handled by the ParseAndValidateBody method +} + +func TestV1UpdateCoin_IndividualFields(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": { + { + "user_id": 1, + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "is_verified": true, + }, + }, + "artist_coins": { + { + "mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "ticker": "$BEAR", + "user_id": 1, + "decimals": 9, + "name": "BEAR", + "description": "Original description", + }, + }, + }) + + // Test updating only description + requestBody := UpdateCoinBody{ + Description: "Updated description only", + } + requestBodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.description": "Updated description only", + "data.twitter": "", + "data.instagram": "", + "data.tiktok": "", + "data.website": "", + }) + + // Test updating only Twitter + requestBody = UpdateCoinBody{ + Twitter: "https://twitter.com/bear_token", + } + requestBodyBytes, err = json.Marshal(requestBody) + assert.NoError(t, err) + + status, body = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.description": "Updated description only", + "data.twitter": "https://twitter.com/bear_token", + "data.instagram": "", + "data.tiktok": "", + "data.website": "", + }) + + // Test updating multiple fields at once + requestBody = UpdateCoinBody{ + Instagram: "https://instagram.com/bear_token", + Tiktok: "https://tiktok.com/@bear_token", + Website: "https://bear-token.com", + } + requestBodyBytes, err = json.Marshal(requestBody) + assert.NoError(t, err) + + status, body = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.description": "Updated description only", + "data.twitter": "https://twitter.com/bear_token", + "data.instagram": "https://instagram.com/bear_token", + "data.tiktok": "https://tiktok.com/@bear_token", + "data.website": "https://bear-token.com", + }) +} + +func TestV1UpdateCoin_URLValidation(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": { + { + "user_id": 1, + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "is_verified": true, + }, + }, + "artist_coins": { + { + "mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "ticker": "$BEAR", + "user_id": 1, + "decimals": 9, + "name": "BEAR", + "description": "Original description", + }, + }, + }) + + // Test invalid Twitter URL + requestBody := UpdateCoinBody{ + Twitter: "not-a-valid-url", + } + requestBodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + status, _ := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 400, status) + + // Test invalid Instagram URL + requestBody = UpdateCoinBody{ + Instagram: "also-not-valid", + } + requestBodyBytes, err = json.Marshal(requestBody) + assert.NoError(t, err) + + status, _ = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 400, status) + + // Test invalid TikTok URL + requestBody = UpdateCoinBody{ + Tiktok: "invalid-url", + } + requestBodyBytes, err = json.Marshal(requestBody) + assert.NoError(t, err) + + status, _ = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 400, status) + + // Test invalid Website URL + requestBody = UpdateCoinBody{ + Website: "definitely-not-a-url", + } + requestBodyBytes, err = json.Marshal(requestBody) + assert.NoError(t, err) + + status, _ = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 400, status) + + // Test valid URLs work + requestBody = UpdateCoinBody{ + Twitter: "https://twitter.com/example", + Instagram: "https://www.instagram.com/example", + Tiktok: "https://www.tiktok.com/@example", + Website: "https://example.com", + } + requestBodyBytes, err = json.Marshal(requestBody) + assert.NoError(t, err) + + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ + "Content-Type": "application/json", + }) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.twitter": "https://twitter.com/example", + "data.instagram": "https://www.instagram.com/example", + "data.tiktok": "https://www.tiktok.com/@example", + "data.website": "https://example.com", + }) +} diff --git a/ddl/migrations/0165_artist_coins_updated_at_and_social_links.sql b/ddl/migrations/0165_artist_coins_updated_at_and_social_links.sql new file mode 100644 index 00000000..cfedc5cc --- /dev/null +++ b/ddl/migrations/0165_artist_coins_updated_at_and_social_links.sql @@ -0,0 +1,53 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'artist_coins' + AND column_name = 'updated_at' + AND table_schema = 'public' + ) THEN + ALTER TABLE artist_coins ADD COLUMN updated_at TIMESTAMP DEFAULT NOW(); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'artist_coins' + AND column_name = 'twitter' + AND table_schema = 'public' + ) THEN + ALTER TABLE artist_coins ADD COLUMN twitter TEXT; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'artist_coins' + AND column_name = 'instagram' + AND table_schema = 'public' + ) THEN + ALTER TABLE artist_coins ADD COLUMN instagram TEXT; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'artist_coins' + AND column_name = 'tiktok' + AND table_schema = 'public' + ) THEN + ALTER TABLE artist_coins ADD COLUMN tiktok TEXT; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'artist_coins' + AND column_name = 'website' + AND table_schema = 'public' + ) THEN + ALTER TABLE artist_coins ADD COLUMN website TEXT; + END IF; +END +$$; diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 42cd713f..03f23050 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -5670,7 +5670,11 @@ CREATE TABLE public.artist_coins ( description text, website text, name text DEFAULT ''::text NOT NULL, - has_discord boolean DEFAULT false NOT NULL + has_discord boolean DEFAULT false NOT NULL, + updated_at timestamp without time zone DEFAULT now(), + twitter text, + instagram text, + tiktok text ); From c78504ecb69529fa58b03d1db392553f763fb11a Mon Sep 17 00:00:00 2001 From: Dharit Tantiviramanond Date: Tue, 30 Sep 2025 13:19:16 -0400 Subject: [PATCH 2/3] handle --- api/v1_coin.go | 12 +- api/v1_coins.go | 31 +++-- api/v1_update_coin.go | 60 ++++---- api/v1_update_coin_test.go | 128 +++++++++--------- ...tist_coins_updated_at_and_social_links.sql | 54 +------- sql/01_schema.sql | 5 +- 6 files changed, 127 insertions(+), 163 deletions(-) diff --git a/api/v1_coin.go b/api/v1_coin.go index 1bc05151..61966b82 100644 --- a/api/v1_coin.go +++ b/api/v1_coin.go @@ -23,9 +23,9 @@ func (app *ApiServer) v1Coin(c *fiber.Ctx) error { artist_coins.logo_uri, artist_coins.description, artist_coins.website, - artist_coins.twitter, - artist_coins.instagram, - artist_coins.tiktok, + artist_coins.x_handle, + artist_coins.instagram_handle, + artist_coins.tiktok_handle, artist_coins.has_discord, artist_coins.created_at, artist_coins.updated_at as coin_updated_at, @@ -120,9 +120,9 @@ func (app *ApiServer) v1CoinByTicker(c *fiber.Ctx) error { artist_coins.logo_uri, artist_coins.description, artist_coins.website, - artist_coins.twitter, - artist_coins.instagram, - artist_coins.tiktok, + artist_coins.x_handle, + artist_coins.instagram_handle, + artist_coins.tiktok_handle, artist_coins.has_discord, artist_coins.created_at, artist_coins.updated_at as coin_updated_at, diff --git a/api/v1_coins.go b/api/v1_coins.go index 990a3960..44e2c6db 100644 --- a/api/v1_coins.go +++ b/api/v1_coins.go @@ -10,20 +10,20 @@ import ( ) type ArtistCoin struct { - Name string `json:"name"` - Ticker string `json:"ticker"` - Mint string `json:"mint"` - Decimals int `json:"decimals"` - OwnerId trashid.HashId `db:"user_id" json:"owner_id"` - LogoUri *string `json:"logo_uri,omitempty"` - Description *string `json:"description,omitempty"` - Website *string `json:"website,omitempty"` - Twitter *string `json:"twitter,omitempty"` - Instagram *string `json:"instagram,omitempty"` - Tiktok *string `json:"tiktok,omitempty"` - HasDiscord bool `json:"has_discord"` - CreatedAt time.Time `json:"created_at"` - CoinUpdatedAt time.Time `json:"coin_updated_at"` + Name string `json:"name"` + Ticker string `json:"ticker"` + Mint string `json:"mint"` + Decimals int `json:"decimals"` + OwnerId trashid.HashId `db:"user_id" json:"owner_id"` + LogoUri *string `json:"logo_uri,omitempty"` + Description *string `json:"description,omitempty"` + Website *string `json:"website,omitempty"` + XHandle *string `json:"x_handle,omitempty"` + InstagramHandle *string `json:"instagram_handle,omitempty"` + TiktokHandle *string `json:"tiktok_handle,omitempty"` + HasDiscord bool `json:"has_discord"` + CreatedAt time.Time `json:"created_at"` + CoinUpdatedAt time.Time `json:"coin_updated_at"` MarketCap float64 `json:"marketCap" db:"market_cap"` FDV float64 `json:"fdv" db:"fdv"` @@ -135,6 +135,9 @@ func (app *ApiServer) v1Coins(c *fiber.Ctx) error { artist_coins.logo_uri, artist_coins.description, artist_coins.website, + artist_coins.x_handle, + artist_coins.instagram_handle, + artist_coins.tiktok_handle, artist_coins.has_discord, artist_coins.created_at, COALESCE(artist_coin_stats.market_cap, 0) as market_cap, diff --git a/api/v1_update_coin.go b/api/v1_update_coin.go index e59db1a4..5b9eecd8 100644 --- a/api/v1_update_coin.go +++ b/api/v1_update_coin.go @@ -2,6 +2,7 @@ package api import ( "errors" + "log" "strings" "time" @@ -10,11 +11,11 @@ import ( ) type UpdateCoinBody struct { - Description string `json:"description" validate:"max=2500"` - Twitter string `json:"twitter" validate:"omitempty,url"` - Instagram string `json:"instagram" validate:"omitempty,url"` - Tiktok string `json:"tiktok" validate:"omitempty,url"` - Website string `json:"website" validate:"omitempty,url"` + Description string `json:"description" validate:"max=2500"` + XHandle string `json:"x_handle" validate:"omitempty,url"` + InstagramHandle string `json:"instagram_handle" validate:"omitempty,url"` + TiktokHandle string `json:"tiktok_handle" validate:"omitempty,url"` + Website string `json:"website" validate:"omitempty,url"` } func (app *ApiServer) v1UpdateCoin(c *fiber.Ctx) error { @@ -56,17 +57,17 @@ func (app *ApiServer) v1UpdateCoin(c *fiber.Ctx) error { setParts = append(setParts, "description = @description") args["description"] = body.Description } - if body.Twitter != "" { - setParts = append(setParts, "twitter = @twitter") - args["twitter"] = body.Twitter + if body.XHandle != "" { + setParts = append(setParts, "x_handle = @x_handle") + args["x_handle"] = body.XHandle } - if body.Instagram != "" { - setParts = append(setParts, "instagram = @instagram") - args["instagram"] = body.Instagram + if body.InstagramHandle != "" { + setParts = append(setParts, "instagram_handle = @instagram_handle") + args["instagram_handle"] = body.InstagramHandle } - if body.Tiktok != "" { - setParts = append(setParts, "tiktok = @tiktok") - args["tiktok"] = body.Tiktok + if body.TiktokHandle != "" { + setParts = append(setParts, "tiktok_handle = @tiktok_handle") + args["tiktok_handle"] = body.TiktokHandle } if body.Website != "" { setParts = append(setParts, "website = @website") @@ -77,28 +78,29 @@ func (app *ApiServer) v1UpdateCoin(c *fiber.Ctx) error { UPDATE artist_coins SET ` + strings.Join(setParts, ", ") + ` WHERE mint = @mint - RETURNING mint, ticker, user_id, decimals, name, logo_uri, description, twitter, instagram, tiktok, website, created_at, updated_at + RETURNING mint, ticker, user_id, decimals, name, logo_uri, description, x_handle, instagram_handle, tiktok_handle, website, created_at, updated_at ` row := app.writePool.QueryRow(c.Context(), sql, args) var result struct { - Mint string `json:"mint"` - Ticker string `json:"ticker"` - UserID int32 `json:"user_id"` - Decimals int32 `json:"decimals"` - Name string `json:"name"` - LogoUri *string `json:"logo_uri"` - Description *string `json:"description"` - Twitter *string `json:"twitter"` - Instagram *string `json:"instagram"` - Tiktok *string `json:"tiktok"` - Website *string `json:"website"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Mint string `json:"mint"` + Ticker string `json:"ticker"` + UserID int32 `json:"user_id"` + Decimals int32 `json:"decimals"` + Name string `json:"name"` + LogoUri *string `json:"logo_uri"` + Description *string `json:"description"` + XHandle *string `json:"x_handle"` + InstagramHandle *string `json:"instagram_handle"` + TiktokHandle *string `json:"tiktok_handle"` + Website *string `json:"website"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } - if err := row.Scan(&result.Mint, &result.Ticker, &result.UserID, &result.Decimals, &result.Name, &result.LogoUri, &result.Description, &result.Twitter, &result.Instagram, &result.Tiktok, &result.Website, &result.CreatedAt, &result.UpdatedAt); err != nil { + if err := row.Scan(&result.Mint, &result.Ticker, &result.UserID, &result.Decimals, &result.Name, &result.LogoUri, &result.Description, &result.XHandle, &result.InstagramHandle, &result.TiktokHandle, &result.Website, &result.CreatedAt, &result.UpdatedAt); err != nil { + log.Println("Failed to update coin", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": "Failed to update coin", }) diff --git a/api/v1_update_coin_test.go b/api/v1_update_coin_test.go index af6b1ee7..ecca022d 100644 --- a/api/v1_update_coin_test.go +++ b/api/v1_update_coin_test.go @@ -33,11 +33,11 @@ func TestV1UpdateCoin(t *testing.T) { }) requestBody := UpdateCoinBody{ - Description: "Updated description for the bear token", - Twitter: "https://twitter.com/bear_token", - Instagram: "https://instagram.com/bear_token", - Tiktok: "https://tiktok.com/@bear_token", - Website: "https://bear-token.com", + Description: "Updated description for the bear token", + XHandle: "https://x.com/bear_token", + InstagramHandle: "https://instagram.com/bear_token", + TiktokHandle: "https://tiktok.com/@bear_token", + Website: "https://bear-token.com", } requestBodyBytes, err := json.Marshal(requestBody) assert.NoError(t, err) @@ -48,31 +48,31 @@ func TestV1UpdateCoin(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", - "data.ticker": "$BEAR", - "data.user_id": 1, - "data.decimals": 9, - "data.name": "BEAR", - "data.logo_uri": "https://example.com/bear-logo.png", - "data.description": "Updated description for the bear token", - "data.twitter": "https://twitter.com/bear_token", - "data.instagram": "https://instagram.com/bear_token", - "data.tiktok": "https://tiktok.com/@bear_token", - "data.website": "https://bear-token.com", + "data.mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "data.ticker": "$BEAR", + "data.user_id": 1, + "data.decimals": 9, + "data.name": "BEAR", + "data.logo_uri": "https://example.com/bear-logo.png", + "data.description": "Updated description for the bear token", + "data.x_handle": "https://x.com/bear_token", + "data.instagram_handle": "https://instagram.com/bear_token", + "data.tiktok_handle": "https://tiktok.com/@bear_token", + "data.website": "https://bear-token.com", }) // Verify the coin was actually updated by fetching it via API status, body = testGet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj") assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", - "data.ticker": "$BEAR", - "data.name": "BEAR", - "data.description": "Updated description for the bear token", - "data.twitter": "https://twitter.com/bear_token", - "data.instagram": "https://instagram.com/bear_token", - "data.tiktok": "https://tiktok.com/@bear_token", - "data.website": "https://bear-token.com", + "data.mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "data.ticker": "$BEAR", + "data.name": "BEAR", + "data.description": "Updated description for the bear token", + "data.x_handle": "https://x.com/bear_token", + "data.instagram_handle": "https://instagram.com/bear_token", + "data.tiktok_handle": "https://tiktok.com/@bear_token", + "data.website": "https://bear-token.com", }) } @@ -89,11 +89,11 @@ func TestV1UpdateCoin_CoinNotFound(t *testing.T) { }) requestBody := UpdateCoinBody{ - Description: "Updated description", - Twitter: "https://twitter.com/test", - Instagram: "https://instagram.com/test", - Tiktok: "https://tiktok.com/@test", - Website: "https://test.com", + Description: "Updated description", + XHandle: "https://x.com/test", + InstagramHandle: "https://instagram.com/test", + TiktokHandle: "https://tiktok.com/@test", + Website: "https://test.com", } requestBodyBytes, err := json.Marshal(requestBody) assert.NoError(t, err) @@ -136,11 +136,11 @@ func TestV1UpdateCoin_Unauthorized(t *testing.T) { }) requestBody := UpdateCoinBody{ - Description: "Updated description", - Twitter: "https://twitter.com/test", - Instagram: "https://instagram.com/test", - Tiktok: "https://tiktok.com/@test", - Website: "https://test.com", + Description: "Updated description", + XHandle: "https://x.com/test", + InstagramHandle: "https://instagram.com/test", + TiktokHandle: "https://tiktok.com/@test", + Website: "https://test.com", } requestBodyBytes, err := json.Marshal(requestBody) assert.NoError(t, err) @@ -233,16 +233,16 @@ func TestV1UpdateCoin_IndividualFields(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.description": "Updated description only", - "data.twitter": "", - "data.instagram": "", - "data.tiktok": "", - "data.website": "", + "data.description": "Updated description only", + "data.x_handle": "", + "data.instagram_handle": "", + "data.tiktok_handle": "", + "data.website": "", }) // Test updating only Twitter requestBody = UpdateCoinBody{ - Twitter: "https://twitter.com/bear_token", + XHandle: "https://x.com/bear_token", } requestBodyBytes, err = json.Marshal(requestBody) assert.NoError(t, err) @@ -253,18 +253,18 @@ func TestV1UpdateCoin_IndividualFields(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.description": "Updated description only", - "data.twitter": "https://twitter.com/bear_token", - "data.instagram": "", - "data.tiktok": "", - "data.website": "", + "data.description": "Updated description only", + "data.x_handle": "https://x.com/bear_token", + "data.instagram_handle": "", + "data.tiktok_handle": "", + "data.website": "", }) // Test updating multiple fields at once requestBody = UpdateCoinBody{ - Instagram: "https://instagram.com/bear_token", - Tiktok: "https://tiktok.com/@bear_token", - Website: "https://bear-token.com", + InstagramHandle: "https://instagram.com/bear_token", + TiktokHandle: "https://tiktok.com/@bear_token", + Website: "https://bear-token.com", } requestBodyBytes, err = json.Marshal(requestBody) assert.NoError(t, err) @@ -275,11 +275,11 @@ func TestV1UpdateCoin_IndividualFields(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.description": "Updated description only", - "data.twitter": "https://twitter.com/bear_token", - "data.instagram": "https://instagram.com/bear_token", - "data.tiktok": "https://tiktok.com/@bear_token", - "data.website": "https://bear-token.com", + "data.description": "Updated description only", + "data.x_handle": "https://x.com/bear_token", + "data.instagram_handle": "https://instagram.com/bear_token", + "data.tiktok_handle": "https://tiktok.com/@bear_token", + "data.website": "https://bear-token.com", }) } @@ -307,7 +307,7 @@ func TestV1UpdateCoin_URLValidation(t *testing.T) { // Test invalid Twitter URL requestBody := UpdateCoinBody{ - Twitter: "not-a-valid-url", + XHandle: "not-a-valid-url", } requestBodyBytes, err := json.Marshal(requestBody) assert.NoError(t, err) @@ -320,7 +320,7 @@ func TestV1UpdateCoin_URLValidation(t *testing.T) { // Test invalid Instagram URL requestBody = UpdateCoinBody{ - Instagram: "also-not-valid", + InstagramHandle: "also-not-valid", } requestBodyBytes, err = json.Marshal(requestBody) assert.NoError(t, err) @@ -333,7 +333,7 @@ func TestV1UpdateCoin_URLValidation(t *testing.T) { // Test invalid TikTok URL requestBody = UpdateCoinBody{ - Tiktok: "invalid-url", + TiktokHandle: "invalid-url", } requestBodyBytes, err = json.Marshal(requestBody) assert.NoError(t, err) @@ -359,10 +359,10 @@ func TestV1UpdateCoin_URLValidation(t *testing.T) { // Test valid URLs work requestBody = UpdateCoinBody{ - Twitter: "https://twitter.com/example", - Instagram: "https://www.instagram.com/example", - Tiktok: "https://www.tiktok.com/@example", - Website: "https://example.com", + XHandle: "https://x.com/example", + InstagramHandle: "https://www.instagram.com/example", + TiktokHandle: "https://www.tiktok.com/@example", + Website: "https://example.com", } requestBodyBytes, err = json.Marshal(requestBody) assert.NoError(t, err) @@ -373,9 +373,9 @@ func TestV1UpdateCoin_URLValidation(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.twitter": "https://twitter.com/example", - "data.instagram": "https://www.instagram.com/example", - "data.tiktok": "https://www.tiktok.com/@example", - "data.website": "https://example.com", + "data.x_handle": "https://x.com/example", + "data.instagram_handle": "https://www.instagram.com/example", + "data.tiktok_handle": "https://www.tiktok.com/@example", + "data.website": "https://example.com", }) } diff --git a/ddl/migrations/0165_artist_coins_updated_at_and_social_links.sql b/ddl/migrations/0165_artist_coins_updated_at_and_social_links.sql index cfedc5cc..3dbd1ef3 100644 --- a/ddl/migrations/0165_artist_coins_updated_at_and_social_links.sql +++ b/ddl/migrations/0165_artist_coins_updated_at_and_social_links.sql @@ -1,53 +1,9 @@ DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = 'artist_coins' - AND column_name = 'updated_at' - AND table_schema = 'public' - ) THEN - ALTER TABLE artist_coins ADD COLUMN updated_at TIMESTAMP DEFAULT NOW(); - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = 'artist_coins' - AND column_name = 'twitter' - AND table_schema = 'public' - ) THEN - ALTER TABLE artist_coins ADD COLUMN twitter TEXT; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = 'artist_coins' - AND column_name = 'instagram' - AND table_schema = 'public' - ) THEN - ALTER TABLE artist_coins ADD COLUMN instagram TEXT; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = 'artist_coins' - AND column_name = 'tiktok' - AND table_schema = 'public' - ) THEN - ALTER TABLE artist_coins ADD COLUMN tiktok TEXT; - END IF; - - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = 'artist_coins' - AND column_name = 'website' - AND table_schema = 'public' - ) THEN - ALTER TABLE artist_coins ADD COLUMN website TEXT; - END IF; + ALTER TABLE artist_coins ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW(); + ALTER TABLE artist_coins ADD COLUMN IF NOT EXISTS x_handle TEXT; + ALTER TABLE artist_coins ADD COLUMN IF NOT EXISTS instagram_handle TEXT; + ALTER TABLE artist_coins ADD COLUMN IF NOT EXISTS tiktok_handle TEXT; + ALTER TABLE artist_coins ADD COLUMN IF NOT EXISTS website TEXT; END $$; diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 03f23050..e98ac792 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -5674,7 +5674,10 @@ CREATE TABLE public.artist_coins ( updated_at timestamp without time zone DEFAULT now(), twitter text, instagram text, - tiktok text + tiktok text, + x_handle text, + instagram_handle text, + tiktok_handle text ); From 872c18d73c9963f5fb7dd5b669271e6dc70d6b7d Mon Sep 17 00:00:00 2001 From: Dharit Tantiviramanond Date: Tue, 30 Sep 2025 14:05:41 -0400 Subject: [PATCH 3/3] swagger + comments --- api/server_test.go | 3 + api/swagger/swagger-v1-full.yaml | 387 ++++++++++++++++++++++++++++++ api/swagger/swagger-v1.yaml | 78 ++++++ api/v1_update_coin.go | 97 +++++--- api/v1_update_coin_test.go | 400 +++++++++++++++++++++---------- 5 files changed, 808 insertions(+), 157 deletions(-) diff --git a/api/server_test.go b/api/server_test.go index 44c8c915..4e9c6c6a 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -295,6 +295,9 @@ func testGetWithWallet(t *testing.T, app *ApiServer, path string, walletAddress func testPostWithWallet(t *testing.T, app *ApiServer, path string, walletAddress string, body []byte, headers map[string]string, dest ...any) (int, []byte) { req := httptest.NewRequest("POST", path, bytes.NewBuffer(body)) + // Set default Content-Type header + req.Header.Set("Content-Type", "application/json") + // Add signature headers if wallet address is provided if walletAddress != "" { sigData := testdata.GetSignatureData(walletAddress) diff --git a/api/swagger/swagger-v1-full.yaml b/api/swagger/swagger-v1-full.yaml index 607c58c0..f0fc6df3 100644 --- a/api/swagger/swagger-v1-full.yaml +++ b/api/swagger/swagger-v1-full.yaml @@ -11,6 +11,8 @@ tags: description: Full playlist related operations - name: users description: Full user operations +- name: coins + description: Coin related operations - name: search description: Full search operations - name: tips @@ -76,6 +78,200 @@ paths: "500": description: Server error content: {} + /coins: + get: + tags: + - coins + operationId: Get Coins + description: 'Gets a list of coins with optional filtering' + parameters: + - name: ticker + in: query + description: Filter by coin ticker(s) + schema: + type: array + items: + type: string + style: form + explode: true + - name: mint + in: query + description: Filter by coin mint address(es) + schema: + type: array + items: + type: string + style: form + explode: true + - name: owner_id + in: query + description: Filter by owner user ID(s) + schema: + type: array + items: + type: string + style: form + explode: true + - name: limit + in: query + description: Maximum number of results to return + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + - name: offset + in: query + description: Number of results to skip + schema: + type: integer + minimum: 0 + default: 0 + - name: query + in: query + description: Search query for ticker, name, or handle + schema: + type: string + - name: sort_method + in: query + description: Sort method + schema: + type: string + enum: [market_cap, price, volume, created_at, holder] + default: market_cap + - name: sort_direction + in: query + description: Sort direction + schema: + type: string + enum: [asc, desc] + default: desc + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/coins_response' + post: + tags: + - coins + operationId: Create Coin + description: 'Creates a new artist coin' + parameters: + - name: user_id + in: query + description: The user ID to create a coin + required: true + schema: + type: string + example: "7eP5n" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/create_coin_request' + responses: + '201': + description: Success - Coin created + content: + application/json: + schema: + $ref: '#/components/schemas/create_coin_response' + '400': + description: Bad request - Invalid parameters + content: {} + '500': + description: Server error + content: {} + /coins/{mint}: + get: + tags: + - coins + operationId: Get Coin + description: 'Gets information about a specific coin by its mint address' + parameters: + - name: mint + in: path + description: The mint address of the coin + required: true + schema: + type: string + example: 9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/coin_response' + post: + tags: + - coins + operationId: Update Coin + description: 'Updates information about a specific coin by its mint address' + parameters: + - name: mint + in: path + description: The mint address of the coin + required: true + schema: + type: string + example: bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj + - name: user_id + in: query + description: The user ID making the update (must be the coin owner) + required: true + schema: + type: string + example: "7eP5n" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/update_coin_request' + responses: + '200': + description: Success - Coin updated + content: + application/json: + schema: + $ref: '#/components/schemas/update_coin_response' + '400': + description: Bad request - Invalid parameters or no fields provided + content: {} + '403': + description: Forbidden - User does not own the coin + content: {} + '404': + description: Not found - Coin does not exist + content: {} + '500': + description: Server error + content: {} + /coins/ticker/{ticker}: + get: + tags: + - coins + operationId: Get Coin By Ticker + description: 'Gets information about a specific coin by its ticker' + parameters: + - name: ticker + in: path + description: The ticker symbol of the coin + required: true + schema: + type: string + example: "$AUDIO" + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/coin_response' /cid_data/{metadata_id}: get: tags: @@ -5720,6 +5916,197 @@ components: $ref: '#/components/schemas/comment' related: $ref: '#/components/schemas/related' + coin: + type: object + description: A coin object + required: + - mint + - ticker + - decimals + - name + - created_at + properties: + mint: + type: string + description: The mint address of the coin + example: "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj" + ticker: + type: string + description: The coin symbol/ticker + example: "$BEAR" + decimals: + type: integer + description: The number of decimals for the coin + example: 9 + name: + type: string + description: The coin name + example: "BEAR" + logo_uri: + type: string + description: The URI for the coin's logo image + example: "https://example.com/logo.png" + description: + type: string + description: The description of the coin + example: "A majestic bear token for wildlife conservation" + x_handle: + type: string + description: X (Twitter) handle for the coin + example: "bear_token" + instagram_handle: + type: string + description: Instagram handle for the coin + example: "bear_token" + tiktok_handle: + type: string + description: TikTok handle for the coin + example: "bear_token" + website: + type: string + description: Website URL for the coin + example: "https://bear-token.com" + has_discord: + type: boolean + description: Whether the coin has a Discord server + example: false + created_at: + type: string + format: date-time + description: The date and time when the coin was created + example: "2024-01-15T10:30:00Z" + updated_at: + type: string + format: date-time + description: The date and time when the coin was last updated + example: "2024-01-15T10:30:00Z" + owner_id: + type: string + description: The user ID of the coin owner + example: "7eP5n" + coin_response: + type: object + properties: + data: + $ref: '#/components/schemas/coin' + coins_response: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/coin' + create_coin_request: + type: object + required: + - mint + - ticker + - decimals + - name + properties: + mint: + type: string + description: The mint address of the coin + example: "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj" + ticker: + type: string + description: The coin symbol/ticker + example: "$BEAR" + decimals: + type: integer + description: The number of decimals for the coin (0-18) + minimum: 0 + maximum: 18 + example: 9 + name: + type: string + description: The coin name + example: "BEAR" + logo_uri: + type: string + description: The URI for the coin's logo image + example: "https://example.com/logo.png" + description: + type: string + description: The description of the coin + example: "A majestic bear token for wildlife conservation" + create_coin_response: + type: object + properties: + data: + type: object + required: + - mint + - ticker + - user_id + - decimals + - name + - created_at + properties: + mint: + type: string + description: The mint address of the coin + example: "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj" + ticker: + type: string + description: The coin symbol/ticker + example: "$BEAR" + user_id: + type: integer + description: The user ID who created the coin + example: 1 + decimals: + type: integer + description: The number of decimals for the coin + example: 9 + name: + type: string + description: The coin name + example: "BEAR" + logo_uri: + type: string + description: The URI for the coin's logo image + example: "https://example.com/logo.png" + created_at: + type: string + format: date-time + description: The date and time when the coin was created + example: "2024-01-15T10:30:00Z" + update_coin_request: + type: object + description: Request body for updating coin information + properties: + description: + type: string + description: The description of the coin (max 2500 characters) + example: "Updated description for the bear token" + maxLength: 2500 + x_handle: + type: string + description: X (Twitter) handle for the coin (without @ symbol) + example: "bear_token" + instagram_handle: + type: string + description: Instagram handle for the coin (without @ symbol) + example: "bear_token" + tiktok_handle: + type: string + description: TikTok handle for the coin (without @ symbol) + example: "bear_token" + website: + type: string + description: Website URL for the coin + example: "https://bear-token.com" + format: uri + update_coin_response: + type: object + properties: + success: + type: boolean + description: Indicates if the update was successful + example: true comment: required: - created_at diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 82bb4895..0e3d3c12 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -3592,6 +3592,51 @@ paths: application/json: schema: $ref: '#/components/schemas/coin_response' + post: + tags: + - coins + operationId: Update Coin + description: 'Updates information about a specific coin by its mint address' + parameters: + - name: mint + in: path + description: The mint address of the coin + required: true + schema: + type: string + example: bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj + - name: user_id + in: query + description: The user ID making the update (must be the coin owner) + required: true + schema: + type: string + example: "7eP5n" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/update_coin_request' + responses: + '200': + description: Success - Coin updated + content: + application/json: + schema: + $ref: '#/components/schemas/update_coin_response' + '400': + description: Bad request - Invalid parameters or no fields provided + content: {} + '403': + description: Forbidden - User does not own the coin + content: {} + '404': + description: Not found - Coin does not exist + content: {} + '500': + description: Server error + content: {} /coins/ticker/{ticker}: get: tags: @@ -5559,6 +5604,39 @@ components: format: date-time description: The date and time when the coin was created example: "2024-01-15T10:30:00Z" + update_coin_request: + type: object + description: Request body for updating coin information + properties: + description: + type: string + description: The description of the coin (max 2500 characters) + example: "Updated description for the bear token" + maxLength: 2500 + x_handle: + type: string + description: X (Twitter) handle for the coin (without @ symbol) + example: "bear_token" + instagram_handle: + type: string + description: Instagram handle for the coin (without @ symbol) + example: "bear_token" + tiktok_handle: + type: string + description: TikTok handle for the coin (without @ symbol) + example: "bear_token" + website: + type: string + description: Website URL for the coin + example: "https://bear-token.com" + format: uri + update_coin_response: + type: object + properties: + success: + type: boolean + description: Indicates if the update was successful + example: true coin_insights: type: object description: | diff --git a/api/v1_update_coin.go b/api/v1_update_coin.go index 5b9eecd8..a639ecb9 100644 --- a/api/v1_update_coin.go +++ b/api/v1_update_coin.go @@ -3,19 +3,30 @@ package api import ( "errors" "log" + "net/url" "strings" - "time" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" ) type UpdateCoinBody struct { - Description string `json:"description" validate:"max=2500"` - XHandle string `json:"x_handle" validate:"omitempty,url"` - InstagramHandle string `json:"instagram_handle" validate:"omitempty,url"` - TiktokHandle string `json:"tiktok_handle" validate:"omitempty,url"` - Website string `json:"website" validate:"omitempty,url"` + Description string `json:"description" validate:"max=2500"` + XHandle *string `json:"x_handle,omitempty"` + InstagramHandle *string `json:"instagram_handle,omitempty"` + TiktokHandle *string `json:"tiktok_handle,omitempty"` + Website *string `json:"website,omitempty"` +} + +func validateURL(s string) error { + u, err := url.Parse(s) + if err != nil { + return err + } + if u.Scheme == "" || u.Host == "" { + return errors.New("invalid URL format") + } + return nil } func (app *ApiServer) v1UpdateCoin(c *fiber.Ctx) error { @@ -52,61 +63,75 @@ func (app *ApiServer) v1UpdateCoin(c *fiber.Ctx) error { // Build dynamic UPDATE query based on provided fields setParts := []string{"updated_at = NOW()"} args := pgx.NamedArgs{"mint": mint} + hasUpdates := false if body.Description != "" { setParts = append(setParts, "description = @description") args["description"] = body.Description + hasUpdates = true } - if body.XHandle != "" { + if body.XHandle != nil { setParts = append(setParts, "x_handle = @x_handle") - args["x_handle"] = body.XHandle + if *body.XHandle == "" { + args["x_handle"] = nil + } else { + args["x_handle"] = *body.XHandle + } + hasUpdates = true } - if body.InstagramHandle != "" { + if body.InstagramHandle != nil { setParts = append(setParts, "instagram_handle = @instagram_handle") - args["instagram_handle"] = body.InstagramHandle + if *body.InstagramHandle == "" { + args["instagram_handle"] = nil + } else { + args["instagram_handle"] = *body.InstagramHandle + } + hasUpdates = true } - if body.TiktokHandle != "" { + if body.TiktokHandle != nil { setParts = append(setParts, "tiktok_handle = @tiktok_handle") - args["tiktok_handle"] = body.TiktokHandle + if *body.TiktokHandle == "" { + args["tiktok_handle"] = nil + } else { + args["tiktok_handle"] = *body.TiktokHandle + } + hasUpdates = true } - if body.Website != "" { + if body.Website != nil { + if *body.Website != "" { + // Validate URL format for non-empty values + if err := validateURL(*body.Website); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid website URL format") + } + } setParts = append(setParts, "website = @website") - args["website"] = body.Website + if *body.Website == "" { + args["website"] = nil + } else { + args["website"] = *body.Website + } + hasUpdates = true + } + + if !hasUpdates { + return fiber.NewError(fiber.StatusBadRequest, "At least one field must be provided for update") } sql := ` UPDATE artist_coins SET ` + strings.Join(setParts, ", ") + ` WHERE mint = @mint - RETURNING mint, ticker, user_id, decimals, name, logo_uri, description, x_handle, instagram_handle, tiktok_handle, website, created_at, updated_at ` - row := app.writePool.QueryRow(c.Context(), sql, args) - - var result struct { - Mint string `json:"mint"` - Ticker string `json:"ticker"` - UserID int32 `json:"user_id"` - Decimals int32 `json:"decimals"` - Name string `json:"name"` - LogoUri *string `json:"logo_uri"` - Description *string `json:"description"` - XHandle *string `json:"x_handle"` - InstagramHandle *string `json:"instagram_handle"` - TiktokHandle *string `json:"tiktok_handle"` - Website *string `json:"website"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - } - - if err := row.Scan(&result.Mint, &result.Ticker, &result.UserID, &result.Decimals, &result.Name, &result.LogoUri, &result.Description, &result.XHandle, &result.InstagramHandle, &result.TiktokHandle, &result.Website, &result.CreatedAt, &result.UpdatedAt); err != nil { + _, err = app.writePool.Exec(c.Context(), sql, args) + if err != nil { log.Println("Failed to update coin", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": "Failed to update coin", }) } - return c.JSON(fiber.Map{ - "data": result, + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, }) } diff --git a/api/v1_update_coin_test.go b/api/v1_update_coin_test.go index ecca022d..b5c9181d 100644 --- a/api/v1_update_coin_test.go +++ b/api/v1_update_coin_test.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "strings" "testing" "api.audius.co/database" @@ -11,7 +12,7 @@ import ( func TestV1UpdateCoin(t *testing.T) { app := emptyTestApp(t) - database.Seed(app.pool.Replicas[0], database.FixtureMap{ + database.Seed(app.writePool, database.FixtureMap{ "users": { { "user_id": 1, @@ -32,33 +33,26 @@ func TestV1UpdateCoin(t *testing.T) { }, }) + xHandle := "bear_token" + instagramHandle := "bear_token" + tiktokHandle := "bear_token" + website := "https://bear-token.com" + requestBody := UpdateCoinBody{ Description: "Updated description for the bear token", - XHandle: "https://x.com/bear_token", - InstagramHandle: "https://instagram.com/bear_token", - TiktokHandle: "https://tiktok.com/@bear_token", - Website: "https://bear-token.com", + XHandle: &xHandle, + InstagramHandle: &instagramHandle, + TiktokHandle: &tiktokHandle, + Website: &website, } requestBodyBytes, err := json.Marshal(requestBody) assert.NoError(t, err) - status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", - }) + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", - "data.ticker": "$BEAR", - "data.user_id": 1, - "data.decimals": 9, - "data.name": "BEAR", - "data.logo_uri": "https://example.com/bear-logo.png", - "data.description": "Updated description for the bear token", - "data.x_handle": "https://x.com/bear_token", - "data.instagram_handle": "https://instagram.com/bear_token", - "data.tiktok_handle": "https://tiktok.com/@bear_token", - "data.website": "https://bear-token.com", + "success": true, }) // Verify the coin was actually updated by fetching it via API @@ -69,16 +63,16 @@ func TestV1UpdateCoin(t *testing.T) { "data.ticker": "$BEAR", "data.name": "BEAR", "data.description": "Updated description for the bear token", - "data.x_handle": "https://x.com/bear_token", - "data.instagram_handle": "https://instagram.com/bear_token", - "data.tiktok_handle": "https://tiktok.com/@bear_token", + "data.x_handle": "bear_token", + "data.instagram_handle": "bear_token", + "data.tiktok_handle": "bear_token", "data.website": "https://bear-token.com", }) } func TestV1UpdateCoin_CoinNotFound(t *testing.T) { app := emptyTestApp(t) - database.Seed(app.pool.Replicas[0], database.FixtureMap{ + database.Seed(app.writePool, database.FixtureMap{ "users": { { "user_id": 1, @@ -88,19 +82,22 @@ func TestV1UpdateCoin_CoinNotFound(t *testing.T) { }, }) + xHandle2 := "test_handle" + instagramHandle2 := "test_handle" + tiktokHandle2 := "test_handle" + website2 := "https://test.com" + requestBody := UpdateCoinBody{ Description: "Updated description", - XHandle: "https://x.com/test", - InstagramHandle: "https://instagram.com/test", - TiktokHandle: "https://tiktok.com/@test", - Website: "https://test.com", + XHandle: &xHandle2, + InstagramHandle: &instagramHandle2, + TiktokHandle: &tiktokHandle2, + Website: &website2, } requestBodyBytes, err := json.Marshal(requestBody) assert.NoError(t, err) - status, body := testPostWithWallet(t, app, "/v1/coins/nonexistentMint?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", - }) + status, body := testPostWithWallet(t, app, "/v1/coins/nonexistentMint?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) assert.Equal(t, 404, status) jsonAssert(t, body, map[string]any{ @@ -110,7 +107,7 @@ func TestV1UpdateCoin_CoinNotFound(t *testing.T) { func TestV1UpdateCoin_Unauthorized(t *testing.T) { app := emptyTestApp(t) - database.Seed(app.pool.Replicas[0], database.FixtureMap{ + database.Seed(app.writePool, database.FixtureMap{ "users": { { "user_id": 1, @@ -135,20 +132,23 @@ func TestV1UpdateCoin_Unauthorized(t *testing.T) { }, }) + xHandle3 := "test_handle_3" + instagramHandle3 := "test_handle_3" + tiktokHandle3 := "test_handle_3" + website3 := "https://test.com" + requestBody := UpdateCoinBody{ Description: "Updated description", - XHandle: "https://x.com/test", - InstagramHandle: "https://instagram.com/test", - TiktokHandle: "https://tiktok.com/@test", - Website: "https://test.com", + XHandle: &xHandle3, + InstagramHandle: &instagramHandle3, + TiktokHandle: &tiktokHandle3, + Website: &website3, } requestBodyBytes, err := json.Marshal(requestBody) assert.NoError(t, err) // Try to update with user 2 (who doesn't own the coin) - status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(2), "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", - }) + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(2), "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0", requestBodyBytes, nil) assert.Equal(t, 403, status) jsonAssert(t, body, map[string]any{ @@ -158,7 +158,7 @@ func TestV1UpdateCoin_Unauthorized(t *testing.T) { func TestV1UpdateCoin_Validation(t *testing.T) { app := emptyTestApp(t) - database.Seed(app.pool.Replicas[0], database.FixtureMap{ + database.Seed(app.writePool, database.FixtureMap{ "users": { { "user_id": 1, @@ -179,10 +179,7 @@ func TestV1UpdateCoin_Validation(t *testing.T) { }) // Test with description that's too long (>2500 chars) - longDescription := "" - for len(longDescription) <= 2500 { - longDescription += "a" - } + longDescription := strings.Repeat("a", 2501) requestBody := UpdateCoinBody{ Description: longDescription, @@ -190,17 +187,14 @@ func TestV1UpdateCoin_Validation(t *testing.T) { requestBodyBytes, err := json.Marshal(requestBody) assert.NoError(t, err) - status, _ := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", - }) + status, _ := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) assert.Equal(t, 400, status) - // The validation error will be handled by the ParseAndValidateBody method } func TestV1UpdateCoin_IndividualFields(t *testing.T) { app := emptyTestApp(t) - database.Seed(app.pool.Replicas[0], database.FixtureMap{ + database.Seed(app.writePool, database.FixtureMap{ "users": { { "user_id": 1, @@ -227,65 +221,52 @@ func TestV1UpdateCoin_IndividualFields(t *testing.T) { requestBodyBytes, err := json.Marshal(requestBody) assert.NoError(t, err) - status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", - }) + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.description": "Updated description only", - "data.x_handle": "", - "data.instagram_handle": "", - "data.tiktok_handle": "", - "data.website": "", + "success": true, }) // Test updating only Twitter + xHandle4 := "bear_token_handle" requestBody = UpdateCoinBody{ - XHandle: "https://x.com/bear_token", + XHandle: &xHandle4, } requestBodyBytes, err = json.Marshal(requestBody) assert.NoError(t, err) - status, body = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", - }) + status, body = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.description": "Updated description only", - "data.x_handle": "https://x.com/bear_token", - "data.instagram_handle": "", - "data.tiktok_handle": "", - "data.website": "", + "success": true, }) // Test updating multiple fields at once + instagramHandle5 := "bear_token_insta" + tiktokHandle5 := "bear_token_tiktok" + website5 := "https://bear-token.com" + requestBody = UpdateCoinBody{ - InstagramHandle: "https://instagram.com/bear_token", - TiktokHandle: "https://tiktok.com/@bear_token", - Website: "https://bear-token.com", + InstagramHandle: &instagramHandle5, + TiktokHandle: &tiktokHandle5, + Website: &website5, } requestBodyBytes, err = json.Marshal(requestBody) assert.NoError(t, err) - status, body = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", - }) + status, body = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.description": "Updated description only", - "data.x_handle": "https://x.com/bear_token", - "data.instagram_handle": "https://instagram.com/bear_token", - "data.tiktok_handle": "https://tiktok.com/@bear_token", - "data.website": "https://bear-token.com", + "success": true, }) } -func TestV1UpdateCoin_URLValidation(t *testing.T) { +func TestV1UpdateCoin_NoFields(t *testing.T) { app := emptyTestApp(t) - database.Seed(app.pool.Replicas[0], database.FixtureMap{ + database.Seed(app.writePool, database.FixtureMap{ "users": { { "user_id": 1, @@ -305,77 +286,254 @@ func TestV1UpdateCoin_URLValidation(t *testing.T) { }, }) - // Test invalid Twitter URL - requestBody := UpdateCoinBody{ - XHandle: "not-a-valid-url", - } + // Test updating with no fields provided (empty request body) - should fail + requestBody := UpdateCoinBody{} requestBodyBytes, err := json.Marshal(requestBody) assert.NoError(t, err) - status, _ := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", - }) + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) assert.Equal(t, 400, status) - - // Test invalid Instagram URL - requestBody = UpdateCoinBody{ - InstagramHandle: "also-not-valid", - } - requestBodyBytes, err = json.Marshal(requestBody) - assert.NoError(t, err) - - status, _ = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", + jsonAssert(t, body, map[string]any{ + "error": "At least one field must be provided for update", }) +} - assert.Equal(t, 400, status) +func TestV1UpdateCoin_URLValidation(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.writePool, database.FixtureMap{ + "users": { + { + "user_id": 1, + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "is_verified": true, + }, + }, + "artist_coins": { + { + "mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "ticker": "$BEAR", + "user_id": 1, + "decimals": 9, + "name": "BEAR", + "description": "Original description", + }, + }, + }) - // Test invalid TikTok URL - requestBody = UpdateCoinBody{ - TiktokHandle: "invalid-url", + // Test invalid Website URL + invalidWebsite := "definitely-not-a-url" + requestBody := UpdateCoinBody{ + Website: &invalidWebsite, } - requestBodyBytes, err = json.Marshal(requestBody) + requestBodyBytes, err := json.Marshal(requestBody) assert.NoError(t, err) - status, _ = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", - }) + status, _ := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) assert.Equal(t, 400, status) - // Test invalid Website URL + // Test valid URLs work + validXHandle := "example_handle" + validInstagramHandle := "example_handle" + validTiktokHandle := "example_handle" + validWebsite := "https://example.com" + requestBody = UpdateCoinBody{ - Website: "definitely-not-a-url", + XHandle: &validXHandle, + InstagramHandle: &validInstagramHandle, + TiktokHandle: &validTiktokHandle, + Website: &validWebsite, } requestBodyBytes, err = json.Marshal(requestBody) assert.NoError(t, err) - status, _ = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", - }) + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) - assert.Equal(t, 400, status) + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "success": true, + }) - // Test valid URLs work + // Test deleting handles by passing empty strings + emptyString := "" requestBody = UpdateCoinBody{ - XHandle: "https://x.com/example", - InstagramHandle: "https://www.instagram.com/example", - TiktokHandle: "https://www.tiktok.com/@example", - Website: "https://example.com", + XHandle: &emptyString, + InstagramHandle: &emptyString, + TiktokHandle: &emptyString, + Website: &emptyString, } requestBodyBytes, err = json.Marshal(requestBody) assert.NoError(t, err) - status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, map[string]string{ - "Content-Type": "application/json", - }) + status, body = testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.x_handle": "https://x.com/example", - "data.instagram_handle": "https://www.instagram.com/example", - "data.tiktok_handle": "https://www.tiktok.com/@example", - "data.website": "https://example.com", + "data.x_handle": nil, + "data.instagram_handle": nil, + "data.tiktok_handle": nil, + "data.website": nil, + }) +} + +func TestV1UpdateCoin_DeleteFields(t *testing.T) { + // Test deleting x_handle only + t.Run("delete x_handle", func(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.writePool, database.FixtureMap{ + "users": { + { + "user_id": 1, + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "is_verified": true, + }, + }, + "artist_coins": { + { + "mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "ticker": "$BEAR", + "user_id": 1, + "decimals": 9, + "name": "BEAR", + "description": "Original description", + "x_handle": "original_handle", + "instagram_handle": "original_handle", + "tiktok_handle": "original_handle", + "website": "https://original.com", + }, + }, + }) + + emptyString := "" + requestBody := UpdateCoinBody{ + XHandle: &emptyString, + } + requestBodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "success": true, + }) + + // Verify the deletion via GET + status, body = testGet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.x_handle": nil, + "data.instagram_handle": "original_handle", + "data.tiktok_handle": "original_handle", + "data.website": "https://original.com", + }) + }) + + // Test deleting instagram_handle only + t.Run("delete instagram_handle", func(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.writePool, database.FixtureMap{ + "users": { + { + "user_id": 1, + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "is_verified": true, + }, + }, + "artist_coins": { + { + "mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "ticker": "$BEAR", + "user_id": 1, + "decimals": 9, + "name": "BEAR", + "description": "Original description", + "x_handle": "original_handle", + "instagram_handle": "original_handle", + "tiktok_handle": "original_handle", + "website": "https://original.com", + }, + }, + }) + + emptyString := "" + requestBody := UpdateCoinBody{ + InstagramHandle: &emptyString, + } + requestBodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "success": true, + }) + + // Verify the deletion via GET + status, body = testGet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.x_handle": "original_handle", + "data.instagram_handle": nil, + "data.tiktok_handle": "original_handle", + "data.website": "https://original.com", + }) + }) + + // Test deleting all handles + t.Run("delete all handles", func(t *testing.T) { + app := emptyTestApp(t) + database.Seed(app.writePool, database.FixtureMap{ + "users": { + { + "user_id": 1, + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "is_verified": true, + }, + }, + "artist_coins": { + { + "mint": "bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj", + "ticker": "$BEAR", + "user_id": 1, + "decimals": 9, + "name": "BEAR", + "description": "Original description", + "x_handle": "original_handle", + "instagram_handle": "original_handle", + "tiktok_handle": "original_handle", + "website": "https://original.com", + }, + }, + }) + + emptyString := "" + requestBody := UpdateCoinBody{ + XHandle: &emptyString, + InstagramHandle: &emptyString, + TiktokHandle: &emptyString, + Website: &emptyString, + } + requestBodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + status, body := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "success": true, + }) + + // Verify all deletions via GET + status, body = testGet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj") + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.x_handle": nil, + "data.instagram_handle": nil, + "data.tiktok_handle": nil, + "data.website": nil, + }) }) }