diff options
author | Calvin Morrison <calvin@pobox.com> | 2025-09-03 21:15:36 -0400 |
---|---|---|
committer | Calvin Morrison <calvin@pobox.com> | 2025-09-03 21:15:36 -0400 |
commit | 49fa5aa2a127bdf8924d02bf77e5086b39c7a447 (patch) | |
tree | 61d86a7705dacc9fddccc29fa79d075d83ab8059 /server/test |
Diffstat (limited to 'server/test')
-rw-r--r-- | server/test/jchat_SUITE.erl | 252 | ||||
-rw-r--r-- | server/test/jchat_auth_SUITE.erl | 188 | ||||
-rw-r--r-- | server/test/jchat_http_SUITE.erl | 224 | ||||
-rw-r--r-- | server/test/jchat_perf_SUITE.erl | 185 | ||||
-rw-r--r-- | server/test/jchat_prop_SUITE.erl | 131 |
5 files changed, 980 insertions, 0 deletions
diff --git a/server/test/jchat_SUITE.erl b/server/test/jchat_SUITE.erl new file mode 100644 index 0000000..ab6f375 --- /dev/null +++ b/server/test/jchat_SUITE.erl @@ -0,0 +1,252 @@ +-module(jchat_SUITE). + +-include_lib("common_test/include/ct.hrl"). +-include("../src/jchat.hrl"). + +%% CT callbacks +-export([all/0, init_per_suite/1, end_per_suite/1, + init_per_testcase/2, end_per_testcase/2]). + +%% Test cases +-export([test_session_endpoint/1, + test_core_echo/1, + test_conversation_create/1, + test_conversation_get/1, + test_message_create/1, + test_message_get/1, + test_invalid_method/1, + test_invalid_json/1, + test_unknown_capability/1]). + +all() -> + [test_session_endpoint, + test_core_echo, + test_conversation_create, + test_conversation_get, + test_message_create, + test_message_get, + test_invalid_method, + test_invalid_json, + test_unknown_capability]. + +init_per_suite(Config) -> + % Start the application + application:ensure_all_started(jchat), + timer:sleep(1000), % Allow startup + Config. + +end_per_suite(_Config) -> + application:stop(jchat), + ok. + +init_per_testcase(_TestCase, Config) -> + Config. + +end_per_testcase(_TestCase, _Config) -> + ok. + +%% Test cases + +test_session_endpoint(_Config) -> + URL = "http://localhost:8080/jmap/session", + {ok, {{_, 200, _}, Headers, Body}} = httpc:request(get, {URL, []}, [], []), + + % Check content type + {"content-type", "application/json"} = lists:keyfind("content-type", 1, Headers), + + % Parse and validate session object + {ok, Session} = jchat_utils:json_decode(list_to_binary(Body)), + + % Validate required fields + true = maps:is_key(<<"capabilities">>, Session), + true = maps:is_key(<<"accounts">>, Session), + true = maps:is_key(<<"apiUrl">>, Session), + + % Check chat capability + Capabilities = maps:get(<<"capabilities">>, Session), + true = maps:is_key(<<"urn:ietf:params:jmap:chat">>, Capabilities). + +test_core_echo(_Config) -> + Request = #{ + <<"using">> => [<<"urn:ietf:params:jmap:core">>], + <<"methodCalls">> => [ + [<<"Core/echo">>, #{<<"hello">> => <<"world">>}, <<"c1">>] + ] + }, + + Response = make_jmap_request(Request), + + MethodResponses = maps:get(<<"methodResponses">>, Response), + [EchoResponse] = MethodResponses, + [<<"Core/echo">>, Args, <<"c1">>] = EchoResponse, + + <<"world">> = maps:get(<<"hello">>, Args). + +test_conversation_create(_Config) -> + Request = #{ + <<"using">> => [<<"urn:ietf:params:jmap:core">>, <<"urn:ietf:params:jmap:chat">>], + <<"methodCalls">> => [ + [<<"Conversation/set">>, #{ + <<"accountId">> => <<"default">>, + <<"create">> => #{ + <<"conv1">> => #{ + <<"title">> => <<"Test Conversation">>, + <<"participantIds">> => [<<"user1">>, <<"user2">>] + } + } + }, <<"c1">>] + ] + }, + + Response = make_jmap_request(Request), + + MethodResponses = maps:get(<<"methodResponses">>, Response), + [SetResponse] = MethodResponses, + [<<"Conversation/set">>, Args, <<"c1">>] = SetResponse, + + Created = maps:get(<<"created">>, Args), + true = maps:is_key(<<"conv1">>, Created). + +test_conversation_get(_Config) -> + % First create a conversation + {ok, Conv} = jchat_db:create_conversation(<<"test-conv-1">>, #{ + title => <<"Test Conversation">>, + participant_ids => [<<"user1">>] + }), + + Request = #{ + <<"using">> => [<<"urn:ietf:params:jmap:core">>, <<"urn:ietf:params:jmap:chat">>], + <<"methodCalls">> => [ + [<<"Conversation/get">>, #{ + <<"accountId">> => <<"default">>, + <<"ids">> => [<<"test-conv-1">>] + }, <<"c1">>] + ] + }, + + Response = make_jmap_request(Request), + + MethodResponses = maps:get(<<"methodResponses">>, Response), + [GetResponse] = MethodResponses, + [<<"Conversation/get">>, Args, <<"c1">>] = GetResponse, + + List = maps:get(<<"list">>, Args), + [ConvData] = List, + <<"test-conv-1">> = maps:get(<<"id">>, ConvData). + +test_message_create(_Config) -> + % First create a conversation + {ok, _Conv} = jchat_db:create_conversation(<<"test-conv-2">>, #{ + title => <<"Message Test Conversation">>, + participant_ids => [<<"user1">>] + }), + + Request = #{ + <<"using">> => [<<"urn:ietf:params:jmap:core">>, <<"urn:ietf:params:jmap:chat">>], + <<"methodCalls">> => [ + [<<"Message/set">>, #{ + <<"accountId">> => <<"default">>, + <<"create">> => #{ + <<"msg1">> => #{ + <<"conversationId">> => <<"test-conv-2">>, + <<"body">> => <<"Hello, World!">>, + <<"senderId">> => <<"user1">> + } + } + }, <<"c1">>] + ] + }, + + Response = make_jmap_request(Request), + + MethodResponses = maps:get(<<"methodResponses">>, Response), + [SetResponse] = MethodResponses, + [<<"Message/set">>, Args, <<"c1">>] = SetResponse, + + Created = maps:get(<<"created">>, Args), + true = maps:is_key(<<"msg1">>, Created). + +test_message_get(_Config) -> + % Create conversation and message + {ok, _Conv} = jchat_db:create_conversation(<<"test-conv-3">>, #{ + title => <<"Get Test Conversation">>, + participant_ids => [<<"user1">>] + }), + {ok, _Msg} = jchat_db:create_message(<<"test-msg-1">>, #{ + conversation_id => <<"test-conv-3">>, + sender_id => <<"user1">>, + body => <<"Test message">> + }), + + Request = #{ + <<"using">> => [<<"urn:ietf:params:jmap:core">>, <<"urn:ietf:params:jmap:chat">>], + <<"methodCalls">> => [ + [<<"Message/get">>, #{ + <<"accountId">> => <<"default">>, + <<"ids">> => [<<"test-msg-1">>] + }, <<"c1">>] + ] + }, + + Response = make_jmap_request(Request), + + MethodResponses = maps:get(<<"methodResponses">>, Response), + [GetResponse] = MethodResponses, + [<<"Message/get">>, Args, <<"c1">>] = GetResponse, + + List = maps:get(<<"list">>, Args), + [MsgData] = List, + <<"test-msg-1">> = maps:get(<<"id">>, MsgData). + +test_invalid_method(_Config) -> + Request = #{ + <<"using">> => [<<"urn:ietf:params:jmap:core">>], + <<"methodCalls">> => [ + [<<"Invalid/method">>, #{}, <<"c1">>] + ] + }, + + Response = make_jmap_request(Request), + + MethodResponses = maps:get(<<"methodResponses">>, Response), + [ErrorResponse] = MethodResponses, + [<<"error">>, ErrorArgs, <<"c1">>] = ErrorResponse, + + <<"unknownMethod">> = maps:get(<<"type">>, ErrorArgs). + +test_invalid_json(_Config) -> + URL = "http://localhost:8080/jmap/api", + InvalidJSON = "{ invalid json", + + {ok, {{_, 400, _}, _Headers, Body}} = httpc:request(post, + {URL, [], "application/json", InvalidJSON}, [], []), + + {ok, ErrorResponse} = jchat_utils:json_decode(list_to_binary(Body)), + <<"urn:ietf:params:jmap:error:notJSON">> = maps:get(<<"type">>, ErrorResponse). + +test_unknown_capability(_Config) -> + Request = #{ + <<"using">> => [<<"unknown:capability">>], + <<"methodCalls">> => [] + }, + + RequestJSON = jchat_utils:json_encode(Request), + URL = "http://localhost:8080/jmap/api", + + {ok, {{_, 400, _}, _Headers, Body}} = httpc:request(post, + {URL, [], "application/json", RequestJSON}, [], []), + + {ok, ErrorResponse} = jchat_utils:json_decode(list_to_binary(Body)), + <<"urn:ietf:params:jmap:error:unknownCapability">> = maps:get(<<"type">>, ErrorResponse). + +%% Helper functions + +make_jmap_request(Request) -> + RequestJSON = jchat_utils:json_encode(Request), + URL = "http://localhost:8080/jmap/api", + + {ok, {{_, 200, _}, _Headers, Body}} = httpc:request(post, + {URL, [], "application/json", RequestJSON}, [], []), + + {ok, Response} = jchat_utils:json_decode(list_to_binary(Body)), + Response. diff --git a/server/test/jchat_auth_SUITE.erl b/server/test/jchat_auth_SUITE.erl new file mode 100644 index 0000000..91e7cbc --- /dev/null +++ b/server/test/jchat_auth_SUITE.erl @@ -0,0 +1,188 @@ +-module(jchat_auth_SUITE). +-compile(export_all). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include("../src/jchat.hrl"). + +%%==================================================================== +%% CT Callbacks +%%==================================================================== + +suite() -> + [{timetrap, {seconds, 30}}]. + +init_per_suite(Config) -> + % Start the application + application:ensure_all_started(jchat), + % Wait a bit for server to start + timer:sleep(1000), + Config. + +end_per_suite(_Config) -> + application:stop(jchat), + ok. + +init_per_testcase(_TestCase, Config) -> + % Clean up any existing test data + mnesia:clear_table(user), + Config. + +end_per_testcase(_TestCase, _Config) -> + ok. + +all() -> + [test_user_registration, + test_user_login, + test_duplicate_email_registration, + test_invalid_login, + test_jwt_token_validation, + test_jmap_api_authentication, + test_password_hashing, + test_user_creation_flow]. + +%%==================================================================== +%% Test Cases +%%==================================================================== + +test_user_registration(_Config) -> + Email = <<"test@example.com">>, + Password = <<"testpass123">>, + DisplayName = <<"Test User">>, + + % Test user registration + {ok, User} = jchat_auth:register_user(Email, Password, DisplayName), + + % Verify user fields + ?assertEqual(Email, User#user.email), + ?assertEqual(DisplayName, User#user.display_name), + ?assert(is_binary(User#user.id)), + ?assert(is_binary(User#user.password_hash)), + ?assertNotEqual(Password, User#user.password_hash), + ?assertEqual(true, User#user.is_active), + ?assertEqual(<<"local">>, User#user.auth_provider). + +test_user_login(_Config) -> + Email = <<"test@example.com">>, + Password = <<"testpass123">>, + DisplayName = <<"Test User">>, + + % Register user first + {ok, _User} = jchat_auth:register_user(Email, Password, DisplayName), + + % Test login + {ok, {AuthUser, Token}} = jchat_auth:authenticate_user(Email, Password), + + % Verify user and token + ?assertEqual(Email, AuthUser#user.email), + ?assert(is_binary(Token)), + ?assert(byte_size(Token) > 0). + +test_duplicate_email_registration(_Config) -> + Email = <<"test@example.com">>, + Password = <<"testpass123">>, + DisplayName = <<"Test User">>, + + % Register user first time + {ok, _User} = jchat_auth:register_user(Email, Password, DisplayName), + + % Try to register same email again + Result = jchat_auth:register_user(Email, Password, DisplayName), + ?assertMatch({error, _}, Result). + +test_invalid_login(_Config) -> + Email = <<"test@example.com">>, + Password = <<"testpass123">>, + WrongPassword = <<"wrongpass">>, + DisplayName = <<"Test User">>, + + % Register user + {ok, _User} = jchat_auth:register_user(Email, Password, DisplayName), + + % Try login with wrong password + Result = jchat_auth:authenticate_user(Email, WrongPassword), + ?assertMatch({error, _}, Result), + + % Try login with non-existent email + Result2 = jchat_auth:authenticate_user(<<"nonexistent@example.com">>, Password), + ?assertMatch({error, _}, Result2). + +test_jwt_token_validation(_Config) -> + Email = <<"test@example.com">>, + Password = <<"testpass123">>, + DisplayName = <<"Test User">>, + + % Register and login + {ok, _User} = jchat_auth:register_user(Email, Password, DisplayName), + {ok, {AuthUser, Token}} = jchat_auth:authenticate_user(Email, Password), + + % Validate token + {ok, ValidatedUser} = jchat_auth:validate_token(Token), + ?assertEqual(AuthUser#user.id, ValidatedUser#user.id), + ?assertEqual(AuthUser#user.email, ValidatedUser#user.email), + + % Test invalid token + InvalidToken = <<"invalid.token.here">>, + Result = jchat_auth:validate_token(InvalidToken), + ?assertMatch({error, _}, Result). + +test_jmap_api_authentication(_Config) -> + Email = <<"test@example.com">>, + Password = <<"testpass123">>, + DisplayName = <<"Test User">>, + + % Register and login + {ok, _User} = jchat_auth:register_user(Email, Password, DisplayName), + {ok, {_AuthUser, Token}} = jchat_auth:authenticate_user(Email, Password), + + % Create mock request with auth header + AuthHeader = <<"Bearer ", Token/binary>>, + + % Test authentication context creation + {ok, AuthContext} = jchat_auth:create_auth_context(AuthHeader), + ?assertMatch(#{user := _, account_id := _}, AuthContext), + + User = maps:get(user, AuthContext), + ?assertEqual(Email, User#user.email). + +test_password_hashing(_Config) -> + Password1 = <<"password123">>, + Password2 = <<"password123">>, + Password3 = <<"differentpass">>, + + % Hash the same password twice + Hash1 = jchat_auth:hash_password(Password1), + Hash2 = jchat_auth:hash_password(Password2), + + % Hashes should be different (salt makes them unique) + ?assertNotEqual(Hash1, Hash2), + + % But both should verify correctly + ?assert(jchat_auth:verify_password(Password1, Hash1)), + ?assert(jchat_auth:verify_password(Password2, Hash2)), + + % Wrong password should not verify + ?assertNot(jchat_auth:verify_password(Password3, Hash1)). + +test_user_creation_flow(_Config) -> + % Test the full user creation flow including database storage + Email = <<"flow.test@example.com">>, + Password = <<"flowtest123">>, + DisplayName = <<"Flow Test User">>, + + % Register user (this should create in database) + {ok, User} = jchat_auth:register_user(Email, Password, DisplayName), + UserId = User#user.id, + + % Verify user exists in database + {ok, DbUser} = jchat_db:get_user_by_id(UserId), + ?assertEqual(Email, DbUser#user.email), + ?assertEqual(DisplayName, DbUser#user.display_name), + + % Verify user can be found by email + {ok, EmailUser} = jchat_db:get_user_by_email(Email), + ?assertEqual(UserId, EmailUser#user.id), + + % Test login retrieves the same user + {ok, {LoginUser, _Token}} = jchat_auth:authenticate_user(Email, Password), + ?assertEqual(UserId, LoginUser#user.id). diff --git a/server/test/jchat_http_SUITE.erl b/server/test/jchat_http_SUITE.erl new file mode 100644 index 0000000..5c8804f --- /dev/null +++ b/server/test/jchat_http_SUITE.erl @@ -0,0 +1,224 @@ +-module(jchat_http_SUITE). +-compile(export_all). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +%%==================================================================== +%% CT Callbacks +%%==================================================================== + +suite() -> + [{timetrap, {seconds, 30}}]. + +init_per_suite(Config) -> + % Start the application + application:ensure_all_started(jchat), + % Wait for server to start + timer:sleep(1000), + [{server_url, "http://localhost:8081"} | Config]. + +end_per_suite(_Config) -> + application:stop(jchat), + ok. + +init_per_testcase(_TestCase, Config) -> + % Clean up test data + mnesia:clear_table(user), + Config. + +end_per_testcase(_TestCase, _Config) -> + ok. + +all() -> + [test_auth_register_endpoint, + test_auth_login_endpoint, + test_auth_me_endpoint, + test_jmap_api_with_auth, + test_jmap_api_without_auth, + test_cors_headers]. + +%%==================================================================== +%% Test Cases +%%==================================================================== + +test_auth_register_endpoint(Config) -> + ServerUrl = ?config(server_url, Config), + + % Prepare registration data + Email = "test@example.com", + Password = "testpass123", + DisplayName = "Test User", + + ReqBody = jsx:encode(#{ + <<"email">> => list_to_binary(Email), + <<"password">> => list_to_binary(Password), + <<"displayName">> => list_to_binary(DisplayName) + }), + + % Make registration request + Url = ServerUrl ++ "/auth/register", + Headers = [{"content-type", "application/json"}], + + {ok, {{_Version, 201, _ReasonPhrase}, _Headers, ResponseBody}} = + httpc:request(post, {Url, Headers, "application/json", ReqBody}, [], []), + + % Parse response + ResponseMap = jsx:decode(list_to_binary(ResponseBody)), + ?assert(maps:is_key(<<"token">>, ResponseMap)), + ?assert(maps:is_key(<<"user">>, ResponseMap)), + + User = maps:get(<<"user">>, ResponseMap), + ?assertEqual(list_to_binary(Email), maps:get(<<"email">>, User)), + ?assertEqual(list_to_binary(DisplayName), maps:get(<<"displayName">>, User)). + +test_auth_login_endpoint(Config) -> + ServerUrl = ?config(server_url, Config), + + % First register a user + Email = "login.test@example.com", + Password = "logintest123", + DisplayName = "Login Test User", + + {ok, _User} = jchat_auth:register_user( + list_to_binary(Email), + list_to_binary(Password), + list_to_binary(DisplayName) + ), + + % Now test login + ReqBody = jsx:encode(#{ + <<"email">> => list_to_binary(Email), + <<"password">> => list_to_binary(Password) + }), + + Url = ServerUrl ++ "/auth/login", + Headers = [{"content-type", "application/json"}], + + {ok, {{_Version, 200, _ReasonPhrase}, _Headers, ResponseBody}} = + httpc:request(post, {Url, Headers, "application/json", ReqBody}, [], []), + + % Parse response + ResponseMap = jsx:decode(list_to_binary(ResponseBody)), + ?assert(maps:is_key(<<"token">>, ResponseMap)), + ?assert(maps:is_key(<<"user">>, ResponseMap)). + +test_auth_me_endpoint(Config) -> + ServerUrl = ?config(server_url, Config), + + % Register and login to get token + Email = "me.test@example.com", + Password = "metest123", + DisplayName = "Me Test User", + + {ok, _User} = jchat_auth:register_user( + list_to_binary(Email), + list_to_binary(Password), + list_to_binary(DisplayName) + ), + + {ok, {_AuthUser, Token}} = jchat_auth:authenticate_user( + list_to_binary(Email), + list_to_binary(Password) + ), + + % Test /auth/me endpoint + Url = ServerUrl ++ "/auth/me", + Headers = [{"authorization", "Bearer " ++ binary_to_list(Token)}], + + {ok, {{_Version, 200, _ReasonPhrase}, _Headers, ResponseBody}} = + httpc:request(get, {Url, Headers}, [], []), + + % Parse response + ResponseMap = jsx:decode(list_to_binary(ResponseBody)), + ?assert(maps:is_key(<<"user">>, ResponseMap)), + + User = maps:get(<<"user">>, ResponseMap), + ?assertEqual(list_to_binary(Email), maps:get(<<"email">>, User)). + +test_jmap_api_with_auth(Config) -> + ServerUrl = ?config(server_url, Config), + + % Register and login to get token + Email = "jmap.test@example.com", + Password = "jmaptest123", + DisplayName = "JMAP Test User", + + {ok, _User} = jchat_auth:register_user( + list_to_binary(Email), + list_to_binary(Password), + list_to_binary(DisplayName) + ), + + {ok, {_AuthUser, Token}} = jchat_auth:authenticate_user( + list_to_binary(Email), + list_to_binary(Password) + ), + + % Test JMAP API call + ReqBody = jsx:encode(#{ + <<"using">> => [<<"urn:ietf:params:jmap:core">>, <<"https://jmap.io/jchat/">>], + <<"methodCalls">> => [ + [<<"Conversation/query">>, #{ + <<"accountId">> => <<"default">>, + <<"filter">> => #{}, + <<"sort">> => [#{<<"property">> => <<"lastMessageAt">>, <<"isAscending">> => false}] + }, <<"c1">>] + ] + }), + + Url = ServerUrl ++ "/jmap/api", + Headers = [ + {"content-type", "application/json"}, + {"authorization", "Bearer " ++ binary_to_list(Token)} + ], + + {ok, {{_Version, 200, _ReasonPhrase}, _Headers, ResponseBody}} = + httpc:request(post, {Url, Headers, "application/json", ReqBody}, [], []), + + % Parse response + ResponseMap = jsx:decode(list_to_binary(ResponseBody)), + ?assert(maps:is_key(<<"methodResponses">>, ResponseMap)). + +test_jmap_api_without_auth(Config) -> + ServerUrl = ?config(server_url, Config), + + % Test JMAP API call without authentication + ReqBody = jsx:encode(#{ + <<"using">> => [<<"urn:ietf:params:jmap:core">>, <<"https://jmap.io/jchat/">>], + <<"methodCalls">> => [ + [<<"Conversation/query">>, #{ + <<"accountId">> => <<"default">>, + <<"filter">> => #{}, + <<"sort">> => [#{<<"property">> => <<"lastMessageAt">>, <<"isAscending">> => false}] + }, <<"c1">>] + ] + }), + + Url = ServerUrl ++ "/jmap/api", + Headers = [{"content-type", "application/json"}], + + {ok, {{_Version, 401, _ReasonPhrase}, _Headers, _ResponseBody}} = + httpc:request(post, {Url, Headers, "application/json", ReqBody}, [], []). + +test_cors_headers(Config) -> + ServerUrl = ?config(server_url, Config), + + % Test CORS preflight + Url = ServerUrl ++ "/auth/register", + Headers = [ + {"origin", "http://localhost:3000"}, + {"access-control-request-method", "POST"}, + {"access-control-request-headers", "content-type,authorization"} + ], + + {ok, {{_Version, StatusCode, _ReasonPhrase}, ResponseHeaders, _ResponseBody}} = + httpc:request(options, {Url, Headers}, [], []), + + % Should return 200 or 204 for OPTIONS + ?assert(StatusCode =:= 200 orelse StatusCode =:= 204), + + % Check for CORS headers + HeadersMap = maps:from_list(ResponseHeaders), + ?assert(maps:is_key("access-control-allow-origin", HeadersMap) orelse + maps:is_key("Access-Control-Allow-Origin", HeadersMap)). diff --git a/server/test/jchat_perf_SUITE.erl b/server/test/jchat_perf_SUITE.erl new file mode 100644 index 0000000..5feccbb --- /dev/null +++ b/server/test/jchat_perf_SUITE.erl @@ -0,0 +1,185 @@ +-module(jchat_perf_SUITE). + +-include_lib("common_test/include/ct.hrl"). +-include("../src/jchat.hrl"). + +%% CT callbacks +-export([all/0, init_per_suite/1, end_per_suite/1]). + +%% Test cases +-export([test_message_creation_throughput/1, + test_conversation_query_performance/1, + test_concurrent_requests/1, + test_large_conversation/1]). + +all() -> + [test_message_creation_throughput, + test_conversation_query_performance, + test_concurrent_requests, + test_large_conversation]. + +init_per_suite(Config) -> + application:ensure_all_started(jchat), + timer:sleep(1000), + Config. + +end_per_suite(_Config) -> + application:stop(jchat), + ok. + +%% Performance tests + +test_message_creation_throughput(_Config) -> + NumMessages = 1000, + ConvId = <<"perf-conv-1">>, + + % Setup + {ok, _} = jchat_db:create_conversation(ConvId, #{ + title => <<"Performance Test">>, + participant_ids => [<<"user1">>] + }), + + % Measure throughput + StartTime = erlang:monotonic_time(millisecond), + + lists:foreach(fun(N) -> + MsgId = list_to_binary(io_lib:format("msg-~p", [N])), + {ok, _} = jchat_db:create_message(MsgId, #{ + conversation_id => ConvId, + sender_id => <<"user1">>, + body => <<"Performance test message">> + }) + end, lists:seq(1, NumMessages)), + + EndTime = erlang:monotonic_time(millisecond), + Duration = EndTime - StartTime, + Throughput = NumMessages * 1000 / Duration, + + ct:pal("Created ~p messages in ~p ms (~.2f msg/sec)", + [NumMessages, Duration, Throughput]), + + % Should be able to create at least 100 messages/second + true = Throughput > 100.0. + +test_conversation_query_performance(_Config) -> + NumConversations = 100, + NumQueries = 1000, + + % Setup - create conversations + lists:foreach(fun(N) -> + ConvId = list_to_binary(io_lib:format("query-conv-~p", [N])), + {ok, _} = jchat_db:create_conversation(ConvId, #{ + title => list_to_binary(io_lib:format("Conversation ~p", [N])), + participant_ids => [<<"user1">>] + }) + end, lists:seq(1, NumConversations)), + + % Measure query performance + StartTime = erlang:monotonic_time(millisecond), + + lists:foreach(fun(_) -> + {ok, _Conversations} = jchat_db:query_conversations(<<"user1">>, #{}) + end, lists:seq(1, NumQueries)), + + EndTime = erlang:monotonic_time(millisecond), + Duration = EndTime - StartTime, + AvgQueryTime = Duration / NumQueries, + + ct:pal("Executed ~p queries in ~p ms (~.2f ms/query)", + [NumQueries, Duration, AvgQueryTime]), + + % Each query should take less than 10ms on average + true = AvgQueryTime < 10.0. + +test_concurrent_requests(_Config) -> + NumWorkers = 10, + RequestsPerWorker = 100, + ConvId = <<"concurrent-conv">>, + + % Setup + {ok, _} = jchat_db:create_conversation(ConvId, #{ + title => <<"Concurrent Test">>, + participant_ids => [<<"user1">>] + }), + + Parent = self(), + StartTime = erlang:monotonic_time(millisecond), + + % Spawn workers + Workers = [spawn_link(fun() -> + worker_loop(ConvId, RequestsPerWorker, Parent) + end) || _ <- lists:seq(1, NumWorkers)], + + % Wait for all workers to complete + lists:foreach(fun(Worker) -> + receive + {Worker, done} -> ok + after 30000 -> + error(timeout) + end + end, Workers), + + EndTime = erlang:monotonic_time(millisecond), + Duration = EndTime - StartTime, + TotalRequests = NumWorkers * RequestsPerWorker, + Throughput = TotalRequests * 1000 / Duration, + + ct:pal("Completed ~p concurrent requests in ~p ms (~.2f req/sec)", + [TotalRequests, Duration, Throughput]), + + % Should handle at least 50 concurrent req/sec + true = Throughput > 50.0. + +test_large_conversation(_Config) -> + NumMessages = 10000, + ConvId = <<"large-conv">>, + + % Setup + {ok, _} = jchat_db:create_conversation(ConvId, #{ + title => <<"Large Conversation">>, + participant_ids => [<<"user1">>] + }), + + % Create many messages + StartTime = erlang:monotonic_time(millisecond), + + lists:foreach(fun(N) -> + MsgId = list_to_binary(io_lib:format("large-msg-~p", [N])), + Body = list_to_binary(io_lib:format("Message ~p in large conversation", [N])), + {ok, _} = jchat_db:create_message(MsgId, #{ + conversation_id => ConvId, + sender_id => <<"user1">>, + body => Body + }) + end, lists:seq(1, NumMessages)), + + EndTime = erlang:monotonic_time(millisecond), + Duration = EndTime - StartTime, + + ct:pal("Created large conversation with ~p messages in ~p ms", + [NumMessages, Duration]), + + % Test querying the large conversation + QueryStart = erlang:monotonic_time(millisecond), + {ok, Messages} = jchat_db:query_messages(#{in_conversation => ConvId}, #{sort => sent_at}), + QueryEnd = erlang:monotonic_time(millisecond), + QueryDuration = QueryEnd - QueryStart, + + ct:pal("Queried ~p messages in ~p ms", [length(Messages), QueryDuration]), + + % Query should complete reasonably fast even for large conversations + true = QueryDuration < 1000. % Less than 1 second + +%% Helper functions + +worker_loop(ConvId, 0, Parent) -> + Parent ! {self(), done}; +worker_loop(ConvId, N, Parent) -> + % Create a message + MsgId = list_to_binary(io_lib:format("worker-~p-msg-~p", [self(), N])), + {ok, _} = jchat_db:create_message(MsgId, #{ + conversation_id => ConvId, + sender_id => <<"user1">>, + body => <<"Concurrent test message">> + }), + worker_loop(ConvId, N - 1, Parent). diff --git a/server/test/jchat_prop_SUITE.erl b/server/test/jchat_prop_SUITE.erl new file mode 100644 index 0000000..c47e531 --- /dev/null +++ b/server/test/jchat_prop_SUITE.erl @@ -0,0 +1,131 @@ +-module(jchat_prop_SUITE). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("proper/include/proper.hrl"). +-include("../src/jchat.hrl"). + +%% CT callbacks +-export([all/0, init_per_suite/1, end_per_suite/1]). + +%% Test cases +-export([prop_id_validation/1, + prop_conversation_crud/1, + prop_message_crud/1, + prop_json_encoding/1]). + +all() -> + [prop_id_validation, + prop_conversation_crud, + prop_message_crud, + prop_json_encoding]. + +init_per_suite(Config) -> + application:ensure_all_started(jchat), + timer:sleep(1000), + Config. + +end_per_suite(_Config) -> + application:stop(jchat), + ok. + +%% Property tests + +prop_id_validation(_Config) -> + ?assert(proper:quickcheck(?FORALL(Id, valid_id(), + jchat_utils:validate_id(Id) =:= true))), + + ?assert(proper:quickcheck(?FORALL(Id, invalid_id(), + jchat_utils:validate_id(Id) =:= false))). + +prop_conversation_crud(_Config) -> + ?assert(proper:quickcheck(?FORALL({Id, Attrs}, {valid_id(), conversation_attrs()}, + begin + % Create + {ok, Conv} = jchat_db:create_conversation(Id, Attrs), + % Get + {ok, Conv2} = jchat_db:get_conversation(Id), + % Update + Updates = #{title => <<"Updated Title">>}, + {ok, Conv3} = jchat_db:update_conversation(Id, Updates), + % Verify + Conv#conversation.id =:= Conv2#conversation.id andalso + Conv3#conversation.title =:= <<"Updated Title">> + end))). + +prop_message_crud(_Config) -> + ?assert(proper:quickcheck(?FORALL({ConvId, MsgId, Attrs}, + {valid_id(), valid_id(), message_attrs()}, + begin + % Create conversation first + ConvAttrs = #{title => <<"Test">>, participant_ids => [<<"user1">>]}, + {ok, _} = jchat_db:create_conversation(ConvId, ConvAttrs), + + % Create message + MsgAttrs = Attrs#{conversation_id => ConvId, sender_id => <<"user1">>}, + {ok, Msg} = jchat_db:create_message(MsgId, MsgAttrs), + + % Get message + {ok, Msg2} = jchat_db:get_message(MsgId), + + % Verify + Msg#message.id =:= Msg2#message.id + end))). + +prop_json_encoding(_Config) -> + ?assert(proper:quickcheck(?FORALL(Data, json_data(), + begin + JSON = jchat_utils:json_encode(Data), + {ok, Decoded} = jchat_utils:json_decode(JSON), + normalize_json(Data) =:= normalize_json(Decoded) + end))). + +%% Generators + +valid_id() -> + ?LET(Chars, non_empty(list(oneof([ + choose($A, $Z), + choose($a, $z), + choose($0, $9), + return($-), + return($_) + ]))), list_to_binary(Chars)). + +invalid_id() -> + oneof([ + <<>>, % Empty + ?LET(N, choose(256, 1000), list_to_binary(lists:duplicate(N, $a))), % Too long + <<"invalid=chars">>, % Invalid characters + <<"spaces not allowed">> % Spaces + ]). + +conversation_attrs() -> + ?LET({Title, Desc, Archived, Muted, Participants}, + {binary(), oneof([binary(), null]), boolean(), boolean(), list(valid_id())}, + #{title => Title, + description => Desc, + is_archived => Archived, + is_muted => Muted, + participant_ids => Participants}). + +message_attrs() -> + ?LET({Body, BodyType}, {binary(), oneof([<<"text/plain">>, <<"text/html">>])}, + #{body => Body, body_type => BodyType}). + +json_data() -> + ?LAZY(oneof([ + binary(), + integer(), + boolean(), + null, + list(json_data()), + map(binary(), json_data()) + ])). + +%% Helpers + +normalize_json(null) -> null; +normalize_json(Data) when is_map(Data) -> + maps:map(fun(_, V) -> normalize_json(V) end, Data); +normalize_json(Data) when is_list(Data) -> + [normalize_json(Item) || Item <- Data]; +normalize_json(Data) -> Data. |