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/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_coin.go b/api/v1_coin.go index f3a957cc..61966b82 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.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, 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.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, 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..44e2c6db 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"` + 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"` @@ -131,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 new file mode 100644 index 00000000..a639ecb9 --- /dev/null +++ b/api/v1_update_coin.go @@ -0,0 +1,137 @@ +package api + +import ( + "errors" + "log" + "net/url" + "strings" + + "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,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 { + 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} + hasUpdates := false + + if body.Description != "" { + setParts = append(setParts, "description = @description") + args["description"] = body.Description + hasUpdates = true + } + if body.XHandle != nil { + setParts = append(setParts, "x_handle = @x_handle") + if *body.XHandle == "" { + args["x_handle"] = nil + } else { + args["x_handle"] = *body.XHandle + } + hasUpdates = true + } + if body.InstagramHandle != nil { + setParts = append(setParts, "instagram_handle = @instagram_handle") + if *body.InstagramHandle == "" { + args["instagram_handle"] = nil + } else { + args["instagram_handle"] = *body.InstagramHandle + } + hasUpdates = true + } + if body.TiktokHandle != nil { + setParts = append(setParts, "tiktok_handle = @tiktok_handle") + if *body.TiktokHandle == "" { + args["tiktok_handle"] = nil + } else { + args["tiktok_handle"] = *body.TiktokHandle + } + hasUpdates = true + } + 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") + 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 + ` + + _, 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.Status(fiber.StatusOK).JSON(fiber.Map{ + "success": true, + }) +} diff --git a/api/v1_update_coin_test.go b/api/v1_update_coin_test.go new file mode 100644 index 00000000..b5c9181d --- /dev/null +++ b/api/v1_update_coin_test.go @@ -0,0 +1,539 @@ +package api + +import ( + "encoding/json" + "strings" + "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.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", + "logo_uri": "https://example.com/bear-logo.png", + "description": "Original description", + }, + }, + }) + + xHandle := "bear_token" + instagramHandle := "bear_token" + tiktokHandle := "bear_token" + website := "https://bear-token.com" + + requestBody := UpdateCoinBody{ + Description: "Updated description for the bear token", + 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, nil) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "success": true, + }) + + // 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.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.writePool, database.FixtureMap{ + "users": { + { + "user_id": 1, + "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", + "is_verified": true, + }, + }, + }) + + xHandle2 := "test_handle" + instagramHandle2 := "test_handle" + tiktokHandle2 := "test_handle" + website2 := "https://test.com" + + requestBody := UpdateCoinBody{ + Description: "Updated description", + 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, nil) + + 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.writePool, 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", + }, + }, + }) + + xHandle3 := "test_handle_3" + instagramHandle3 := "test_handle_3" + tiktokHandle3 := "test_handle_3" + website3 := "https://test.com" + + requestBody := UpdateCoinBody{ + Description: "Updated description", + 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, nil) + + 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.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 with description that's too long (>2500 chars) + longDescription := strings.Repeat("a", 2501) + + 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, nil) + + assert.Equal(t, 400, status) +} + +func TestV1UpdateCoin_IndividualFields(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 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, nil) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "success": true, + }) + + // Test updating only Twitter + xHandle4 := "bear_token_handle" + requestBody = UpdateCoinBody{ + 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, nil) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "success": true, + }) + + // Test updating multiple fields at once + instagramHandle5 := "bear_token_insta" + tiktokHandle5 := "bear_token_tiktok" + website5 := "https://bear-token.com" + + requestBody = UpdateCoinBody{ + 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, nil) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "success": true, + }) +} + +func TestV1UpdateCoin_NoFields(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 updating with no fields provided (empty request body) - should fail + requestBody := UpdateCoinBody{} + 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, 400, status) + jsonAssert(t, body, map[string]any{ + "error": "At least one field must be provided for update", + }) +} + +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 Website URL + invalidWebsite := "definitely-not-a-url" + requestBody := UpdateCoinBody{ + Website: &invalidWebsite, + } + requestBodyBytes, err := json.Marshal(requestBody) + assert.NoError(t, err) + + status, _ := testPostWithWallet(t, app, "/v1/coins/bearR26zyyB3fNQm5wWv1ZfN8MPQDUMwaAuoG79b1Yj?user_id="+trashid.MustEncodeHashID(1), "0x7d273271690538cf855e5b3002a0dd8c154bb060", requestBodyBytes, nil) + + assert.Equal(t, 400, status) + + // Test valid URLs work + validXHandle := "example_handle" + validInstagramHandle := "example_handle" + validTiktokHandle := "example_handle" + validWebsite := "https://example.com" + + requestBody = UpdateCoinBody{ + XHandle: &validXHandle, + InstagramHandle: &validInstagramHandle, + TiktokHandle: &validTiktokHandle, + Website: &validWebsite, + } + 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, + }) + + // Test deleting handles by passing empty strings + 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{ + "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, + }) + }) +} 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..3dbd1ef3 --- /dev/null +++ b/ddl/migrations/0165_artist_coins_updated_at_and_social_links.sql @@ -0,0 +1,9 @@ +DO $$ +BEGIN + 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 42cd713f..e98ac792 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -5670,7 +5670,14 @@ 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, + x_handle text, + instagram_handle text, + tiktok_handle text );