Skip to content

Commit 5deebc5

Browse files
committed
add some tests for server modules
1 parent e9545e7 commit 5deebc5

File tree

4 files changed

+335
-7
lines changed

4 files changed

+335
-7
lines changed

src/lib/server/utils.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,4 @@ export type Hashed<T> = { hash: string } & T;
2323
* @param input String to compute hash for.
2424
* @param algo Algorithm.
2525
*/
26-
export const computeHash = (input: string, algo = 'sha256'): string => {
27-
const hash = createHash(algo);
28-
hash.update(input);
29-
30-
return hash.digest('hex');
31-
};
26+
export const computeHash = (input: string, algo = 'sha256'): string => createHash(algo).update(input).digest('hex');

tests/server/sitemap.test.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { Sitemap, SitemapIndex } from '$lib/server/sitemap';
3+
import { xml2js } from 'xml-js';
4+
import { gunzipSync } from 'node:zlib';
5+
6+
const toBuf = (body: ReadableStreamReadResult<Uint8Array<ArrayBufferLike>>) => Buffer.from(body.value ?? []);
7+
8+
describe(Sitemap.name, () => {
9+
const baseUrl = new URL('https://www.example.com/');
10+
11+
it('should throw an error when adding duplicate URLs if duplicates are not allowed', () => {
12+
const sitemap = new Sitemap(baseUrl);
13+
sitemap.append({ loc: '/foo' }, false);
14+
15+
expect(() => sitemap.append({ loc: '/foo' }, false)).to.throw(
16+
'Location /foo had already been added to this sitemap: duplicate URLs are not allowed',
17+
);
18+
expect(() => sitemap.append({ loc: '/foo' }, true)).to.not.throw();
19+
});
20+
21+
it('should build an empty sitemap', async () => {
22+
const sitemap = new Sitemap(baseUrl);
23+
24+
const xml = sitemap.toString();
25+
const { elements } = xml2js(xml);
26+
expect(elements).deep.equal([
27+
{
28+
attributes: { xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9' },
29+
name: 'urlset',
30+
type: 'element',
31+
},
32+
]);
33+
34+
const uncompressed = sitemap.toResponse(false);
35+
expect(uncompressed)
36+
.to.be.instanceOf(Response)
37+
.that.satisfies(
38+
(response: Response) =>
39+
response.headers.get('content-type') === 'application/xml' && !response.headers.has('content-encoding'),
40+
);
41+
await expect(uncompressed.body?.getReader().read())
42+
.to.be.a('promise')
43+
.that.resolves.satisfies(
44+
(body: ReadableStreamReadResult<Uint8Array<ArrayBufferLike>>) => toBuf(body)?.toString('utf8') === xml,
45+
);
46+
47+
const compressed = sitemap.toResponse(true);
48+
expect(compressed)
49+
.to.be.instanceOf(Response)
50+
.that.satisfies(
51+
(response: Response) =>
52+
response.headers.get('content-type') === 'application/xml' &&
53+
response.headers.get('content-encoding') === 'gzip',
54+
);
55+
await expect(compressed.body?.getReader().read())
56+
.to.be.a('promise')
57+
.that.resolves.satisfies(
58+
(body: ReadableStreamReadResult<Uint8Array<ArrayBufferLike>>) =>
59+
toBuf(body).toString('utf8') !== xml && gunzipSync(toBuf(body)).toString('utf8') === xml,
60+
);
61+
});
62+
63+
it('should build a sitemap with a few URLs', async () => {
64+
const sitemap = new Sitemap(baseUrl)
65+
.append({ loc: '/foo', changeFreq: 'daily' })
66+
.append({ loc: '/foo', priority: 42 })
67+
.append({ loc: '/bar' })
68+
.append({ loc: '/baz', changeFreq: 'monthly', priority: 1, lastMod: new Date('2025-01-01T00:00:00') });
69+
70+
const xml = sitemap.toString();
71+
const { elements } = xml2js(xml);
72+
expect(elements).deep.equal([
73+
{
74+
attributes: { xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9' },
75+
name: 'urlset',
76+
type: 'element',
77+
elements: [
78+
{
79+
name: 'url',
80+
type: 'element',
81+
elements: [
82+
{ name: 'loc', type: 'element', elements: [{ type: 'text', text: 'https://www.example.com/foo' }] },
83+
{ name: 'changefreq', type: 'element', elements: [{ type: 'text', text: 'daily' }] },
84+
],
85+
},
86+
{
87+
name: 'url',
88+
type: 'element',
89+
elements: [
90+
{ name: 'loc', type: 'element', elements: [{ type: 'text', text: 'https://www.example.com/bar' }] },
91+
],
92+
},
93+
{
94+
name: 'url',
95+
type: 'element',
96+
elements: [
97+
{ name: 'loc', type: 'element', elements: [{ type: 'text', text: 'https://www.example.com/baz' }] },
98+
{ name: 'lastmod', type: 'element', elements: [{ type: 'text', text: '2025-01-01' }] },
99+
{ name: 'changefreq', type: 'element', elements: [{ type: 'text', text: 'monthly' }] },
100+
{ name: 'priority', type: 'element', elements: [{ type: 'text', text: '1.00' }] },
101+
],
102+
},
103+
],
104+
},
105+
]);
106+
107+
const uncompressed = sitemap.toResponse(false);
108+
expect(uncompressed)
109+
.to.be.instanceOf(Response)
110+
.that.satisfies(
111+
(response: Response) =>
112+
response.headers.get('content-type') === 'application/xml' && !response.headers.has('content-encoding'),
113+
);
114+
await expect(uncompressed.body?.getReader().read())
115+
.to.be.a('promise')
116+
.that.resolves.satisfies(
117+
(body: ReadableStreamReadResult<Uint8Array<ArrayBufferLike>>) => toBuf(body)?.toString('utf8') === xml,
118+
);
119+
120+
const compressed = sitemap.toResponse(true);
121+
expect(compressed)
122+
.to.be.instanceOf(Response)
123+
.that.satisfies(
124+
(response: Response) =>
125+
response.headers.get('content-type') === 'application/xml' &&
126+
response.headers.get('content-encoding') === 'gzip',
127+
);
128+
await expect(compressed.body?.getReader().read())
129+
.to.be.a('promise')
130+
.that.resolves.satisfies(
131+
(body: ReadableStreamReadResult<Uint8Array<ArrayBufferLike>>) =>
132+
toBuf(body).toString('utf8') !== xml && gunzipSync(toBuf(body)).toString('utf8') === xml,
133+
);
134+
});
135+
});
136+
137+
describe(SitemapIndex.name, () => {
138+
const baseUrl = new URL('https://www.example.com/');
139+
140+
it('should build an empty sitemap index', async () => {
141+
const sitemapIndex = new SitemapIndex(baseUrl);
142+
143+
const xml = sitemapIndex.toString();
144+
const { elements } = xml2js(xml);
145+
expect(elements).deep.equal([
146+
{
147+
attributes: { xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9' },
148+
name: 'sitemapindex',
149+
type: 'element',
150+
},
151+
]);
152+
153+
const uncompressed = sitemapIndex.toResponse(false);
154+
expect(uncompressed)
155+
.to.be.instanceOf(Response)
156+
.that.satisfies(
157+
(response: Response) =>
158+
response.headers.get('content-type') === 'application/xml' && !response.headers.has('content-encoding'),
159+
);
160+
await expect(uncompressed.body?.getReader().read())
161+
.to.be.a('promise')
162+
.that.resolves.satisfies(
163+
(body: ReadableStreamReadResult<Uint8Array<ArrayBufferLike>>) => toBuf(body)?.toString('utf8') === xml,
164+
);
165+
166+
const compressed = sitemapIndex.toResponse(true);
167+
expect(compressed)
168+
.to.be.instanceOf(Response)
169+
.that.satisfies(
170+
(response: Response) =>
171+
response.headers.get('content-type') === 'application/xml' &&
172+
response.headers.get('content-encoding') === 'gzip',
173+
);
174+
await expect(compressed.body?.getReader().read())
175+
.to.be.a('promise')
176+
.that.resolves.satisfies(
177+
(body: ReadableStreamReadResult<Uint8Array<ArrayBufferLike>>) =>
178+
toBuf(body).toString('utf8') !== xml && gunzipSync(toBuf(body)).toString('utf8') === xml,
179+
);
180+
});
181+
182+
it('should build a sitemap with a few URLs', async () => {
183+
const sitemapIndex = new SitemapIndex(baseUrl)
184+
.append({ loc: '/foo.xml' })
185+
.append({ loc: '/bar.xml', lastMod: new Date('2025-01-01T00:00:00') });
186+
187+
const xml = sitemapIndex.toString();
188+
const { elements } = xml2js(xml);
189+
expect(elements).deep.equal([
190+
{
191+
attributes: { xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9' },
192+
name: 'sitemapindex',
193+
type: 'element',
194+
elements: [
195+
{
196+
name: 'sitemap',
197+
type: 'element',
198+
elements: [
199+
{ name: 'loc', type: 'element', elements: [{ type: 'text', text: 'https://www.example.com/foo.xml' }] },
200+
],
201+
},
202+
{
203+
name: 'sitemap',
204+
type: 'element',
205+
elements: [
206+
{ name: 'loc', type: 'element', elements: [{ type: 'text', text: 'https://www.example.com/bar.xml' }] },
207+
{ name: 'lastmod', type: 'element', elements: [{ type: 'text', text: '2025-01-01' }] },
208+
],
209+
},
210+
],
211+
},
212+
]);
213+
214+
const uncompressed = sitemapIndex.toResponse(false);
215+
expect(uncompressed)
216+
.to.be.instanceOf(Response)
217+
.that.satisfies(
218+
(response: Response) =>
219+
response.headers.get('content-type') === 'application/xml' && !response.headers.has('content-encoding'),
220+
);
221+
await expect(uncompressed.body?.getReader().read())
222+
.to.be.a('promise')
223+
.that.resolves.satisfies(
224+
(body: ReadableStreamReadResult<Uint8Array<ArrayBufferLike>>) => toBuf(body)?.toString('utf8') === xml,
225+
);
226+
227+
const compressed = sitemapIndex.toResponse(true);
228+
expect(compressed)
229+
.to.be.instanceOf(Response)
230+
.that.satisfies(
231+
(response: Response) =>
232+
response.headers.get('content-type') === 'application/xml' &&
233+
response.headers.get('content-encoding') === 'gzip',
234+
);
235+
await expect(compressed.body?.getReader().read())
236+
.to.be.a('promise')
237+
.that.resolves.satisfies(
238+
(body: ReadableStreamReadResult<Uint8Array<ArrayBufferLike>>) =>
239+
toBuf(body).toString('utf8') !== xml && gunzipSync(toBuf(body)).toString('utf8') === xml,
240+
);
241+
});
242+
});

tests/server/utils.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { computeHash, secureId, withTmpDir } from '$lib/server/utils';
2+
import { existsSync, statSync } from 'node:fs';
3+
import { basename } from 'node:path';
4+
import { describe, expect, it } from 'vitest';
5+
6+
describe(secureId.name, () => {
7+
const CASES = {
8+
'should generate a 32 bytes random hash': { bytes: 32, expectedLength: 64 },
9+
'should generate a 18 bytes random hash': { bytes: 18, expectedLength: 36 },
10+
} satisfies Record<string, { bytes: number; expectedLength: number }>;
11+
12+
Object.entries(CASES).forEach(([label, { bytes, expectedLength }]) =>
13+
it(label, () => {
14+
expect(secureId(bytes)).to.be.a('string').with.length(expectedLength);
15+
}),
16+
);
17+
});
18+
19+
describe(withTmpDir.name, () => {
20+
it('should create a temporary directory and remove it upon completion', async () => {
21+
expect.assertions(3);
22+
23+
const expected = Symbol();
24+
let tmpDir: string | undefined = undefined;
25+
const result = withTmpDir('foo-', (path) => {
26+
tmpDir = path;
27+
expect(path)
28+
.to.be.a('string')
29+
.that.satisfies((path: string) => basename(path).startsWith('foo-'))
30+
.and.satisfies((path: string) => existsSync(path) && statSync(path).isDirectory());
31+
32+
return expected;
33+
});
34+
35+
await expect(result).resolves.equal(expected);
36+
37+
expect(tmpDir).satisfy((path: string) => !existsSync(path));
38+
});
39+
40+
it('should create a temporary directory and remove it after an error is thrown', async () => {
41+
expect.assertions(3);
42+
43+
const expected = new Error('my error');
44+
let tmpDir: string | undefined = undefined;
45+
const result = withTmpDir('foo-', (path) => {
46+
tmpDir = path;
47+
expect(path)
48+
.to.be.a('string')
49+
.that.satisfies((path: string) => basename(path).startsWith('foo-'))
50+
.and.satisfies((path: string) => existsSync(path) && statSync(path).isDirectory());
51+
52+
throw expected;
53+
});
54+
55+
await expect(result).rejects.equal(expected);
56+
57+
expect(tmpDir).satisfy((path: string) => !existsSync(path));
58+
});
59+
});
60+
61+
describe(computeHash.name, () => {
62+
const CASES = {
63+
'should compute md5': {
64+
input: 'password',
65+
expected: '5f4dcc3b5aa765d61d8327deb882cf99',
66+
algorithm: 'md5',
67+
},
68+
'should compute sha1': {
69+
input: 'foo bar',
70+
expected: '3773dea65156909838fa6c22825cafe090ff8030',
71+
algorithm: 'sha1',
72+
},
73+
'should compute sha256': {
74+
input: 'hello world',
75+
expected: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
76+
algorithm: 'sha256',
77+
},
78+
'should compute sha512': {
79+
input: 'example string',
80+
expected:
81+
'f63ffbf293e2631e013dc2a0958f54f6797f096c36adda6806f717e1d4a314c0fb443ec71eec73cfbd8efa1ad2c709b902066e6356396b97a7ea5191de349012',
82+
algorithm: 'sha512',
83+
},
84+
} satisfies Record<string, { input: string; expected: string; algorithm: string }>;
85+
86+
Object.entries(CASES).forEach(([label, { input, algorithm, expected }]) =>
87+
it(label, () => {
88+
expect(computeHash(input, algorithm)).equals(expected);
89+
}),
90+
);
91+
});

vitest.workspace.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default defineWorkspace([
1212
{
1313
extends: './vitest.config.ts',
1414
test: {
15-
include: ['tests/**/*.{test,spec}.ts', '!tests/**/*.server.{test,spec}.ts'],
15+
include: ['tests/**/*.{test,spec}.ts', '!tests/**/*.server.{test,spec}.ts', '!tests/server/**/*.{test,spec}.ts'],
1616
name: 'browser',
1717
browser: {
1818
enabled: true,

0 commit comments

Comments
 (0)