diff --git a/e2e_test.ts b/e2e_test.ts index 10aabc57a..868758ba9 100644 --- a/e2e_test.ts +++ b/e2e_test.ts @@ -125,6 +125,7 @@ Deno.test("[e2e] GET /", async () => { Deno.test("[e2e] GET /callback", async (test) => { setupEnv(); + const id = crypto.randomUUID(); const login = crypto.randomUUID(); const sessionId = crypto.randomUUID(); @@ -137,17 +138,19 @@ Deno.test("[e2e] GET /callback", async (test) => { }, sessionId, }; - const id = crypto.randomUUID(); const handleCallbackStub = stub( _internals, "handleCallback", returnsNext([Promise.resolve(handleCallbackResp)]), ); const githubRespBody = { + id, login, email: crypto.randomUUID(), }; - const stripeRespBody: Partial> = { id }; + const stripeRespBody: Partial> = { + id: crypto.randomUUID(), + }; const fetchStub = stub( window, "fetch", @@ -161,7 +164,7 @@ Deno.test("[e2e] GET /callback", async (test) => { handleCallbackStub.restore(); fetchStub.restore(); - const user = await getUser(githubRespBody.login); + const user = await getUser(githubRespBody.id); assert(user !== null); assertEquals(user.sessionId, handleCallbackResp.sessionId); }); @@ -175,17 +178,19 @@ Deno.test("[e2e] GET /callback", async (test) => { }, sessionId: crypto.randomUUID(), }; - const id = crypto.randomUUID(); const handleCallbackStub = stub( _internals, "handleCallback", returnsNext([Promise.resolve(handleCallbackResp)]), ); const githubRespBody = { + id, login, email: crypto.randomUUID(), }; - const stripeRespBody: Partial> = { id }; + const stripeRespBody: Partial> = { + id: crypto.randomUUID(), + }; const fetchStub = stub( window, "fetch", @@ -199,7 +204,7 @@ Deno.test("[e2e] GET /callback", async (test) => { handleCallbackStub.restore(); fetchStub.restore(); - const user = await getUser(githubRespBody.login); + const user = await getUser(githubRespBody.id); assert(user !== null); assertNotEquals(user.sessionId, sessionId); }); @@ -418,7 +423,7 @@ Deno.test("[e2e] POST /submit", async (test) => { body, }), ); - const items = await collectValues(listItemsByUser(user.login)); + const items = await collectValues(listItemsByUser(user.id)); assertRedirect(resp, "/"); // Deep partial match since the item ID is a ULID generated at runtime @@ -493,7 +498,7 @@ Deno.test("[e2e] GET /api/users/[login]/items", async (test) => { const user = randomUser(); const item: Item = { ...randomItem(), - userLogin: user.login, + userId: user.id, }; const req = new Request(`http://localhost/api/users/${user.login}/items`); @@ -547,7 +552,7 @@ Deno.test("[e2e] POST /api/vote", async (test) => { }); await test.step("creates a vote", async () => { - const item = { ...randomItem(), userLogin: user.login }; + const item = { ...randomItem(), userId: user.id }; await createItem(item); const resp = await handler( new Request(url, { @@ -700,7 +705,7 @@ Deno.test("[e2e] POST /api/stripe-webhooks", async (test) => { constructEventAsyncStub.restore(); assertEquals(resp.status, STATUS_CODE.Created); - assertEquals(await getUser(user.login), { ...user, isSubscribed: true }); + assertEquals(await getUser(user.id), { ...user, isSubscribed: true }); }); await test.step("serves not found response if the user is not found for subscription deletion", async () => { @@ -750,7 +755,7 @@ Deno.test("[e2e] POST /api/stripe-webhooks", async (test) => { constructEventAsyncStub.restore(); - assertEquals(await getUser(user.login), { ...user, isSubscribed: false }); + assertEquals(await getUser(user.id), { ...user, isSubscribed: false }); assertEquals(resp.status, STATUS_CODE.Accepted); }); @@ -970,11 +975,11 @@ Deno.test("[e2e] GET /api/me/votes", async () => { await createItem(item1); await createItem(item2); await createVote({ - userLogin: user.login, + userId: user.id, itemId: item1.id, }); await createVote({ - userLogin: user.login, + userId: user.id, itemId: item2.id, }); const resp = await handler( diff --git a/islands/ItemsList.tsx b/islands/ItemsList.tsx index c3a97b48e..54c6905a5 100644 --- a/islands/ItemsList.tsx +++ b/islands/ItemsList.tsx @@ -105,12 +105,12 @@ function ItemSummary(props: ItemSummaryProps) {

- - {props.item.userLogin} + + {props.item.userId} {" "} {timeAgo(new Date(decodeTime(props.item.id)))}

diff --git a/plugins/kv_oauth.ts b/plugins/kv_oauth.ts index 8a741f292..883203318 100644 --- a/plugins/kv_oauth.ts +++ b/plugins/kv_oauth.ts @@ -42,10 +42,11 @@ export default { ); const githubUser = await getGitHubUser(tokens.accessToken); - const user = await getUser(githubUser.login); + const user = await getUser(githubUser.id); if (user === null) { const user: User = { + id: githubUser.id, login: githubUser.login, sessionId, isSubscribed: false, diff --git a/routes/api/me/votes.ts b/routes/api/me/votes.ts index e2566eda5..cba29ae53 100644 --- a/routes/api/me/votes.ts +++ b/routes/api/me/votes.ts @@ -5,7 +5,7 @@ import { SignedInState } from "@/plugins/session.ts"; export const handler: Handlers = { async GET(_req, ctx) { - const iter = listItemsVotedByUser(ctx.state.sessionUser.login); + const iter = listItemsVotedByUser(ctx.state.sessionUser.id); const items = await collectValues(iter); return Response.json(items); }, diff --git a/routes/api/users/[login]/index.ts b/routes/api/users/[login]/index.ts index 10fcb51be..382015feb 100644 --- a/routes/api/users/[login]/index.ts +++ b/routes/api/users/[login]/index.ts @@ -1,10 +1,10 @@ // Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. import type { Handlers } from "$fresh/server.ts"; -import { getUser } from "@/utils/db.ts"; +import { getUserByLogin } from "@/utils/db.ts"; export const handler: Handlers = { async GET(_req, ctx) { - const user = await getUser(ctx.params.login); + const user = await getUserByLogin(ctx.params.login); if (user === null) throw new Deno.errors.NotFound("User not found"); return Response.json(user); }, diff --git a/routes/api/users/[login]/items.ts b/routes/api/users/[login]/items.ts index 99cd23aa6..7ae90d9d9 100644 --- a/routes/api/users/[login]/items.ts +++ b/routes/api/users/[login]/items.ts @@ -1,15 +1,15 @@ // Copyright 2023-2024 the Deno authors. All rights reserved. MIT license. import type { Handlers } from "$fresh/server.ts"; -import { collectValues, getUser, listItemsByUser } from "@/utils/db.ts"; +import { collectValues, getUserByLogin, listItemsByUser } from "@/utils/db.ts"; import { getCursor } from "@/utils/http.ts"; export const handler: Handlers = { async GET(req, ctx) { - const user = await getUser(ctx.params.login); + const user = await getUserByLogin(ctx.params.login); if (user === null) throw new Deno.errors.NotFound("User not found"); const url = new URL(req.url); - const iter = listItemsByUser(ctx.params.login, { + const iter = listItemsByUser(user.id, { cursor: getCursor(url), limit: 10, }); diff --git a/routes/api/vote.ts b/routes/api/vote.ts index 3398d6ef3..32f012c58 100644 --- a/routes/api/vote.ts +++ b/routes/api/vote.ts @@ -14,7 +14,7 @@ export const handler: Handlers = { await createVote({ itemId, - userLogin: ctx.state.sessionUser.login, + userId: ctx.state.sessionUser.id, }); return new Response(null, { status: STATUS_CODE.Created }); diff --git a/routes/submit.tsx b/routes/submit.tsx index c7511dc7f..39abcd6e1 100644 --- a/routes/submit.tsx +++ b/routes/submit.tsx @@ -33,7 +33,7 @@ export const handler: Handlers = { await createItem({ id: ulid(), - userLogin: ctx.state.sessionUser.login, + userId: ctx.state.sessionUser.id, title, url, score: 0, diff --git a/utils/db.ts b/utils/db.ts index 0de59738c..d859c5cde 100644 --- a/utils/db.ts +++ b/utils/db.ts @@ -21,7 +21,7 @@ export const kv = await Deno.openKv(path); * * const items = await collectValues(listItems()); * items[0].id; // Returns "01H9YD2RVCYTBVJEYEJEV5D1S1"; - * items[0].userLogin; // Returns "snoop" + * items[0].userId; // Returns "13d643e1-ad65-42bf-be9f-31c95e1b94d8" * items[0].title; // Returns "example-title" * items[0].url; // Returns "http://example.com" * items[0].score; // Returns 420 @@ -35,7 +35,7 @@ export async function collectValues(iter: Deno.KvListIterator) { export interface Item { // Uses ULID id: string; - userLogin: string; + userId: string; title: string; url: string; score: number; @@ -45,7 +45,7 @@ export interface Item { export function randomItem(): Item { return { id: ulid(), - userLogin: crypto.randomUUID(), + userId: crypto.randomUUID(), title: crypto.randomUUID(), url: `http://${crypto.randomUUID()}.com`, score: 0, @@ -63,7 +63,7 @@ export function randomItem(): Item { * * await createItem({ * id: ulid(), - * userLogin: "john_doe", + * userId: "13d643e1-ad65-42bf-be9f-31c95e1b94d8", * title: "example-title", * url: "https://example.com", * score: 0, @@ -72,7 +72,7 @@ export function randomItem(): Item { */ export async function createItem(item: Item) { const itemsKey = ["items", item.id]; - const itemsByUserKey = ["items_by_user", item.userLogin, item.id]; + const itemsByUserKey = ["items_by_user", item.userId, item.id]; const res = await kv.atomic() .check({ key: itemsKey, versionstamp: null }) @@ -93,7 +93,7 @@ export async function createItem(item: Item) { * * const item = await getItem("01H9YD2RVCYTBVJEYEJEV5D1S1"); * item?.id; // Returns "01H9YD2RVCYTBVJEYEJEV5D1S1"; - * item?.userLogin; // Returns "snoop" + * item?.userId; // Returns "13d643e1-ad65-42bf-be9f-31c95e1b94d8" * item?.title; // Returns "example-title" * item?.url; // Returns "http://example.com" * item?.score; // Returns 420 @@ -114,8 +114,8 @@ export async function getItem(id: string) { * * for await (const entry of listItems()) { * entry.value.id; // Returns "01H9YD2RVCYTBVJEYEJEV5D1S1" - * entry.value.userLogin; // Returns "pedro" - * entry.key; // Returns ["items_voted_by_user", "01H9YD2RVCYTBVJEYEJEV5D1S1", "pedro"] + * entry.value.userId; // Returns "13d643e1-ad65-42bf-be9f-31c95e1b94d8" + * entry.key; // Returns ["items_voted_by_user", "01H9YD2RVCYTBVJEYEJEV5D1S1", "13d643e1-ad65-42bf-be9f-31c95e1b94d8"] * entry.versionstamp; // Returns "00000000000000010000" * } * ``` @@ -132,25 +132,25 @@ export function listItems(options?: Deno.KvListOptions) { * ```ts * import { listItemsByUser } from "@/utils/db.ts"; * - * for await (const entry of listItemsByUser("pedro")) { + * for await (const entry of listItemsByUser("13d643e1-ad65-42bf-be9f-31c95e1b94d8")) { * entry.value.id; // Returns "01H9YD2RVCYTBVJEYEJEV5D1S1" - * entry.value.userLogin; // Returns "pedro" - * entry.key; // Returns ["items_voted_by_user", "01H9YD2RVCYTBVJEYEJEV5D1S1", "pedro"] + * entry.value.userId; // Returns "13d643e1-ad65-42bf-be9f-31c95e1b94d8" + * entry.key; // Returns ["items_voted_by_user", "01H9YD2RVCYTBVJEYEJEV5D1S1", "13d643e1-ad65-42bf-be9f-31c95e1b94d8"] * entry.versionstamp; // Returns "00000000000000010000" * } * ``` */ export function listItemsByUser( - userLogin: string, + userId: string, options?: Deno.KvListOptions, ) { - return kv.list({ prefix: ["items_by_user", userLogin] }, options); + return kv.list({ prefix: ["items_by_user", userId] }, options); } // Vote export interface Vote { itemId: string; - userLogin: string; + userId: string; } /** @@ -163,13 +163,13 @@ export interface Vote { * * await createVote({ * itemId: "01H9YD2RVCYTBVJEYEJEV5D1S1", - * userLogin: "pedro", + * userId: "pedro", * }); * ``` */ export async function createVote(vote: Vote) { const itemKey = ["items", vote.itemId]; - const userKey = ["users", vote.userLogin]; + const userKey = ["users", vote.userId]; const [itemRes, userRes] = await kv.getMany<[Item, User]>([itemKey, userKey]); const item = itemRes.value; const user = userRes.value; @@ -178,15 +178,15 @@ export async function createVote(vote: Vote) { const itemVotedByUserKey = [ "items_voted_by_user", - vote.userLogin, + vote.userId, vote.itemId, ]; const userVotedForItemKey = [ "users_voted_for_item", vote.itemId, - vote.userLogin, + vote.userId, ]; - const itemByUserKey = ["items_by_user", item.userLogin, item.id]; + const itemByUserKey = ["items_by_user", item.userId, item.id]; item.score++; @@ -214,18 +214,19 @@ export async function createVote(vote: Vote) { * * for await (const entry of listItemsVotedByUser("john")) { * entry.value.id; // Returns "01H9YD2RVCYTBVJEYEJEV5D1S1" - * entry.value.userLogin; // Returns "pedro" - * entry.key; // Returns ["items_voted_by_user", "01H9YD2RVCYTBVJEYEJEV5D1S1", "pedro"] + * entry.value.userId; // Returns "13d643e1-ad65-42bf-be9f-31c95e1b94d8" + * entry.key; // Returns ["items_voted_by_user", "01H9YD2RVCYTBVJEYEJEV5D1S1", "13d643e1-ad65-42bf-be9f-31c95e1b94d8"] * entry.versionstamp; // Returns "00000000000000010000" * } * ``` */ -export function listItemsVotedByUser(userLogin: string) { - return kv.list({ prefix: ["items_voted_by_user", userLogin] }); +export function listItemsVotedByUser(userId: string) { + return kv.list({ prefix: ["items_voted_by_user", userId] }); } // User export interface User { + id: string; // AKA username login: string; sessionId: string; @@ -240,6 +241,7 @@ export interface User { /** For testing */ export function randomUser(): User { return { + id: crypto.randomUUID(), login: crypto.randomUUID(), sessionId: crypto.randomUUID(), isSubscribed: false, @@ -256,6 +258,7 @@ export function randomUser(): User { * import { createUser } from "@/utils/db.ts"; * * await createUser({ + * id: "13d643e1-ad65-42bf-be9f-31c95e1b94d8", * login: "john", * sessionId: crypto.randomUUID(), * isSubscribed: false, @@ -263,13 +266,16 @@ export function randomUser(): User { * ``` */ export async function createUser(user: User) { - const usersKey = ["users", user.login]; + const usersKey = ["users", user.id]; + const usersByLoginKey = ["users_by_login", user.login]; const usersBySessionKey = ["users_by_session", user.sessionId]; const atomicOp = kv.atomic() .check({ key: usersKey, versionstamp: null }) + .check({ key: usersByLoginKey, versionstamp: null }) .check({ key: usersBySessionKey, versionstamp: null }) .set(usersKey, user) + .set(usersByLoginKey, user) .set(usersBySessionKey, user); if (user.stripeCustomerId !== undefined) { @@ -294,6 +300,7 @@ export async function createUser(user: User) { * import { updateUser } from "@/utils/db.ts"; * * await updateUser({ + * id: "13d643e1-ad65-42bf-be9f-31c95e1b94d8", * login: "john", * sessionId: crypto.randomUUID(), * isSubscribed: false, @@ -301,11 +308,13 @@ export async function createUser(user: User) { * ``` */ export async function updateUser(user: User) { - const usersKey = ["users", user.login]; + const usersKey = ["users", user.id]; + const usersByLoginKey = ["users_by_login", user.login]; const usersBySessionKey = ["users_by_session", user.sessionId]; const atomicOp = kv.atomic() .set(usersKey, user) + .set(usersByLoginKey, user) .set(usersBySessionKey, user); if (user.stripeCustomerId !== undefined) { @@ -329,6 +338,7 @@ export async function updateUser(user: User) { * import { updateUserSession } from "@/utils/db.ts"; * * await updateUserSession({ + * id: "13d643e1-ad65-42bf-be9f-31c95e1b94d8", * login: "john", * sessionId: "xxx", * isSubscribed: false, @@ -336,13 +346,15 @@ export async function updateUser(user: User) { * ``` */ export async function updateUserSession(user: User, sessionId: string) { - const userKey = ["users", user.login]; + const userKey = ["users", user.id]; + const usersByLoginKey = ["users_by_login", user.login]; const oldUserBySessionKey = ["users_by_session", user.sessionId]; const newUserBySessionKey = ["users_by_session", sessionId]; const newUser: User = { ...user, sessionId }; const atomicOp = kv.atomic() .set(userKey, newUser) + .set(usersByLoginKey, newUser) .delete(oldUserBySessionKey) .check({ key: newUserBySessionKey, versionstamp: null }) .set(newUserBySessionKey, newUser); @@ -361,20 +373,40 @@ export async function updateUserSession(user: User, sessionId: string) { } /** - * Gets the user with the given login from the database. + * Gets the user with the given ID from the database. * * @example * ```ts * import { getUser } from "@/utils/db.ts"; * - * const user = await getUser("jack"); + * const user = await getUser("13d643e1-ad65-42bf-be9f-31c95e1b94d8"); + * user?.id; // Returns "13d643e1-ad65-42bf-be9f-31c95e1b94d8" + * user?.login; // Returns "jack" + * user?.sessionId; // Returns "xxx" + * user?.isSubscribed; // Returns false + * ``` + */ +export async function getUser(id: string) { + const res = await kv.get(["users", id]); + return res.value; +} + +/** + * Gets the user with the given username from the database. + * + * @example + * ```ts + * import { getUserByLogin } from "@/utils/db.ts"; + * + * const user = await getUserByLogin("jack"); + * user?.id; // Returns "13d643e1-ad65-42bf-be9f-31c95e1b94d8" * user?.login; // Returns "jack" * user?.sessionId; // Returns "xxx" * user?.isSubscribed; // Returns false * ``` */ -export async function getUser(login: string) { - const res = await kv.get(["users", login]); +export async function getUserByLogin(login: string) { + const res = await kv.get(["users_by_login", login]); return res.value; } @@ -390,6 +422,7 @@ export async function getUser(login: string) { * import { getUserBySession } from "@/utils/db.ts"; * * const user = await getUserBySession("xxx"); + * user?.id; // Returns "13d643e1-ad65-42bf-be9f-31c95e1b94d8" * user?.login; // Returns "jack" * user?.sessionId; // Returns "xxx" * user?.isSubscribed; // Returns false @@ -413,6 +446,7 @@ export async function getUserBySession(sessionId: string) { * import { getUserByStripeCustomer } from "@/utils/db.ts"; * * const user = await getUserByStripeCustomer("123"); + * user?.id; // Returns "13d643e1-ad65-42bf-be9f-31c95e1b94d8" * user?.login; // Returns "jack" * user?.sessionId; // Returns "xxx" * user?.isSubscribed; // Returns false @@ -436,6 +470,7 @@ export async function getUserByStripeCustomer(stripeCustomerId: string) { * import { listUsers } from "@/utils/db.ts"; * * for await (const entry of listUsers()) { + * entry.value.id; // Returns "13d643e1-ad65-42bf-be9f-31c95e1b94d8" * entry.value.login; // Returns "jack" * entry.value.sessionId; // Returns "xxx" * entry.value.isSubscribed; // Returns false diff --git a/utils/db_test.ts b/utils/db_test.ts index eb4a7c3ca..c563b5af4 100644 --- a/utils/db_test.ts +++ b/utils/db_test.ts @@ -21,24 +21,25 @@ import { updateUserSession, type User, } from "./db.ts"; +import { getUserByLogin } from "@/utils/db.ts"; Deno.test("[db] items", async () => { const user = randomUser(); const item1: Item = { ...randomItem(), id: ulid(), - userLogin: user.login, + userId: user.id, }; const item2: Item = { ...randomItem(), id: ulid(Date.now() + 1_000), - userLogin: user.login, + userId: user.id, }; assertEquals(await getItem(item1.id), null); assertEquals(await getItem(item2.id), null); assertEquals(await collectValues(listItems()), []); - assertEquals(await collectValues(listItemsByUser(user.login)), []); + assertEquals(await collectValues(listItemsByUser(user.id)), []); await createItem(item1); await createItem(item2); @@ -47,7 +48,7 @@ Deno.test("[db] items", async () => { assertEquals(await getItem(item1.id), item1); assertEquals(await getItem(item2.id), item2); assertEquals(await collectValues(listItems()), [item1, item2]); - assertEquals(await collectValues(listItemsByUser(user.login)), [ + assertEquals(await collectValues(listItemsByUser(user.id)), [ item1, item2, ]); @@ -56,19 +57,22 @@ Deno.test("[db] items", async () => { Deno.test("[db] user", async () => { const user = randomUser(); + assertEquals(await getUser(user.id), null); assertEquals(await getUser(user.login), null); assertEquals(await getUserBySession(user.sessionId), null); assertEquals(await getUserByStripeCustomer(user.stripeCustomerId!), null); await createUser(user); await assertRejects(async () => await createUser(user)); - assertEquals(await getUser(user.login), user); + assertEquals(await getUser(user.id), user); + assertEquals(await getUserByLogin(user.login), user); assertEquals(await getUserBySession(user.sessionId), user); assertEquals(await getUserByStripeCustomer(user.stripeCustomerId!), user); const subscribedUser: User = { ...user, isSubscribed: true }; await updateUser(subscribedUser); - assertEquals(await getUser(subscribedUser.login), subscribedUser); + assertEquals(await getUser(subscribedUser.id), subscribedUser); + assertEquals(await getUserByLogin(subscribedUser.login), subscribedUser); assertEquals( await getUserBySession(subscribedUser.sessionId), subscribedUser, @@ -98,11 +102,11 @@ Deno.test("[db] votes", async () => { const user = randomUser(); const vote = { itemId: item.id, - userLogin: user.login, + userId: user.id, createdAt: new Date(), }; - assertEquals(await collectValues(listItemsVotedByUser(user.login)), []); + assertEquals(await collectValues(listItemsVotedByUser(user.id)), []); await assertRejects( async () => await createVote(vote), @@ -119,7 +123,7 @@ Deno.test("[db] votes", async () => { await createVote(vote); item.score++; - assertEquals(await collectValues(listItemsVotedByUser(user.login)), [item]); + assertEquals(await collectValues(listItemsVotedByUser(user.id)), [item]); await assertRejects(async () => await createVote(vote)); }); @@ -128,13 +132,13 @@ Deno.test("[db] getAreVotedByUser()", async () => { const user = randomUser(); const vote = { itemId: item.id, - userLogin: user.login, + userId: user.id, createdAt: new Date(), }; assertEquals(await getItem(item.id), null); - assertEquals(await getUser(user.login), null); - assertEquals(await getAreVotedByUser([item], user.login), [false]); + assertEquals(await getUser(user.id), null); + assertEquals(await getAreVotedByUser([item], user.id), [false]); await createItem(item); await createUser(user); @@ -142,6 +146,6 @@ Deno.test("[db] getAreVotedByUser()", async () => { item.score++; assertEquals(await getItem(item.id), item); - assertEquals(await getUser(user.login), user); - assertEquals(await getAreVotedByUser([item], user.login), [true]); + assertEquals(await getUser(user.id), user); + assertEquals(await getAreVotedByUser([item], user.id), [true]); }); diff --git a/utils/github.ts b/utils/github.ts index 6b7f1ff15..0a2031c96 100644 --- a/utils/github.ts +++ b/utils/github.ts @@ -12,6 +12,7 @@ export function isGitHubSetup() { } interface GitHubUser { + id: string; login: string; email: string; } diff --git a/utils/github_test.ts b/utils/github_test.ts index bdaab9316..939be7190 100644 --- a/utils/github_test.ts +++ b/utils/github_test.ts @@ -27,7 +27,11 @@ Deno.test("[plugins] getGitHubUser()", async (test) => { }); await test.step("resolves to a GitHub user object", async () => { - const body = { login: crypto.randomUUID(), email: crypto.randomUUID() }; + const body = { + id: crypto.randomUUID(), + login: crypto.randomUUID(), + email: crypto.randomUUID(), + }; const fetchStub = stub( window, "fetch",