Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21f53f257c | |||
| 8ca6a450a3 | |||
| 58d8d52194 | |||
| 7d617bbb30 | |||
| cd6dfa345b | |||
| 1967cc2c22 |
@@ -12,7 +12,7 @@ Images available on [Docker Hub](https://hub.docker.com/r/sjdonado/bit/tags).
|
|||||||
It is feature-complete by design. Its strength lies in simplicity, a reliable URL shortener without unnecessary bloat. Bug fixes will continue, but new features aren't planned.
|
It is feature-complete by design. Its strength lies in simplicity, a reliable URL shortener without unnecessary bloat. Bug fixes will continue, but new features aren't planned.
|
||||||
|
|
||||||
- Minimal tracking setup: Country, browser, os, referer. No cookies or persistent tracking mechanisms are used beyond what's available from a basic client's request.
|
- Minimal tracking setup: Country, browser, os, referer. No cookies or persistent tracking mechanisms are used beyond what's available from a basic client's request.
|
||||||
- Flexible request forwarding system passes client context (IP, user-agent) to destinations via standard X-Forwarded-For and X-Forwarded-User-Agent headers, enabling advanced tracking and integration capabilities when needed.
|
- Flexible request forwarding system passes client context (IP, user-agent) to destinations via standard `X-Forwarded-For` and `User-Agent` headers, enabling advanced tracking and integration capabilities when needed.
|
||||||
- Multiple users are supported via API key authentication. Create, list and delete via the [CLI](docs/SETUP.md#cli).
|
- Multiple users are supported via API key authentication. Create, list and delete via the [CLI](docs/SETUP.md#cli).
|
||||||
|
|
||||||
## Minimum Requirements
|
## Minimum Requirements
|
||||||
|
|||||||
+70
-7
@@ -53,7 +53,7 @@ module App::Controllers::Link
|
|||||||
link = Database.get_by(Link, slug: slug)
|
link = Database.get_by(Link, slug: slug)
|
||||||
raise App::NotFoundException.new(env) if !link
|
raise App::NotFoundException.new(env) if !link
|
||||||
|
|
||||||
remote_address = env.request.remote_address.try &.to_s
|
remote_address = env.request.headers["Cf-Connecting-Ip"]?.try(&.presence) || env.request.remote_address.try &.to_s
|
||||||
user_agent_str = env.request.headers["User-Agent"]? || "Unknown"
|
user_agent_str = env.request.headers["User-Agent"]? || "Unknown"
|
||||||
|
|
||||||
client_ip = IpLookup.extract_ip(remote_address) || "Unknown"
|
client_ip = IpLookup.extract_ip(remote_address) || "Unknown"
|
||||||
@@ -62,7 +62,7 @@ module App::Controllers::Link
|
|||||||
env.response.headers["Location"] = link.url!
|
env.response.headers["Location"] = link.url!
|
||||||
|
|
||||||
env.response.headers["X-Forwarded-For"] = client_ip
|
env.response.headers["X-Forwarded-For"] = client_ip
|
||||||
env.response.headers["X-Forwarded-User-Agent"] = user_agent_str
|
env.response.headers["User-Agent"] = user_agent_str
|
||||||
|
|
||||||
spawn do
|
spawn do
|
||||||
ip_lookup = client_ip != "Unknown" ? IpLookup.new(client_ip) : nil
|
ip_lookup = client_ip != "Unknown" ? IpLookup.new(client_ip) : nil
|
||||||
@@ -97,10 +97,30 @@ module App::Controllers::Link
|
|||||||
def call(env)
|
def call(env)
|
||||||
user = env.get("user").as(User)
|
user = env.get("user").as(User)
|
||||||
|
|
||||||
query = Database::Query.where(user_id: user.id.as(String))
|
limit = (env.params.query["limit"]? || "100").to_i
|
||||||
links = Database.all(Link, query, preload: [:clicks])
|
cursor = env.params.query["cursor"]?
|
||||||
|
|
||||||
|
query = Database::Query.where(user_id: user.id.as(String))
|
||||||
|
if cursor
|
||||||
|
query = query.where("id < ?", cursor)
|
||||||
|
end
|
||||||
|
|
||||||
|
query = query.order_by("id DESC").limit(limit + 1)
|
||||||
|
links = Database.all(Link, query)
|
||||||
|
|
||||||
|
has_more = links.size > limit
|
||||||
|
links = links[0...limit] if has_more
|
||||||
|
|
||||||
|
next_cursor = has_more ? links.last.id : nil
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"data" => links.map { |link| App::Serializers::Link.new(link) },
|
||||||
|
"pagination" => {
|
||||||
|
"has_more" => has_more,
|
||||||
|
"next" => next_cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response = {"data" => links.map { |link| App::Serializers::Link.new(link) }}
|
|
||||||
response.to_json
|
response.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -114,15 +134,58 @@ module App::Controllers::Link
|
|||||||
link_id = env.params.url["id"]
|
link_id = env.params.url["id"]
|
||||||
|
|
||||||
query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
|
query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
|
||||||
link = Database.all(Link, query, preload: [:clicks]).first?
|
link = Database.all(Link, query).first?
|
||||||
|
|
||||||
raise App::NotFoundException.new(env) if link.nil?
|
raise App::NotFoundException.new(env) if link.nil?
|
||||||
|
|
||||||
|
clicks_query = Database::Query.where(link_id: link_id.as(String))
|
||||||
|
.order_by("id DESC")
|
||||||
|
.limit(100)
|
||||||
|
link.clicks = Database.all(Click, clicks_query)
|
||||||
|
|
||||||
response = {"data" => App::Serializers::Link.new(link)}
|
response = {"data" => App::Serializers::Link.new(link)}
|
||||||
response.to_json
|
response.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class Clicks < App::Lib::BaseController
|
||||||
|
include App::Models
|
||||||
|
include App::Lib
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
user = env.get("user").as(User)
|
||||||
|
link_id = env.params.url["id"]
|
||||||
|
|
||||||
|
link_query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
|
||||||
|
link = Database.all(Link, link_query).first?
|
||||||
|
raise App::NotFoundException.new(env) if link.nil?
|
||||||
|
|
||||||
|
limit = (env.params.query["limit"]? || "100").to_i
|
||||||
|
cursor = env.params.query["cursor"]?
|
||||||
|
|
||||||
|
query = Database::Query.where(link_id: link_id.as(String))
|
||||||
|
if cursor
|
||||||
|
query = query.where("id < ?", cursor)
|
||||||
|
end
|
||||||
|
|
||||||
|
query = query.order_by("id DESC").limit(limit + 1)
|
||||||
|
clicks = Database.all(Click, query)
|
||||||
|
|
||||||
|
has_more = clicks.size > limit
|
||||||
|
clicks = clicks[0...limit] if has_more
|
||||||
|
next_cursor = has_more ? clicks.last.id : nil
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"data" => clicks.map { |click| App::Serializers::Click.new(click) },
|
||||||
|
"pagination" => {
|
||||||
|
"has_more" => has_more,
|
||||||
|
"next" => next_cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class Update < App::Lib::BaseController
|
class Update < App::Lib::BaseController
|
||||||
include App::Models
|
include App::Models
|
||||||
include App::Lib
|
include App::Lib
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ module App
|
|||||||
Controllers::Link::Get.new.call(env)
|
Controllers::Link::Get.new.call(env)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "/api/links/:id/clicks" do |env|
|
||||||
|
Controllers::Link::Clicks.new.call(env)
|
||||||
|
end
|
||||||
|
|
||||||
post "/api/links" do |env|
|
post "/api/links" do |env|
|
||||||
Controllers::Link::Create.new.call(env)
|
Controllers::Link::Create.new.call(env)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,7 +16,15 @@ module App::Serializers
|
|||||||
builder.field("id", @link.id)
|
builder.field("id", @link.id)
|
||||||
builder.field("refer", @refer)
|
builder.field("refer", @refer)
|
||||||
builder.field("origin", @link.url)
|
builder.field("origin", @link.url)
|
||||||
builder.field("clicks", @link.clicks.map { |click| App::Serializers::Click.new(click) })
|
|
||||||
|
begin
|
||||||
|
clicks = @link.clicks
|
||||||
|
unless clicks.empty?
|
||||||
|
builder.field("clicks", clicks.map { |click| App::Serializers::Click.new(click) })
|
||||||
|
end
|
||||||
|
rescue Crecto::AssociationNotLoaded
|
||||||
|
# Association not loaded, skip this field silently
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
module App
|
|
||||||
VERSION = "0.1.0"
|
|
||||||
end
|
|
||||||
+40
-22
@@ -10,7 +10,6 @@
|
|||||||
"data": "pong"
|
"data": "pong"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Redirect by Slug**
|
2. **Redirect by Slug**
|
||||||
|
|
||||||
- Endpoint: `GET /:slug`
|
- Endpoint: `GET /:slug`
|
||||||
@@ -21,7 +20,9 @@
|
|||||||
|
|
||||||
- Endpoint: `GET /api/links`
|
- Endpoint: `GET /api/links`
|
||||||
- Headers: `X-Api-Key`
|
- Headers: `X-Api-Key`
|
||||||
- Payload: None
|
- Query Parameters:
|
||||||
|
- `limit` (optional): Number of results per page (default: 100)
|
||||||
|
- `cursor` (optional): Pagination cursor from previous response
|
||||||
- Response Example
|
- Response Example
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -29,28 +30,21 @@
|
|||||||
{
|
{
|
||||||
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
|
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
|
||||||
"refer": "http://localhost:4000/3wP4BQ",
|
"refer": "http://localhost:4000/3wP4BQ",
|
||||||
"origin": "https://monocuco.donado.co",
|
"origin": "https://monocuco.donado.co"
|
||||||
"clicks": [
|
|
||||||
{
|
|
||||||
"id": "730e2202-58f9-478c-a24c-f1c561df6716",
|
|
||||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
|
|
||||||
"country": "DE",
|
|
||||||
"browser": "Firefox",
|
|
||||||
"os": "Mac OS X",
|
|
||||||
"referer": "Direct",
|
|
||||||
"created_at": "2024-07-12T19:25:22Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"pagination": {
|
||||||
|
"has_more": true,
|
||||||
|
"next": "75e0a7f4-9c5e-1235-b546-eb9c5e40f7ac"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **List link by ID**
|
4. **List link by ID**
|
||||||
|
|
||||||
- Endpoint: `GET /api/links/:id`
|
- Endpoint: `GET /api/links/:id`
|
||||||
- Headers: `X-Api-Key`
|
- Headers: `X-Api-Key`
|
||||||
- Payload: None
|
- Payload: None
|
||||||
|
- Note: This endpoint returns up to 100 of the most recent clicks. For complete click history, use the `/api/links/:id/clicks` endpoint with pagination.
|
||||||
- Response Example
|
- Response Example
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -73,9 +67,35 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Create new link**
|
5. **List Clicks for a Link**
|
||||||
|
- Endpoint: `GET /api/links/:id/clicks`
|
||||||
|
- Headers: `X-Api-Key`
|
||||||
|
- Query Parameters:
|
||||||
|
- `limit` (optional): Number of results per page (default: 100)
|
||||||
|
- `cursor` (optional): Pagination cursor from previous response
|
||||||
|
- Response Example
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "730e2202-58f9-478c-a24c-f1c561df6716",
|
||||||
|
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
|
||||||
|
"country": "DE",
|
||||||
|
"browser": "Firefox",
|
||||||
|
"os": "Mac OS X",
|
||||||
|
"referer": "Direct",
|
||||||
|
"created_at": "2024-07-12T19:25:22Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"has_more": true,
|
||||||
|
"next": "629e3301-47f8-389b-b24c-f1c561df9825"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- Endpoint\*\*: `POST /api/links`
|
6. **Create new link**
|
||||||
|
- Endpoint: `POST /api/links`
|
||||||
- Payload:
|
- Payload:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -95,8 +115,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Update an existing link by ID**
|
7. **Update an existing link by ID**
|
||||||
|
|
||||||
- Endpoint: `PUT /api/links/:id`
|
- Endpoint: `PUT /api/links/:id`
|
||||||
- Payload:
|
- Payload:
|
||||||
```json
|
```json
|
||||||
@@ -117,8 +136,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
7. **Delete a link by ID**
|
8. **Delete a link by ID**
|
||||||
|
|
||||||
- Endpoint: `DELETE /api/links/:id`
|
- Endpoint: `DELETE /api/links/:id`
|
||||||
- Payload: None
|
- Payload: None
|
||||||
- Headers: `X-Api-Key`
|
- Headers: `X-Api-Key`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: bit
|
name: bit
|
||||||
version: 1.5.0
|
version: 1.5.1
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Juan Rodriguez <sjdonado@icloud.com>
|
- Juan Rodriguez <sjdonado@icloud.com>
|
||||||
|
|||||||
+145
-15
@@ -97,7 +97,7 @@ describe "App::Controllers::Link" do
|
|||||||
})
|
})
|
||||||
|
|
||||||
response.headers["Location"].should eq(link)
|
response.headers["Location"].should eq(link)
|
||||||
response.headers["X-Forwarded-User-Agent"].should eq(user_agent)
|
response.headers["User-Agent"].should eq(user_agent)
|
||||||
response.headers.has_key?("X-Forwarded-For").should be_true
|
response.headers.has_key?("X-Forwarded-For").should be_true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -161,8 +161,8 @@ describe "App::Controllers::Link" do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "All" do
|
describe "All" do
|
||||||
it "should return all links" do
|
it "should return all links with pagination" do
|
||||||
links = ["https://google.com", "google.com", "google.com.co"]
|
links = ["https://sjdonado.com", "sjdonado.com", "sjdonado.com.co"]
|
||||||
test_user = create_test_user()
|
test_user = create_test_user()
|
||||||
|
|
||||||
links.each do |link|
|
links.each do |link|
|
||||||
@@ -171,14 +171,58 @@ describe "App::Controllers::Link" do
|
|||||||
|
|
||||||
get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||||
|
|
||||||
parsed_response = Hash(String, Array(Hash(String, String | Int64 | Array(Hash(String, String))))).from_json(response.body)
|
parsed_response = Hash(String, Array(Hash(String, String | Int64)) | Hash(String, Bool | String?)).from_json(response.body)
|
||||||
parsed_response["data"][0]["origin"].should eq(links[0])
|
|
||||||
parsed_response["data"][1]["origin"].should eq(links[1])
|
# Check that each link is in the response data
|
||||||
parsed_response["data"][2]["origin"].should eq(links[2])
|
origins = parsed_response["data"].as(Array).map { |link| link["origin"] }
|
||||||
|
links.each do |link|
|
||||||
|
origins.should contain(link)
|
||||||
|
end
|
||||||
|
|
||||||
|
parsed_response["pagination"].as(Hash)["has_more"].should be_false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should respect custom limit parameter" do
|
||||||
|
test_user = create_test_user()
|
||||||
|
|
||||||
|
5.times do |i|
|
||||||
|
create_test_link(test_user, "https://example.com/#{i}")
|
||||||
|
end
|
||||||
|
|
||||||
|
get("/api/links?limit=2", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||||
|
|
||||||
|
parsed_response = Hash(String, Array(Hash(String, String | Int64)) | Hash(String, Bool | String?)).from_json(response.body)
|
||||||
|
parsed_response["data"].as(Array).size.should eq(2)
|
||||||
|
parsed_response["pagination"].as(Hash)["has_more"].should be_true
|
||||||
|
parsed_response["pagination"].as(Hash)["next"].should_not be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should support cursor-based pagination" do
|
||||||
|
test_user = create_test_user()
|
||||||
|
|
||||||
|
5.times do |i|
|
||||||
|
create_test_link(test_user, "https://example.com/#{i}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get first page
|
||||||
|
get("/api/links?limit=2", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||||
|
first_page = Hash(String, Array(Hash(String, String | Int64)) | Hash(String, Bool | String?)).from_json(response.body)
|
||||||
|
cursor = first_page["pagination"].as(Hash)["next"]
|
||||||
|
|
||||||
|
# Get second page using cursor
|
||||||
|
get("/api/links?limit=2&cursor=#{cursor}", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||||
|
second_page = Hash(String, Array(Hash(String, String | Int64)) | Hash(String, Bool | String?)).from_json(response.body)
|
||||||
|
|
||||||
|
# Ensure different links are returned
|
||||||
|
first_page_ids = first_page["data"].as(Array).map { |link| link["id"] }
|
||||||
|
second_page_ids = second_page["data"].as(Array).map { |link| link["id"] }
|
||||||
|
|
||||||
|
# Check that no IDs from first page appear in second page
|
||||||
|
(first_page_ids & second_page_ids).empty?.should be_true
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should return owned links only" do
|
it "should return owned links only" do
|
||||||
links = ["https://google.de", "google.de", "google.edu.co", "x.com"]
|
links = ["https://donado.co", "donado.co", "uninorte.edu.co", "kagi.com"]
|
||||||
test_user = create_test_user()
|
test_user = create_test_user()
|
||||||
|
|
||||||
links[0..2].each do |link|
|
links[0..2].each do |link|
|
||||||
@@ -190,11 +234,14 @@ describe "App::Controllers::Link" do
|
|||||||
|
|
||||||
get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||||
|
|
||||||
parsed_response = Hash(String, Array(Hash(String, String | Int64 | Array(Hash(String, String))))).from_json(response.body)
|
parsed_response = Hash(String, Array(Hash(String, String | Int64)) | Hash(String, Bool | String?)).from_json(response.body)
|
||||||
parsed_response["data"].size.should eq(3)
|
parsed_response["data"].as(Array).size.should eq(3)
|
||||||
parsed_response["data"][0]["origin"].should eq(links[0])
|
|
||||||
parsed_response["data"][1]["origin"].should eq(links[1])
|
origins = parsed_response["data"].as(Array).map { |link| link["origin"] }
|
||||||
parsed_response["data"][2]["origin"].should eq(links[2])
|
links[0..2].each do |link|
|
||||||
|
origins.should contain(link)
|
||||||
|
end
|
||||||
|
origins.should_not contain(links[3])
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should return 401 - missing api key" do
|
it "should return 401 - missing api key" do
|
||||||
@@ -207,16 +254,20 @@ describe "App::Controllers::Link" do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "Get" do
|
describe "Get" do
|
||||||
it "should return the specified link with click details" do
|
it "should return the specified link with limited click details" do
|
||||||
link = "https://bing.com"
|
link = "https://bing.com"
|
||||||
test_user = create_test_user()
|
test_user = create_test_user()
|
||||||
test_link = create_test_link(test_user, link)
|
test_link = create_test_link(test_user, link)
|
||||||
|
|
||||||
|
110.times do
|
||||||
|
create_test_click(test_link)
|
||||||
|
end
|
||||||
|
|
||||||
get("/api/links/#{test_link.id}", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
get("/api/links/#{test_link.id}", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||||
|
|
||||||
parsed_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
|
parsed_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
|
||||||
parsed_response["data"]["origin"].should eq(link)
|
parsed_response["data"]["origin"].should eq(link)
|
||||||
parsed_response["data"]["clicks"].should be_a(Array(Hash(String, String)))
|
parsed_response["data"]["clicks"].as(Array).size.should eq(100)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should return 404 - link does not exist" do
|
it "should return 404 - link does not exist" do
|
||||||
@@ -238,6 +289,85 @@ describe "App::Controllers::Link" do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "Clicks" do
|
||||||
|
it "should return paginated clicks for a link" do
|
||||||
|
link = "https://example.com"
|
||||||
|
test_user = create_test_user()
|
||||||
|
test_link = create_test_link(test_user, link)
|
||||||
|
|
||||||
|
5.times do
|
||||||
|
create_test_click(test_link)
|
||||||
|
end
|
||||||
|
|
||||||
|
get("/api/links/#{test_link.id}/clicks", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||||
|
|
||||||
|
parsed_response = Hash(String, Array(Hash(String, String)) | Hash(String, Bool | String?)).from_json(response.body)
|
||||||
|
parsed_response["data"].as(Array).size.should eq(5)
|
||||||
|
parsed_response["pagination"].as(Hash)["has_more"].should be_false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should respect limit parameter" do
|
||||||
|
link = "https://example.com"
|
||||||
|
test_user = create_test_user()
|
||||||
|
test_link = create_test_link(test_user, link)
|
||||||
|
|
||||||
|
10.times do
|
||||||
|
create_test_click(test_link)
|
||||||
|
end
|
||||||
|
|
||||||
|
get("/api/links/#{test_link.id}/clicks?limit=3", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||||
|
|
||||||
|
parsed_response = Hash(String, Array(Hash(String, String)) | Hash(String, Bool | String?)).from_json(response.body)
|
||||||
|
parsed_response["data"].as(Array).size.should eq(3)
|
||||||
|
parsed_response["pagination"].as(Hash)["has_more"].should be_true
|
||||||
|
parsed_response["pagination"].as(Hash)["next"].should_not be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should support cursor-based pagination" do
|
||||||
|
link = "https://example.com"
|
||||||
|
test_user = create_test_user()
|
||||||
|
test_link = create_test_link(test_user, link)
|
||||||
|
|
||||||
|
10.times do
|
||||||
|
create_test_click(test_link)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get first page
|
||||||
|
get("/api/links/#{test_link.id}/clicks?limit=3", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||||
|
first_page = Hash(String, Array(Hash(String, String)) | Hash(String, Bool | String?)).from_json(response.body)
|
||||||
|
cursor = first_page["pagination"].as(Hash)["next"]
|
||||||
|
|
||||||
|
# Get second page using cursor
|
||||||
|
get("/api/links/#{test_link.id}/clicks?limit=3&cursor=#{cursor}", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||||
|
second_page = Hash(String, Array(Hash(String, String)) | Hash(String, Bool | String?)).from_json(response.body)
|
||||||
|
|
||||||
|
# Ensure different clicks are returned
|
||||||
|
first_page_ids = first_page["data"].as(Array).map { |click| click["id"] }
|
||||||
|
second_page_ids = second_page["data"].as(Array).map { |click| click["id"] }
|
||||||
|
|
||||||
|
# Check that no IDs from first page appear in second page
|
||||||
|
(first_page_ids & second_page_ids).empty?.should be_true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should return 404 - link does not exist" do
|
||||||
|
test_user = create_test_user()
|
||||||
|
|
||||||
|
get("/api/links/nonexistent_id/clicks", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||||
|
|
||||||
|
expected = {"error" => "Resource not found"}.to_json
|
||||||
|
response.status_code.should eq(404)
|
||||||
|
response.body.should eq(expected)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should return 401 - missing api key" do
|
||||||
|
get("/api/links/1/clicks")
|
||||||
|
|
||||||
|
expected = {"error" => "Unauthorized access"}.to_json
|
||||||
|
response.status_code.should eq(401)
|
||||||
|
response.body.should eq(expected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "Update" do
|
describe "Update" do
|
||||||
it "should update link url" do
|
it "should update link url" do
|
||||||
link = "https://github.com"
|
link = "https://github.com"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ describe "App::Controllers::Ping" do
|
|||||||
it "should return pong" do
|
it "should return pong" do
|
||||||
get "/api/ping"
|
get "/api/ping"
|
||||||
|
|
||||||
expected = {"pong" => "ok"}.to_json
|
expected = {"data" => "pong"}.to_json
|
||||||
response.body.should eq(expected)
|
response.body.should eq(expected)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ def create_test_link(user, url)
|
|||||||
link
|
link
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_test_click(link)
|
||||||
|
click = App::Models::Click.new
|
||||||
|
click.id = UUID.v4.to_s
|
||||||
|
click.user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0"
|
||||||
|
click.browser = "Firefox"
|
||||||
|
click.os = "Mac OS X"
|
||||||
|
click.referer = "example.com"
|
||||||
|
click.country = "US"
|
||||||
|
click.created_at = Time.utc
|
||||||
|
click.link = link
|
||||||
|
click.link_id = link.id
|
||||||
|
|
||||||
|
changeset = App::Lib::Database.insert(click)
|
||||||
|
unless changeset.valid?
|
||||||
|
error_messages = changeset.errors.map { |error| "#{error}" }.join(", ")
|
||||||
|
raise "Test click creation failed: #{error_messages}"
|
||||||
|
end
|
||||||
|
click
|
||||||
|
end
|
||||||
|
|
||||||
def get_test_link(link_id)
|
def get_test_link(link_id)
|
||||||
query = App::Lib::Database::Query.where(id: link_id.as(String)).limit(1)
|
query = App::Lib::Database::Query.where(id: link_id.as(String)).limit(1)
|
||||||
link = App::Lib::Database.all(App::Models::Link, query, preload: [:clicks]).first?
|
link = App::Lib::Database.all(App::Models::Link, query, preload: [:clicks]).first?
|
||||||
|
|||||||
Reference in New Issue
Block a user