aboutsummaryrefslogtreecommitdiff
path: root/server/test
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2025-09-03 21:15:36 -0400
committerCalvin Morrison <calvin@pobox.com>2025-09-03 21:15:36 -0400
commit49fa5aa2a127bdf8924d02bf77e5086b39c7a447 (patch)
tree61d86a7705dacc9fddccc29fa79d075d83ab8059 /server/test
i vibe coded itHEADmaster
Diffstat (limited to 'server/test')
-rw-r--r--server/test/jchat_SUITE.erl252
-rw-r--r--server/test/jchat_auth_SUITE.erl188
-rw-r--r--server/test/jchat_http_SUITE.erl224
-rw-r--r--server/test/jchat_perf_SUITE.erl185
-rw-r--r--server/test/jchat_prop_SUITE.erl131
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.