Skip to content

Commit

Permalink
feat: "LAST" command
Browse files Browse the repository at this point in the history
Responses are based on what the `handle_LAST` returns.

- 420 If the current article number is invalid.
- 412 If the currently selected newsgroup is invalid.
- 422 if the current article number is already the first article of the
newsgroup.
- 223 n message-if if article found.

The current article number is set to the next lower number in the group
if suceeds.
  • Loading branch information
sntran committed Mar 11, 2021
1 parent 77f8d26 commit f0e2d4a
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 1 deletion.
8 changes: 8 additions & 0 deletions lib/gen_nntp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ defmodule GenNNTP do
when number: non_neg_integer(),
arg: :gen_nttp.message_id() | {number, group :: String.t()}

@callback handle_LAST(arg, state) ::
{:ok, { number, :gen_nttp.article() }, state }|
{:ok, false, state} |
{:error, reason :: String.t(), state}
when number: non_neg_integer(),
arg: :gen_nttp.message_id() | {number, group :: String.t()}

@callback handle_ARTICLE(arg, state) ::
{:ok, { number, :gen_nttp.article() }, state }|
{:ok, false, state} |
Expand Down Expand Up @@ -109,6 +116,7 @@ defmodule GenNNTP do
handle_GROUP: 2,
handle_LISTGROUP: 2,
handle_NEXT: 2,
handle_LAST: 2,
handle_ARTICLE: 2,
handle_HEAD: 2,
handle_BODY: 2,
Expand Down
50 changes: 50 additions & 0 deletions src/gen_nntp.erl
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@
when Number :: non_neg_integer(),
Arg :: message_id() | {Number, Group :: binary()}.

-callback handle_LAST(Arg, state()) ->
{ok, { Number, article() }, state()}
| {ok, false, state()}
| {error, Reason :: binary(), state()}
when Number :: non_neg_integer(),
Arg :: message_id() | {Number, Group :: binary()}.

