#include #include #include "gemini_response.h" #include "gemini_url.h" #include "gemtext.h" #include "text_buffer.h" using namespace roro::gemini; void setUp() {} void tearDown() {} static void resolvesTo(const char* ref, const char* expected) { TEST_ASSERT_EQUAL_STRING_MESSAGE(expected, resolve("gemini://a/b/c/d;p?q", ref).c_str(), ref); } // RFC 3986, section 5.4.1, with gemini:// for http://. void test_rfc3986_normal_examples() { resolvesTo("g:h", "g:h"); resolvesTo("g", "gemini://a/b/c/g"); resolvesTo("./g", "gemini://a/b/c/g"); resolvesTo("g/", "gemini://a/b/c/g/"); resolvesTo("/g", "gemini://a/g"); resolvesTo("//g", "gemini://g"); resolvesTo("?y", "gemini://a/b/c/d;p?y"); resolvesTo("g?y", "gemini://a/b/c/g?y"); resolvesTo("#s", "gemini://a/b/c/d;p?q#s"); resolvesTo("g#s", "gemini://a/b/c/g#s"); resolvesTo("g?y#s", "gemini://a/b/c/g?y#s"); resolvesTo(";x", "gemini://a/b/c/;x"); resolvesTo("", "gemini://a/b/c/d;p?q"); resolvesTo(".", "gemini://a/b/c/"); resolvesTo("./", "gemini://a/b/c/"); resolvesTo("..", "gemini://a/b/"); resolvesTo("../", "gemini://a/b/"); resolvesTo("../g", "gemini://a/b/g"); resolvesTo("../..", "gemini://a/"); resolvesTo("../../", "gemini://a/"); resolvesTo("../../g", "gemini://a/g"); } // Section 5.4.2. void test_rfc3986_abnormal_examples() { resolvesTo("../../../g", "gemini://a/g"); resolvesTo("../../../../g", "gemini://a/g"); resolvesTo("/./g", "gemini://a/g"); resolvesTo("/../g", "gemini://a/g"); resolvesTo("g.", "gemini://a/b/c/g."); resolvesTo(".g", "gemini://a/b/c/.g"); resolvesTo("./../g", "gemini://a/b/g"); resolvesTo("g/./h", "gemini://a/b/c/g/h"); resolvesTo("g/../h", "gemini://a/b/c/h"); resolvesTo("g;x=1/./y", "gemini://a/b/c/g;x=1/y"); resolvesTo("g;x=1/../y", "gemini://a/b/c/y"); } void test_parse_a_gemini_url() { Url u; TEST_ASSERT_TRUE(parseUrl("gemini://Example.ORG:1966/a/b?c=d#e", u)); TEST_ASSERT_EQUAL_STRING("gemini", u.scheme.c_str()); TEST_ASSERT_EQUAL_STRING("example.org", u.host.c_str()); // hosts are case-insensitive TEST_ASSERT_EQUAL(1966, u.port); TEST_ASSERT_EQUAL_STRING("/a/b", u.path.c_str()); TEST_ASSERT_EQUAL_STRING("c=d", u.query.c_str()); TEST_ASSERT_EQUAL_STRING("e", u.fragment.c_str()); TEST_ASSERT_TRUE(parseUrl("gemini://example.org", u)); TEST_ASSERT_EQUAL(1965, u.portOrDefault()); TEST_ASSERT_FALSE(parseUrl("not a url", u)); } void test_the_request_drops_the_fragment_and_never_has_an_empty_path() { TEST_ASSERT_EQUAL_STRING("gemini://example.org/", requestUrl("gemini://example.org").c_str()); TEST_ASSERT_EQUAL_STRING("gemini://example.org/a?b", requestUrl("gemini://example.org/a?b#frag").c_str()); TEST_ASSERT_EQUAL_STRING("gemini://example.org:1966/", requestUrl("gemini://EXAMPLE.org:1966").c_str()); } void test_other_schemes_are_recognised() { TEST_ASSERT_TRUE(isGemini("gemini://a/")); TEST_ASSERT_FALSE(isGemini("https://a/")); TEST_ASSERT_FALSE(isGemini("gopher://a/")); TEST_ASSERT_FALSE(isGemini("mailto:someone@example.org")); } void test_query_encoding() { TEST_ASSERT_EQUAL_STRING("hello%20world", encodeQuery("hello world").c_str()); TEST_ASSERT_EQUAL_STRING("caf%C3%A9%3F%26", encodeQuery("caf\xC3\xA9?&").c_str()); TEST_ASSERT_EQUAL_STRING("a-b_c.d~e", encodeQuery("a-b_c.d~e").c_str()); } void test_response_header() { Header h; TEST_ASSERT_TRUE(parseHeader("20 text/gemini; charset=utf-8; lang=en", h)); TEST_ASSERT_EQUAL(20, h.status); TEST_ASSERT_EQUAL_STRING("text/gemini", h.mimeType().c_str()); TEST_ASSERT_EQUAL_STRING("utf-8", h.parameter("charset").c_str()); TEST_ASSERT_TRUE(h.category() == Category::Success); TEST_ASSERT_TRUE(parseHeader("20", h)); // no MIME type: text/gemini by default TEST_ASSERT_EQUAL_STRING("text/gemini", h.mimeType().c_str()); TEST_ASSERT_TRUE(parseHeader("31 gemini://cosmos.skyjake.fi/", h)); TEST_ASSERT_TRUE(h.category() == Category::Redirect); TEST_ASSERT_EQUAL_STRING("gemini://cosmos.skyjake.fi/", h.meta.c_str()); TEST_ASSERT_TRUE(parseHeader("11 Password", h)); TEST_ASSERT_TRUE(h.category() == Category::Input); TEST_ASSERT_TRUE(h.sensitiveInput()); TEST_ASSERT_TRUE(parseHeader("51 Not found", h)); TEST_ASSERT_TRUE(h.category() == Category::PermanentFailure); TEST_ASSERT_TRUE(parseHeader("60 Certificate required", h)); TEST_ASSERT_TRUE(h.category() == Category::ClientCertificate); } void test_malformed_headers_are_refused() { Header h; TEST_ASSERT_FALSE(parseHeader("", h)); TEST_ASSERT_FALSE(parseHeader("2 text/gemini", h)); TEST_ASSERT_FALSE(parseHeader("ab text/gemini", h)); TEST_ASSERT_FALSE(parseHeader("20text/gemini", h)); TEST_ASSERT_FALSE(parseHeader("20 " + std::string(1025, 'x'), h)); } void test_gemtext_line_types() { auto lines = parseGemtext( "# Title\n## Sub\n### Subsub\nPlain text\n* item\n> quoted\n=> gemini://a/b Label here\n=>/rel\n" "```ascii art\n# not a heading\n=> not a link\n```\nafter\n"); TEST_ASSERT_EQUAL(13, static_cast(lines.size())); TEST_ASSERT_TRUE(lines[0].type == LineType::Heading1); TEST_ASSERT_EQUAL_STRING("Title", lines[0].text.c_str()); TEST_ASSERT_TRUE(lines[1].type == LineType::Heading2); TEST_ASSERT_TRUE(lines[2].type == LineType::Heading3); TEST_ASSERT_TRUE(lines[3].type == LineType::Text); TEST_ASSERT_TRUE(lines[4].type == LineType::ListItem); TEST_ASSERT_EQUAL_STRING("item", lines[4].text.c_str()); TEST_ASSERT_TRUE(lines[5].type == LineType::Quote); TEST_ASSERT_EQUAL_STRING("quoted", lines[5].text.c_str()); TEST_ASSERT_TRUE(lines[6].type == LineType::Link); TEST_ASSERT_EQUAL_STRING("gemini://a/b", lines[6].url.c_str()); TEST_ASSERT_EQUAL_STRING("Label here", lines[6].text.c_str()); TEST_ASSERT_TRUE(lines[7].type == LineType::Link); TEST_ASSERT_EQUAL_STRING("/rel", lines[7].url.c_str()); TEST_ASSERT_EQUAL_STRING("/rel", lines[7].text.c_str()); // no label: the URL is the label TEST_ASSERT_TRUE(lines[8].type == LineType::PreToggle); TEST_ASSERT_EQUAL_STRING("ascii art", lines[8].text.c_str()); TEST_ASSERT_TRUE(lines[9].type == LineType::Preformatted); TEST_ASSERT_EQUAL_STRING("# not a heading", lines[9].text.c_str()); TEST_ASSERT_TRUE(lines[10].type == LineType::Preformatted); TEST_ASSERT_TRUE(lines[11].type == LineType::PreToggle); TEST_ASSERT_TRUE(lines[12].type == LineType::Text); } void test_gemtext_handles_crlf_and_a_missing_last_newline() { auto lines = parseGemtext("one\r\n=>\tgemini://x/\t spaced label \r\ntwo"); TEST_ASSERT_EQUAL(3, static_cast(lines.size())); TEST_ASSERT_EQUAL_STRING("one", lines[0].text.c_str()); TEST_ASSERT_EQUAL_STRING("gemini://x/", lines[1].url.c_str()); TEST_ASSERT_EQUAL_STRING("spaced label", lines[1].text.c_str()); TEST_ASSERT_EQUAL_STRING("two", lines[2].text.c_str()); } void test_display_text_keeps_latin1_and_replaces_the_rest() { TEST_ASSERT_EQUAL_STRING("caf\xC3\xA9", displayText("caf\xC3\xA9").c_str()); // é stays TEST_ASSERT_EQUAL_STRING("? and ?", displayText("\xE2\x9C\x93 and \xF0\x9F\x99\x82").c_str()); // ✓ and 🙂 TEST_ASSERT_EQUAL_STRING("bad ? byte", displayText("bad \xFF byte").c_str()); TEST_ASSERT_EQUAL_STRING("a b", displayText("a\tb").c_str()); // tab: three spaces here } void test_saved_page_paths() { TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/index.gmi", savedPath("gemini://example.org/").c_str()); TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/index.gmi", savedPath("gemini://example.org").c_str()); TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/log/2026/post.gmi", savedPath("gemini://example.org/log/2026/post.gmi").c_str()); TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/log/index.gmi", savedPath("gemini://example.org/log/").c_str()); TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/about.gmi", savedPath("gemini://example.org/about").c_str()); TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org_1966/a~q_x.gmi", savedPath("gemini://example.org:1966/a?q:x#frag").c_str()); } void test_text_buffer_keeps_lines_across_any_feed() { TextBuffer b; std::string doc = "# Title\r\nline two\n\nlast without end"; for (size_t i = 0; i < doc.size(); i += 3) b.append(doc.data() + i, std::min(3, doc.size() - i)); b.finish(); TEST_ASSERT_EQUAL(4, static_cast(b.lineCount())); TEST_ASSERT_EQUAL_STRING("# Title", b.line(0).c_str()); TEST_ASSERT_EQUAL_STRING("line two", b.line(1).c_str()); TEST_ASSERT_EQUAL_STRING("", b.line(2).c_str()); TEST_ASSERT_EQUAL_STRING("last without end", b.line(3).c_str()); TEST_ASSERT_EQUAL(doc.size(), b.bytes()); } void test_text_buffer_spreads_over_chunks_without_splitting_lines() { TextBuffer b; std::string line(1000, 'x'); for (int i = 0; i < 20; i++) { std::string l = std::to_string(i) + line + "\n"; b.append(l.data(), l.size()); } std::string big(6000, 'y'); // longer than a chunk big += "\n"; b.append(big.data(), big.size()); TEST_ASSERT_EQUAL(21, static_cast(b.lineCount())); for (int i = 0; i < 20; i++) TEST_ASSERT_EQUAL_STRING((std::to_string(i) + line).c_str(), b.line(i).c_str()); TEST_ASSERT_EQUAL(6000, static_cast(b.line(20).size())); } int main() { UNITY_BEGIN(); RUN_TEST(test_rfc3986_normal_examples); RUN_TEST(test_rfc3986_abnormal_examples); RUN_TEST(test_parse_a_gemini_url); RUN_TEST(test_the_request_drops_the_fragment_and_never_has_an_empty_path); RUN_TEST(test_other_schemes_are_recognised); RUN_TEST(test_query_encoding); RUN_TEST(test_response_header); RUN_TEST(test_malformed_headers_are_refused); RUN_TEST(test_gemtext_line_types); RUN_TEST(test_gemtext_handles_crlf_and_a_missing_last_newline); RUN_TEST(test_display_text_keeps_latin1_and_replaces_the_rest); RUN_TEST(test_saved_page_paths); RUN_TEST(test_text_buffer_keeps_lines_across_any_feed); RUN_TEST(test_text_buffer_spreads_over_chunks_without_splitting_lines); return UNITY_END(); }