/**
 * Tests that a user projection which fakes an internal topology-change event is handled gracefully
 * in a sharded cluster.
 * TODO SERVER-65778: rework this test when we can handle faked internal events more robustly.
 *
 * Tests that if a user fakes an internal event with a projection nothing crashes, so not valuable
 * to test with a config shard.
 * @tags: [assumes_read_preference_unchanged, config_shard_incompatible]
 */
import {ShardingTest} from "jstests/libs/shardingtest.js";

const numShards = 2;

const st = new ShardingTest({
    shards: numShards,
    rs: {nodes: 1, setParameter: {writePeriodicNoops: true, periodicNoopIntervalSecs: 1}}
});

const mongosConn = st.s;

const testDB = mongosConn.getDB(jsTestName());
const adminDB = mongosConn.getDB("admin");
const testColl = testDB.test;

// Insert one test document that points to a valid shard, and one that points to an invalid shard.
// These will generate change events that look identical to a config.shards entry, except for 'ns'.
// It also means that the documentKey field in the resume token will look like a potentially valid
// new-shard document.
const existingShardDoc = testDB.getSiblingDB("config").shards.find({_id: st.rs0.name}).next();
const existingShardWrongNameDoc = {
    _id: "nonExistentName",
    host: existingShardDoc.host
};
const existingShardWrongHostDoc = {
    _id: st.rs1.name,
    host: `${st.rs1.name}/${st.rs1.host}-wrong:${st.rs1.ports[0]}`
};
const fakeShardDoc = {
    _id: "shardX",
    host: "shardX/nonExistentHost:27017"
};
const invalidShardDoc = {
    _id: "shardY",
    host: null
};
const configDotShardsNs = {
    db: "config",
    coll: "shards"
};
assert.commandWorked(testColl.insert(existingShardWrongNameDoc));
assert.commandWorked(testColl.insert(existingShardWrongHostDoc));
assert.commandWorked(testColl.insert(existingShardDoc));
assert.commandWorked(testColl.insert(invalidShardDoc));
assert.commandWorked(testColl.insert(fakeShardDoc));

// Log the shard description documents that we just inserted into the collection.
jsTestLog("Shard docs: " + tojson(testColl.find().toArray()));

// Helper function which opens a stream with the given projection and asserts that its behaviour
// conforms to the specified arguments; it will either throw the given error code, or return the
// expected events. Passing an empty array will confirm that we see no events in the stream. We
// further confirm that the faked events do not cause additional cursors to be opened.
function assertChangeStreamBehaviour(projection, expectedEvents, expectedErrorCode = null) {
    // Can't expect both to see events and to throw an exception.
    assert(!(expectedEvents && expectedErrorCode));

    // Generate a random ID for this stream.
    const commentID = `${Math.random()}`;

    // Create a change stream cursor with the specified projection.
    var csCursor = testColl.watch([{$addFields: projection}],
                                  {startAtOperationTime: Timestamp(1, 1), comment: commentID});

    // Confirm that the observed events match the expected events, if specified.
    if (expectedEvents && expectedEvents.length > 0) {
        for (let expectedEvent of expectedEvents) {
            assert.soon(() => csCursor.hasNext());
            const nextEvent = csCursor.next();
            for (let fieldName in expectedEvent) {
                assert.eq(
                    expectedEvent[fieldName], nextEvent[fieldName], {expectedEvent, nextEvent});
            }
        }
    }
    // If there are no expected events, confirm that the token advances without seeing anything.
    if (expectedEvents && expectedEvents.length == 0) {
        const startPoint = csCursor.getResumeToken();
        assert.soon(() => {
            assert(!csCursor.hasNext(), () => tojson(csCursor.next()));
            return bsonWoCompare(csCursor.getResumeToken(), startPoint) > 0;
        });
    }

    // If we expect an error code, assert that we throw it soon.
    if (expectedErrorCode) {
        assert.soon(() => {
            try {
                assert.throwsWithCode(() => csCursor.hasNext(), expectedErrorCode);
            } catch (err) {
                return false;
            }
            return true;
        });
    } else {
        // Otherwise, confirm that we still only have a single cursor on each shard. It's possible
        // that the same cursor will be listed as both active and inactive, so group by cursorId.
        const openCursors = adminDB
                                .aggregate([
                                    {$currentOp: {idleCursors: true}},
                                    {$match: {"cursor.originatingCommand.comment": commentID}},
                                    {
                                        $group: {
                                            _id: {shard: "$shard", cursorId: "$cursor.cursorId"},
                                            currentOps: {$push: "$$ROOT"}
                                        }
                                    }
                                ])
                                .toArray();
        assert.eq(openCursors.length,
                  numShards,
                  // Dump all the running operations for better debuggability.
                  () => tojson(adminDB.aggregate([{$currentOp: {idleCursors: true}}]).toArray()));
    }

    // Close the change stream when we are done.
    csCursor.close();
}

