Repository navigation
Store and Groups
A Store holds groups, articles and posted articles
(store.go). fakeserver.Options{Store: st} serves
it with the built-in reader (NNTP Commands). All methods are safe for
concurrent use, so a test can change the store while a server under test is
connected. Every returned value is a copy. NewStore() and &Store{} are
the same.
type Group struct {
Name, Description string
Low, High, Count int64
Posting bool
Created time.Time
}- A new group has
Low1,High0,Count0. -
Highis a high-water mark, as in INN: the highest number ever given out. Removing articles never lowers it, and numbers are not reused. -
Lowis the lowest existing number. A group whose articles were all removed hasLow=High+1 andCount0 (RFC 3977 6.1.1.2). -
Countis the number of articles; gaps are not counted.
| Method | Effect |
|---|---|
AddGroup(name, description) |
create a group (nothing happens if it exists); Posting true, Created now |
AddGroupAt(name, description, created) |
the same with a creation time, for NEWGROUPS |
SetPosting(group, bool) |
the y/n flag of LIST ACTIVE |
Group(name), Groups()
|
look up one group, or all sorted by name |
type Article struct {
Number int64
MessageID string
Headers []string // "Name: value", without CRLF
Body []string // without CRLF and without dot-stuffing
Date time.Time
Arrived time.Time // when it reached the server, for NEWNEWS
}AddArticle(group, a) files a copy of a and returns its number:
-
a.Numberwhen it is above 0 (it must be free), elseHigh+1. Big numbers work: binary groups above 6×10^10 can be set up directly. - A missing Message-ID is taken from the header, else generated as
<number.group@fakeserver.invalid>. - A zero
Datebecomes now, and so does a zeroArrived. SetArrivedto date an article for NEWNEWS; it is independent ofDateand of the Date header, as INN's arrival time is. Each crossposted copy has its own. - A group that doesn't exist is created.
- To crosspost, call
AddArticleonce per group. - Test-setup mistakes panic: an empty group name, a negative or taken
number, a
MessageIDthat differs from the Message-ID header, a generated group (see below). For an overview record whose Message-ID differs from the article's, useStoreOptions.OverviewFields.
When served, an article gets Message-ID, Date and Newsgroups (every
group it is filed in) headers if it lacks them. a.WithDefaults(group)
returns that version.
st := fakeserver.NewStore()
st.AddGroup("alt.binaries.test", "big numbers")
n := st.AddArticle("alt.binaries.test", fakeserver.Article{
Number: 60000000000,
MessageID: "<big@example.invalid>",
Headers: []string{"From: a@example.invalid", "Subject: big"},
})
st.AddArticle("alt.binaries.test", fakeserver.Article{}) // 60000000001, Message-ID generated
st.RemoveArticle("alt.binaries.test", n)
// Group: Low 60000000001, High 60000000001, Count 1| Method | Returns |
|---|---|
Article(group, n) |
one article |
ByMessageID(id) |
the first article stored under id, and every group and number it is in |
Numbers(group) |
all existing numbers, ascending |
Overview(group, from, to) |
the articles in [from, to]; to ≤ 0 means up to High
|
Next(group, n), Last(group, n)
|
the next existing number above or below n
|
a.Header(name) |
a header value: case-insensitive, unfolded, "" when absent |
| Method | Effect |
|---|---|
RemoveArticle(group, n) |
remove one article; High stays, Low and Count follow, and ByMessageID forgets this location |
Expire(group, below) |
remove every article numbered below below; returns how many |
Cancel(id) |
remove the article from every group it is in; returns how many |
Since v0.5.0 a group can be generated: its articles are made when a client asks for them and are never stored, so a group of 10^7 articles or more costs no memory. This is how a provider's big binary group looks.
st := fakeserver.NewStore()
st.AddGenerated("alt.binaries.huge", "ten million articles",
fakeserver.Generator{Low: 1, High: 10_000_000})
s := fakeserver.New(t, fakeserver.Options{Store: st})
c := client.Dial(t, s.Addr())
c.Cmd("GROUP alt.binaries.huge") // 211 10000000 1 10000000 alt.binaries.huge
c.Cmd("XOVER 9999901-10000000") // 224 and 100 records
over := c.ReadDotLines()
code, line := c.Cmd("XOVER 1-") // 503: the range spans more than 100000 numbers
st.SetGeneratedHigh("alt.binaries.huge", 10_000_050) // 50 new articles
st.Expire("alt.binaries.huge", 1000) // Low is now 1000| Generator field | Effect |
|---|---|
Low, High
|
the numbers; every number between them exists unless Article says it is a hole |
Article |
func(group, n) (Article, bool) makes article n; false makes n a hole. nil: GeneratedArticle
|
Lookup |
func(id) (int64, bool) maps a Message-ID to its number, for ARTICLE <id>, OVER <id> and ByMessageID. nil: IDs of the form <n.group@fakeserver.invalid>
|
Limit |
the most numbers one OVER, XOVER or LISTGROUP may span (holes count); 0 = DefaultGenLimit (100000), below 0 = no limit |
-
GeneratedArticle(group, n)is the default article: Fromposter@generated.invalid, Subjectgenerated article n in group, Message-ID<n.group@fakeserver.invalid>, Date 2020-01-01 plusnseconds, one body line. The samenalways gives the same article. A generated article without anArrivedarrived at itsDate. -
CountisHigh-Low+1, holes included, like the estimate of a real server. - A request that spans more numbers than
Limitgets503 fakeserver: ...instead of a reply the test can't hold. A client that asks for the whole of a huge group shows up as an error. NEWNEWS looks at a matching generated group whole, so it gets the503too when the group spans more thanLimit, whatever the time asked for. - GROUP, LIST, ARTICLE by number or Message-ID, NEXT, LAST and OVER, XOVER and LISTGROUP with a range work as for stored articles.
-
ExpireraisesLow;SetGeneratedHigh(group, high)movesHigh(new articles arrive, or a lower value removes the newest). -
NumbersIn(group, from, to)returns the existing numbers in a range;Numberspanics when the group spans more than the limit.GenLimit(group)returns the limit (0 for a stored group). -
AddArticlepanics on a generated group;RemoveArticleandCancelfind nothing there. -
AddGeneratedpanics for an empty name, a group that already has stored articles,Lowbelow 1 orHighbelowLow-1.
A generator with holes and its own limit:
st.AddGenerated("alt.holes", "", fakeserver.Generator{
Low: 1, High: 1_000_000,
Article: func(group string, n int64) (fakeserver.Article, bool) {
if n%10 == 0 {
return fakeserver.Article{}, false // every 10th number is a hole
}
return fakeserver.GeneratedArticle(group, n)
},
Limit: 5000,
})POST checks the article (Newsgroups, Subject and From required, a new
Message-ID in <...>), records it with AddPosted, and files it into each
group of its Newsgroups header. Posted() returns what was posted;
Articles() on the server has the raw wire lines. A posted article's
Arrived is the time it was posted, whatever its Date header says.
NEWNEWS wildmat date time [GMT] lists the Message-IDs of the articles in
the groups the wildmat matches whose Arrived is at or after the time, as
nntpget asks for them:
st := fakeserver.NewStore()
st.AddArticle("alt.test", fakeserver.Article{
MessageID: "<old@example.invalid>",
Arrived: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
})
st.AddArticle("alt.test", fakeserver.Article{MessageID: "<new@example.invalid>"}) // arrives now
// NEWNEWS alt.* 20260101 000000 GMT → 230, <new@example.invalid>, .- Each Message-ID is listed once (a crossposted article where it is first found), in group name order and by number within a group.
- The time is always UTC, with or without
GMT. - Many servers turn NEWNEWS off:
StoreOptions.NoNewnewsmakes it an unknown command (500, not in CAPABILITIES or HELP). INN answers502 NEWNEWS command disabled by administratorinstead; useFaults.Override"NEWNEWS"for that.
Options.StoreOpts changes how the Store server answers.
| Field | Effect |
|---|---|
User, Pass
|
require AUTHINFO USER/PASS; 480 before it. A 480 written to a logged-in connection (also one from a fault) ends the login |
ReaderOnly |
greet with 201; POST gets 440 |
OverviewFmt |
the LIST OVERVIEW.FMT answer. It doesn't change the OVER lines |
GroupNumbers |
func(conn int, g Group) Group: the numbers GROUP, LISTGROUP and LIST ACTIVE report, per connection. The articles served stay the stored ones |
ListgroupRange |
"reject": 501 for LISTGROUP group range; "ignore": the whole group whatever the range |
NoOver, NoXover
|
that command is unknown (500, not in HELP); NoOver also drops OVER MSGID from CAPABILITIES |
NoNewnews |
NEWNEWS is unknown (500) and not in CAPABILITIES or HELP |
EmptyOverReply |
the reply to an OVER/XOVER range without articles, instead of 423 |
MaxOverRange |
the largest OVER/XOVER range; an open range ends at High
|
OverRangeTooLarge |
the reply to a larger range; when nil, only the records of its first MaxOverRange numbers |
OverviewFields |
func(conn int, group string, fields []string) []string changes the fields of every OVER/XOVER line before they are joined (since v0.5.0); see below |
Compress |
offer COMPRESS DEFLATE (RFC 8054); see Fake Server |
CompressOutputOnly |
offer it, but compress only the server's output and read the client's commands uncompressed: a half implementation, for client tests |
st.HighestExisting is a ready-made GroupNumbers: it reports the highest
existing number as High, like servers whose last number goes down when
the newest articles are cancelled. Real World Server Behaviours shows how
to use these options.
OVER and XOVER lines always have these fields, in this order (the default
LIST OVERVIEW.FMT): Subject, From, Date, Message-ID, References, :bytes,
:lines, Xref:full.
- Tabs, CR, LF and NUL in header values become spaces.
-
:bytesis head and body with CRLF line ends plus the blank line between them;:linesthe body lines. - Xref is the stored Xref header, else
Xref: fakeserver group:number ...over every group the article is in.
StoreOptions.OverviewFields gets the fields of each line (fields[0] is
the article number, then the fields above; conn is the connection's ID)
and returns the fields to send. It may change, drop or add fields. Use it
for overview data out of step with the articles, such as a Message-ID that
isn't the one in the article's header:
StoreOpts: fakeserver.StoreOptions{
OverviewFields: func(conn int, group string, f []string) []string {
if f[0] == "2" {
f[4] = "<stale@example.invalid>" // the Message-ID field
}
return f
},
},The articles themselves stay as stored.
nntp-testkit is free software under the GNU General Public License, version 2 — see LICENSE.