-callback handle_ARTICLE(Arg, state()) ->
{ok, { Number, article() }, state()}
| {ok, false, state()}
Expand Down Expand Up @@ -138,6 +145,7 @@
handle_GROUP/2,
handle_LISTGROUP/2,
handle_NEXT/2,
handle_LAST/2,
handle_ARTICLE/2,
handle_HEAD/2,
handle_BODY/2,
Expand Down Expand Up @@ -527,6 +535,46 @@ handle_command(<<"NEXT">> = Cmd, Client) ->
end,
{reply, Reply, Client#client{article_number = NewNumber, state = NewState}};

% The currently selected group is invalid, and no argument is specified.
handle_command(<<"LAST">>, #client{group = invalid} = Client) ->
{reply, <<"412 No newsgroup selected">>, Client};

% Have currently selected group, but current article number is invalid.
handle_command(<<"LAST">>, #client{article_number = invalid} = Client) ->
{reply, <<"420 Current article number is invalid">>, Client};

handle_command(<<"LAST">> = Cmd, Client) ->
#client{
module = Module,
state = State,
group = Group,
article_number = ArticleNumber
} = Client,

% Asks the callback module to provide the capacitities at this moment.
{ok, Capabilities, State1} = Module:handle_CAPABILITIES(State),

{Reply, NewState} = case is_capable(Cmd, Capabilities) of
true ->
{ok, ArticleInfo, State2} = Module:handle_LAST({ArticleNumber, Group}, State1),
case ArticleInfo of
false -> {<<"423 No article with that number">>, State2};

% Current article number is already the first article
{ArticleNumber, _} ->
Response = <<"422 No previous article in this group">>,
{Response, State2};

% Previous article exists
{Number, #{id := Id}} ->
Response = [<<"223 ">>, integer_to_binary(Number), <<" ">>, Id, <<" Article found">>],
{Response, State2}
end;
false ->
{<<"412 No newsgroup selected">>, State1}
end,
{reply, Reply, Client#client{state = NewState}};

% Both ARTICLE, HEAD, BODY, and STAT have similar response.
handle_command(<<"ARTICLE", Arg/binary>>, Client) ->
handle_article(<<"ARTICLE">>, Arg, Client);
Expand Down Expand Up @@ -703,6 +751,8 @@ is_capable(<<"LISTGROUP", _Arg/binary>>, Capabilities) ->
is_capable(<<"READER">>, Capabilities);
is_capable(<<"NEXT", _Arg/binary>>, Capabilities) ->
is_capable(<<"READER">>, Capabilities);
is_capable(<<"LAST", _Arg/binary>>, Capabilities) ->
is_capable(<<"READER">>, Capabilities);
is_capable(<<"ARTICLE", _Arg/binary>>, Capabilities) ->
is_capable(<<"READER">>, Capabilities);
is_capable(<<"HEAD", _Arg/binary>>, Capabilities) ->
Expand Down
120 changes: 120 additions & 0 deletions test/gen_nntp_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,126 @@ defmodule GenNNTPTest do

end

describe "@callback handle_LAST/2" do
# Default to have "READER" capability for "LAST" to work.
@describetag capabilities: ["READER"]

setup [
:setup_articles, :setup_groups, :setup_group_articles,
:setup_CAPABILITIES, :setup_GROUP, :setup_NEXT, :setup_LAST,
:setup_server, :setup_socket
]

# The setup sets a list of capabilities with "READER" by default, so we empty it here.
@tag capabilities: []
test "is not called when there is no READER capability", context do
%{socket: socket} = context

:ok = :gen_tcp.send(socket, "LAST\r\n")

refute_receive(
{:called_back, :handle_LAST, 2},
100,
"@callback handle_LAST/2 should not be called when the server has no READER capability"
)
end

# The setup sets a list of capabilities with "READER" by default, so we empty it here.
@tag capabilities: []
test "responds with 412 when there is no READER capability", context do
%{socket: socket} = context

:ok = :gen_tcp.send(socket, "LAST\r\n")
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
# @sntran: This is unclear to me, as the specs say nothing about this case.
assert response =~ ~r/^412 /
end

test "responds with 412 if currently selected newsgroup is invalid", context do
%{socket: socket} = context

:ok = :gen_tcp.send(socket, "LAST\r\n")
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
assert response =~ ~r/^412 /
end

test "is called when the client asks for it and currently selected newsgroup is valid", context do
%{socket: socket} = context

refute_receive(
{:called_back, :handle_LAST, 2},
100,
"@callback handle_LAST/2 should not be called when client has not asked for it"
)

group_name = "misc.test"
# Calling "GROUP" should set the current article number to the first article in the group
:ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n")
{:ok, _response} = :gen_tcp.recv(socket, 0, 1000)

:ok = :gen_tcp.send(socket, "LAST\r\n")

assert_receive(
{:called_back, :handle_LAST, 2},
100,
"@callback handle_LAST/2 was not called"
)
end

test "responds with 420 if current article number is invalid", context do
%{socket: socket, groups: groups} = context

# This is the case where currently selected newsgroup is valid, but it's empty.
group_name = "example.empty.newsgroup"
{^group_name, 0, 0, 0, _} = List.keyfind(groups, group_name, 0, false)

# Calling "GROUP" should set the current article number to the first article in the group
:ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n")
{:ok, _response} = :gen_tcp.recv(socket, 0, 1000)

:ok = :gen_tcp.send(socket, "LAST\r\n")
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
assert response =~ ~r/^420 /
end

test "responds with 422 when current article number is already the first article of the newsgroup", context do
%{socket: socket} = context

group_name = "misc.test"
# Calling "GROUP" should set the current article number to the first article in the group
:ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n")
{:ok, _response} = :gen_tcp.recv(socket, 0, 1000)

# Because we have just switched group, the callback should return the same article number.
:ok = :gen_tcp.send(socket, "LAST\r\n")
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
assert response =~ ~r/^422 /
end

test "responds with `223 n message-id` of the previous article", context do
%{socket: socket, groups: groups, group_articles: group_articles} = context

group_name = "misc.test"
{^group_name, _estimate, low, _high, _numbers} = List.keyfind(groups, group_name, 0)
# Get the message id of the next article number.
{_, message_id} = List.keyfind(group_articles, {low, group_name}, 0)

# Calling "GROUP" should set the current article number to the first article in the group
:ok = :gen_tcp.send(socket, "GROUP #{group_name}\r\n")
{:ok, _response} = :gen_tcp.recv(socket, 0, 1000)

# Moves to the next article.
:ok = :gen_tcp.send(socket, "NEXT\r\n")
{:ok, _response} = :gen_tcp.recv(socket, 0, 1000)

# "LAST" should move back to the first article.
:ok = :gen_tcp.send(socket, "LAST\r\n")
{:ok, response} = :gen_tcp.recv(socket, 0, 1000)
assert response =~ ~r/^223 #{low} #{message_id} /
end

end

describe "@callback handle_ARTICLE/2" do
# Default to have "READER" capability for "ARTICLE" to work.
@describetag capabilities: ["READER"]
Expand Down
19 changes: 18 additions & 1 deletion test/support/test_case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ defmodule GenNNTP.TestCase do
end

def setup_NEXT(context) do
# articles = context[:articles]
groups = context[:groups]
group_articles = context[:group_articles]

Expand All @@ -134,6 +133,24 @@ defmodule GenNNTP.TestCase do
end]
end

def setup_LAST(context) do
groups = context[:groups]
group_articles = context[:group_articles]

[handle_LAST: fn({article_number, group}, state) ->
Kernel.send(:tester, {:called_back, :handle_LAST, 2})

# The group is guaranteed to exist here (in testing).
{_, _number, _low, _high, numbers} = List.keyfind(groups, group, 0)
# Get the previous article in that newsgroup whose number is less than
# the current article number.
prev = Enum.find(numbers, article_number, fn(number) -> number < article_number end)
{_, message_id} = List.keyfind(group_articles, {prev, group}, 0)

{:ok, {prev, %{id: message_id}}, state}
end]
end

def setup_ARTICLE(context) do
articles = context[:articles]
group_articles = context[:group_articles]
Expand Down
13 changes: 13 additions & 0 deletions test/support/test_nntp_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ defmodule TestNNTPServer do
end
end

@impl GenNNTP
def handle_LAST({article_number, _group} = arg, client) do
state = client[:state]

case maybe_apply(client, :handle_LAST, [arg, state], {:ok, {article_number, %{}}, state}) do
{:ok, article_info, state} ->
client = put_in(client[:state], state)
{:ok, article_info, client}
other ->
other
end
end

@impl GenNNTP
def handle_ARTICLE(arg, client) do
state = client[:state]
Expand Down

0 comments on commit f0e2d4a

Please sign in to comment.