diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..9f2753a --- /dev/null +++ b/.env.test @@ -0,0 +1,3 @@ +PORT=4001 +DATABASE_URL=sqlite3://./sqlite/data.test.db?journal_mode=wal&synchronous=normal&foreign_keys=true +APP_URL=http://localhost:4001 diff --git a/shard.lock b/shard.lock index 2125b7f..dc5c0bf 100644 --- a/shard.lock +++ b/shard.lock @@ -10,11 +10,11 @@ shards: db: git: https://github.com/crystal-lang/crystal-db.git - version: 0.13.1 + version: 0.11.0 dotenv: git: https://github.com/gdotdesign/cr-dotenv.git - version: 0.6.0 + version: 1.0.0 exception_page: git: https://github.com/crystal-loot/exception_page.git @@ -24,15 +24,27 @@ shards: git: https://github.com/kemalcr/kemal.git version: 1.5.0 + micrate: + git: https://github.com/amberframework/micrate.git + version: 0.15.1 + + mysql: + git: https://github.com/crystal-lang/crystal-mysql.git + version: 0.14.0 + + pg: + git: https://github.com/will/crystal-pg.git + version: 0.26.0 + radix: git: https://github.com/luislavena/radix.git version: 0.4.1 spec-kemal: git: https://github.com/kemalcr/spec-kemal.git - version: 0.5.0 + version: 1.0.0 sqlite3: git: https://github.com/crystal-lang/crystal-sqlite3.git - version: 0.21.0 + version: 0.19.0 diff --git a/shard.yml b/shard.yml index 54dc8e4..ac91c9a 100644 --- a/shard.yml +++ b/shard.yml @@ -5,8 +5,10 @@ authors: - Juan Rodriguez targets: - pa: - main: src/url-shortener.cr + url-shortener: + main: url-shortener.cr + cli: + main: scripts/cli.cr dependencies: kemal: @@ -21,6 +23,9 @@ development_dependencies: github: gdotdesign/cr-dotenv spec-kemal: github: kemalcr/spec-kemal + micrate: + github: amberframework/micrate + version: 0.15.1 crystal: '>= 1.12.1' diff --git a/spec/integration/link_spec.cr b/spec/integration/link_spec.cr new file mode 100644 index 0000000..e78ee74 --- /dev/null +++ b/spec/integration/link_spec.cr @@ -0,0 +1,226 @@ +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)).from_json(response.body) + parsed_response["data"]["origin"].should eq(payload["url"]) + 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"}.to_json + response.status_code.should eq(401) + response.body.should eq(expected) + end + end + + describe "Index" do + it "should redirect to origin domain" do + link = "https://kagi.com" + test_user = create_test_user() + + test_link = create_test_link(test_user, link) + serialized_link = App::Serializers::Link.new(test_link) + + get(serialized_link.refer, headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s}) + + response.headers["Location"].should eq(link) + end + + it "should increase click counter after redirect" do + link = "https://kagi.com" + test_user = create_test_user() + + test_link = create_test_link(test_user, link) + serialized_link = App::Serializers::Link.new(test_link) + + get(serialized_link.refer, headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s}) + + updated_test_link = get_test_link(test_link.id) + + response.headers["Location"].should eq(link) + updated_test_link.click_counter.should eq(1) + end + + it "should return 404 - link does not exist" do + link = "https://kagi.com" + test_user = create_test_user() + + test_link = create_test_link(test_user, link) + serialized_link = App::Serializers::Link.new(test_link) + + delete_test_link(test_link.id) + + get(serialized_link.refer, headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s}) + + expected = {"error" => "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" do + links = ["https://google.com", "google.com", "google.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))).from_json(response.body) + parsed_response["data"][0]["origin"].should eq(links[0]) + parsed_response["data"][1]["origin"].should eq(links[1]) + parsed_response["data"][2]["origin"].should eq(links[2]) + end + + it "should return owned links only" do + links = ["https://google.com", "google.com", "google.com.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))).from_json(response.body) + parsed_response["data"].size.should eq(3) + parsed_response["data"][0]["origin"].should eq(links[0]) + parsed_response["data"][1]["origin"].should eq(links[1]) + parsed_response["data"][2]["origin"].should eq(links[2]) + end + + it "should return 401 - missing api key" do + get "/api/links" + + expected = {"error" => "Unauthorized"}.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://kagi.com" + test_user = create_test_user() + test_link = create_test_link(test_user, link) + + payload = {"url" => "https://kagi.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)).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/1", + headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s}, + body: payload.to_json + ) + + expected = {"error" => "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"}.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://kagi.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/1", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s}) + + expected = {"error" => "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"}.to_json + response.status_code.should eq(401) + response.body.should eq(expected) + end + end +end diff --git a/spec/pa_spec.cr b/spec/pa_spec.cr deleted file mode 100644 index 21781a0..0000000 --- a/spec/pa_spec.cr +++ /dev/null @@ -1,9 +0,0 @@ -require "./spec_helper" - -describe Url::Shortener do - # TODO: Write tests - - it "works" do - false.should eq(true) - end -end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index d022140..ea7dfd5 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,2 +1,48 @@ -require "spec" -require "../src/pa" +require "uuid" + +require "spec-kemal" +require "micrate" + +require "../url-shortener" + +Spec.before_suite do + Micrate::DB.connection_url = ENV["DATABASE_URL"] + Micrate::Cli.run_up +end + +def create_test_user + user = App::Models::User.new + user.id = UUID.v4.to_s + user.name = "Tester" + user.api_key = Random::Secure.urlsafe_base64() + + changeset = App::Lib::Database.insert(user) + if !changeset.valid? + raise "Test user creation failed" + end + + user +end + +def create_test_link(user, url) + link = App::Models::Link.new + link.id = UUID.v4.to_s + link.url = url + link.slug = Random::Secure.urlsafe_base64(4) + link.user = user + + changeset = App::Lib::Database.insert(link) + if !changeset.valid? + raise "Test link creation failed" + end + + link +end + +def get_test_link(link_id) + App::Lib::Database.get!(App::Models::Link, link_id) +end + +def delete_test_link(link_id) + App::Lib::Database.raw_exec("DELETE FROM links WHERE id = (?)", link_id) # tempfix: Database.delete does not work +end