// Test that a projection which fakes a 'migrateChunkToNewShard' event is swallowed but has no
// effect.
let testProjection = {operationType: "migrateChunkToNewShard"};
assertChangeStreamBehaviour(testProjection, []);

// Test that a projection which fakes an event on config.shards with a non-string operationType is
// allowed to pass through.
testProjection = {
    ns: configDotShardsNs,
    operationType: null
};
assertChangeStreamBehaviour(testProjection, [
    {operationType: null, fullDocument: existingShardWrongNameDoc},
    {operationType: null, fullDocument: existingShardWrongHostDoc},
    {operationType: null, fullDocument: existingShardDoc},
    {operationType: null, fullDocument: invalidShardDoc},
    {operationType: null, fullDocument: fakeShardDoc}
]);

// Test that a projection which fakes an event on config.shards with a non-timestamp clusterTime
// is allowed to pass through.
testProjection = {
    ns: configDotShardsNs,
    clusterTime: null
};
assertChangeStreamBehaviour(testProjection, [
    {clusterTime: null, fullDocument: existingShardWrongNameDoc},
    {clusterTime: null, fullDocument: existingShardWrongHostDoc},
    {clusterTime: null, fullDocument: existingShardDoc},
    {clusterTime: null, fullDocument: invalidShardDoc},
    {clusterTime: null, fullDocument: fakeShardDoc}
]);

// Test that a projection which fakes an event on config.shards with a non-object fullDocument
// is allowed to pass through.
testProjection = {
    ns: configDotShardsNs,
    fullDocument: null
};
assertChangeStreamBehaviour(testProjection, [
    {fullDocument: null},
    {fullDocument: null},
    {fullDocument: null},
    {fullDocument: null},
    {fullDocument: null}
]);

// Test that a projection which fakes a new-shard event on config.shards with a valid fullDocument
// pointing to an existing shard is swallowed but has no effect.
testProjection = {
    ns: configDotShardsNs,
    fullDocument: existingShardDoc
};
assertChangeStreamBehaviour(testProjection, []);

// Test that a projection which fakes a new-shard event on config.shards with a valid fullDocument
// pointing to an existing shard's host, but the wrong shard name, throws as it attempts to connect.
testProjection = {
    ns: configDotShardsNs,
    fullDocument: existingShardWrongNameDoc
};
assertChangeStreamBehaviour(testProjection, null, ErrorCodes.ShardNotFound);

// Test that a projection which fakes a new-shard event on config.shards with a valid fullDocument
// pointing to an existing shard's name, but the wrong host, is swallowed and has no effect.
testProjection = {
    ns: configDotShardsNs,
    fullDocument: existingShardWrongHostDoc
};
assertChangeStreamBehaviour(testProjection, []);

// Test that a projection which fakes a new-shard event on config.shards with a valid fullDocument
// pointing to a non-existent shard throws as it attempts to connect.
testProjection = {
    ns: configDotShardsNs,
    fullDocument: fakeShardDoc
};
assertChangeStreamBehaviour(testProjection, null, ErrorCodes.ShardNotFound);

// Test that a projection which fakes a new-shard event on config.shards with an invalid
// fullDocument throws a validation exception.
testProjection = {
    ns: configDotShardsNs,
    fullDocument: invalidShardDoc
};
assertChangeStreamBehaviour(testProjection, null, ErrorCodes.TypeMismatch);

st.stop();