mirror of
https://github.com/LemmyNet/lemmy
synced 2024-11-10 06:54:12 +00:00
Add api tests for image endpoints (#4150)
* Add api tests for image endpoints (fixes #4105) * curl instead of wget * add missing files * revert cargo update * simplify setup * use const * rename to image.spec.ts * adjust to client changes * update client lib * remove todos, move import * try to fix ci --------- Co-authored-by: SleeplessOne1917 <insomnia_void@protonmail.com>
This commit is contained in:
parent
7cb20200d8
commit
525359f7c5
15 changed files with 126 additions and 62 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -20,6 +20,8 @@ query_testing/**/reports/*.json
|
|||
api_tests/node_modules
|
||||
api_tests/.yalc
|
||||
api_tests/yalc.lock
|
||||
api_tests/test.png
|
||||
api_tests/pict-rs
|
||||
|
||||
# pictrs data
|
||||
pictrs/
|
||||
|
|
16
Cargo.lock
generated
16
Cargo.lock
generated
|
@ -1997,9 +1997,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.10"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427"
|
||||
checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
|
@ -3306,9 +3306,9 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
|
|||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.57"
|
||||
version = "0.10.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c"
|
||||
checksum = "7a257ad03cd8fb16ad4172fedf8094451e1af1c4b70097636ef2eac9a5f0cc33"
|
||||
dependencies = [
|
||||
"bitflags 2.4.1",
|
||||
"cfg-if",
|
||||
|
@ -3338,9 +3338,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
|||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.93"
|
||||
version = "0.9.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db4d56a4c0478783083cfafcc42493dd4a981d41669da64b4572a2a089b51b1d"
|
||||
checksum = "40a4130519a360279579c2053038317e40eff64d13fd3f004f9e1b72b8a6aaf9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
@ -5356,9 +5356,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.9"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d68074620f57a0b21594d9735eb2e98ab38b17f80d3fcb189fca266771ca60d"
|
||||
checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
|
|
|
@ -9,22 +9,24 @@
|
|||
"scripts": {
|
||||
"lint": "tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx src && prettier --check 'src/**/*.ts'",
|
||||
"fix": "prettier --write src && eslint --fix src",
|
||||
"api-test": "jest -i follow.spec.ts && jest -i post.spec.ts && jest -i comment.spec.ts && jest -i private_message.spec.ts && jest -i user.spec.ts && jest -i community.spec.ts",
|
||||
"api-test": "jest -i follow.spec.ts && jest -i post.spec.ts && jest -i comment.spec.ts && jest -i private_message.spec.ts && jest -i user.spec.ts && jest -i community.spec.ts && jest -i image.spec.ts",
|
||||
"api-test-comment": "jest -i comment.spec.ts",
|
||||
"api-test-post": "jest -i post.spec.ts",
|
||||
"api-test-user": "jest -i user.spec.ts",
|
||||
"api-test-community": "jest -i community.spec.ts",
|
||||
"api-test-private-message": "jest -i private_message.spec.ts"
|
||||
"api-test-private-message": "jest -i private_message.spec.ts",
|
||||
"api-test-image": "jest -i image.spec.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.8",
|
||||
"@types/node": "^20.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"download-file-sync": "^1.0.4",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"jest": "^29.5.0",
|
||||
"lemmy-js-client": "0.19.0-rc.12",
|
||||
"lemmy-js-client": "0.19.0-alpha.18",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.0.4"
|
||||
|
|
|
@ -8,6 +8,17 @@ export RUST_LOG="warn,lemmy_server=debug,lemmy_federate=debug,lemmy_api=debug,le
|
|||
|
||||
export LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queue has delays in the scale of 30s-5min
|
||||
|
||||
# pictrs setup
|
||||
if ! [ -f "pict-rs" ]; then
|
||||
curl "https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.0-beta.2/pict-rs-linux-amd64" -o api_tests/pict-rs
|
||||
chmod +x api_tests/pict-rs
|
||||
fi
|
||||
./api_tests/pict-rs \
|
||||
run -a 0.0.0.0:8080 \
|
||||
--danger-dummy-mode \
|
||||
filesystem -p /tmp/pictrs/files \
|
||||
sled -p /tmp/pictrs/sled-repo 2>&1 &
|
||||
|
||||
for INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do
|
||||
echo "DB URL: ${LEMMY_DATABASE_URL} INSTANCE: $INSTANCE"
|
||||
psql "${LEMMY_DATABASE_URL}/lemmy" -c "DROP DATABASE IF EXISTS $INSTANCE"
|
||||
|
|
|
@ -14,6 +14,8 @@ yarn
|
|||
yarn api-test || true
|
||||
|
||||
killall -s1 lemmy_server || true
|
||||
killall -s1 pict-rs || true
|
||||
for INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do
|
||||
psql "$LEMMY_DATABASE_URL" -c "DROP DATABASE $INSTANCE"
|
||||
done
|
||||
rm -r /tmp/pictrs
|
||||
|
|
|
@ -54,8 +54,8 @@ beforeAll(async () => {
|
|||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await unfollows();
|
||||
afterAll(() => {
|
||||
unfollows();
|
||||
});
|
||||
|
||||
function assertCommentFederation(
|
||||
|
@ -94,7 +94,9 @@ test("Create a comment", async () => {
|
|||
});
|
||||
|
||||
test("Create a comment in a non-existent post", async () => {
|
||||
await expect(createComment(alpha, -1)).rejects.toBe("couldnt_find_post");
|
||||
await expect(createComment(alpha, -1)).rejects.toStrictEqual(
|
||||
Error("couldnt_find_post"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Update a comment", async () => {
|
||||
|
@ -143,7 +145,7 @@ test("Delete a comment", async () => {
|
|||
await waitUntil(
|
||||
() =>
|
||||
resolveComment(gamma, commentRes.comment_view.comment).catch(e => e),
|
||||
r => r !== "couldnt_find_object",
|
||||
r => r.message !== "couldnt_find_object",
|
||||
)
|
||||
).comment;
|
||||
if (!gammaComment) {
|
||||
|
@ -160,13 +162,13 @@ test("Delete a comment", async () => {
|
|||
// Make sure that comment is undefined on beta
|
||||
await waitUntil(
|
||||
() => resolveComment(beta, commentRes.comment_view.comment).catch(e => e),
|
||||
e => e === "couldnt_find_object",
|
||||
e => e.message == "couldnt_find_object",
|
||||
);
|
||||
|
||||
// Make sure that comment is undefined on gamma after delete
|
||||
await waitUntil(
|
||||
() => resolveComment(gamma, commentRes.comment_view.comment).catch(e => e),
|
||||
e => e === "couldnt_find_object",
|
||||
e => e.message === "couldnt_find_object",
|
||||
);
|
||||
|
||||
// Test undeleting the comment
|
||||
|
@ -181,7 +183,7 @@ test("Delete a comment", async () => {
|
|||
let betaComment2 = (
|
||||
await waitUntil(
|
||||
() => resolveComment(beta, commentRes.comment_view.comment).catch(e => e),
|
||||
e => e !== "couldnt_find_object",
|
||||
e => e.message !== "couldnt_find_object",
|
||||
)
|
||||
).comment;
|
||||
expect(betaComment2?.comment.deleted).toBe(false);
|
||||
|
|
|
@ -34,9 +34,7 @@ import {
|
|||
} from "./shared";
|
||||
import { EditSite, LemmyHttp } from "lemmy-js-client";
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupLogins();
|
||||
});
|
||||
beforeAll(setupLogins);
|
||||
|
||||
function assertCommunityFederation(
|
||||
communityOne?: CommunityView,
|
||||
|
@ -66,8 +64,8 @@ test("Create community", async () => {
|
|||
|
||||
// A dupe check
|
||||
let prevName = communityRes.community_view.community.name;
|
||||
await expect(createCommunity(alpha, prevName)).rejects.toBe(
|
||||
"community_already_exists",
|
||||
await expect(createCommunity(alpha, prevName)).rejects.toStrictEqual(
|
||||
Error("community_already_exists"),
|
||||
);
|
||||
|
||||
// Cache the community on beta, make sure it has the other fields
|
||||
|
@ -333,8 +331,8 @@ test("Get community for different casing on domain", async () => {
|
|||
|
||||
// A dupe check
|
||||
let prevName = communityRes.community_view.community.name;
|
||||
await expect(createCommunity(alpha, prevName)).rejects.toBe(
|
||||
"community_already_exists",
|
||||
await expect(createCommunity(alpha, prevName)).rejects.toStrictEqual(
|
||||
Error("community_already_exists"),
|
||||
);
|
||||
|
||||
// Cache the community on beta, make sure it has the other fields
|
||||
|
|
|
@ -10,12 +10,10 @@ import {
|
|||
waitUntil,
|
||||
} from "./shared";
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupLogins();
|
||||
});
|
||||
beforeAll(setupLogins);
|
||||
|
||||
afterAll(async () => {
|
||||
await unfollowRemotes(alpha);
|
||||
afterAll(() => {
|
||||
unfollowRemotes(alpha);
|
||||
});
|
||||
|
||||
test("Follow federated community", async () => {
|
||||
|
|
44
api_tests/src/image.spec.ts
Normal file
44
api_tests/src/image.spec.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
jest.setTimeout(120000);
|
||||
|
||||
import { UploadImage, DeleteImage } from "lemmy-js-client";
|
||||
import { alpha, setupLogins, unfollowRemotes } from "./shared";
|
||||
import fs = require("fs");
|
||||
const downloadFileSync = require("download-file-sync");
|
||||
|
||||
beforeAll(setupLogins);
|
||||
|
||||
afterAll(() => {
|
||||
unfollowRemotes(alpha);
|
||||
});
|
||||
|
||||
test("Upload image and delete it", async () => {
|
||||
// upload test image
|
||||
const upload_image = fs.readFileSync("test.png");
|
||||
const upload_form: UploadImage = {
|
||||
image: upload_image,
|
||||
};
|
||||
const upload = await alpha.uploadImage(upload_form);
|
||||
console.log(upload);
|
||||
expect(upload.files![0].file).toBeDefined();
|
||||
expect(upload.files![0].delete_token).toBeDefined();
|
||||
expect(upload.url).toBeDefined();
|
||||
expect(upload.delete_url).toBeDefined();
|
||||
|
||||
// ensure that image download is working. theres probably a better way to do this
|
||||
const content = downloadFileSync(upload.url);
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
||||
// delete image
|
||||
const delete_form: DeleteImage = {
|
||||
token: upload.files![0].delete_token,
|
||||
filename: upload.files![0].file,
|
||||
};
|
||||
const delete_ = await alpha.deleteImage(delete_form);
|
||||
expect(delete_).toBe(true);
|
||||
|
||||
// ensure that image is deleted
|
||||
const content2 = downloadFileSync(upload.url);
|
||||
expect(content2).toBe("");
|
||||
});
|
||||
|
||||
// TODO: add tests for image purging
|
|
@ -50,8 +50,8 @@ beforeAll(async () => {
|
|||
await unfollows();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await unfollows();
|
||||
afterAll(() => {
|
||||
unfollows();
|
||||
});
|
||||
|
||||
function assertPostFederation(postOne?: PostView, postTwo?: PostView) {
|
||||
|
@ -96,18 +96,20 @@ test("Create a post", async () => {
|
|||
assertPostFederation(betaPost, postRes.post_view);
|
||||
|
||||
// Delta only follows beta, so it should not see an alpha ap_id
|
||||
await expect(resolvePost(delta, postRes.post_view.post)).rejects.toBe(
|
||||
"couldnt_find_object",
|
||||
);
|
||||
await expect(
|
||||
resolvePost(delta, postRes.post_view.post),
|
||||
).rejects.toStrictEqual(Error("couldnt_find_object"));
|
||||
|
||||
// Epsilon has alpha blocked, it should not see the alpha post
|
||||
await expect(resolvePost(epsilon, postRes.post_view.post)).rejects.toBe(
|
||||
"couldnt_find_object",
|
||||
);
|
||||
await expect(
|
||||
resolvePost(epsilon, postRes.post_view.post),
|
||||
).rejects.toStrictEqual(Error("couldnt_find_object"));
|
||||
});
|
||||
|
||||
test("Create a post in a non-existent community", async () => {
|
||||
await expect(createPost(alpha, -2)).rejects.toBe("couldnt_find_community");
|
||||
await expect(createPost(alpha, -2)).rejects.toStrictEqual(
|
||||
Error("couldnt_find_community"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Unlike a post", async () => {
|
||||
|
@ -157,8 +159,8 @@ test("Update a post", async () => {
|
|||
assertPostFederation(betaPost, updatedPost.post_view);
|
||||
|
||||
// Make sure lemmy beta cannot update the post
|
||||
await expect(editPost(beta, betaPost.post)).rejects.toBe(
|
||||
"no_post_edit_allowed",
|
||||
await expect(editPost(beta, betaPost.post)).rejects.toStrictEqual(
|
||||
Error("no_post_edit_allowed"),
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -226,7 +228,9 @@ test("Lock a post", async () => {
|
|||
);
|
||||
|
||||
// Try to make a new comment there, on alpha
|
||||
await expect(createComment(alpha, alphaPost1.post.id)).rejects.toBe("locked");
|
||||
await expect(createComment(alpha, alphaPost1.post.id)).rejects.toStrictEqual(
|
||||
Error("locked"),
|
||||
);
|
||||
|
||||
// Unlock a post
|
||||
let unlockedPost = await lockPost(beta, false, betaPost1.post);
|
||||
|
@ -281,8 +285,8 @@ test("Delete a post", async () => {
|
|||
assertPostFederation(betaPost2, undeletedPost.post_view);
|
||||
|
||||
// Make sure lemmy beta cannot delete the post
|
||||
await expect(deletePost(beta, true, betaPost2.post)).rejects.toBe(
|
||||
"no_post_edit_allowed",
|
||||
await expect(deletePost(beta, true, betaPost2.post)).rejects.toStrictEqual(
|
||||
Error("no_post_edit_allowed"),
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -483,12 +487,12 @@ test.skip("Enforce community ban for federated user", async () => {
|
|||
|
||||
// ensure that the post by alpha got removed
|
||||
await expect(getPost(alpha, searchBeta1.posts[0].post.id)).rejects.toBe(
|
||||
"unknown",
|
||||
Error("unknown"),
|
||||
);
|
||||
|
||||
// Alpha tries to make post on beta, but it fails because of ban
|
||||
await expect(createPost(alpha, betaCommunity.community.id)).rejects.toBe(
|
||||
"banned_from_community",
|
||||
Error("banned_from_community"),
|
||||
);
|
||||
|
||||
// Unban alpha
|
||||
|
|
|
@ -20,8 +20,8 @@ beforeAll(async () => {
|
|||
recipient_id = 3;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await unfollowRemotes(alpha);
|
||||
afterAll(() => {
|
||||
unfollowRemotes(alpha);
|
||||
});
|
||||
|
||||
test("Create a private message", async () => {
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
InstanceId,
|
||||
LemmyHttp,
|
||||
PostView,
|
||||
SuccessResponse,
|
||||
} from "lemmy-js-client";
|
||||
import { CreatePost } from "lemmy-js-client/dist/types/CreatePost";
|
||||
import { DeletePost } from "lemmy-js-client/dist/types/DeletePost";
|
||||
|
@ -58,7 +59,6 @@ import { Register } from "lemmy-js-client/dist/types/Register";
|
|||
import { SaveUserSettings } from "lemmy-js-client/dist/types/SaveUserSettings";
|
||||
import { DeleteAccount } from "lemmy-js-client/dist/types/DeleteAccount";
|
||||
import { GetSiteResponse } from "lemmy-js-client/dist/types/GetSiteResponse";
|
||||
import { DeleteAccountResponse } from "lemmy-js-client/dist/types/DeleteAccountResponse";
|
||||
import { PrivateMessagesResponse } from "lemmy-js-client/dist/types/PrivateMessagesResponse";
|
||||
import { GetPrivateMessages } from "lemmy-js-client/dist/types/GetPrivateMessages";
|
||||
import { PostReportResponse } from "lemmy-js-client/dist/types/PostReportResponse";
|
||||
|
@ -637,7 +637,7 @@ export async function loginUser(
|
|||
|
||||
export async function saveUserSettingsBio(
|
||||
api: LemmyHttp,
|
||||
): Promise<LoginResponse> {
|
||||
): Promise<SuccessResponse> {
|
||||
let form: SaveUserSettings = {
|
||||
show_nsfw: true,
|
||||
blur_nsfw: false,
|
||||
|
@ -655,7 +655,7 @@ export async function saveUserSettingsBio(
|
|||
|
||||
export async function saveUserSettingsFederated(
|
||||
api: LemmyHttp,
|
||||
): Promise<LoginResponse> {
|
||||
): Promise<SuccessResponse> {
|
||||
let avatar = "https://image.flaticon.com/icons/png/512/35/35896.png";
|
||||
let banner = "https://image.flaticon.com/icons/png/512/36/35896.png";
|
||||
let bio = "a changed bio";
|
||||
|
@ -679,7 +679,7 @@ export async function saveUserSettingsFederated(
|
|||
export async function saveUserSettings(
|
||||
api: LemmyHttp,
|
||||
form: SaveUserSettings,
|
||||
): Promise<LoginResponse> {
|
||||
): Promise<SuccessResponse> {
|
||||
return api.saveUserSettings(form);
|
||||
}
|
||||
export async function getPersonDetails(
|
||||
|
@ -692,9 +692,7 @@ export async function getPersonDetails(
|
|||
return api.getPersonDetails(form);
|
||||
}
|
||||
|
||||
export async function deleteUser(
|
||||
api: LemmyHttp,
|
||||
): Promise<DeleteAccountResponse> {
|
||||
export async function deleteUser(api: LemmyHttp): Promise<SuccessResponse> {
|
||||
let form: DeleteAccount = {
|
||||
delete_content: true,
|
||||
password,
|
||||
|
|
|
@ -22,9 +22,7 @@ import {
|
|||
import { LemmyHttp, SaveUserSettings } from "lemmy-js-client";
|
||||
import { GetPosts } from "lemmy-js-client/dist/types/GetPosts";
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupLogins();
|
||||
});
|
||||
beforeAll(setupLogins);
|
||||
|
||||
let apShortname: string;
|
||||
|
||||
|
|
BIN
api_tests/test.png
Normal file
BIN
api_tests/test.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
|
@ -1270,6 +1270,11 @@ doctrine@^3.0.0:
|
|||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
download-file-sync@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/download-file-sync/-/download-file-sync-1.0.4.tgz#d3e3c543f836f41039455b9034c72e355b036019"
|
||||
integrity sha512-vH92qNH508jZZA12HQNq/aiMDfagr4JvjFiI17Bi8oYjsxwv5ZVIi7iHkYmUXxOQUr90tcVX+8EPePjAqG1Y0w==
|
||||
|
||||
electron-to-chromium@^1.4.535:
|
||||
version "1.4.537"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.537.tgz#aac4101db53066be1e49baedd000a26bc754adc9"
|
||||
|
@ -2281,10 +2286,10 @@ kleur@^3.0.3:
|
|||
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
|
||||
integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
|
||||
|
||||
lemmy-js-client@0.19.0-rc.12:
|
||||
version "0.19.0-rc.12"
|
||||
resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.19.0-rc.12.tgz#e3bd4e21b1966d583ab790ef70ece8394b012b48"
|
||||
integrity sha512-1iu2fW9vlb3TrI+QR/ODP3+5pWZB0rUqL1wH09IzomDXohCqoQvfmXpwArmgF4Eq8GZgjkcfeMDC2gMrfw/i7Q==
|
||||
lemmy-js-client@0.19.0-alpha.18:
|
||||
version "0.19.0-alpha.18"
|
||||
resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.19.0-alpha.18.tgz#f94841681cabdf9d5c4ce7048eacb57557f68724"
|
||||
integrity sha512-cKJfKKnjK+ijk0Yd6ydtne3Y4FILp2RbQg05pCru9n6PCyPAa85eQL4QxPB1PPed20ckSZRcHLcnr/bYFDgpaw==
|
||||
dependencies:
|
||||
cross-fetch "^3.1.5"
|
||||
form-data "^4.0.0"
|
||||
|
|
Loading…
Reference in a new issue