447 lines
16 KiB
Crystal
447 lines
16 KiB
Crystal
require "../spec_helper"
|
|
require "../../app/models/*"
|
|
|
|
API_KEY = Random::Secure.urlsafe_base64
|
|
|
|
describe "App::Controllers::Link" do
|
|
describe "Create" do
|
|
it "should create link" do
|
|
test_user = create_test_user()
|
|
|
|
payload = {"url" => "https://kagi.com"}
|
|
post(
|
|
"/api/links",
|
|
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
|
|
body: payload.to_json
|
|
)
|
|
|
|
parsed_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String | Int64)))).from_json(response.body)
|
|
parsed_response["data"]["origin"].should eq(payload["url"])
|
|
end
|
|
|
|
it "should return existing link if url already exists" do
|
|
test_user = create_test_user()
|
|
|
|
payload = {"url" => "http://idonthavespotify.donado.co"}
|
|
post(
|
|
"/api/links",
|
|
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
|
|
body: payload.to_json
|
|
)
|
|
|
|
first_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
|
|
first_response["data"]["origin"].should eq(payload["url"])
|
|
|
|
post(
|
|
"/api/links",
|
|
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
|
|
body: payload.to_json
|
|
)
|
|
|
|
second_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
|
|
second_response["data"]["origin"].should eq(payload["url"])
|
|
second_response["data"]["id"].should eq(first_response["data"]["id"])
|
|
end
|
|
|
|
it "should return 400 - url required field" do
|
|
test_user = create_test_user()
|
|
|
|
payload = {"test" => "https://kagi.com"}
|
|
post(
|
|
"/api/links",
|
|
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
|
|
body: payload.to_json
|
|
)
|
|
|
|
expected = {"error" => "url: Required field"}.to_json
|
|
response.body.should eq(expected)
|
|
end
|
|
|
|
it "should return 400 - invalid url" do
|
|
test_user = create_test_user()
|
|
|
|
payload = {"url" => "test"}
|
|
post(
|
|
"/api/links",
|
|
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
|
|
body: payload.to_json
|
|
)
|
|
|
|
expected = {"errors" => {"url" => ["is invalid"]}}.to_json
|
|
response.body.should eq(expected)
|
|
end
|
|
|
|
it "should return 401 - missing api key" do
|
|
payload = {"url" => "https://kagi.com"}
|
|
post("/api/links", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: payload.to_json)
|
|
|
|
expected = {"error" => "Unauthorized access"}.to_json
|
|
response.status_code.should eq(401)
|
|
response.body.should eq(expected)
|
|
end
|
|
end
|
|
|
|
describe "Index" do
|
|
it "should redirect to origin domain with forwarded headers" do
|
|
link = "https://test.com"
|
|
test_user = create_test_user()
|
|
|
|
test_link = create_test_link(test_user, link)
|
|
|
|
user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0"
|
|
|
|
get("/#{test_link.slug}", headers: HTTP::Headers{
|
|
"X-Api-Key" => test_user.api_key.to_s,
|
|
"User-Agent" => user_agent
|
|
})
|
|
|
|
response.headers["Location"].should eq(link)
|
|
response.headers["User-Agent"].should eq(user_agent)
|
|
response.headers.has_key?("X-Forwarded-For").should be_true
|
|
end
|
|
|
|
it "should create a new click after redirect with proper information" do
|
|
link = "https://sjdonado.com"
|
|
test_user = create_test_user()
|
|
|
|
test_link = create_test_link(test_user, link)
|
|
|
|
user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0"
|
|
referer = "https://example.com/page"
|
|
|
|
get("/#{test_link.slug}", headers: HTTP::Headers{
|
|
"User-Agent" => user_agent,
|
|
"Referer" => referer
|
|
})
|
|
|
|
Fiber.yield # replace yield with sleep 5 to debug errors
|
|
|
|
response.headers["Location"].should eq(link)
|
|
|
|
# Verify that the click was recorded
|
|
updated_test_link = get_test_link(test_link.id.not_nil!)
|
|
updated_test_link.clicks.size.should eq(test_link.clicks.size + 1)
|
|
|
|
# Verify click details
|
|
latest_click = updated_test_link.clicks.last
|
|
latest_click.user_agent.should eq(user_agent)
|
|
latest_click.browser.should eq("Firefox")
|
|
latest_click.os.should eq("Mac OS X")
|
|
latest_click.referer.should eq("example.com") # Should extract host from the referer
|
|
end
|
|
|
|
it "should create a click with utm_source when no referer is provided" do
|
|
link = "https://sjdonado.com"
|
|
test_user = create_test_user()
|
|
|
|
test_link = create_test_link(test_user, link)
|
|
|
|
# Add utm_source parameter
|
|
user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0"
|
|
get("/#{test_link.slug}?utm_source=email_campaign", headers: HTTP::Headers{
|
|
"User-Agent" => user_agent
|
|
})
|
|
|
|
sleep 0.2.seconds # Wait for async click creation
|
|
|
|
updated_test_link = get_test_link(test_link.id.not_nil!)
|
|
latest_click = updated_test_link.clicks.last
|
|
latest_click.referer.should eq("email_campaign")
|
|
end
|
|
|
|
it "should return 404 - link does not exist" do
|
|
test_user = create_test_user()
|
|
|
|
get("/R4kj2")
|
|
|
|
expected = {"error" => "Resource not found"}.to_json
|
|
response.status_code.should eq(404)
|
|
response.body.should eq(expected)
|
|
end
|
|
end
|
|
|
|
describe "All" do
|
|
it "should return all links with pagination" do
|
|
links = ["https://sjdonado.com", "sjdonado.com", "sjdonado.com.co"]
|
|
test_user = create_test_user()
|
|
|
|
links.each do |link|
|
|
create_test_link(test_user, link)
|
|
end
|
|
|
|
get("/api/links", 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? | Int64?)).from_json(response.body)
|
|
|
|
# Check that each link is in the response data
|
|
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? | Int64?)).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? | Int64?)).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? | Int64?)).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
|
|
|
|
it "should return owned links only" do
|
|
links = ["https://donado.co", "donado.co", "uninorte.edu.co", "kagi.com"]
|
|
test_user = create_test_user()
|
|
|
|
links[0..2].each do |link|
|
|
create_test_link(test_user, link)
|
|
end
|
|
|
|
test_other_user = create_test_user()
|
|
create_test_link(test_other_user, links[3])
|
|
|
|
get("/api/links", 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? | Int64?)).from_json(response.body)
|
|
parsed_response["data"].as(Array).size.should eq(3)
|
|
|
|
origins = parsed_response["data"].as(Array).map { |link| link["origin"] }
|
|
links[0..2].each do |link|
|
|
origins.should contain(link)
|
|
end
|
|
origins.should_not contain(links[3])
|
|
end
|
|
|
|
it "should return 401 - missing api key" do
|
|
get "/api/links"
|
|
|
|
expected = {"error" => "Unauthorized access"}.to_json
|
|
response.status_code.should eq(401)
|
|
response.body.should eq(expected)
|
|
end
|
|
end
|
|
|
|
describe "Get" do
|
|
it "should return the specified link with limited click details" do
|
|
link = "https://bing.com"
|
|
test_user = create_test_user()
|
|
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})
|
|
|
|
parsed_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String | Int64)))).from_json(response.body)
|
|
parsed_response["data"]["origin"].should eq(link)
|
|
parsed_response["data"]["clicks"].as(Array).size.should eq(100)
|
|
end
|
|
|
|
it "should return 404 - link does not exist" do
|
|
test_user = create_test_user()
|
|
|
|
get("/api/links/999999", 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"
|
|
|
|
expected = {"error" => "Unauthorized access"}.to_json
|
|
response.status_code.should eq(401)
|
|
response.body.should eq(expected)
|
|
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 | Int64)) | Hash(String, Bool | String? | Int64?)).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 | Int64)) | Hash(String, Bool | String? | Int64?)).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 | Int64)) | Hash(String, Bool | String? | Int64?)).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 | Int64)) | Hash(String, Bool | String? | Int64?)).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/999999/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
|
|
it "should update link url" do
|
|
link = "https://github.com"
|
|
test_user = create_test_user()
|
|
test_link = create_test_link(test_user, link)
|
|
|
|
payload = {"url" => "https://github.com.co"}
|
|
put(
|
|
"/api/links/#{test_link.id}",
|
|
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
|
|
body: payload.to_json
|
|
)
|
|
|
|
parsed_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String | Int64)))).from_json(response.body)
|
|
parsed_response["data"]["origin"].should eq(payload["url"])
|
|
end
|
|
|
|
it "should return 404 - link does not exist" do
|
|
test_user = create_test_user()
|
|
|
|
payload = {"url" => "https://kagi.com.co"}
|
|
put(
|
|
"/api/links/999999",
|
|
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
|
|
body: payload.to_json
|
|
)
|
|
|
|
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
|
|
payload = {"url" => "https://kagi.com.co"}
|
|
put(
|
|
"/api/links/1",
|
|
headers: HTTP::Headers{"Content-Type" => "application/json"},
|
|
body: payload.to_json
|
|
)
|
|
|
|
expected = {"error" => "Unauthorized access"}.to_json
|
|
response.status_code.should eq(401)
|
|
response.body.should eq(expected)
|
|
end
|
|
end
|
|
|
|
describe "Delete" do
|
|
it "should delete link url" do
|
|
link = "https://news.ycombinator.com"
|
|
test_user = create_test_user()
|
|
test_link = create_test_link(test_user, link)
|
|
|
|
delete("/api/links/#{test_link.id}", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
|
|
|
response.status_code.should eq(204)
|
|
end
|
|
|
|
it "should return 404 - link does not exist" do
|
|
test_user = create_test_user()
|
|
|
|
delete("/api/links/999999", 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
|
|
delete "/api/links/1"
|
|
|
|
expected = {"error" => "Unauthorized access"}.to_json
|
|
response.status_code.should eq(401)
|
|
response.body.should eq(expected)
|
|
end
|
|
end
|
|
end
|