CI/CD: ci, deploy-ift и deploy-stage через EventHubDevOps; фиксы calendar view, archive, API-тесты, traefik-coraza
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# ВРЕМЕННО ОТКЛЮЧЕНО — CI/CD требует донастройки (см. EventHubBack#23).
|
# Устарело — заменено на ci.yml и deploy-ift.yml / deploy-stage.yml (EventHubDevOps).
|
||||||
# Ручной запуск: Actions → Run workflow
|
# Ручной запуск: Actions → Run workflow
|
||||||
name: Test & Deploy to Snapdeploy
|
name: Test & Deploy to Snapdeploy (legacy)
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
# on:
|
# on:
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Build eventhub image
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
load: true
|
||||||
|
tags: eventhub:latest
|
||||||
|
|
||||||
|
- name: Build API test image
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/ApiTests.Dockerfile
|
||||||
|
load: true
|
||||||
|
tags: eventhub-api-tests:latest
|
||||||
|
|
||||||
|
- name: Run API tests (local CT)
|
||||||
|
run: docker run --rm eventhub-api-tests:latest
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: Deploy IFT (core)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-ift-core:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: git.sabilin.com
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push eventhub
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: git.sabilin.com/eventhub/eventhub:ift
|
||||||
|
|
||||||
|
- name: Build and push traefik-coraza
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: docker/traefik
|
||||||
|
file: docker/traefik/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: git.sabilin.com/eventhub/traefik-coraza:ift
|
||||||
|
|
||||||
|
- name: Build and push fallback
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: docker/fallback
|
||||||
|
file: docker/fallback/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: git.sabilin.com/eventhub/fallback:ift
|
||||||
|
|
||||||
|
- name: Build and push bot-emulator
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: test/emulate_users/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: git.sabilin.com/eventhub/bot-emulator-users:ift
|
||||||
|
|
||||||
|
- name: Deploy core on IFT via SSH
|
||||||
|
uses: appleboy/ssh-action@v0.1.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.IFT_SSH_HOST }}
|
||||||
|
username: ${{ secrets.IFT_SSH_USER }}
|
||||||
|
key: ${{ secrets.IFT_SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
export REGISTRY_USER='${{ secrets.REGISTRY_USER }}'
|
||||||
|
export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}'
|
||||||
|
/opt/eventhub-ift/devops/scripts/deploy-service.sh ift core
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
# ВРЕМЕННО ОТКЛЮЧЕНО — CI/CD требует донастройки.
|
name: Deploy stage (core)
|
||||||
# Ручной запуск: Actions → Run workflow
|
|
||||||
name: Deploy to Stage
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
# on:
|
|
||||||
# push:
|
|
||||||
# tags:
|
|
||||||
# - 'v*'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-stage:
|
deploy-stage-core:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -17,7 +15,7 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
- name: Login to registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: git.sabilin.com
|
registry: git.sabilin.com
|
||||||
@@ -34,18 +32,18 @@ jobs:
|
|||||||
- name: Build and push bot-emulator
|
- name: Build and push bot-emulator
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: ./docker/bot-emulator-users
|
context: .
|
||||||
|
file: test/emulate_users/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: git.sabilin.com/eventhub/bot-emulator-users:stage
|
tags: git.sabilin.com/eventhub/bot-emulator-users:stage
|
||||||
|
|
||||||
- name: Deploy to stage server
|
- name: Deploy core on stage via SSH
|
||||||
uses: appleboy/ssh-action@v0.1.5
|
uses: appleboy/ssh-action@v0.1.5
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.STAGE_SSH_HOST }}
|
host: ${{ secrets.STAGE_SSH_HOST }}
|
||||||
username: ${{ secrets.STAGE_SSH_USER }}
|
username: ${{ secrets.STAGE_SSH_USER }}
|
||||||
key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||||
script: |
|
script: |
|
||||||
cd /opt/eventhub-stage
|
export REGISTRY_USER='${{ secrets.REGISTRY_USER }}'
|
||||||
docker pull git.sabilin.com/eventhub/eventhub:stage
|
export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}'
|
||||||
docker pull git.sabilin.com/eventhub/bot-emulator-users:stage
|
/opt/eventhub-stage/devops/scripts/deploy-service.sh stage core
|
||||||
docker stack deploy -c docker-compose.stage.yml eventhub-stage --with-registry-auth
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# Основной образ EventHub (единственный для нод)
|
# Основной образ EventHub (единственный для нод)
|
||||||
docker build -t eventhub:latest -f docker/Dockerfile .
|
docker build -t eventhub:latest -f docker/Dockerfile .
|
||||||
|
|
||||||
|
# Traefik + Coraza WAF (wasm встроен в образ, без plugins.traefik.io)
|
||||||
|
docker build -t traefik-coraza:latest -f docker/traefik/Dockerfile docker/traefik
|
||||||
|
|
||||||
# Сервис заглушка
|
# Сервис заглушка
|
||||||
docker build -t fallback:latest -f docker/fallback/Dockerfile docker/fallback
|
docker build -t fallback:latest -f docker/fallback/Dockerfile docker/fallback
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: "3.8"
|
|||||||
services:
|
services:
|
||||||
# ================== Балансировщик ==================
|
# ================== Балансировщик ==================
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:latest
|
image: traefik-coraza:latest
|
||||||
command:
|
command:
|
||||||
# - "--log.level=DEBUG"
|
# - "--log.level=DEBUG"
|
||||||
- "--api.insecure=true"
|
- "--api.insecure=true"
|
||||||
@@ -21,8 +21,7 @@ services:
|
|||||||
- "--accesslog=true"
|
- "--accesslog=true"
|
||||||
- "--accesslog.filepath=/var/log/traefik/access.log"
|
- "--accesslog.filepath=/var/log/traefik/access.log"
|
||||||
- "--accesslog.format=json"
|
- "--accesslog.format=json"
|
||||||
- "--experimental.plugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik"
|
- "--experimental.localPlugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik"
|
||||||
- "--experimental.plugins.coraza.version=v0.3.0"
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
@@ -32,7 +31,6 @@ services:
|
|||||||
- "./traefik/certs:/etc/traefik/certs:ro"
|
- "./traefik/certs:/etc/traefik/certs:ro"
|
||||||
- "./traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro"
|
- "./traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro"
|
||||||
- "traefik-logs:/var/log/traefik"
|
- "traefik-logs:/var/log/traefik"
|
||||||
- "traefik-plugins:/plugins-storage"
|
|
||||||
networks:
|
networks:
|
||||||
- eventhub-net
|
- eventhub-net
|
||||||
deploy:
|
deploy:
|
||||||
@@ -226,5 +224,4 @@ volumes:
|
|||||||
prometheus-data:
|
prometheus-data:
|
||||||
grafana-data:
|
grafana-data:
|
||||||
traefik-logs:
|
traefik-logs:
|
||||||
loglynx-data:
|
loglynx-data:
|
||||||
traefik-plugins:
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
services:
|
services:
|
||||||
# ================== Балансировщик (HTTPS/WSS, WAF, логи) ==================
|
# ================== Балансировщик (HTTPS/WSS, WAF, логи) ==================
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:latest
|
image: traefik-coraza:latest
|
||||||
user: "0:1001" # группа docker на хосте
|
user: "0:1001" # группа docker на хосте
|
||||||
command:
|
command:
|
||||||
- "--api.insecure=true"
|
- "--api.insecure=true"
|
||||||
@@ -20,9 +20,8 @@ services:
|
|||||||
- "--accesslog=true"
|
- "--accesslog=true"
|
||||||
- "--accesslog.filepath=/var/log/traefik/access.log"
|
- "--accesslog.filepath=/var/log/traefik/access.log"
|
||||||
- "--accesslog.format=json"
|
- "--accesslog.format=json"
|
||||||
# Coraza WAF
|
# Coraza WAF (локальный wasm из образа)
|
||||||
- "--experimental.plugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik"
|
- "--experimental.localPlugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik"
|
||||||
- "--experimental.plugins.coraza.version=v0.2.0"
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM traefik:v3.2.0
|
||||||
|
|
||||||
|
ARG CORAZA_VERSION=v0.3.0
|
||||||
|
ARG CORAZA_MODULE=github.com/jcchavezs/coraza-http-wasm-traefik
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl unzip \
|
||||||
|
&& mkdir -p /plugins-local/src/github.com/jcchavezs/coraza-http-wasm-traefik \
|
||||||
|
&& curl -fsSL -o /tmp/coraza.zip \
|
||||||
|
"https://github.com/jcchavezs/coraza-http-wasm-traefik/releases/download/${CORAZA_VERSION}/coraza-http-wasm-${CORAZA_VERSION}.zip" \
|
||||||
|
&& unzip -q /tmp/coraza.zip -d /plugins-local/src/github.com/jcchavezs/coraza-http-wasm-traefik \
|
||||||
|
&& rm /tmp/coraza.zip
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
-export([fetch/3]).
|
-export([fetch/3]).
|
||||||
|
|
||||||
fetch(CalendarId, Year, Month) ->
|
fetch(CalendarId, Year, Month) ->
|
||||||
Start = {Year, Month, 1, 0, 0, 0},
|
Start = {{Year, Month, 1}, {0, 0, 0}},
|
||||||
End = {Year, Month, 31, 23, 59, 59},
|
End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}},
|
||||||
mnesia:dirty_select(event, [{#event{calendar_id = CalendarId,
|
mnesia:dirty_select(event, [{#event{calendar_id = CalendarId,
|
||||||
start_time = '$1', _ = '_'},
|
start_time = '$1', _ = '_'},
|
||||||
[{'>=','$1', Start},{'=<','$1', End}],
|
[{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}],
|
||||||
['$_']}]).
|
['$_']}]).
|
||||||
@@ -16,7 +16,7 @@ init([]) ->
|
|||||||
{ok, #{}}.
|
{ok, #{}}.
|
||||||
|
|
||||||
handle_call({get_node, Day}, _From, State) ->
|
handle_call({get_node, Day}, _From, State) ->
|
||||||
Node = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()),
|
Node = archive_node_name(Day),
|
||||||
case maps:find(Node, State) of
|
case maps:find(Node, State) of
|
||||||
{ok, _Info} -> % переменная не используется, ок
|
{ok, _Info} -> % переменная не используется, ок
|
||||||
{reply, {ok, Node}, update_access(State, Node)};
|
{reply, {ok, Node}, update_access(State, Node)};
|
||||||
@@ -55,14 +55,48 @@ update_access(State, Node) ->
|
|||||||
State#{Node => Info#{timer => Ref, last_access => erlang:monotonic_time()}}.
|
State#{Node => Info#{timer => Ref, last_access => erlang:monotonic_time()}}.
|
||||||
|
|
||||||
start_archive_node(Day) ->
|
start_archive_node(Day) ->
|
||||||
Node = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()),
|
Node = archive_node_name(Day),
|
||||||
case slave:start(host(), Node) of
|
case start_archive_peer(Node) of
|
||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
rpc:call(Node, mnesia, start, []),
|
case ensure_archive_node_ready(Node) of
|
||||||
{ok, Node};
|
ok -> {ok, Node};
|
||||||
Error -> Error
|
{error, Reason} -> {error, Reason}
|
||||||
|
end;
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
start_archive_peer(Node) ->
|
||||||
|
case os:getenv("CLUSTER_MODE") of
|
||||||
|
"true" ->
|
||||||
|
case peer:start_link(#{name => Node, host => host()}) of
|
||||||
|
{ok, _} -> {ok, Node};
|
||||||
|
{error, {already_started, _}} -> {ok, Node};
|
||||||
|
Error -> Error
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
CookieStr = atom_to_list(erlang:get_cookie()),
|
||||||
|
case slave:start(host(), Node, "-setcookie " ++ CookieStr) of
|
||||||
|
{ok, _} -> {ok, Node};
|
||||||
|
Error -> Error
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
ensure_archive_node_ready(Node) ->
|
||||||
|
case rpc:call(Node, mnesia, start, [], 5000) of
|
||||||
|
ok ->
|
||||||
|
case rpc:call(Node, code, ensure_loaded, [archive_fetcher], 5000) of
|
||||||
|
{module, archive_fetcher} -> ok;
|
||||||
|
{error, Reason} -> {error, Reason};
|
||||||
|
{badrpc, Reason} -> {error, Reason}
|
||||||
|
end;
|
||||||
|
{badrpc, Reason} -> {error, Reason};
|
||||||
|
Other -> {error, Other}
|
||||||
|
end.
|
||||||
|
|
||||||
|
archive_node_name(Day) ->
|
||||||
|
list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()).
|
||||||
|
|
||||||
stop_archive_node(Node) ->
|
stop_archive_node(Node) ->
|
||||||
rpc:cast(Node, init, stop, []).
|
rpc:cast(Node, init, stop, []).
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
-include("records.hrl").
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-define(ARCHIVE_CALL_TIMEOUT, 3000).
|
||||||
|
|
||||||
%%% cowboy_handler callback
|
%%% cowboy_handler callback
|
||||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||||
init(Req, Opts) ->
|
init(Req, Opts) ->
|
||||||
@@ -135,10 +137,28 @@ fetch_hot_events(CalendarId, Year, Month) ->
|
|||||||
]).
|
]).
|
||||||
|
|
||||||
%% @private Извлекает архивные события через RPC на архивный узел.
|
%% @private Извлекает архивные события через RPC на архивный узел.
|
||||||
|
%% При недоступности архива возвращает события из основной Mnesia.
|
||||||
-spec fetch_archive_events(binary(), integer(), integer()) -> list(#event{}).
|
-spec fetch_archive_events(binary(), integer(), integer()) -> list(#event{}).
|
||||||
fetch_archive_events(CalendarId, Year, Month) ->
|
fetch_archive_events(CalendarId, Year, Month) ->
|
||||||
DayStr = io_lib:format("~4..0B~2..0B", [Year, Month]),
|
DayStr = lists:flatten(io_lib:format("~4..0B~2..0B", [Year, Month])),
|
||||||
case archive_manager:get_archive_node(lists:flatten(DayStr)) of
|
case safe_get_archive_node(DayStr) of
|
||||||
{ok, Node} -> rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month]);
|
{ok, Node} ->
|
||||||
{error, _} -> []
|
case rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month],
|
||||||
|
?ARCHIVE_CALL_TIMEOUT) of
|
||||||
|
Events when is_list(Events) -> Events;
|
||||||
|
_ -> fetch_hot_events(CalendarId, Year, Month)
|
||||||
|
end;
|
||||||
|
{error, _} ->
|
||||||
|
fetch_hot_events(CalendarId, Year, Month)
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec safe_get_archive_node(string()) -> {ok, node()} | {error, term()}.
|
||||||
|
safe_get_archive_node(Day) ->
|
||||||
|
try gen_server:call(archive_manager, {get_node, Day}, ?ARCHIVE_CALL_TIMEOUT) of
|
||||||
|
{ok, Node} when is_atom(Node) -> {ok, Node};
|
||||||
|
{error, Reason} -> {error, Reason};
|
||||||
|
Other -> {error, Other}
|
||||||
|
catch
|
||||||
|
exit:{timeout, _} -> {error, timeout};
|
||||||
|
exit:{noproc, _} -> {error, noproc}
|
||||||
end.
|
end.
|
||||||
@@ -41,7 +41,8 @@
|
|||||||
client_delete/2,
|
client_delete/2,
|
||||||
admin_patch/3]).
|
admin_patch/3]).
|
||||||
-export([verify_user/2]).
|
-export([verify_user/2]).
|
||||||
-export([future_date_iso8601/0]).
|
-export([future_date_iso8601/0, iso8601_utc/1, future_month_range_iso8601/0,
|
||||||
|
next_month_start_iso8601/0, next_month_range_iso8601/0]).
|
||||||
|
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
%%% Конфигурация окружения (CT_MODE, ...)
|
%%% Конфигурация окружения (CT_MODE, ...)
|
||||||
@@ -283,11 +284,39 @@ future_date() ->
|
|||||||
|
|
||||||
-spec future_date_iso8601() -> binary().
|
-spec future_date_iso8601() -> binary().
|
||||||
future_date_iso8601() ->
|
future_date_iso8601() ->
|
||||||
Seconds = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 86400,
|
iso8601_utc(future_date()).
|
||||||
{{Y,M,D},{H,Min,S}} = calendar:gregorian_seconds_to_datetime(Seconds),
|
|
||||||
|
-spec iso8601_utc(calendar:datetime()) -> binary().
|
||||||
|
iso8601_utc({{Y, M, D}, {H, Min, S}}) ->
|
||||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
|
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
|
||||||
[Y, M, D, H, Min, S])).
|
[Y, M, D, H, Min, S])).
|
||||||
|
|
||||||
|
-spec future_month_range_iso8601() -> {binary(), binary()}.
|
||||||
|
future_month_range_iso8601() ->
|
||||||
|
{{Y, M, _D}, _} = future_date(),
|
||||||
|
month_range_iso8601(Y, M).
|
||||||
|
|
||||||
|
-spec next_month_start_iso8601() -> binary().
|
||||||
|
next_month_start_iso8601() ->
|
||||||
|
{{Y, M, _}, _} = future_date(),
|
||||||
|
{NY, NM} = next_month(Y, M),
|
||||||
|
iso8601_utc({{NY, NM, 1}, {10, 0, 0}}).
|
||||||
|
|
||||||
|
-spec next_month_range_iso8601() -> {binary(), binary()}.
|
||||||
|
next_month_range_iso8601() ->
|
||||||
|
{{Y, M, _}, _} = future_date(),
|
||||||
|
{NY, NM} = next_month(Y, M),
|
||||||
|
month_range_iso8601(NY, NM).
|
||||||
|
|
||||||
|
month_range_iso8601(Y, M) ->
|
||||||
|
From = iso8601_utc({{Y, M, 1}, {0, 0, 0}}),
|
||||||
|
LastDay = calendar:last_day_of_the_month(Y, M),
|
||||||
|
To = iso8601_utc({{Y, M, LastDay}, {23, 59, 59}}),
|
||||||
|
{From, To}.
|
||||||
|
|
||||||
|
next_month(Y, 12) -> {Y + 1, 1};
|
||||||
|
next_month(Y, M) -> {Y, M + 1}.
|
||||||
|
|
||||||
-spec register_and_login(binary(), binary()) -> binary().
|
-spec register_and_login(binary(), binary()) -> binary().
|
||||||
register_and_login(Email, Password) ->
|
register_and_login(Email, Password) ->
|
||||||
% 1. Регистрируем пользователя
|
% 1. Регистрируем пользователя
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ test_create_single_event(Token, CalId) ->
|
|||||||
%% @doc GET /v1/calendars/:calendar_id/events?from=...&to=... – список с фильтром.
|
%% @doc GET /v1/calendars/:calendar_id/events?from=...&to=... – список с фильтром.
|
||||||
test_list_events_with_dates(Token, CalId) ->
|
test_list_events_with_dates(Token, CalId) ->
|
||||||
ct:pal(" TEST: List events with date range"),
|
ct:pal(" TEST: List events with date range"),
|
||||||
Path = <<"/v1/calendars/", CalId/binary, "/events?from=2026-05-01T00:00:00Z&to=2026-07-01T00:00:00Z">>,
|
{From, To} = api_test_runner:future_month_range_iso8601(),
|
||||||
|
Path = <<"/v1/calendars/", CalId/binary, "/events?from=", From/binary,
|
||||||
|
"&to=", To/binary>>,
|
||||||
Events = api_test_runner:client_get(Path, Token),
|
Events = api_test_runner:client_get(Path, Token),
|
||||||
?assert(is_list(Events)),
|
?assert(is_list(Events)),
|
||||||
?assert(length(Events) >= 1),
|
?assert(length(Events) >= 1),
|
||||||
@@ -58,7 +60,7 @@ test_create_recurring_event(Token, CalId) ->
|
|||||||
Path = <<"/v1/calendars/", CalId/binary, "/events">>,
|
Path = <<"/v1/calendars/", CalId/binary, "/events">>,
|
||||||
Body = jsx:encode(#{
|
Body = jsx:encode(#{
|
||||||
title => <<"Weekly Meeting">>,
|
title => <<"Weekly Meeting">>,
|
||||||
start_time => api_test_runner:future_date_iso8601(),
|
start_time => api_test_runner:next_month_start_iso8601(),
|
||||||
duration => 60,
|
duration => 60,
|
||||||
recurrence => #{
|
recurrence => #{
|
||||||
freq => <<"WEEKLY">>,
|
freq => <<"WEEKLY">>,
|
||||||
@@ -70,11 +72,12 @@ test_create_recurring_event(Token, CalId) ->
|
|||||||
#{<<"id">> := RecurringId} = jsx:decode(list_to_binary(RespBody), [return_maps]),
|
#{<<"id">> := RecurringId} = jsx:decode(list_to_binary(RespBody), [return_maps]),
|
||||||
ct:pal(" Created recurring event ~s", [RecurringId]),
|
ct:pal(" Created recurring event ~s", [RecurringId]),
|
||||||
|
|
||||||
% Запрашиваем вхождения на месяц
|
{From, To} = api_test_runner:next_month_range_iso8601(),
|
||||||
OccPath = <<"/v1/events/", RecurringId/binary, "/occurrences?from=2026-06-01T00:00:00Z&to=2026-06-30T00:00:00Z">>,
|
OccPath = <<"/v1/events/", RecurringId/binary, "/occurrences?from=", From/binary,
|
||||||
|
"&to=", To/binary>>,
|
||||||
Occs = api_test_runner:client_get(OccPath, Token),
|
Occs = api_test_runner:client_get(OccPath, Token),
|
||||||
?assert(is_list(Occs)),
|
?assert(is_list(Occs)),
|
||||||
?assert(length(Occs) >= 4), % минимум 4 недели в июне
|
?assert(length(Occs) >= 4), % минимум 4 недели в полном месяце
|
||||||
FirstOcc = hd(Occs),
|
FirstOcc = hd(Occs),
|
||||||
?assert(maps:is_key(<<"start_time">>, FirstOcc)),
|
?assert(maps:is_key(<<"start_time">>, FirstOcc)),
|
||||||
ct:pal(" OK: ~p occurrences found", [length(Occs)]).
|
ct:pal(" OK: ~p occurrences found", [length(Occs)]).
|
||||||
@@ -36,8 +36,9 @@ test() ->
|
|||||||
duration => 30,
|
duration => 30,
|
||||||
recurrence => #{freq => <<"WEEKLY">>, interval => 1}}),
|
recurrence => #{freq => <<"WEEKLY">>, interval => 1}}),
|
||||||
|
|
||||||
% Получаем вхождения (ответ – список карт)
|
{From, To} = api_test_runner:future_month_range_iso8601(),
|
||||||
OccPath = <<"/v1/events/", RecurringId/binary, "/occurrences?from=2026-06-01T00:00:00Z&to=2026-06-30T00:00:00Z">>,
|
OccPath = <<"/v1/events/", RecurringId/binary, "/occurrences?from=", From/binary,
|
||||||
|
"&to=", To/binary>>,
|
||||||
Occurrences = api_test_runner:client_get(OccPath, OwnerToken),
|
Occurrences = api_test_runner:client_get(OccPath, OwnerToken),
|
||||||
?assert(is_list(Occurrences)),
|
?assert(is_list(Occurrences)),
|
||||||
?assert(length(Occurrences) >= 1),
|
?assert(length(Occurrences) >= 1),
|
||||||
|
|||||||
@@ -93,9 +93,9 @@ test_tag_filter(Token) ->
|
|||||||
|
|
||||||
test_date_filter(Token) ->
|
test_date_filter(Token) ->
|
||||||
ct:pal(" TEST: Search with date range"),
|
ct:pal(" TEST: Search with date range"),
|
||||||
Events = extract_events(
|
{From, To} = api_test_runner:future_month_range_iso8601(),
|
||||||
api_test_runner:client_get(
|
Path = <<"/v1/search?from=", From/binary, "&to=", To/binary>>,
|
||||||
<<"/v1/search?from=2026-06-01T00:00:00Z&to=2026-06-15T23:59:59Z">>, Token)),
|
Events = extract_events(api_test_runner:client_get(Path, Token)),
|
||||||
?assert(is_list(Events)),
|
?assert(is_list(Events)),
|
||||||
?assert(length(Events) >= 1),
|
?assert(length(Events) >= 1),
|
||||||
ct:pal(" OK: ~p events", [length(Events)]).
|
ct:pal(" OK: ~p events", [length(Events)]).
|
||||||
|
|||||||
Reference in New Issue
Block a user